The opcode table was shifted by one from 0x16 to 0x2C, and nine widths were wrong with it: 0x1C was named monitor-enter and given one unit where the specification has const-class at two; 0x17 was const-wide/16 at 2 where the spec has const-wide/32 at 3; 0x18 was 3 where the spec has const-wide at 5; 0x26 was throw at 1 where the spec has fill-array-data at 3. Each wrong entry shifts every instruction after it in the same method, and the decode keeps producing plausible instructions, so nothing looks broken until an operand index walks off its table.
Measured on a real 8.9 MB dex before the fix: 65 of 50,791 method bodies did not decode to their declared boundary, and 32 carried an operand index outside its table. After: 50,791 of 50,791 land exactly on insns_off + insns_size*2, with 0 illegal indices. Cross-checked against androguard 4.1.4, which shares no code with this project: identical per-instruction width sequences for all 50,791 bodies.
Names and widths now come from the format specification table, and width is a lookup (OP_UNITS) instead of a chain of range tests -- the shape that let a one-observation-at-a-time repair look complete. The goto family moves with it in branch_target() and describe(), and in dex_find_insn.py kind map, where a search for a branch was matching throw and goto/32.
The decoder could lose sync mid-method and keep producing plausible instructions,
only shifted -- so every offset derived after the fault point was silently wrong.
Found by differential testing against two independent decoders (dexlib2 and
androguard), not by reading the table and trusting it.
Fixed, each verified by asserting the walk ends exactly on
insns_off + insns_size*2 across three real dex images:
- Payload widths. The field after `00 <ident>` is a different quantity per payload
kind: packed-switch has size(uint16)+first_key(int32), sparse-switch has
size(uint16)+size*(key,target), fill-array-data has element_width(uint16)+
size(uint32). All three were read as `1 + <one uint16>`, so a 30-element
four-byte-wide array payload counted as 5 units instead of 64.
- 0x22 is 22c (2 units); 0x23/0x24 are 35c/3rc (3 units). They sat in each other's
groups -- the inline comment already said `22c new-array` on a group returning 3.
- 0x20 was grouped as 2 units.
Result: methods that fail to land on the exact end went from 137/113/70 to 44/51/30
across the three images, a 55-68% reduction.
Two further slot-wise edits (shifting the goto family by one width) were made during
the investigation and REVERTED: measurement showed alignment got worse, because the
name table's misalignment had made the wrong slot look authoritative.
Remaining defect, recorded in-file rather than guessed at: OP_NAMES is misaligned with
the opcode slots from roughly 0x1a onward (the slot named `array-length` decodes as
`instance-of`, `goto/16` decodes as `goto`). Widths key on the opcode value, not the
name, so this does not corrupt a decode by itself -- but the names are unsafe as a
lookup key and they misled a reader here. Fixing it means rebuilding the table from an
independent decoder in one pass, not editing it slot by slot.
check_repo.py: 0 problems. check_refs.py: 182 reachable, 0 dangling.
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.
Ran the scripts against a real Flutter AOT / multi-dex / 36-.so APK (Dart 3.6.0,
com.tingfeng.tool) and recorded what each one actually did. Two of them were wrong
in the direction that reads as a target finding, one Coverage claim overstated what
ships, and four documentation statements were false.
Fixed:
dart_pprefs.py decoded only the general-purpose register file, so `ldr dN,[x27,#imm]`
(0xFD400000) was never matched and every double constant read from the object pool
was dropped -- 302 pool offsets and 1802 sites on this sample, one of them with 150
references that would have been reported as zero. The documented advice is to count
references before editing a shared constant, so a one-sided quiet gap here produces
exactly the wrong decision. Now covers both register files; a purpose-written
arbiter agrees with it to 0/0 across .text, against 0/302 before.
Documented but deliberately not fixed, because a fix would not have been run:
elf_plt.py misses the last PLT stub slot (`off < fsz - 16`), which on libloader.so
turned a real, called import (dl_iterate_phdr) into "not a resolved stub".
find_refs.py advertises dex-directory input but iter_files() accepts only .smali,
so it answers "no references" with exit 0 for input it never read.
so_constpatch.py's rebuild drops the zip `extra` field and destroys 4 KB alignment.
Each has its root cause and fix sketch recorded.
Documentation corrections in dart-aot.md:
blutter's asm/ does contain full instruction listings with pool annotations -- the
file said to ignore it, and it is the richest artifact in the chain.
The first build costs ~78 s, not "tens of minutes"; MSVC 19.34 suffices, not VS 2022.
The file-PP offset mapping is not recoverable from the binary (4241 shared strings,
4237 distinct deltas), so a snapshot-decoding front end is a dependency, now stated.
Documented aotopsy as the no-toolchain route, and the trap that its version label is
a structural profile name (it says 3.6.2 for a 3.6.0 snapshot).
Coverage now separates what was measured from what is inferred, names blutter/aotopsy
as a prerequisite instead of implying the pool decodes itself, lists the scripts this
pass did not run, and says plainly that the packer / VMP / integrity-check scenarios
were not exercised at all.
Added hand-off points for JNI, dex-observed hardening, and the existing native boundary
documents, plus docs/verification-jiongnew/ -- the evidence record, including the four
gate outputs verbatim. No second skill: the gap is one front-end dependency, not a
subject area, and relocating Flutter docs would not have supplied it.
The skill lived at the repository root, which the CLI reads as a single-skill repository and which makes a second skill impossible to add. SKILL.md, references/ and scripts/ move under skills/apk-reverse/ with git mv, so history follows and no in-skill reference needs changing (they are relative to SKILL.md).
check_repo.py now discovers every skills/*/SKILL.md, validates frontmatter by parsing it instead of pattern-matching it, and requires the README to name skills through explicit skills/<name>/... paths.
check_refs.py is new: it audits file.md section cross-references against the real headings in the target file. It found six dangling ones, all fixed here.
SKILL.md: the description is now quoted. Unquoted, it contained a colon-space, which is invalid YAML, so the CLI skipped the skill and reported No skills found. Adds a Coverage section stating what is out of scope and what to do when nothing matches. toolchain.md: IDA is not GUI-only (idalib-mcp is a headless MCP server), plus a section on MCP servers as external dependencies and the deobfuscate-before-LLM boundary. README.md: install via npx skills add, the new layout, coverage and maintenance tools.
Verified: check_repo.py and check_refs.py both clean, build_scripts.py audit 0 findings, npx skills add . --list reports apk-reverse, and an install into a scratch directory lands SKILL.md plus 28 references and 38 scripts.