23 KiB
Extension verification — offline emulation (unidbg) and Frida RPC
Covers the 2026-09 extension pass that added references/emulation-and-rpc.md,
scripts/frida_rpc_serve.py and scripts/rpc_template.js. Strength labels as defined in this
directory's README: observed (exact command + output recorded here), inferred (follows from
observed facts, step not executed), unverified (assumed or externally reported, not independently
confirmed here).
Reference environment for this pass: Windows host, Python 3.14.0, host frida 16.7.19 +
frida-python 16.7.19 (frida-tools 13.7.1), adb 37.0.1, JDK 17.0.4.1 (with JAVA_HOME
unset in the shell — see §5). Device <DEVICE>: Android 11 / API 30, arm64-v8a, rooted
(Magisk), on-device frida-server 16.7.19. Workbench artifacts live in tools/_work/t3-rpc/ (not part
of the skill).
Device-exclusivity constraint during this pass: another task was unpacking the hardened Flutter
sample on the same device. This pass never attached to, spawned or force-stopped that sample's
process, and never killed or restarted the device-side frida-server. All RPC work targeted unrelated
processes (system apps and third-party apps). De-identification: the sample's package label, real
package name and md5 are elided in this document as com.example.app / <label>; command shapes,
counts, class names and timings are verbatim.
0. Summary of what this pass established
| Claim | Label |
|---|---|
droidasc 0.1.1.post1 and ddc 0.1.8 both run on this host; both read the hardened sample's shell dex |
observed |
| Both tools see only the 4 shell classes in the APK's dex; business classes are absent (shell dex only) | observed |
frida_rpc_serve.py --help, all three modes, reconnect, spawn and attach-by-pid work against a live device |
observed |
| Attach by package name fails on this ROM while attach by pid succeeds for the same process | observed |
The device-side server may be (re)started on a non-default port; --remote HOST:PORT recovers, device-serial lookup does not |
observed |
| unidbg 0.9.10-SNAPSHOT builds and boots an Android emulator on this host after a pom fix; one bundled test suite passes | observed |
Any target .so actually emulated under unidbg |
not done (unverified) |
frida 17 compatibility of rpc_template.js export lookup |
unverified (host has 16.7.19 only) |
1. ASC (droidasc) — observed
1.1 Install and version
$ python -m pip show droidasc
Name: droidasc
Version: 0.1.1.post1
Summary: ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers
License-Expression: Apache-2.0
Location: C:\Python314\Lib\site-packages
Requires: androguard
Install line used (recorded after the fact; the package was already present on this host):
python -m pip install droidasc. Distribution metadata declares only androguard as a dependency —
no JVM is required (unlike the asc successor tooling in the upstream MG1937/ASC repository,
which is a separate project). The console script is named droidasc, not asc: there is no
asc.exe in C:\Python314\Scripts, and asc --version fails with
The term 'asc' is not recognized.... python -m droidasc is the equivalent entry point.
1.2 Subcommands
$ python -m droidasc --help
usage: python.exe -m droidasc [-h]
{getclass,listclass,getmanifest,findrefs} ...
ASC tooling entry.
positional arguments:
{getclass,listclass,getmanifest,findrefs}
getclass Locate the target class in APK, extract one DEX in memory, then decompile.
listclass List classes across all DEX entries; use --prefix to filter by package/class prefix.
getmanifest Decode AndroidManifest.xml from APK and print it as XML.
findrefs Find code references for string/type/method/field across all DEX entries in APK.
examples:
droidasc app.apk --gui
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
droidasc listclass app.apk --prefix com.poc
droidasc getmanifest app.apk -o AndroidManifest.xml
droidasc findrefs app.apk string token -o string_refs.txt
--gui and the bundled asc_client web UI were not exercised (unverified, headless pass).
1.3 Measured against the sample
$ droidasc getmanifest <sample>.apk -o asc-manifest-verify.xml # 0.44 s, 32,345 B
<manifest ... package="com.example.app" platformBuildVersionCode="34" ... android:versionCode="1143"
android:versionName="0.9.0" android:compileSdkVersion="34" ...>
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="28"/>
<uses-permission android:name="android.permission.INTERNET"/>
... (permissions list follows; manifest decodes cleanly)
$ droidasc findrefs <sample>.apk string jiagu -o asc-findrefs-jiagu.txt # 0.42 s
classes.dex | Lcom/stub/StubApp;-><clinit> | matched=(libjiagu)
classes.dex | Lcom/stub/StubApp;->attachBaseContext | matched=(/.jiagu; jiagu; jiagu_x86)
$ droidasc getclass <sample>.apk com.stub.StubApp -o asc-StubApp-verify.java # 0.33 s, 22,802 B
package com.stub;
public final class StubApp extends android.app.Application { ... }
$ droidasc listclass <sample>.apk --prefix com.stub # 0.23 s
Lcom/stub/StubApp;
$ droidasc listclass <sample>.apk -o asc-listclass-all.txt # 0.18 s, 4 lines
Lcom/stub/StubApp;
Lcom/tianyu/util/Configuration;
Lcom/tianyu/util/DtcLoader;
Lcom/tianyu/util/a;
findrefs reports the shell's own strings (libjiagu, jiagu_x86, /.jiagu) — i.e. the packer
signature is visible without unpacking, but nothing of the application is.
1.4 Limits hit
$ droidasc getclass <sample>.apk com.example.app.MainActivity -o asc-mainactivity.java
Error: Class Lcom/example/app/MainActivity; not found in APK.
exit = 1
This is the limit that matters for this sample class: the full class listing is 4 classes, so on
a packed APK ASC is a manifest + shell-inspector, not an application decompiler. The application code
lives in that shell's payload (assets/, the native unpacker and the AOT/Flutter layer), which ASC
does not touch. ASC still earns its place in the flow: getmanifest in under half a second is the
fastest manifest pass measured in this repository's tool set, and findrefs on shell class names is
how the packer was identified here.
Observed behaviours worth knowing: droidasc prints per-command timing nowhere, writes to -o only,
and returns a clean Error: ... line plus exit 1 when a class is missing.
2. ddc — observed
2.1 Version and acquisition
$ bin/ddc.exe -V
ddc 0.1.8 — DEX → Java decompiler
https://github.com/ejfkdev/ddc
ddc is a Rust binary (upstream: ejfkdev/ddc); the Windows build
used here is tools/_work/t3-rpc/bin/ddc.exe, 1,028,096 B, not on PATH and with no version
resource in the PE header (VersionInfo empty), so ddc -V is the only way to learn its version.
No JVM, no Python. Subcommands advertised by ddc help: info, listclasses, manifest,
mainactivity, res, strings, findrefs, callers, members, hierarchy, largest, disasm,
getclass, getmethod, plus a full-decompile mode (ddc <INPUT>... [OUTPUT]).
2.2 Measured against the sample
$ bin/ddc.exe info <sample>.apk # 0.24 s
label <label>
package com.example.app
version 0.9.0 (1143)
application com.stub.StubApp
launcher com.example.app.MainActivity
sdk 19–28
size 36.9 MB (38688120 bytes)
md5 1a037a045205184d4e8272092997334f
image dex classes methods fields strings
<sample>!classes.dex 035 4 266 23 481
total: 1 image(s), 4 classes
$ bin/ddc.exe findrefs <sample>.apk string jiagu # 0.13 s
dex kind class method refs
classes.dex const-string com/stub/StubApp <clinit>()V "libjiagu"
classes.dex const-string com/stub/StubApp attachBaseContext(...)V "jiagu_x86"; "jiagu"; "/.jiagu"
$ bin/ddc.exe strings <sample>.apk -f jiagu --with-locations # 0.14 s
dex string used-by
classes.dex "/.jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu_x86" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "libjiagu" com/stub/StubApp <clinit>()V
ddc info reaches the same conclusion as ASC by a different route (class/method/string counts per
dex image) and adds the resources-derived label and the launcher verification; findrefs output is
tabular and one line per (method, string) pair, which is directly pasteable into a report.
2.3 Limits hit
$ bin/ddc.exe getclass <sample>.apk com.example.app.MainActivity
ddc: class com.example.app.MainActivity not found in the selected image(s)
(try `ddc listclasses <input> <pattern>`)
exit = 2
Exit codes measured on this build: info → 0, -V → 0, getclass with a missing class → 2.
The missing-class path also reprints the full help text on stdout, so a wrapper that only checks
"did anything print" will misread it as success — key off the exit code. As with ASC, the packed APK
exposes only the shell dex; ddc cannot see application classes either, which is a property of the
artifact, not of the tool.
3. Prior-attempt outputs under test — and the defects found
skills/apk-reverse/references/emulation-and-rpc.md, scripts/frida_rpc_serve.py and
scripts/rpc_template.js were already on disk from an interrupted attempt of this task. This pass
read all three, ran the script, and fixed four defects. rpc_template.js needed no change.
| # | Defect (observed) | Fix |
|---|---|---|
| 1 | RpcBridge._teardown() never detached the session and used obj.off(...) if obj is self.session else None / obj.unload() if hasattr(obj, "unload") — a script object has unload(), a session has detach(), so the expression released only the script. Every reload()/reconnect cycle leaked one attached session on the device. |
Rewritten to unload the script, remove the detached handler and session.detach() explicitly. |
| 2 | list_exports() called Script.list_exports(), which frida 16.7.19 answers with DeprecationWarning: Script.list_exports will become asynchronous in the future, use the explicit Script.list_exports_sync instead (printed on every list command). |
Tries list_exports_sync first, then the old name, then local introspection. |
| 3 | --mode call raised out of main(): an unknown export produced frida's own unable to find method 'x' and a raw Python traceback; a bad --args value dumped a JSONDecodeError traceback. Scripted callers got a stack trace instead of a result. |
run_call_mode() now returns one JSON object ({"ok": false, "error": ...}) and exit code 1 for both cases; call() consults the advertised export list first so the message is no such rpc export: nope (available: add ping ...). |
| 4 | frida.TimedOutError was absent from the retry whitelist. It is not a TransportError subclass (frida.TimedOutError.__mro__ → (TimedOutError, Exception, BaseException, object)), so a device that timed out during attach produced a traceback in an otherwise recoverable situation — reproduced live (§4.4). |
Retryable types are now collected by name into RETRYABLE (TransportError, NotSupportedError, ServerNotRunningError, ProcessNotFoundError, InvalidOperationError, TimedOutError) and used by both ensure() and the initial connect in main(). |
Post-fix evidence:
$ python skills/apk-reverse/scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --pid 19938 \
--script skills/apk-reverse/scripts/rpc_template.js --mode call --export add --args "[40, 2]"
{"ok": true, "export": "add", "result": 42} exit = 0
$ ... --mode call --export nope
{"ok": false, "export": "nope", "error": "no such rpc export: nope
(available: add ping getpackagename callnative callnativeaddr)"} exit = 1
$ ... --mode call --export add --args "not-json"
{"ok": false, "error": "--args is not valid JSON: Expecting value: line 1 column 1 (char 0)"}
exit = 1
Known cosmetic behaviour left as is: in --mode call the bridge connects before --args is parsed,
so a bad-args invocation still performs a device attach.
4. Frida RPC against the live device — observed
4.1 Attach path: by name fails, by pid works
Attach by package name failed for every package tried, with the process demonstrably alive in
both frida-ps -Uai and ps -A:
$ python ...frida_rpc_serve.py --device-serial <DEVICE> --package com.android.settings ...
[bridge] connect failed (unable to find process with name 'com.android.settings'); retry 1 in 1.0s
... (5 attempts) ...
initial connect failed: could not (re)connect after 5 attempts
$ python ... --package io.github.vvb2060.magisk ...
[bridge] connect failed (unable to find process with name 'io.github.vvb2060.magisk'); retry 1 in 0.5s
initial connect failed: could not (re)connect after 1 attempts
The same processes attach normally by pid:
$ python ... --pid 19938 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.settings"}
$ python ... --pid 21493 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.email"}
So the gap is in the name→pid lookup the server serves, not in injection: --pid definitely
does bypass it. references/emulation-and-rpc.md had claimed the opposite ("--pid does not bypass
a gap that hides the process from the server's own table"); that row was corrected in this pass, and
the corrected wording is what the reference now carries.
4.2 The transport itself
| # | Command (pid 19938 = com.android.settings, unrelated to the sample) |
Result |
|---|---|---|
| 1 | --mode call --export add --args "[40, 2]" |
{"ok": true, "result": 42} |
| 2 | --mode call --export ping |
{"ok": true, "result": "pong"} |
| 3 | --mode call --export getpackagename |
{"ok": true, "result": "com.android.settings"} |
| 4 | --mode call --export callnative --args '["libc.so","getpid","int",[],[]]' |
{"ok": true, "result": 19938} (matches the pid — NativeFunction wrapping works) |
| 5 | --mode repl, feeding list / reload / list / add [40, 2] / nope |
add ping getpackagename callnative callnativeaddr; reloaded; same list; 42; error: no such rpc export: nope (available: ...) |
| 6 | --mode http, GET /exports |
{"ok":true,"exports":[...5 names...]} |
| 7 | --mode http, POST /call {"export":"add","args":[40,2]} |
{"ok":true,"result":42} |
| 8 | --mode http, POST /call {"export":"getpackagename","args":[]} |
{"ok":true,"result":"com.android.settings"} |
| 9 | --mode http, POST /call with an unknown export |
HTTP 502 + {"ok":false,"error":"no such rpc export: nope (available: ...)"} |
| 10 | --mode http, POST /reload then POST /call add [1,2] |
{"ok":true} then {"ok":true,"result":3} — the re-attach path works, and no DeprecationWarning is printed any more |
| 11 | --spawn --package io.github.vvb2060.magisk --mode call --export getpackagename |
{"ok": true, "result": "io.github.vvb2060.magisk"} (spawn → script load → resume → RPC) |
Every script load also delivered the template's own liveness message:
[script] {"ev": "RPC-READY", "exports": ["add","ping","getpackagename","callnative","callnativeaddr"]}.
That line is the cheapest health check for a session and is worth grepping for in automation.
4.3 The server's port moved under us
Mid-pass, --device-serial <DEVICE> began failing on attach while frida-ps -U kept working:
$ python ... --device-serial <DEVICE> --pid 19938 ...
[bridge] connect failed (unable to connect to remote frida-server: closed); retry 1 in 1.0s
... (3 attempts) ...
$ python -c "import frida;d=frida.get_device('<DEVICE>');d.attach(19938)"
attach failed: ServerNotRunningError unable to connect to remote frida-server: closed
$ adb -s <DEVICE> shell 'su -c "ps -A"' | Select-String 'svc16|frida'
root 1876 1 2227684 55288 do_sys_poll S .svc16 # only this instance alive
$ adb -s <DEVICE> shell 'su -c "netstat -ltnp"' | Select-String 2704
tcp 0 0 0.0.0.0:27043 0.0.0.0:* LISTEN 1876/.svc16 # nothing on 27042
The visible frida-server process (pid 23405) was gone; a disguised copy (.svc16, pid 1876) was
listening on 27043 only. frida-ps -U still worked because frida's USB transport probes a range
of ports, while the scripted device-serial path landed on a dead default port. Recovery, with the
forward already present (adb forward --list shows tcp:27043 tcp:27043):
$ python ... --remote 127.0.0.1:27043 --pid 19938 --mode call --export getpackagename
{"ok": true, "result": "com.android.settings"}
This pass neither started nor killed any server instance; it only observed that the instance changed
and switched to the explicit --remote form. That form is now the recommended one in the reference's
failure table.
4.4 A third-party process that refuses the handshake
$ python ... --remote 127.0.0.1:27043 --pid 19984 --mode call --export getpackagename
frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID 19984
An ordinary third-party app process (com.xunmeng.pinduoduo, listed by frida-ps -Uai) simply did
not answer the agent handshake within frida's timeout. Pre-fix this surfaced as a traceback; post-fix
the bridge treats it as retryable and reports initial connect failed: ... with exit 2. This is an
app-side property, not a bridge property — for connectivity probes prefer a system or stable app
process.
4.5 Device state after the pass
frida-ps -U and frida-ps -Uai both still answer; the sample's process was never touched; the
device-side server was never killed or restarted by this pass.
5. unidbg — observed (build/boot) and unverified (target emulation)
5.1 Build
$ git clone --depth 1 https://github.com/zhkl0228/unidbg.git # version 0.9.10-SNAPSHOT
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
Error: JAVA_HOME not found in your environment. # JAVA_HOME was unset on this host
...
[ERROR] Failed to execute goal ...maven-compiler-plugin:3.3:compile on project unidbg-api:
[ERROR] .../AbstractARMDebugger.java:[79,43] reference to Module is ambiguous
[ERROR] both com.github.unidbg.Module and java.lang.Module match
The shipped pom pins <source>8</source><target>8</target> with maven-compiler-plugin 3.3. On
JDK 9+ that pair does not select the JDK 8 API, so java.lang.Module stays visible and every
unqualified Module in AbstractARMDebugger becomes ambiguous — the build dies in unidbg-api.
Two local work-copy changes fix it (recorded in tools/_work/t3-rpc/unidbg/pom.xml, not part of the
skill and not committed):
maven-compiler-plugin 3.3 -> 3.8.1 # 3.13 is rejected: the bundled wrapper is Maven 3.5.4,
# and 3.13 requires Maven >= 3.6.3
<source>8</source><target>8</target> -> <release>8</release> # hides java.lang.Module
Result with JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1:
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
[INFO] unidbg-api ......... SUCCESS [ 40.838 s]
[INFO] unidbg-android ..... SUCCESS [ 4.941 s]
[INFO] BUILD SUCCESS (total 48.4 s, after a one-off dependency download)
5.2 Boot check
The pom sets <maven.test.skip>true</maven.test.skip>, so -Dmaven.test.skip=false is required to
run anything:
$ .\mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
Backend: DynarmicBackend64
=== Memory Leak Report === ... Address: 0x12000000, Size: 4096 ...
[INFO] BUILD SUCCESS
This proves the toolchain end to end at the environment level: emulator construction, memory
allocator, backend selection (Dynarmic, with Hypervisor and Unicorn2 factories registered) and
Android 11 (AndroidResolver(23)-style) library resolution all execute on this host.
5.3 What was not done — unverified
- Loading and calling any target
.so, including this sample'slibjiagu*.soorlibapp.so. NocreateDalvikVM,loadLibrary,callJNI_OnLoadorAbstractJnistub was exercised. - Consequently every per-library statement in
references/emulation-and-rpc.md§Part A — which JNI callbacks a hardened library demands, how many stub rounds it costs, which/procor signature checks it runs — remains inferred from the library's shape and community practice. The reference's "Measured here" section was rewritten in this pass to say exactly that. - Note for anyone porting an older tutorial:
emulator.createCode()/ theAssemblyhelper class do not exist in 0.9.10-SNAPSHOT (noAssembly*.javaunder the tree, nocreateCodesymbol in any module). Recipes that assemble raw ARM machine code through that API need the current backend/JNI entry points instead. unidbg-android's bundled demos (TTEncrypt,SignUtil,QDReaderJni, …) each expect their own.so/APK to be placed first; none were run, so the demo names in the reference are pointers, not verified commands.
6. Consolidated list of things that did not work (for the pass summary)
- Attach by package name on this ROM (both
com.android.settingsandio.github.vvb2060.magiskfailed while listed byfrida-ps -Uai). Workaround: pid, fromfrida-ps -U/pidof. - Device-serial attach once the device's server came back on 27043 —
ServerNotRunningError: unable to connect to remote frida-server: closed. Workaround:adb forward+--remote. - Attach by pid to
com.xunmeng.pinduoduo—frida.TimedOutError; the process never answered the handshake. ascas a command name (the console script isdroidasc), andmvnwwithoutJAVA_HOME.- unidbg out of the box on JDK 17 (
Moduleambiguity) and unidbg tests out of the box (maven.test.skip=true). - Application-class decompilation of a packed APK by either ASC or ddc (nothing to fix — the dex simply is not there; this is the case that justifies the whole emulation/RPC route).
7. Artifacts produced by this pass
tools/_work/t3-rpc/ (not committed): asc-manifest-verify.xml, asc-StubApp-verify.java,
asc-listclass-all.txt, asc-listclass-stub.log, asc-getmanifest.log, asc-findrefs.log,
asc-findrefs-jiagu.txt, asc-getclass.log, ddc-info-verify.txt, ddc-findrefs-jiagu.txt,
ddc-strings-jiagu.txt, rpc-*.log (one per command in §4), bin/ddc.exe, unidbg/ (patched work
copy).
Files changed in the repository by this pass: references/emulation-and-rpc.md (corrected failure
table, corrected unidbg build recipe, corrected "Measured here" labelling, and a stale section
reference repointed to SKILL.md §Stop conditions) and scripts/frida_rpc_serve.py (the four fixes
in §3).