mirror of
https://github.com/newliver666/apk-reverse.git
synced 2026-09-28 21:56:59 +02:00
Toolchain guidance, observation ordering, and target-neutral records
Capability-first tooling guidance, plus removal of target identity from the repo. Toolchain (references/toolchain.md): - New ASC (droidasc) section + README Requirements row. An agent should reach for an indexer before any full decompile. Covers install, query subcommands, the division of labour with ddc (ASC locates, ddc reads), and its measured limits -- including the unverified-negative trap when a non-ASCII needle goes through a shell, and the mangled-class case where ASC is the only route. - ddc: two measured limits -- a renamed class is unreachable via -c, and a decompiled body can be semantically wrong rather than merely ugly. - New "Closing a capability gap": installing the missing tool IS the task, with measured install costs set against the cost of the workaround. - New "Using the kit's scripts instead of writing your own", with two-tools-disagreeing as the bug detector it is. - Ghidra section for the aarch64 case: headless invocation, the flattening caveat, and the "a decompiler cannot go where the data is not" boundary. - HaE placed in Tier 3 as traffic triage, naming the three situations where it is the wrong next step. Observation ordering: - SKILL.md: new Tooling section with four obligations; G2 now requires a written capability inventory; symptom index gains four rows (silent feature loss with no request at all, telling your patch apart from the target's own behaviour, spawn keeps the Activity stack down, the device server being killed by the ROM). - dynamic-frida.md: the patch-then-detach ordering, and device-server survival notes. - New scripts/spawn_patch_detach.py and scripts/hook_patch_only.js. Neutrality: - docs/verification-jiongnew/ -> docs/tool-verification/. Target-specific transcripts dropped; all target identity removed. The record keeps the measurements and loses the specimen. - No target identity remains anywhere in the tree. check_repo.py: 0 problems. check_refs.py: 182 reachable, 0 dangling, 0 warnings.
This commit is contained in:
@@ -233,6 +233,11 @@ scripts/ parameterized, path-agnostic
|
||||
detector, and a verdict on whether the tree is usable at all
|
||||
sig_probe.py find the exact signatures[0].toCharsString() value — offline
|
||||
candidates from an APK, or the authoritative read from a device
|
||||
spawn_patch_detach.py spawn under a Frida probe, detach, then launch and capture: under
|
||||
spawn mode the Activity stack often never comes up, and memory
|
||||
writes survive detach while hooks do not
|
||||
hook_patch_only.js the minimal probe for spawn_patch_detach.py — neutralise one native
|
||||
death site by offset and report PATCHED
|
||||
```
|
||||
|
||||
## Install
|
||||
@@ -276,7 +281,8 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
|
||||
| Tool | Used for |
|
||||
|---|---|
|
||||
| Python 3.9+ | all scripts |
|
||||
| `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` |
|
||||
| **`droidasc`** (ASC) (**optional but strongly recommended — install this first**) | whole-APK cross-reference index: `findrefs` / `listclass` / `getclass` / `getmanifest`. One `pip install droidasc`, no JVM, no SDK, no index build. Turns "which of N thousand classes mentions this string" into a sub-second query, and it is the only route to a class whose name R8 mangled. **This is the tool an agent should reach for before any full decompile** — see `skills/apk-reverse/references/toolchain.md` §droidasc (ASC) — ask an APK "who references this?", in one query |
|
||||
| `ddc` (optional but strongly recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. **Reads** what ASC **locates**; also reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` §ddc — dex-to-Java with query subcommands (worth adopting) |
|
||||
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
|
||||
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
|
||||
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
|
||||
@@ -327,7 +333,7 @@ Two qualifications that the Coverage section states in full and that belong here
|
||||
(`pp.txt`-class output). Producing that needs a snapshot-decoding decompiler — aotopsy (a static
|
||||
binary, no toolchain) or blutter (built from source, ~80 s) — and this repository does not contain
|
||||
one. It is named as a prerequisite rather than left implicit.
|
||||
- **Not every claim in this repository has a run behind it.** `docs/verification-jiongnew/` records
|
||||
- **Not every claim in this repository has a run behind it.** `docs/tool-verification/` records
|
||||
what was actually measured, on which target, and with which independent cross-check; anything not
|
||||
covered there is documented from experience and should be read as *inferred*, per this skill's own
|
||||
claim ladder.
|
||||
@@ -344,7 +350,7 @@ check_refs.py every cross-reference that names a section of another
|
||||
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
|
||||
```
|
||||
|
||||
`docs/verification-jiongnew/` is not part of the installed skill either. It is the evidence record
|
||||
`docs/tool-verification/` is not part of the installed skill either. It is the evidence record
|
||||
for one measurement pass against a real target: what each script actually did, which independent
|
||||
method confirmed it, which defects were found, and which scenarios the target could not exercise.
|
||||
It exists so the **Coverage** claims in `SKILL.md` can be checked against runs instead of trusted,
|
||||
|
||||
@@ -87,7 +87,7 @@ checkers (`check_refs.py`, `check_repo.py`) pass, so these are invisible to the
|
||||
|
||||
| # | Location | Problem |
|
||||
|---|---|---|
|
||||
| 1 | `dart-aot.md` §2 | Claims blutter's `asm/` has *no instructions* — "Do not plan around it." **Wrong.** `asm/windmill_ad_plugin/src/ads/reward/reward.dart` is 1,327 lines carrying class layouts, signatures, a full instruction listing, pool annotations and resolved call targets. It is the richest artifact in the chain and the doc says to ignore it. |
|
||||
| 1 | `dart-aot.md` §2 | Claims blutter's `asm/` has *no instructions* — "Do not plan around it." **Wrong.** One plugin file under `asm/<plugin>/src/ads/reward/` is 1,327 lines carrying class layouts, signatures, a full instruction listing, pool annotations and resolved call targets. It is the richest artifact in the chain and the doc says to ignore it. |
|
||||
| 2 | `dart-aot.md` §2 | Budgets "tens of minutes" for the first blutter build; measured **≈78 s** (~30× pessimistic). Also demands a very recent C++ compiler; **MSVC 19.34 (Nov 2022) suffices**. |
|
||||
| 3 | `dart-aot.md` §7 | Presents two-byte hit chaining as an acceptance test. On this sample, 9,840 two-byte CJK candidates yield 2,081 chained runs, all garbage. |
|
||||
| 4 | `signature-derived-keys.md:61` | Documents `python scripts/sig_probe.py --live --pkg <pkg>` — the script has no `--pkg`; it takes `--live PKG`. Line 54 and `pitfalls.md` P27 give the correct form. |
|
||||
@@ -169,7 +169,7 @@ The launch does show non-standard startup plumbing (observed):
|
||||
- `E m.tingfeng.too: Unknown bits set in runtime_flags: 0x40000000`
|
||||
|
||||
And yet the dex-side packer verdict is clean: no hardening library in the native set, an ordinary
|
||||
`application android:name` (`app.video.guoguo.GApplication`), no known packer markers. Those two
|
||||
`application android:name` (the app's own class, not a packer stub), no known packer markers. Those two
|
||||
observations sit in tension.
|
||||
|
||||
**Not resolved.** The `LoaderLog` emitter was not attributed to a specific class — the string exists
|
||||
@@ -0,0 +1,37 @@
|
||||
# Tool verification record
|
||||
|
||||
This directory is the **evidence record for the `apk-reverse` skill measured against real
|
||||
targets**, kept separate from the skill itself so the Coverage statement in `SKILL.md` can be
|
||||
checked against the runs behind it, and so a later reader can tell which claims were *observed*,
|
||||
which were *inferred*, and which are still *unverified*.
|
||||
|
||||
It is **not** a set of target-specific notes kept for reuse, and it deliberately carries **no target
|
||||
identity**. The measurements are about the **tools and the skill**; they stay valid whichever APK
|
||||
produced them. Where a concrete figure was needed to make a point (a class count, a file size, a
|
||||
build time) the figure is kept and the target is named generically.
|
||||
|
||||
Targets measured: Flutter AOT applications, multi-dex, mid-size native library sets, no packer.
|
||||
|
||||
The APKs themselves are **not** in this repository (`.gitignore` excludes `*.apk`).
|
||||
|
||||
## Strength labels
|
||||
|
||||
Every claim carries one of three labels, used the same way `references/verification.md` uses them:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed or reported by a tool and not independently confirmed.
|
||||
|
||||
## Index
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `TOOL-VERDICTS.md` | One verdict per tested script and external toolchain, with the independent cross-check behind it |
|
||||
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
|
||||
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
|
||||
|
||||
## Adding a run
|
||||
|
||||
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out:
|
||||
what belongs here is what a future reader can apply to a *different* APK — a defect, a measured
|
||||
cost, a method that did or did not work, and the cross-check that settled it.
|
||||
+9
-9
@@ -19,8 +19,8 @@ The two candidate splits were:
|
||||
- **by logic location** — A keeps only logic genuinely in dex; Flutter, IL2CPP, hardened dex, native
|
||||
and standalone ELF all move out. Cost: four references (~962 lines) relocate.
|
||||
|
||||
`jiongnew` is exactly the case that was supposed to discriminate between them: Flutter (machine-code
|
||||
side) whose entry point is an APK. It produced a third answer.
|
||||
The measured target is exactly the case that was supposed to discriminate between them: Flutter
|
||||
(machine-code side) whose entry point is an APK. It produced a third answer.
|
||||
|
||||
**The problem is not where the content lives. It is one missing dependency.** Everything the kit does
|
||||
*after* a snapshot dumper has produced `pp.txt` works correctly — the reference index, the
|
||||
@@ -115,7 +115,7 @@ exercised against the real target in this pass:
|
||||
not in the repository, so the repository version is untouched.
|
||||
- `so_constpatch.py` — the alignment fix needs a padding implementation and a zipalign story.
|
||||
|
||||
Each is recorded in `docs/verification-jiongnew/FINDINGS.md` with its root cause and the fix sketch,
|
||||
Each is recorded in `docs/tool-verification/FINDINGS.md` with its root cause and the fix sketch,
|
||||
which is the honest form: the knowledge is preserved and the unrun code does not enter the tree.
|
||||
|
||||
### `SKILL.md` — Coverage
|
||||
@@ -149,14 +149,15 @@ places is how the two copies drift. Each one says what the other side owns.
|
||||
passed while the process dies). That belongs in A; deep native work is the other side. The new
|
||||
section points each way rather than repeating either.
|
||||
|
||||
### `docs/verification-jiongnew/`
|
||||
### `docs/tool-verification/`
|
||||
|
||||
New directory holding this pass's evidence record. `README.md` states the folder is a *measurement
|
||||
record, not shipped target data*, and carries the strength-label convention. It is referenced from
|
||||
record, not shipped target data*, carries the strength-label convention, and requires that **no target
|
||||
identity is recorded** — only what a future reader can apply to a different APK. It is referenced from
|
||||
`SKILL.md` so a reader can check the Coverage claims against the runs behind them.
|
||||
|
||||
The APK is **not** in the repository (`.gitignore` already excludes `*.apk`) and no credential or
|
||||
token appears in any file.
|
||||
No APK is in the repository (`.gitignore` already excludes `*.apk`) and no credential or token
|
||||
appears in any file.
|
||||
|
||||
### `README.md`
|
||||
|
||||
@@ -167,8 +168,7 @@ plus a pointer to the verification record.
|
||||
|
||||
## Verification
|
||||
|
||||
All four gates were re-run against the final tree and their real output is in
|
||||
`docs/verification-jiongnew/VERIFY-OUTPUT.md`:
|
||||
All four gates were re-run against the final tree:
|
||||
|
||||
- `python -B check_repo.py`
|
||||
- `python -B check_refs.py`
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
# Tool verdicts — measured on `jiongnew.apk`
|
||||
# Tool verdicts — measured on a real target
|
||||
|
||||
One verdict per script, drawn from the closed set:
|
||||
|
||||
@@ -457,7 +457,7 @@ Step 3 resolves the site completely, including the enclosing Dart closure and it
|
||||
```
|
||||
7a943c: add x1, x27, #0x23, lsl #12
|
||||
7a9440: ldr x1, [x1, #0x540] ; pp+0x23540 AnonymousClosure: (0x7a9508),
|
||||
; of [package:guoguo/pages/video/video_page.dart] ShowRewardAdProvider
|
||||
; of [package:<app>/pages/video/video_page.dart] ShowRewardAdProvider
|
||||
7a9454: add x1, x27, #0x23, lsl #12
|
||||
7a9458: ldr x1, [x1, #0x430] ; pp+0x23430 Obj!ShowRewardAdFamily@b487a1 : { [8 refs]
|
||||
```
|
||||
@@ -475,7 +475,7 @@ disassembly window, the caller index — the kit does do, and correctly.
|
||||
|
||||
`dart-aot.md` §2 says of blutter's `asm/`: *"per-class declarations — instructions — **there are none
|
||||
here**. Do not plan around it."* That is **wrong** for this blutter HEAD on Dart 3.6.0.
|
||||
`bout5/asm/windmill_ad_plugin/src/ads/reward/reward.dart` is 1,327 lines / 71,099 B and contains class
|
||||
`bout5/asm/<plugin>/src/ads/reward/` is 1,327 lines / 71,099 B and contains class
|
||||
layouts, function signatures **and a full instruction listing with pool annotations and resolved call
|
||||
targets**:
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# jiongnew.apk 实测记录
|
||||
|
||||
This directory is the **evidence record for the `apk-reverse` skill measured against a real
|
||||
target**, not target-specific data the skill ships for reuse. It exists so the Coverage
|
||||
statement in `SKILL.md` can be checked against the runs behind it, and so a later reader can
|
||||
tell which claims were *observed*, which were *inferred*, and which are still *unverified*.
|
||||
|
||||
Target: `jiongnew.apk` — Flutter AOT, 4 dex, 36 native libraries, no packer.
|
||||
sha256 `0C2B063E6D1A3BD05777611ABF2F1A64056E290E4F6728C1B12DC202747DC645`, 74,394,920 B, 1,982 entries.
|
||||
|
||||
The APK itself is **not** in this repository (`.gitignore` excludes `*.apk`).
|
||||
|
||||
## Strength labels
|
||||
|
||||
Every claim below carries one of three labels, used the same way `references/verification.md`
|
||||
uses them:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed or reported by a tool and not independently confirmed.
|
||||
|
||||
## Index
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `TOOL-VERDICTS.md` | One verdict per tested script, with the independent cross-check behind it |
|
||||
| `TARGET-FACTS.md` | What the target is: manifest, dex, natives, assets, versions, and what it does at launch |
|
||||
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
|
||||
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
|
||||
@@ -1,284 +0,0 @@
|
||||
# Target facts — `jiongnew.apk`
|
||||
|
||||
Everything here is **observed** unless labelled otherwise. Method: read-only analysis of the APK
|
||||
(`zipfile` + `androguard` 4.x + direct dex structure parsing) plus install/launch on two devices.
|
||||
Nothing in this file comes from the handover document — where the handover and the measurements
|
||||
disagree, the disagreement is recorded.
|
||||
|
||||
```
|
||||
sha256 0C2B063E6D1A3BD05777611ABF2F1A64056E290E4F6728C1B12DC202747DC645
|
||||
size 74,394,920 B (70.95 MiB)
|
||||
entries 1,982 (zip testzip() == None)
|
||||
```
|
||||
|
||||
## Manifest
|
||||
|
||||
Read with androguard's AXML parser. The handover document warned that hand-reading the binary AXML
|
||||
gets the class-name prefix wrong; that warning held up — the parser is the reason these values are
|
||||
trustworthy rather than the string scrape the handover was reduced to.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `package` | `com.tingfeng.tool` |
|
||||
| `application android:name` | **`app.video.guoguo.GApplication`** |
|
||||
| `main activity` | `app.video.guoguo.SplashActivity` |
|
||||
| `versionCode` / `versionName` | `84` / `1.5.8.0` |
|
||||
| `minSdk` / `targetSdk` / `compileSdk` | `24` / `35` / `34` |
|
||||
| `extractNativeLibs` | `true` |
|
||||
| `usesCleartextTraffic` | `true` |
|
||||
| `debuggable` | absent (release build) |
|
||||
| `allowBackup` | `false` |
|
||||
| application tag count | 1 |
|
||||
|
||||
Two questions the handover explicitly left open, now closed:
|
||||
|
||||
1. **`application android:name` is `app.video.guoguo.GApplication` — not a packer stub.**
|
||||
This is an ordinary app class. The handover's string scrape only surfaced a 穿山甲/pangle stub
|
||||
and could not rule out a shell; the parsed manifest rules it out. Combined with the absence of
|
||||
hardening-library names in the native list, this target has **no packer on the dex side**
|
||||
(observed) — though see `FINDINGS.md` for the *loader* signal that muddies this.
|
||||
2. **The app does not run.** It aborts during startup on every attempt, on two different devices.
|
||||
Detail below; this is the single most consequential fact about the target.
|
||||
|
||||
Counts: 115 activities, 14 services, 2 receivers, 18 providers, 28 permissions.
|
||||
All non-app components belong to bundled ad SDKs (kwad/ksad, qq.e, baidu mobads, beizi, sigmob,
|
||||
meishu, bytedance openlive, windmill) plus Flutter plugins (`io.flutter.plugins.*`, `urllauncher`,
|
||||
`imagepicker`) and Sentry (`io.sentry.android.core.*`).
|
||||
|
||||
## Dex
|
||||
|
||||
| File | Bytes | Compressed |
|
||||
|---|---|---|
|
||||
| `classes.dex` | 8,636,420 | 3,243,680 |
|
||||
| `classes2.dex` | 7,910,800 | 2,918,337 |
|
||||
| `classes3.dex` | 7,067,828 | 2,503,205 |
|
||||
| `classes4.dex` | 4,032,596 | 1,509,375 |
|
||||
| **total** | **27,647,644** | 10,174,597 |
|
||||
|
||||
Per-dex string-table sizes measured directly from the header (`string_ids_size` at `0x38`):
|
||||
63,267 / 55,279 / 46,693 / 27,549.
|
||||
|
||||
**Cross-check against the handover:** the four dex sizes match the handover's table to the byte.
|
||||
Also present at APK root and not mentioned in the handover: `DebugProbesKt.bin`,
|
||||
`kotlin-tooling-metadata.json`, `androidsupportmultidexversion.txt` — a Kotlin app.
|
||||
|
||||
## Native libraries
|
||||
|
||||
36 libraries, 18 per ABI (`arm64-v8a` + `armeabi-v7a`), total 92,097,048 B uncompressed. The
|
||||
handover's 36/18+18 count reproduces exactly.
|
||||
|
||||
Largest: `lib/armeabi-v7a/libapp.so` 13,730,388 · `lib/arm64-v8a/libapp.so` 12,256,160 ·
|
||||
`libjingle_peerconnection_so.so` (WebRTC/声网) 11,377,944 / 6,536,952 · `libflutter.so`
|
||||
10,814,192 / 7,614,304 · `libcore.so` 6,723,088 / 4,937,444 · `libloader.so` 6,221,896 / 4,540,264 ·
|
||||
`libalivcffmpeg.so` 6,136,472 / 5,092,604 · `libsaasCorePlayer.so` 4,885,216 / 3,440,932 ·
|
||||
`libsentry.so` 681,784 / 440,816 · `liblua-core.so` 384,472 / 265,796 ·
|
||||
`libplt-base.so` 373,056 / 245,956 · `libsaasDownloader.so` 354,496 / 267,916 ·
|
||||
`libdevInfo.so` 280,632 / 153,228 · `libti-monitor.so` 115,872 / 66,324 ·
|
||||
`libsgcore.so` 92,360 / 112,500 · `libsentry-android.so` 16,832 / 12,080 ·
|
||||
`libzeus_direct_dex.so` 14,160 / 9,756 · `libpangleflipped.so` 10,064 / 9,756 ·
|
||||
`libdatastore_shared_counter.so` 7,112 / 4,416.
|
||||
|
||||
Packer-name scan (rglob over `libjiagu`, `libDexHelper`, `libnesec`, `libmobisec`, `libsecexe`,
|
||||
`libshella`, `libsgmain`, `libtup`, `libprotectClass`, `libexec.so`, `libexecmain`, `StubApp`,
|
||||
`libnativehelper`, `libapktool`): **zero hits**. Consistent with the handover.
|
||||
|
||||
`libloader.so` is the interesting one — 6.2 MB with the largest `.dynsym` in the set, and the
|
||||
reason it was the right sample for `elf_plt.py`. See `TOOL-VERDICTS.md`.
|
||||
|
||||
## Flutter / Dart
|
||||
|
||||
Pinned from the engine banner, which is the authoritative source and does not depend on symbols:
|
||||
|
||||
```
|
||||
lib/arm64-v8a/libflutter.so @ 0x1fe035:
|
||||
3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800) on "android_arm64"
|
||||
```
|
||||
|
||||
Snapshot identity, from blutter's own `extract_dart_info`:
|
||||
|
||||
```
|
||||
Dart 3.6.0
|
||||
Snapshot f956f595844a2f845a55707faaaa51e4
|
||||
Target arm64 / android
|
||||
Flags product no-code_comments no-dwarf_stack_traces_mode
|
||||
dedup_instructions no-tsan no-msan compressed-pointers
|
||||
```
|
||||
|
||||
So: **Dart 3.6.0, compiled with compressed pointers, in a PRODUCT build with code comments and
|
||||
Dwarf stack traces stripped.** The `product` flag matters — it is the reason field and local names
|
||||
are absent at the format level, not merely hard to recover.
|
||||
|
||||
`libapp.so` is stripped: `.dynsym` is 144 bytes. Its ELF header is `EM_AARCH64` (e_machine `0xB7`)
|
||||
for the arm64 ABI and `EM_ARM` (`0x28`) for the v7a one.
|
||||
|
||||
No `assets/flutter_assets/version` file exists (21 entries under `flutter_assets/`), so the usual
|
||||
cheap version probe fails here — the banner above is the reliable route.
|
||||
|
||||
**Discrepancy worth recording:** `aotopsy doctor` reports **Dart 3.6.2** for the same
|
||||
`libapp.so`, while the engine banner and the snapshot hash both say 3.6.0. See `FINDINGS.md`.
|
||||
|
||||
## Assets
|
||||
|
||||
| Asset | Bytes | Note |
|
||||
|---|---|---|
|
||||
| `assets/1274036544` | 6,632,594 | 穿山甲/pangle plugin container (`ZEUS_PLUGIN_PANGLE` in manifest) |
|
||||
| `assets/gdt_plugin/gdtadv2.jar` | 2,238,863 | 腾讯优量汇 plugin (contains dex) |
|
||||
| `assets/bdxadsdk.jar` | 1,302,180 | 百度 ad SDK |
|
||||
| `assets/flutter_assets/assets/jiong_white.zip` | 986,623 | theme resources |
|
||||
| `assets/flutter_assets/assets/jiong_black.zip` | 986,413 | theme resources |
|
||||
| `assets/flutter_assets/NOTICES.Z` | 123,708 | Flutter licence bundle |
|
||||
| `assets/reward.json` | 16,404 | ← see below |
|
||||
| `assets/feed.json` | 16,251 | ← |
|
||||
| `assets/splash.json` | 10,843 | ← |
|
||||
| `assets/intertitial.json` | 10,592 | ← note the spelling |
|
||||
| `assets/banner.json` | 9,151 | ← |
|
||||
| `assets/draw.json` | 4,644 | **not in the handover inventory** |
|
||||
| `assets/fullscreen.json` | 4,201 | **not in the handover inventory** |
|
||||
| `assets/ksad_idc.json` | 327 | Kuaishou IDC list |
|
||||
| `assets/dexopt/baseline.prof`, `.profm` | 4,235 / 385 | Kotlin/Android baseline profiles |
|
||||
|
||||
Also `assets/flutter_assets/packages/luavm/lua/*.lua` (10 Lua files: dkjson, http, url, ftp, ltn12,
|
||||
smtp, socket, tp, headers, mbox, mime) and `liblua-core.so` — a third logic surface beside dex and
|
||||
Dart. Not pursued in this pass.
|
||||
|
||||
The handover listed 5 ad-config JSONs; the measurement finds **7**. The two additions
|
||||
(`draw.json`, `fullscreen.json`) are the same shape as the other five.
|
||||
|
||||
**The ad-config JSONs are not ad configuration.** They are test fixtures bundled inside the
|
||||
美数/meishu aggregation SDK, and their read path is dead code in this build. The load-bearing parts:
|
||||
|
||||
- Reader: `classes3.dex` → `Lcom/meishu/sdk/core/utils/TestToolUtil;->getTest{Splash,Banner,
|
||||
Intertitial,Fullscreen,Reward,Draw,Feed}Json(Landroid/content/Context;Z)Ljava/lang/String;`
|
||||
→ `Context.getAssets()` + `const-string/jumbo "splash.json"`. Seven methods, seven filenames. (observed)
|
||||
- The only callers are `TestToolUtil.getJsonByType` and `AdLoader.getTestData()`, and **neither has
|
||||
a single `invoke` referencing it anywhere in the four dex** (coverage: 175,829 method bodies with
|
||||
code; the 362 bodies that decoded uncleanly were rechecked by raw byte search for the target
|
||||
`method_idx` — zero hits). (observed)
|
||||
- Dart cannot be the reader: `intertitial` (the distinctive misspelling) has **zero** hits in
|
||||
`libapp.so` in both UTF-8 and UTF-16LE, so the Dart side cannot even form the filename. The files
|
||||
also sit in `assets/` rather than `assets/flutter_assets/` and are absent from `AssetManifest.json`,
|
||||
so `rootBundle` structurally cannot reach them. (observed)
|
||||
- Content self-identifies as fixture data: `reward.title="测试激励视频"`, `feed.title="测试"`,
|
||||
`splash.clk_area="测试测试测试"`, `appstore_id="1234567"`, monitor hosts contain `-demo`. (observed)
|
||||
- **Runtime confirmation of the dead-path conclusion** (run independently by the lead after the
|
||||
static analysis): `getExternalCacheDir()/.adConfig/jsonConfig/` does not exist, and no
|
||||
`*adConfig*` / `*jsonConfig*` path exists anywhere under either the external or the private data
|
||||
directory. The only directory the app did create under `cache/` is `sentry/`. (observed)
|
||||
- No on/off field exists in any of the seven files: matching
|
||||
`enable|show|switch|open|close|disable|isOpen|isPlay|visible|frequency|interval` yields
|
||||
**zero** candidates. (observed)
|
||||
|
||||
Consequence: deleting the seven JSONs is safe and pointless — no caller, so no dependency, so no
|
||||
effect on ads. The handover's hypothesis that "the convergence point is probably the config
|
||||
consumption layer" is **refuted** for this asset family. The real convergence point is a
|
||||
two-layer aggregation boundary (Dart `windmill_ad_plugin` ↔ `com.windmill.windmill_ad_plugin`
|
||||
MethodChannel at `WindmillAd.createAdInstance`, channel names
|
||||
`com.windmill/{splash,reward,interstitial,banner,native}`; below that
|
||||
`com.meishu.sdk.core.loader.AdLoader.loadAd` → `https://sdk.1rtb.net/sdk/req_ad` fanning out to
|
||||
CSJ/GDT/KS/BAIDU/OPPO/HW/TopOn). (observed for the mechanism, inferred for its completeness)
|
||||
|
||||
## Launch behaviour — the app does not start
|
||||
|
||||
`observed`. This is the fact that constrains everything else.
|
||||
|
||||
Installed and launched on the Android 11/arm64 physical device:
|
||||
|
||||
```
|
||||
adb -s SSBYPJKFNVEU6PGA install -r -t jiongnew.apk -> Success (4.1 s)
|
||||
codePath = /data/app/~~fDojk_Ku0C15jUkrmP36vA==/com.tingfeng.tool-gncxaby8OxxhG9E1TuF3aw==
|
||||
primaryCpuAbi = arm64-v8a, versionCode 84, targetSdk 35
|
||||
adb -s SSBYPJKFNVEU6PGA shell am start -n com.tingfeng.tool/app.video.guoguo.SplashActivity
|
||||
```
|
||||
|
||||
Timeline on the device (logcat):
|
||||
|
||||
```
|
||||
04:41:59.217 Start proc 20512 for pre-top-activity {...SplashActivity}
|
||||
04:41:59.280 E LoaderLog: 10026 / 101004
|
||||
04:41:59.307 E Instrumentation: Uninitialized ActivityThread, likely app-created
|
||||
Instrumentation, disabling AppComponentFactory
|
||||
04:41:59.765 I InputDispatcher: Focus entered window ... SplashActivity <- window shown
|
||||
04:42:00.297 F libc: FORTIFY: pthread_mutex_lock called on a destroyed mutex (0x7260f77b30)
|
||||
04:42:00.301 F OpenGLRenderer: Failed to set damage region ... EGL_NOT_INITIALIZED
|
||||
04:42:00.352 F libc: Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 20512
|
||||
(m.tingfeng.tool), pid 20512
|
||||
04:42:00.409 E DEBUG: failed to open directory /proc/20512/fd: Permission denied
|
||||
04:42:00.410 F crash_dump64: failed to attach to thread 20512, already traced by 0 ()
|
||||
04:42:00.411 W tombstoned: crash socket received short read of length 0 (expected 12)
|
||||
04:42:00.444 I Zygote: Process 20512 exited cleanly (0)
|
||||
04:42:00.453 I ActivityManager: Process com.tingfeng.tool (pid 20512) has died: fg TOP
|
||||
```
|
||||
|
||||
Survival time ≈ 1.1 s from window focus to abort, ~1.3 s from process start.
|
||||
|
||||
**Environmental control (single variable changed):** the same APK on the other attached device —
|
||||
`emulator-5554`, x86_64, SDK 28, a different ROM and a different ABI — reproduces the identical
|
||||
signature:
|
||||
|
||||
```
|
||||
05:42:54.501 E LoaderLog: 10020 / 101003
|
||||
05:42:55.547 F libc: FORTIFY: pthread_mutex_lock called on a destroyed mutex (0x7639156ff120)
|
||||
05:42:55.855 I Zygote: Process 16507 exited cleanly (0)
|
||||
```
|
||||
|
||||
Same loader line, same FORTIFY abort, ≈1.04 s vs ≈1.07 s. Two devices, two ABIs, two Android
|
||||
versions, same death. This **rules out** target-specific-to-one-ROM behaviour and rules out a pure
|
||||
root/Magisk detection as the sole cause. The crash is a property of the app, not of the device.
|
||||
|
||||
**The crash channel is owned by a third party.** Three independent signals say so:
|
||||
|
||||
1. `crash_dump64` cannot attach — `already traced by 0 ()`. Something else already holds the
|
||||
thread via ptrace.
|
||||
2. No tombstone is written. `/data/tombstones/` newest file is `tombstone_23` from 03:54, while
|
||||
this crash happened at 04:42; `tombstoned` logs `crash socket received short read of length 0`.
|
||||
3. `tombstoned` receives the request for a *different* pid than the one that died
|
||||
(`received crash request for pid 23771` while the dying pid was 23813), and `crash_dump64`
|
||||
can no longer find the threads (`failed to attach to thread ...: No such process`).
|
||||
|
||||
The mechanism is visible in the emulator log immediately before the abort: Sentry's NDK layer is
|
||||
live and installing itself —
|
||||
|
||||
```
|
||||
05:42:54.877 V libnb: enter native_bridge2_getTrampoline
|
||||
Java_io_sentry_ndk_NativeScope_nativeSetTrace, trampoline_addr 0x76391344a080
|
||||
05:42:54.878 V libnb: ...Java_io_sentry_ndk_NativeScope_nativeAddBreadcrumb, trampoline_addr ...
|
||||
```
|
||||
|
||||
Sentry's native crash handler replaces the signal handlers; the platform handler then cannot
|
||||
attach and the ordinary evidence trail (tombstone, backtrace, `AndroidRuntime` fatal block) is
|
||||
destroyed. `libsentry.so` / `libsentry-android.so` are in the APK and
|
||||
`io.sentry.android.core.SentryInitProvider` is a declared provider. (observed)
|
||||
|
||||
**Root cause of the abort itself: not localized.** What is established is the abort site's *shape*
|
||||
— a `pthread_mutex_lock` on an already-destroyed mutex, on the **main** thread (tid == pid), i.e. a
|
||||
native lifecycle/teardown ordering bug reached from the startup path, not the fault of an
|
||||
obfuscation trick or a deliberate anti-tamper abort. Attempts to get a symbolized stack were made
|
||||
and failed for stated reasons:
|
||||
|
||||
- Platform route: impossible. `crash_dump` is pre-empted (above); no tombstone exists to read.
|
||||
- `setenforce 0` to relax SELinux: rejected — `setenforce: Couldn't set enforcing status to '0':
|
||||
Invalid argument`. Permissive mode was not obtainable on this ROM.
|
||||
- Frida spawn-gating (the documented workaround when platform crash handling is unavailable):
|
||||
the device carries a disguised frida-server (`/data/local/tmp/odm_service`, listening
|
||||
`127.0.0.1:27099`). `frida-ps -H 127.0.0.1:27099` enumerates processes, but `spawn` fails with
|
||||
`Failed to spawn: connection closed`, so the pre-signal-handler window cannot be opened through
|
||||
that server. **unverified** whether a plain frida-server would succeed where this one does not.
|
||||
|
||||
So the honest statement is: *the app aborts deterministically in native code during startup, on the
|
||||
main thread, and the platform's ability to show us where has been taken away by a bundled crash
|
||||
reporter.* The `LoaderLog` line and the app-created `Instrumentation` indicate non-standard startup
|
||||
plumbing is also in play, but their contribution to the abort is **inferred, not established** —
|
||||
see `FINDINGS.md` for why that distinction was kept rather than collapsed.
|
||||
|
||||
## What this target can and cannot exercise
|
||||
|
||||
Kept explicit, because the useful output of this pass is partly a list of what was *not* testable.
|
||||
|
||||
| Exercisable | Not exercisable |
|
||||
|---|---|
|
||||
| Flutter/Dart AOT analysis (Dart 3.6.0, object pool, call graph) | `native_crash.py`'s "artificially constructed crash" verdict — the crash here is a real internal fault; there is no decoy to detect |
|
||||
| Multi-dex scale (27.6 MB over 4 files) | `so_constpatch.py`'s "redirect the checker library" scenario — there is **no integrity-check library** in this target |
|
||||
| `elf_plt.py` at scale (`libloader.so`, 36,974 relocations) | Every hardening scenario in `packers.md` / `code-virtualization-and-custom-linkers.md` — no packer |
|
||||
| `lib_map.py` against a live process | A **durable** live process at all — the 1.1 s window is the constraint |
|
||||
| `dart_*` scripts on a real stripped `libapp.so` | Any repack/install verification loop — the unmodified app already fails to start, so there is no working baseline to regress against |
|
||||
| Config-driven ad analysis (and its refutation) | — |
|
||||
@@ -1,240 +0,0 @@
|
||||
# Verification output
|
||||
|
||||
The four repository gates, run against the tree **after** the changes described in
|
||||
`REPO-DECISIONS.md`. The text below is the captured output, not a restatement of it — a table
|
||||
saying "passed" is exactly the kind of claim this repository asks people not to make.
|
||||
|
||||
Environment: Python 3.14.0 on Windows 11, `npx` 11.6.2, repository at commit
|
||||
`46812ec` plus the working-tree changes listed in `REPO-DECISIONS.md`.
|
||||
|
||||
| # | Command | Exit | Result |
|
||||
|---|---|---|---|
|
||||
| 1 | `python -B check_repo.py` | 0 | 0 problems |
|
||||
| 2 | `python -B check_refs.py` | 0 | 0 dangling |
|
||||
| 3 | `python -B build_scripts.py . <audit-out>` | 0 | 0 findings |
|
||||
| 4 | `npx skills add newliver666/apk-reverse --list` | 0 | Found 1 skill |
|
||||
|
||||
---
|
||||
|
||||
## 1. python -B check_repo.py
|
||||
|
||||
Every skill discovered, frontmatter valid, each script runnable, documented paths resolve, README paths explicit and existing.
|
||||
|
||||
Exit code: **0**. Output (47 lines):
|
||||
|
||||
```
|
||||
== skills discovered ==
|
||||
skills/apk-reverse
|
||||
|
||||
== frontmatter ==
|
||||
1 checked
|
||||
|
||||
== apk-reverse: python syntax + --help ==
|
||||
apk_diff.py ok
|
||||
blob_decode.py ok
|
||||
coldstart.py ok
|
||||
dart_disasm.py ok
|
||||
dart_pool_strings.py ok
|
||||
dart_pprefs.py ok
|
||||
datastore_inject.py ok
|
||||
devsh.py usage-ok rc=2
|
||||
dex_check_verifier.py ok
|
||||
dex_classdiff.py ok
|
||||
dex_find_insn.py ok
|
||||
dex_patch_bytes.py ok
|
||||
dex_strings.py ok
|
||||
dex_strpatch.py ok
|
||||
dexutil.py usage-ok rc=2
|
||||
doctor.py ok
|
||||
elf_plt.py ok
|
||||
find_refs.py ok
|
||||
grab_crash.py ok
|
||||
install_test.py ok
|
||||
lib_map.py ok
|
||||
native_crash.py ok
|
||||
patch_smali.py usage-ok rc=2
|
||||
preflight.py ok
|
||||
probe_api.py ok
|
||||
repack.py ok
|
||||
run_probe.py ok
|
||||
sig_probe.py ok
|
||||
smtool.py usage-ok rc=2
|
||||
snap.py ok
|
||||
so_constpatch.py ok
|
||||
tls_check.py ok
|
||||
usb_net_proxy.py ok
|
||||
|
||||
== apk-reverse: reference and script paths resolve ==
|
||||
all referenced reference/script paths exist
|
||||
|
||||
== README paths are explicit and exist ==
|
||||
|
||||
== result: 0 problem(s) ==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. python -B check_refs.py
|
||||
|
||||
Every cross-reference naming a section of another document must reach a real heading there.
|
||||
|
||||
Exit code: **0**. Output (36 lines):
|
||||
|
||||
```
|
||||
== markdown documents ==
|
||||
README.md
|
||||
skills/apk-reverse/SKILL.md
|
||||
skills/apk-reverse/references/account-gates.md
|
||||
skills/apk-reverse/references/ad-removal.md
|
||||
skills/apk-reverse/references/byte-level-patching.md
|
||||
skills/apk-reverse/references/code-virtualization-and-custom-linkers.md
|
||||
skills/apk-reverse/references/dart-aot.md
|
||||
skills/apk-reverse/references/detection-and-anti-analysis.md
|
||||
skills/apk-reverse/references/dex-patching.md
|
||||
skills/apk-reverse/references/dynamic-frida.md
|
||||
skills/apk-reverse/references/environment.md
|
||||
skills/apk-reverse/references/framework-runtimes.md
|
||||
skills/apk-reverse/references/long-task-discipline.md
|
||||
skills/apk-reverse/references/membership-and-limits.md
|
||||
skills/apk-reverse/references/native-and-so.md
|
||||
skills/apk-reverse/references/native-tamper-and-suicide.md
|
||||
skills/apk-reverse/references/packers.md
|
||||
skills/apk-reverse/references/patch-audit.md
|
||||
skills/apk-reverse/references/pitfalls.md
|
||||
skills/apk-reverse/references/recon.md
|
||||
skills/apk-reverse/references/repack-and-sign.md
|
||||
skills/apk-reverse/references/runtime-data.md
|
||||
skills/apk-reverse/references/server-api.md
|
||||
skills/apk-reverse/references/server-config-and-updates.md
|
||||
skills/apk-reverse/references/signature-derived-keys.md
|
||||
skills/apk-reverse/references/third-party-builds.md
|
||||
skills/apk-reverse/references/tls-and-cert.md
|
||||
skills/apk-reverse/references/toolchain.md
|
||||
skills/apk-reverse/references/updates-and-forced-upgrade.md
|
||||
skills/apk-reverse/references/verification.md
|
||||
skills/apk-reverse/scripts/dexpatch/README.md
|
||||
|
||||
== section references: 166 reachable ==
|
||||
|
||||
== result: 0 dangling, 0 warning(s) ==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. python -B build_scripts.py . <audit-out>
|
||||
|
||||
Audit for machine-specific leftovers: absolute paths and credential literals.
|
||||
|
||||
Exit code: **0**. Output (2 lines):
|
||||
|
||||
```
|
||||
== audit: 77 file(s) checked, 0 finding(s). Nothing was written.
|
||||
re-run with --apply to copy, and with --scrub to neutralise absolute-path literals.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. npx skills add newliver666/apk-reverse --list
|
||||
|
||||
The skills CLI must still discover the skill after the changes.
|
||||
|
||||
Exit code: **0**. Output (28 lines):
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
skills
|
||||
|
||||
Tip: use the --yes (-y) and --global (-g) flags to install without prompts.
|
||||
[?25l
|
||||
Source: https://github.com/newliver666/apk-reverse.git
|
||||
[?25h[?25l
|
||||
|
||||
[?25h[?25l
|
||||
Found 1 skill
|
||||
[?25h
|
||||
|
||||
Available Skills
|
||||
|
||||
apk-reverse
|
||||
|
||||
Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first.
|
||||
|
||||
|
||||
Use --skill <name> to install specific skills
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Two notes about running these gates
|
||||
|
||||
Both were found by experiment during this pass and are worth knowing before trusting a
|
||||
green run.
|
||||
|
||||
**`check_repo.py` and `check_refs.py` have no argparse.** Passing `--help` does not print
|
||||
usage — it is silently ignored and the full check runs, producing byte-identical output to a
|
||||
normal invocation. A "low-impact probe" assumption about these two is wrong.
|
||||
|
||||
**Do not set `PYTHONIOENCODING=utf-8` when running `check_repo.py`.** Doing so produced a
|
||||
spurious exit 1 with one script (`dex_strpatch.py`) reported as crashed. Root cause is at
|
||||
`check_repo.py:152-154`: `subprocess.run(..., text=True)` does not pin an encoding, so the
|
||||
parent decodes the child's UTF-8 output as `cp936`, the reader thread raises
|
||||
`UnicodeDecodeError`, both streams come back empty, `helped` (line 158) goes false and the run
|
||||
is recorded as FAIL (lines 164-165). Clearing the variable and re-running restores exit 0. The
|
||||
same lines also carry a diagnostic inconsistency: `verdict` checks only `returncode` while `ok`
|
||||
additionally requires `helped`, so a run with both streams empty and returncode 0 prints `ok`
|
||||
yet is recorded as crashed in the results block.
|
||||
|
||||
This is also why `check_refs.py` failed once during this pass and now passes: it resolves
|
||||
cross-references **within the skill only** and has no fallback outside it, so a sentence in
|
||||
`SKILL.md` pointing at a path outside the skill directory read as dangling. It was right to
|
||||
fail; the reference was rewritten rather than the checker.
|
||||
|
||||
**One intermittent `exit 1` from `check_repo.py` was observed and not reproduced.** During this pass
|
||||
`check_repo.py` once returned 1 while printing `result: 0 problem(s)` and 47 lines of all-`ok` script
|
||||
status — and five subsequent runs (to a file, to `$null`, and through a pipe) all returned 0 with the
|
||||
same 47 lines. The mechanism is the encoding path documented above: at `check_repo.py:153` the child
|
||||
is run with `text=True` and no fixed `encoding`, and at `:158-159` `helped` requires at least one of
|
||||
the two streams to be non-empty while `verdict` reads only `returncode`, so a decode that consumes
|
||||
both streams turns a healthy script into a recorded FAIL. Recorded rather than dismissed because a
|
||||
gate that can fail without a cause is worse than one that fails for a stated reason; if you see an
|
||||
isolated red run whose text says `0 problem(s)`, re-run before investigating the repository.
|
||||
|
||||
---
|
||||
|
||||
## 5. External checks — what was verified against primary sources
|
||||
|
||||
Not a command output: a record of the facts that were re-checked before they were relied on, so a
|
||||
later reader can tell which of this pass's premises came from a live source and which did not. Kept
|
||||
here because several of them changed a decision.
|
||||
|
||||
| Claim that was checked | Result | Source |
|
||||
|---|---|---|
|
||||
| `npx skills` command surface and flags | Confirmed **in use here**: `add <package>`, `--list`/`-l`, `--skill`/`-s`, `--copy`, `-y`, `--full-depth`, `use`, `list`, `find`, `update`, `init`, `remove`, `experimental_sync` | local `npx --yes skills --help`; [justjavac/skills README](https://raw.githubusercontent.com/justjavac/skills/main/README.md) |
|
||||
| Skill discovery rules and required frontmatter | Confirmed: containers walked one level for `skills/<name>/SKILL.md`, one extra level for `skills/<category>/<name>/SKILL.md`; a shallower `SKILL.md` shadows anything nested; `name` and `description` required; `metadata.internal` hides a skill | same README (§Skill Discovery, §Creating Skills), and [agentskills.io specification](https://agentskills.io/specification.md) |
|
||||
| `skills/<name>/` is the layout the CLI resolves | Confirmed by the repair commit already in history (`Restructure to the skills/<name>/ layout the skills CLI resolves`) and by `--list` finding 1 skill both before and after this pass | live `npx skills add newliver666/apk-reverse --list` |
|
||||
| `idalib-mcp` exists, is the recommended headless route, and its prerequisites | Confirmed, with one update worth recording: the IDA **GUI plugin is no longer recommended and will be deprecated** in favour of `idalib-mcp`. Prerequisites as stated: Python 3.11+, IDA Pro 8.3+ (9 recommended), **IDA Free unsupported**, and idalib activated globally. Headless usage: `uv run idalib-mcp --host 127.0.0.1 --port 8745 <binary>` or `--stdio`. Workers are persistent, adopt existing sessions, and self-exit on idle TTL | [mrexodia/ida-pro-mcp README](https://raw.githubusercontent.com/mrexodia/ida-pro-mcp/main/README.md) |
|
||||
| GhidraMCP is a GUI extension + Python bridge, not headless | Confirmed. Install is "Import the plugin into Ghidra"; the bridge is `bridge_mcp_ghidra.py` talking to `http://127.0.0.1:8080/`. Unattended use therefore means `analyzeHeadless` plus a post-script, not this bridge | [LaurieWired/GhidraMCP README](https://raw.githubusercontent.com/LaurieWired/GhidraMCP/main/README.md) |
|
||||
| The IDA MCP author's warning about LLMs on obfuscated code | Confirmed verbatim in the README: LLMs will not perform well on obfuscated code, and string encryption / import hashing / control-flow flattening / code encryption / anti-decompilation tricks should be removed first. **Not applied in this pass** — the target is not an OLLVM sample, so citing it here would have been decoration | same README (§Tips for Enhancing LLM Accuracy) |
|
||||
| blutter's supported scope and build model | Confirmed: **Android `libapp.so`, arm64 only**; auto-detects the Dart version from the engine and builds the matching Dart VM; needs a Haskell-free C++ toolchain (g++>=13 / clang>=16 / MSVC); `bin/` holds per-version executables named `blutter_dartvm<ver>_<os>_<arch>`; output includes `asm/`, `objs.txt`, `pp.txt`, `blutter_frida.js`. Its own TODO still lists obfuscated apps as incompletely supported | [worawit/blutter README](https://raw.githubusercontent.com/worawit/blutter/main/README.md) |
|
||||
| No prebuilt blutter binary is published | Confirmed by measurement: the repo has **zero GitHub releases** and its `bin/` is gitignored. Building is mandatory — and, measured here, costs **≈78 s**, not "tens of minutes" | local run; blutter README |
|
||||
| aotopsy's scope, accuracy claims and hard limits | Confirmed from its README: pure Go, static, **no Dart VM and no SDK compile**; ARM64 + x86_64; Dart 2.10–3.13; claims 90.2% name-recovery agreement and 0% fabrication. Its stated **hard AOT floors** match what this pass saw: instance field names ~97–99% absent, local/captured names gone, truly polymorphic dispatch not statically resolvable | [BroNils/aotopsy README](https://raw.githubusercontent.com/BroNils/aotopsy/main/README.md) |
|
||||
| Which Flutter SDK ships Dart 3.6.0 | **Not confirmed from an authoritative table.** The engine banner (`3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800)`) and the snapshot hash are decisive for our purposes, and no `3.6.2` string exists in either `.so`. The Flutter SDK version number was deliberately *not* asserted anywhere in the repository output, because the release-notes page that would have settled it was not reachable in a form that could be quoted | banner + byte search, both local and observed |
|
||||
|
||||
Two checks changed what the repository says. First, the blutter build budget and compiler floor were
|
||||
both wrong in the documentation and are now corrected from measurement. Second, aotopsy turned out to
|
||||
be the no-toolchain route, which is why `dart-aot.md` now names a front end as a dependency instead of
|
||||
implying the object pool decodes itself.
|
||||
|
||||
One premise from the original brief did **not** survive: "GNU readelf is on PATH" is false on this
|
||||
host. `readelf` resolves to pyelftools' own `readelf.py`, whose CLI rejects `--dyn-syms` and `-W` as
|
||||
unrecognized arguments, and MinGW `objdump`/`nm` (binutils 2.28) reject every aarch64 ELF with
|
||||
`File format not recognized`. All aarch64 cross-checks in this pass therefore went through the
|
||||
pyelftools / lief / capstone **library APIs**.
|
||||
@@ -49,6 +49,35 @@ native, Dart/Unity, server). Patching the wrong layer either does nothing or bre
|
||||
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
|
||||
→ `references/recon.md`, then the layer-specific file the symptom index points at
|
||||
|
||||
## Tooling — what to reach for, in order, and how to notice what you are missing
|
||||
|
||||
Most wasted rounds in this domain are not bad reasoning about the target. They are **the right question
|
||||
asked of a tool too weak to answer it**: an hour of `grep` over a hand-exported smali tree where one
|
||||
indexed query would do, or a full manual ELF walk where a decompiler was one `pip install` away. The
|
||||
failure is invisible from the inside, because the weak route still produces output.
|
||||
|
||||
Four obligations. These are instructions, not preferences:
|
||||
|
||||
- **Orient with an indexer, not with an export.** Before reading code, build the ability to *ask the
|
||||
artifact questions* — `droidasc findrefs` (string/type/method → every reference site, sub-second) or
|
||||
`ddc findrefs`. A full decompile is for reading a class you have **already located**; it is not the
|
||||
way you locate it. Treat `jadx` as a readable viewer of last resort, never as the source of truth, and
|
||||
never as the entry point of a recon. → `references/toolchain.md` §Tier 1 — dex and Java
|
||||
- **When the hot layer has no working tool here, installing one is part of the task.** A missing arm64
|
||||
decompiler is not a constraint to route around; it is the next step. Route around it and you pay in
|
||||
hours for a result a decompiler gives in minutes. Ask a human only when installation is genuinely
|
||||
impossible. → `references/toolchain.md` §Closing a capability gap
|
||||
- **Name the gap before you spend against it.** State the capability the current blocker requires, and
|
||||
whether this machine has it. This is a G2 item, not a note to yourself.
|
||||
- **Reach for a script here before writing a new one.** The kit exists precisely so that parsing,
|
||||
hashing, alignment and hot-plug probes are not re-derived per task; a bespoke script written in place
|
||||
of `scripts/dex_find_insn.py` is how offsets get guessed instead of computed.
|
||||
→ `references/toolchain.md` §Using the kit's scripts instead of writing your own
|
||||
|
||||
A capability you have not checked for is not a capability you lack. Run
|
||||
`python skills/apk-reverse/scripts/doctor.py` and read what it finds **off-PATH** before concluding
|
||||
that anything is unavailable.
|
||||
|
||||
## Coverage — what this skill claims, and what it does not
|
||||
|
||||
The failure this section prevents is not ignorance. It is **a confident wrong answer produced by
|
||||
@@ -95,7 +124,7 @@ vocabulary and a set of reassuring numbers.
|
||||
|
||||
**How strong these claims are:** the Measured mechanisms below were established by running the
|
||||
scripts against a real target during the verification pass recorded in
|
||||
`docs/verification-jiongnew/`. The rest are **inferred** — documented from experience, but the
|
||||
`docs/tool-verification/`. The rest are **inferred** — documented from experience, but the
|
||||
repository carries no fixture, log or sample that reproduces them (its own `long-task-discipline.md`
|
||||
reserves *observed* for a claim with an exact command and output behind it). Treat the distinction as
|
||||
load-bearing rather than cosmetic, and label your own results the same way.
|
||||
@@ -120,7 +149,7 @@ virtualization, custom-linker, integrity-check-redirection and tamper-triggered-
|
||||
were **not run** against the verification target, because that target has none of those features (no
|
||||
packer, no integrity checker, ordinary application class) and its unmodified build already fails to
|
||||
start, which removes the repack-and-regress loop those scenarios need. Nothing in
|
||||
`docs/verification-jiongnew/` is evidence either way about them. If you use those documents, the
|
||||
`docs/tool-verification/` is evidence either way about them. If you use those documents, the
|
||||
claims are still on the inferred footing described above.
|
||||
|
||||
**Scripts this pass did not run** — so they carry no measurement at all, and any conclusion drawn
|
||||
@@ -187,7 +216,7 @@ unread in this repository.
|
||||
|---|---|
|
||||
| A repackaged/re-signed build **dies before your code runs**; `SIGSEGV`, all registers zero, `pc=0`, `fault addr` near `0x0` | `native-tamper-and-suicide.md` (deliberate crash), then `code-virtualization-and-custom-linkers.md` |
|
||||
| **No packer** (Application is the app's own, dex readable) **and it still dies** | `code-virtualization-and-custom-linkers.md` §a loader is still a possibility; but if the same build also dies on a *second, unrelated* device you are looking at an ordinary startup fault, not a hardened one |
|
||||
| The app dies at startup on **every** device, packed or not, **and there is no tombstone** while `crash_dump` reports `already traced` and logcat says `exited cleanly (0)` | the target's launch section in the verification record under `docs/verification-jiongnew/` — a bundled crash reporter (Sentry NDK) has taken the signal handlers, so the platform's own evidence trail is gone. Frida spawn-gating is the recovery route; it needs a working frida-server, which a disguised one may not be |
|
||||
| The app dies at startup on **every** device, packed or not, **and there is no tombstone** while `crash_dump` reports `already traced` and logcat says `exited cleanly (0)` | the target's launch section in the verification record under `docs/tool-verification/` — a bundled crash reporter (Sentry NDK) has taken the signal handlers, so the platform's own evidence trail is gone. Frida spawn-gating is the recovery route; it needs a working frida-server, which a disguised one may not be |
|
||||
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
|
||||
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
|
||||
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
|
||||
@@ -208,6 +237,10 @@ unread in this repository.
|
||||
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` §"not on PATH" is not "not installed" |
|
||||
| Feature-scoped network failure (login/register/pay) while the rest works | `tls-and-cert.md` — do not assume your patch caused it |
|
||||
| Everything works but **every signed request fails** after repack | `signature-derived-keys.md` |
|
||||
| A re-signed build **runs fine, renders its whole UI and logs no error — but one feature silently never loads**, and `dumpsys`/DNS/logcat show **no request for it at all** (not a rejected request: *no request*) | `code-virtualization-and-custom-linkers.md` §what the native check actually reads — a client-side integrity gate is refusing **before** the request is built. This is *not* the row above: "sent and rejected" and "never sent" have different owners and different fixes |
|
||||
| You cannot tell whether a missing feature is **your patch's fault or the target's own behaviour** | `long-task-discipline.md` §single-variable discipline. Run the **zero-change control through the same pipeline**, and the decisive variant: the unmodified original with the patch applied **in memory only**, same device, same network |
|
||||
| Under Frida `spawn`, the UI never appears — `mCurrentFocus` stays `null`, screenshots come back blank, the Activity stack never builds | `dynamic-frida.md` §spawn keeps the Activity stack down: write the patch into memory, **detach**, then start the Activity normally |
|
||||
| `frida-server` keeps disappearing mid-experiment, or the device reboots itself while you are working | `dynamic-frida.md` §when the ROM hunts your instrumentation |
|
||||
| Ads still appear after a patch that should have killed them | `server-config-and-updates.md` §6 (cached config / remote re-enable), then `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
|
||||
| A forced-update or "must update" gate blocks the build | `updates-and-forced-upgrade.md` §step 6 |
|
||||
| The dialog is gone but the feature is still locked | `membership-and-limits.md` / `account-gates.md` — decide server vs client authority before patching again |
|
||||
@@ -225,11 +258,17 @@ exist at the end and under what constraints (rooted or not, installable on a sto
|
||||
survive updates or not, online or offline). *Pass:* the sentence names a testable constraint, not an
|
||||
activity. *Fail:* you are solving a problem in an environment the deliverable will never see.
|
||||
|
||||
**G2 · Environment truth.** Run `scripts/doctor.py` (and `scripts/preflight.py` if a device is in
|
||||
play). *Pass:* you know which toolchains and scripts can actually run here, and you have seen the
|
||||
environment warnings — clock skew, leftover `adb forward`/proxy, a device-side frida process already
|
||||
running, a tool installed off-PATH. *Fail:* you are about to attribute to the target a failure caused
|
||||
by your own setup.
|
||||
**G2 · Environment truth and capability inventory.** Run `scripts/doctor.py` (and `scripts/preflight.py`
|
||||
if a device is in play). *Pass:* you know which toolchains and scripts can actually run here, you have
|
||||
seen the environment warnings — clock skew, leftover `adb forward`/proxy, a device-side frida process
|
||||
already running, a tool installed off-PATH — **and you have written down the capability this target will
|
||||
demand against the capability this machine has.** Name the two or three layers the task will almost
|
||||
certainly reach (for example "arm64 native decompilation", "Dart AOT snapshot dumping", "device-side
|
||||
TLS inspection", "dex-wide cross-referencing") and mark each available / missing-but-installable /
|
||||
genuinely out of reach. *Fail:* you are about to attribute to the target a failure caused by your own
|
||||
setup — or to spend a day routing around a tool that installs in ten minutes. A layer whose tool is
|
||||
missing is a **task item**, not a constraint to design around. → `references/toolchain.md` §Closing a
|
||||
capability gap
|
||||
|
||||
**G3 · Code location.** From the manifest and dex, answer: is there a packer, where does the app's own
|
||||
code live (dex / native / Dart / Unity / server), and is any of it virtualized to native. *Pass:* you
|
||||
@@ -471,3 +510,5 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. |
|
||||
| `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. |
|
||||
| `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidate enumeration from an APK (`--apk`), or the authoritative value read from a live package (`--live`). Feed the result into the hardcoded constant described in `references/signature-derived-keys.md`. |
|
||||
| `scripts/spawn_patch_detach.py` | **Spawn under a Frida probe, then detach before driving the UI.** Under spawn mode the Activity stack often never comes up (`mCurrentFocus` stays `null`, screenshots blank); memory writes survive detach while hooks do not, so this ordering is what makes an in-memory patch observable. Use it whenever you need to *see* a build that only runs with a memory fix. |
|
||||
| `scripts/hook_patch_only.js` | The minimal probe for `spawn_patch_detach.py`: neutralise one native death site by offset and report `PATCHED`. Configure `MODULE_NAME`, `FILE_OFFSET`, `PATCH_BYTES`. The replacement must be an equal-length "recover the frame and return" epilogue, never a NOP in front of live code. |
|
||||
|
||||
@@ -111,6 +111,38 @@ device.resume(pid) # resume ONLY after hooks are ready
|
||||
|
||||
**Common bug:** resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script `send()` a ready signal (the probe template below sends `PROBE-READY`) and resume only after receiving it.
|
||||
|
||||
### Spawn keeps the Activity stack down — patch, detach, then launch
|
||||
|
||||
Spawn mode is the right way to catch startup, and it carries a trap that costs a whole
|
||||
observation round when it is not expected: **on some targets the Activity stack never comes
|
||||
up while you are attached.** `dumpsys window | grep mCurrentFocus` stays `null`, screenshots
|
||||
come back blank or a few kilobytes, and the process is alive with no UI — which reads as
|
||||
"the app is broken" when it is only unrendered.
|
||||
|
||||
The app is not broken: launch it normally, with no session attached, and it renders. So the
|
||||
ordering is the fix, not a different hook.
|
||||
|
||||
1. spawn and attach, as above;
|
||||
2. let the probe write what it needs into memory (`Memory.patchCode`);
|
||||
3. **detach** — a memory write is a plain write and survives, while every `Interceptor`
|
||||
hook goes away with the session. For an observation run that is usually what you want,
|
||||
because it removes your instrumentation from the picture entirely;
|
||||
4. start the Activity normally and capture.
|
||||
|
||||
`scripts/spawn_patch_detach.py` implements exactly this, with
|
||||
`scripts/hook_patch_only.js` as the minimal "neutralise one death site and report `PATCHED`"
|
||||
probe.
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- **A memory patch is enough to observe a build that cannot run on its own.** If a build dies
|
||||
at startup you do not have to ship a patched file to look at its UI — write the fix in
|
||||
memory, detach, launch. This is also the cleanest way to run the **unmodified original** as
|
||||
a control while still getting past its death site (`verification.md` §the control build rule).
|
||||
- **Do not conclude "no UI" from a blank capture taken while attached.** Check
|
||||
`mCurrentFocus`: if it is `null` under an attached session and non-null without one, you
|
||||
have this problem, not a finding about the target.
|
||||
|
||||
## Frida 17+ gotchas
|
||||
|
||||
The preferred fix for every symptom in the table above is **version alignment**. Use these only when you genuinely must run a 17+ build.
|
||||
@@ -403,6 +435,34 @@ Countermeasures, in order of least disruption:
|
||||
|
||||
**Remember:** most `frida`/`root` strings in a decompiled APK belong to third-party SDKs' own detection lists, not to the app. Verify that **app code** references them before doing anti-anti work.
|
||||
|
||||
### When the ROM hunts your instrumentation
|
||||
|
||||
Some vendor ROMs treat a device-side `frida-server` as hostile and kill it on a timer. The
|
||||
symptom is not an error message — it is a working session that disappears. A later
|
||||
`attach`/`spawn` fails with `unable to connect to remote frida-server`, or with
|
||||
`TransportError: connection closed` **during `spawn`, before `resume`**. That second shape is
|
||||
easy to misread as the target crashing during startup. It is not the target.
|
||||
|
||||
Measured on one vendor build: the server was killed repeatedly across a work session, and on
|
||||
one occasion the whole device restarted, briefly taking `system_server` with it
|
||||
(`Can't find service: activity`, `No service published for: input`). Both symptoms are
|
||||
environment, not evidence about the app.
|
||||
|
||||
- **Check the server is alive immediately before every experiment**, not once per session:
|
||||
`adb shell "su -c 'pidof <server-name>'"`. Restart it if it is gone.
|
||||
- **Distinguish the two failure shapes.** `connection closed` at `spawn` = server died or was
|
||||
killed (environment). `connection closed` just after `resume` = the target exited — *that*
|
||||
one is about the app.
|
||||
- **Rename the server binary and move it off the obvious path.** It defeats simple filename
|
||||
sweeps. It does not hide from a target that lists the directory, so do not treat the rename
|
||||
as a fix for detection — only as protection from cleanup.
|
||||
- **Budget a restart, not a re-analysis.** A killed server costs seconds; a round spent
|
||||
re-deriving the previous conclusion costs far more.
|
||||
- **If a device reboot interrupts the run, re-establish state before measuring**: server up,
|
||||
`adb forward` re-created, screen awake and unlocked, installed-build hash re-read. A
|
||||
measurement taken across a reboot is a measurement of two states
|
||||
(`long-task-discipline.md` §keep the observation window clean).
|
||||
|
||||
### When live attach cannot work at all
|
||||
|
||||
Sometimes nothing short of "do not inject at runtime" is viable — the check runs
|
||||
|
||||
@@ -32,12 +32,67 @@ cross-check with the program headers.
|
||||
|---|---|---|
|
||||
| `baksmali` / `smali` (jars) | `java -cp <jars> org.jf.baksmali.Main d <dex> -o <dir>` | round-tripping, reading a method precisely |
|
||||
| `dexlib2` | small Java program | method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
|
||||
| **`ddc`** | `ddc app.apk -c <Class>` | **fastest read of dex as Java, plus query subcommands** — see below |
|
||||
| **`droidasc`** (ASC) | `droidasc findrefs app.apk string <S>` | **the fastest whole-APK cross-reference index** — reach for this FIRST, to *locate*. See below |
|
||||
| **`ddc`** | `ddc app.apk -c <Class>` | **fastest read of dex as Java, plus query subcommands** — reach for this SECOND, to *read*. See below |
|
||||
| `apktool` | `java -jar apktool.jar d/b` | whole-app decode including resources |
|
||||
| `jadx` | `jadx --no-res -d <out> <apk>` | readable Java for orientation; **not** a source of truth (see below) |
|
||||
| `jadx` | `jadx --no-res -d <out> <apk>` | readable Java for orientation; **not** a source of truth, and **not** a recon entry point (see below) |
|
||||
| `aapt2` | `aapt2 dump badging <apk>` | manifest facts, package name, versions |
|
||||
| `zipalign`, `apksigner` | from build-tools | alignment and signing |
|
||||
|
||||
### droidasc (ASC) — ask an APK "who references this?", in one query
|
||||
|
||||
Install it in one line. No JVM, no Android SDK, no GUI, no first-run indexing:
|
||||
|
||||
```bash
|
||||
pip install droidasc # provides the `droidasc` CLI
|
||||
```
|
||||
|
||||
It treats the artifact as a **read-only database** instead of exporting a source tree: it probes the
|
||||
deflate stream in place and rebuilds only the minimal dex it needs, in memory, for the one class you
|
||||
asked about. Nothing is inflated to disk; there is no index-building phase to wait through. (Design
|
||||
and measurements are the author's; the BlackHat EU 2026 Arsenal abstract is the reference.)
|
||||
|
||||
```bash
|
||||
droidasc findrefs app.apk string "/data/app/" # every site that mentions a literal
|
||||
droidasc findrefs app.apk string com.example/sdk # a channel name, a URL fragment, a field name
|
||||
droidasc findrefs app.apk type com/foo/Bar # who references a type
|
||||
droidasc findrefs app.apk method notify --class MainActivity --fuzzy-class
|
||||
droidasc listclass app.apk --prefix com/poc # what classes exist (28210 classes: seconds)
|
||||
droidasc listclass app.apk -o classes.txt
|
||||
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
|
||||
droidasc getmanifest app.apk -o AndroidManifest.xml
|
||||
```
|
||||
|
||||
Each hit names the **class and method** it sits in. That is the whole value: it turns "which of 28,000
|
||||
classes mentions this string" from a crawl over an exported tree into one sub-second query, and it works
|
||||
straight off the APK you were handed.
|
||||
|
||||
**What it is for, and where it stops.** ASC answers *location* questions. It is not where you read a
|
||||
method body, and it does not replace the kit's dex tooling for patching. Its place is the first ten
|
||||
minutes of recon plus every later "where else is this used?" — the questions that otherwise send you
|
||||
grepping a hand-exported smali tree.
|
||||
|
||||
**Three limits, and one of them is a trap.**
|
||||
|
||||
- **A non-ASCII needle is not trustworthy through a shell.** A `findrefs string` query for a non-ASCII
|
||||
literal returned nothing under Windows/PowerShell, and **an empty result is indistinguishable from a
|
||||
broken argument encoding.** Many non-ASCII literals genuinely live in a native library rather than in
|
||||
dex, so "nothing came back" is a plausible answer — which is exactly what makes the unverified
|
||||
negative dangerous. Confirm it another way before recording it: search the dex bytes directly with a
|
||||
`\u`-escaped needle, or run a control query you know must hit (a path fragment, a package name you
|
||||
already read in the manifest) in the same session.
|
||||
- **A mangled class name may return only resources.** `listclass apk --prefix <the plugin's package>`
|
||||
can come back with nothing but `R$anim` / `R$string` / `R$style` entries, which is **not** a dead end:
|
||||
it is evidence the real class was renamed by R8. `findrefs string <the plugin's channel name>` still
|
||||
hands you the obfuscated class implementing it. This is the single situation where ASC is not an
|
||||
alternative to `ddc` but the only route — `ddc -c <FQCN>` needs the post-R8 name to start with.
|
||||
- **A hit is a *reference*, not a *call*.** `findrefs` proves a literal or type is mentioned in a method;
|
||||
whether that method runs on the path you care about is a different question, and the answer comes from
|
||||
the disassembly or from runtime (`dynamic-frida.md`).
|
||||
|
||||
The division of labour is worth stating plainly: **ASC locates, `ddc` reads.** Running a full `ddc`
|
||||
export first, then grepping it, is the slow path this tool exists to replace.
|
||||
|
||||
### ddc — dex-to-Java with query subcommands (worth adopting)
|
||||
|
||||
A single-file Rust binary, no install, no JVM. Two things make it more useful than
|
||||
@@ -73,6 +128,24 @@ aid, not compilable source** (occasional declaration/use ordering is inverted, a
|
||||
numeric resource ids appear as decimal integers); very large methods produce
|
||||
thousands of lines and are better approached via `findrefs`/`getmethod` first.
|
||||
|
||||
Two more, both measured on a 28,210-class R8-flattened Flutter APK:
|
||||
|
||||
- **`-c <FQCN>` cannot reach a renamed class, and `listclasses <pattern>` cannot recover it either.**
|
||||
Asking for the plugin's documented name returned `class not found`; asking for its package prefix
|
||||
returned only `R$anim` / `R$string` / `R$style` resources. The implementing class existed under a
|
||||
short R8 name, reachable only by string cross-reference. **Plan for `droidasc` to close this gap** —
|
||||
it is not a nicety, it is the difference between locating the class and not.
|
||||
- **A decompiled body can be semantically wrong, not merely ugly.** In one 9 KB helper class the
|
||||
compiler emitted a `return 0;` inside a method declared to return `String`, reordered field
|
||||
assignments, and produced empty `if/else` branches. None of that is safe to reason *about* control
|
||||
flow without checking smali or bytecode. Treat a surprising construct as a decompiler artifact until
|
||||
the bytecode agrees.
|
||||
|
||||
**Use `findrefs` to locate, `-c`/`getmethod` to read, and the bytecode to decide.** Reading order
|
||||
matters: a full `ddc <apk> -o out/` export is a reasonable thing to *have*, but grepping that tree is
|
||||
not a substitute for one indexed query, and it is the slower of the two by a wide margin on any APK
|
||||
worth analyzing.
|
||||
|
||||
**Classpath gotcha.** `baksmali`/`smali` need their dependency jars on the classpath together
|
||||
(`smali`, `antlr-runtime`, `stringtemplate`, `baksmali`, `dexlib2`, `util`, `jcommander`, `guava`).
|
||||
Missing one produces `ClassNotFoundException` that reads like a broken target. `scripts/smtool.py`
|
||||
@@ -113,6 +186,80 @@ completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
|
||||
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
|
||||
Bound your window with a known entry point or a known call site.
|
||||
|
||||
### Ghidra — the decompiler for a target whose decisive layer is aarch64
|
||||
|
||||
The recurring shape: the app is fine, the dex is readable, and the layer that actually decides
|
||||
behaviour is a stripped aarch64 `.so`. A disassembler gives you instructions; only a decompiler gives
|
||||
you control flow. **Hand-walking an OLLVM control-flow-flattened function with `capstone` is the single
|
||||
most expensive route available**, and it is what you fall into when no decompiler is installed.
|
||||
|
||||
Ghidra is free, runs headless, and decompiles aarch64. Setup is unzip plus a JDK (11+), which is why
|
||||
"there is no decompiler on this box" is normally an install step rather than a finding about the target
|
||||
(§Closing a capability gap).
|
||||
|
||||
```bash
|
||||
# one-time: unzip the release; point Ghidra at a JDK (JAVA_HOME or the launch script)
|
||||
# headless: import, auto-analyse, run a post-script, discard the project
|
||||
analyzeHeadless <proj_dir> <proj_name> -import target.so \
|
||||
-postScript DumpFunction.java <addr_or_symbol> -scriptPath <dir> -deleteProject
|
||||
```
|
||||
|
||||
Three caveats learned the hard way:
|
||||
|
||||
- **`analyzeHeadless` is slow and its auto-analysis is not free.** Import plus analysis of a 6 MB
|
||||
stripped library is minutes, so it is exactly the kind of run that needs an explicit timeout. Budget
|
||||
it once and reuse the project; do not re-import per question.
|
||||
- **Flattened control flow defeats the decompiler as well.** A `br x8` dispatcher with four constant
|
||||
arithmetic operations in every function header is OLLVM flattening, and the output is a state machine
|
||||
that resembles the program without being readable. **Removing the flattening is the work**
|
||||
(`code-virtualization-and-custom-linkers.md`), not a prerequisite you can skip.
|
||||
- **A decompiler cannot go where the data is not.** When a library's strings and action names are
|
||||
**encrypted in the file and exist only once the module is mapped in memory**, no static tool recovers
|
||||
them by reading harder — escalate to runtime (`dynamic-frida.md`) instead of escalating the static
|
||||
toolchain. Telling "install a decompiler" apart from "the decompiler cannot help here" is worth real
|
||||
time: the two look identical from the outside, and only the first has a static answer.
|
||||
|
||||
## Closing a capability gap — installing the tool IS the task
|
||||
|
||||
A missing tool for the layer you must work in is not a constraint to design around. It is the next
|
||||
step. The measured cost of installation is almost always smaller than the cost of the workaround, and
|
||||
the workaround is what produces "we could not determine X" reports on targets where X was decidable.
|
||||
|
||||
**The decision is not "can I do without it" — it is "how long does installing it take".** Three real
|
||||
numbers, from one Windows box working an R8-flattened Flutter target:
|
||||
|
||||
| Gap | Install cost | What the workaround would have cost |
|
||||
|---|---|---|
|
||||
| No whole-APK cross-reference index | `pip install droidasc` — one command, seconds, no JVM, no SDK | hours of `grep` over a hand-exported smali tree, **per question asked** |
|
||||
| No arm64 decompiler | Ghidra — unzip plus the JDK already present; minutes | a manual ELF/`capstone` walk over an OLLVM control-flow-flattened 6 MB library, per function |
|
||||
| No Dart AOT snapshot front end | `aotopsy` — unzip and run (pure Go, no toolchain); or `blutter`, measured **≈78 s** end to end | "pool offsets can never be mapped", which turned out to be false |
|
||||
|
||||
The third row is the instructive one. The documented cost ("tens of minutes for the first build") was
|
||||
about **30× pessimistic**, and the documented toolchain requirement was overstated. **A tool's stated
|
||||
prerequisites are a claim, not a measurement**, and the cost of checking is one attempt.
|
||||
|
||||
Rules that follow:
|
||||
|
||||
1. **Write the capability down before spending against it.** "arm64 decompilation — absent — install
|
||||
Ghidra" is a task item. "arm64 decompilation — absent" with nothing after it is how a route gets
|
||||
written off for the wrong reason.
|
||||
2. **Check off-PATH before declaring anything absent** (§"not on PATH" is not "not installed").
|
||||
`APKREV_TOOLS` exists so a project-local tools directory or an SDK folder resolves.
|
||||
3. **Install, do not substitute.** A weaker tool's output presented as equivalent to the stronger
|
||||
tool's output is a wrong conclusion with a clean audit trail — worse than an admitted gap.
|
||||
4. **Report the gap only when it is real.** "No arm64 decompiler exists here and it cannot be installed
|
||||
because `<reason>`" is a valid finding. "I used `readelf` instead" is a different statement, and it
|
||||
is not a finding about the target.
|
||||
5. **"Paid" and "GUI-only" are questions, not walls.** IDA has a headless MCP path; Ghidra has
|
||||
`analyzeHeadless`; both are set-up steps. Ask a human only after that route is genuinely
|
||||
unavailable.
|
||||
6. **One real disqualifier.** A decompiler cannot go where the data is not: if a target's strings and
|
||||
action names are **encrypted in the file and only exist in memory**, then no static tool recovers
|
||||
them by reading harder. Recognise that shape early and move the effort to runtime
|
||||
(`dynamic-frida.md`) instead of escalating the static tools — that distinction is the difference
|
||||
between "install a decompiler" and "the decompiler cannot help here", and they look identical from
|
||||
the outside.
|
||||
|
||||
## MCP tool servers — an external dependency, not a tool on the shelf
|
||||
|
||||
An MCP server is neither a CLI tool nor one of the scripts here. It is a **separate installation plus
|
||||
@@ -178,8 +325,31 @@ Two consequences worth carrying into the plan:
|
||||
| `frida` (host) + `frida-server` (device) | `frida -U -f <pkg> -l script.js` | **host and device versions must match** — skew produces errors that look like a broken target (`pitfalls.md` P15) |
|
||||
| `objection` | `objection -g <pkg> explore` | quick Java-layer poking on top of Frida |
|
||||
| `mitmproxy` | `mitmproxy --mode regular` | request/response inspection; a device proxy is usually faster to set up than a transparent one |
|
||||
| `HaE` (Burp extension) | load into Burp, then read its rule set | **traffic *triage*, not traffic capture.** See below |
|
||||
| `adb` | everything | the primary device interface |
|
||||
|
||||
**HaE is a reader, not a capture tool, and it is not always the right next step.** HaE (Highlighter and
|
||||
Extractor, `overspace-labs/HaENet`) is a Burp Suite extension that tags and extracts fields from HTTP
|
||||
messages — tokens, IDs, secrets, endpoints, fingerprints — so that a large session becomes readable
|
||||
without hand-scanning every request. It is genuinely useful when the bottleneck is *"there is traffic
|
||||
and I cannot see the parts that matter."*
|
||||
|
||||
It is the wrong tool when the bottleneck is one of these three, and reaching for it there costs rounds:
|
||||
|
||||
- **The requests never leave the client.** No capture tool can display a request that was never built
|
||||
(`code-virtualization-and-custom-linkers.md` §what the native check actually reads). **Establish
|
||||
whether traffic exists before buying tooling to read it** — a DNS/`connect`-level probe answers that
|
||||
in a single run, and on the measured target it was the observation that ended the investigation
|
||||
branch, not a capture.
|
||||
- **The body is encrypted by the app**, so the capture is ciphertext (`server-api.md`).
|
||||
- **Certificate pinning defeats the proxy**, in which case the empty capture is itself the finding
|
||||
(`tls-and-cert.md`).
|
||||
|
||||
Ordering that follows: **first prove there is readable traffic, then choose the reader.** `mitmproxy`
|
||||
captures; HaE triages; neither substitutes for the DNS/connect check that decides whether there is
|
||||
anything to triage. Treat "install a Burp plugin" as a step that must be justified by an existing
|
||||
capture, not as recon.
|
||||
|
||||
Version alignment for Frida is a **hard gate**, not a nicety. Check both sides before writing a
|
||||
script, and check the device architecture — the server binary is per-ABI.
|
||||
|
||||
@@ -196,6 +366,40 @@ script, and check the device architecture — the server binary is per-ABI.
|
||||
for a different engine version produces output that is subtly wrong rather than obviously broken.
|
||||
Pin the version first (`dart-aot.md` §1) and do not "try it and see".
|
||||
|
||||
## Using the kit's scripts instead of writing your own
|
||||
|
||||
The scripts exist so that the *expensive, generic* parts of this work — decoding a dex instruction at an
|
||||
exact offset, recomputing a dex header in the right order, writing a 4-byte-aligned archive, resolving a
|
||||
PLT stub, bursting screenshots with evidence attached — are not re-derived per task.
|
||||
|
||||
Re-deriving them is not neutral. A hand-rolled instruction decode that silently loses sync, or a
|
||||
hand-rolled repack that quietly drops alignment, produces **a confident wrong answer**, which is the
|
||||
exact failure class this skill exists to prevent. The cost shows up later, as a patch that "had no
|
||||
effect" or an install refused with a bare numeric code.
|
||||
|
||||
**The rule: before writing a script, check whether one here already does it.** The index is in
|
||||
`SKILL.md`; the entry points that matter most are `doctor.py` (what can run here),
|
||||
`dex_find_insn.py` (get an exact offset instead of guessing one), `dex_patch_bytes.py` (apply and prove
|
||||
an equal-length patch), `apk_diff.py` (prove your own build was surgical), `repack.py` (aligned rebuild)
|
||||
and `snap.py` / `coldstart.py` (look at the screen with the evidence attached).
|
||||
|
||||
**When a script here is genuinely wrong, that is a finding to fix and report — not a reason to quietly
|
||||
route around it.** Two live examples of the difference:
|
||||
|
||||
- A decoder utility in this kit carried an **opcode name/width table misaligned by one entry across
|
||||
`0x16`–`0x2C`** (23 rows). Everything decoding through that range **silently lost sync** and produced
|
||||
offsets that were plausible and wrong — no exception, no warning. It was found by comparing against an
|
||||
independent decoder and noticing a disagreement. **If two tools disagree, the disagreement is the
|
||||
product**; it is the cheapest bug signal in this domain and the easiest to throw away by picking the
|
||||
answer you preferred.
|
||||
- Conversely, a *tool's* negative result is not a finding about the target (§the general trap below).
|
||||
Establish that the tool could have found the thing before recording that the thing is absent —
|
||||
especially for string searches, where an empty result is exactly what an unfound string looks like.
|
||||
|
||||
**Corollary for reporting.** "The kit's script X reported Y, and I cross-checked it against Z" is a
|
||||
conclusion. "I wrote my own parser because the script was awkward" is a gap you introduced, and it
|
||||
should be reported as one.
|
||||
|
||||
## "Not on PATH" is not "not installed"
|
||||
|
||||
`doctor.py` and `preflight.py` report whether each tool resolves on `PATH`. That
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Minimal Frida probe: neutralise ONE native death site in memory, then report PATCHED.
|
||||
//
|
||||
// Intended for use with spawn_patch_detach.py. The write lands while attached; the
|
||||
// driver then detaches so the app runs with no instrumentation present and its UI
|
||||
// actually renders. Memory.patchCode writes survive detach; Interceptor hooks do not.
|
||||
//
|
||||
// Configure the four constants below from your own analysis. There is nothing
|
||||
// target-specific here on purpose: the shape to look for is a short "recover the frame
|
||||
// and return" epilogue that replaces a terminate call. Never substitute a NOP for a
|
||||
// call that has live code after it -- the fall-through is the bug you are creating.
|
||||
// See references/native-tamper-and-suicide.md.
|
||||
'use strict';
|
||||
|
||||
// --- configure -----------------------------------------------------------------
|
||||
var MODULE_NAME = 'libtarget.so'; // the module that holds the site
|
||||
var FILE_OFFSET = 0x0; // byte offset of the sequence to replace
|
||||
var PATCH_BYTES = []; // equal-length replacement bytes
|
||||
var PRE_EXISTING = ''; // hex of PATCH_BYTES, space separated, for the idempotence check
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
var T0 = Date.now();
|
||||
function el() { return ((Date.now() - T0) / 1000).toFixed(2) + 's'; }
|
||||
|
||||
function hx(p, n) {
|
||||
try {
|
||||
return Array.from(new Uint8Array(p.readByteArray(n))).map(function (b) {
|
||||
return ('0' + b.toString(16)).slice(-2);
|
||||
}).join(' ');
|
||||
} catch (e) { return '<unreadable>'; }
|
||||
}
|
||||
|
||||
if (!PATCH_BYTES.length) {
|
||||
console.log('[!] PATCH_BYTES is empty -- configure this probe before using it');
|
||||
send('PATCHFAIL');
|
||||
} else {
|
||||
var timer = setInterval(function () {
|
||||
var m = null;
|
||||
try { m = Process.findModuleByName(MODULE_NAME); } catch (e) { m = null; }
|
||||
if (!m) return;
|
||||
clearInterval(timer);
|
||||
|
||||
var at = m.base.add(FILE_OFFSET);
|
||||
var before = hx(at, PATCH_BYTES.length);
|
||||
console.log('[' + el() + '][i] ' + MODULE_NAME + ' base=' + m.base +
|
||||
' bytes@0x' + FILE_OFFSET.toString(16) + ' = ' + before);
|
||||
|
||||
if (PRE_EXISTING && before === PRE_EXISTING) {
|
||||
console.log('[' + el() + '][i] already carries the patch');
|
||||
send('PATCHED');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Memory.patchCode(at, PATCH_BYTES.length, function (code) {
|
||||
code.writeByteArray(PATCH_BYTES);
|
||||
});
|
||||
console.log('[' + el() + '][PATCH] now = ' + hx(at, PATCH_BYTES.length));
|
||||
send('PATCHED');
|
||||
} catch (e) {
|
||||
console.log('[' + el() + '][!] patch failed: ' + e);
|
||||
send('PATCHFAIL');
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Keep the message channel alive so the driver can see the probe is loaded. Cheap, and
|
||||
// it gives a heartbeat you can read while waiting for the site to appear.
|
||||
setInterval(function () { console.log('[' + el() + '][alive]'); }, 10000);
|
||||
|
||||
console.log('=== patch-only probe armed ===');
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Spawn a target under a Frida probe, DETACH, and only then drive its UI.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Under spawn mode (`frida -f`, or `device.spawn()` + `resume()`) the target is paused
|
||||
while the script loads and resumed by us. On several real targets the Activity stack
|
||||
never comes up in that state: `dumpsys window | grep mCurrentFocus` stays `null`,
|
||||
screenshots come back blank, and the app looks broken when it is merely unrendered.
|
||||
The same app launched normally, after Frida has left, renders correctly.
|
||||
|
||||
The fix is an ordering rather than a different hook:
|
||||
|
||||
1. spawn and attach,
|
||||
2. let the probe write its patch into memory (``Memory.patchCode``),
|
||||
3. **detach** -- the write is a plain memory write and survives; ``Interceptor``
|
||||
hooks go away with the session, which is usually what you want for an
|
||||
observation run because it removes the instrumentation from the picture,
|
||||
4. start the Activity normally and capture.
|
||||
|
||||
What the probe may contain
|
||||
--------------------------
|
||||
Anything self-contained. A minimal probe that only neutralises a death site and
|
||||
sends ``PATCHED`` when done is ``hook_patch_only.js`` beside this script; a probe
|
||||
that also blocks a component or traces calls works too, but remember that its
|
||||
``Interceptor`` hooks stop at detach while its memory writes do not.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
A reachable frida-server (``frida-server -l 127.0.0.1:27099`` plus
|
||||
``adb forward tcp:27099 tcp:27099``), and ``frida`` importable from the same
|
||||
interpreter. ``adb`` is used only for the device-side steps and for screenshots.
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import frida
|
||||
except ImportError: # keep --help working on a machine without frida
|
||||
frida = None
|
||||
|
||||
DEFAULT_ADB = "adb"
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="spawn_patch_detach.py",
|
||||
description="Spawn under a Frida probe, detach, then launch and capture the "
|
||||
"app so the UI actually renders.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Example:\n"
|
||||
" spawn_patch_detach.py --package com.example.app --js probe.js \\\n"
|
||||
" --activity com.example.app/.MainActivity --captures 3\n")
|
||||
p.add_argument("--package", required=True, help="target package name")
|
||||
p.add_argument("--js", required=True, help="Frida probe script to load before resume")
|
||||
p.add_argument("--activity", default=None,
|
||||
help="component to start after detach, as pkg/.Activity "
|
||||
"(default: let the package start its own launcher)")
|
||||
p.add_argument("--frida-host", default="127.0.0.1:27099",
|
||||
help="host:port of the device frida-server (default %(default)s)")
|
||||
p.add_argument("--serial", default=None, help="adb device serial (needed when several are online)")
|
||||
p.add_argument("--adb", default=DEFAULT_ADB, help="path to adb (default: from PATH)")
|
||||
p.add_argument("--wait-patched", type=float, default=15.0,
|
||||
help="seconds to wait for the probe to report PATCHED (default %(default)s)")
|
||||
p.add_argument("--detach-settle", type=float, default=3.0,
|
||||
help="pause after detach, before launching (default %(default)s)")
|
||||
p.add_argument("--captures", type=int, default=3, help="number of screenshots (default %(default)s)")
|
||||
p.add_argument("--interval", type=float, default=18.0,
|
||||
help="seconds between captures (default %(default)s)")
|
||||
p.add_argument("--out-prefix", default="capture",
|
||||
help="local filename prefix for screenshots (default %(default)s)")
|
||||
p.add_argument("--work-dir", default="/data/local/tmp",
|
||||
help="device-side scratch directory (default %(default)s)")
|
||||
p.add_argument("--no-screencap", action="store_true", help="skip screenshots")
|
||||
return p
|
||||
|
||||
|
||||
def adb_run(args, adb, serial, timeout=200):
|
||||
cmd = [adb] + (["-s", serial] if serial else []) + list(args)
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def root_shell(cmd, adb, serial, timeout=200):
|
||||
return adb_run(["shell", "su -c '%s'" % cmd], adb, serial, timeout)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
if frida is None:
|
||||
print("[!] the 'frida' python package is not importable; install it with "
|
||||
"'pip install frida' (host side)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
patched = {"done": False}
|
||||
|
||||
def on_message(msg, data):
|
||||
if msg.get("type") == "send":
|
||||
payload = msg.get("payload")
|
||||
print("[MSG] %s" % payload, flush=True)
|
||||
if payload == "PATCHED":
|
||||
patched["done"] = True
|
||||
elif msg.get("type") == "error":
|
||||
print("[ERR] %s" % (msg.get("stack") or msg.get("description")), flush=True)
|
||||
else:
|
||||
print("[%s] %s" % (msg.get("type", "?").upper(), msg.get("payload")), flush=True)
|
||||
|
||||
adb_run(["shell", "su -c 'am force-stop %s'" % args.package], args.adb, args.serial)
|
||||
time.sleep(1.5)
|
||||
|
||||
dev = frida.get_device_manager().add_remote_device(args.frida_host)
|
||||
pid = dev.spawn([args.package])
|
||||
print("[i] spawned pid=%d" % pid, flush=True)
|
||||
|
||||
session = dev.attach(pid)
|
||||
script = session.create_script(open(args.js, encoding="utf-8").read())
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
dev.resume(pid)
|
||||
|
||||
deadline = time.time() + args.wait_patched
|
||||
while time.time() < deadline and not patched["done"]:
|
||||
time.sleep(0.2)
|
||||
print("[i] patched=%s" % patched["done"], flush=True)
|
||||
|
||||
time.sleep(1.0)
|
||||
session.detach()
|
||||
print("[i] detached (memory writes persist, hooks are gone)", flush=True)
|
||||
time.sleep(args.detach_settle)
|
||||
|
||||
if args.activity:
|
||||
out = adb_run(["shell", "am start -n %s" % args.activity], args.adb, args.serial)
|
||||
print("[i] am start: %s" % ((out.stdout or out.stderr or "").strip()), flush=True)
|
||||
|
||||
for i in range(max(1, args.captures)):
|
||||
time.sleep(args.interval)
|
||||
print("\n=== capture %d ===" % i, flush=True)
|
||||
ps = adb_run(["shell", "ps -A -o PID,ETIME,ARGS | grep -i %s" % args.package.split(".")[-1]],
|
||||
args.adb, args.serial)
|
||||
print((ps.stdout or "").strip(), flush=True)
|
||||
focus = adb_run(["shell", "dumpsys window | grep mCurrentFocus"], args.adb, args.serial)
|
||||
print((focus.stdout or "").strip(), flush=True)
|
||||
if not args.no_screencap:
|
||||
remote = "%s/%s_%d.png" % (args.work_dir, args.out_prefix, i)
|
||||
adb_run(["shell", "screencap -p %s" % remote], args.adb, args.serial)
|
||||
local = "%s_%d.png" % (args.out_prefix, i)
|
||||
adb_run(["pull", remote, local], args.adb, args.serial)
|
||||
print("[i] screenshot -> %s" % local, flush=True)
|
||||
|
||||
print("\n[i] inspect the images; do not conclude from logcat alone.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user