mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-16 06:14:23 +08:00
find_refs: read .dex directly, and stop reporting unreadable input as zero refs
The docstring promised "a smali tree or a directory of dex files" and delivered neither: iter_files() yielded only .smali, a direct .dex was read as UTF-8 text and regex-matched against smali syntax, and a dex-only directory yielded nothing at all. A real dex, a dex directory and a typo path all printed [total refs] 0 and a note blaming the needle, so the one tool the skill recommends for judging blast radius before patching returned a confident false zero. A .dex is now decoded through dexutil (no baksmali needed), a directory is walked for .smali and .dex alike, an archive is opened for its classes*.dex, input that cannot be read is refused with exit 2 and a reason, and every run prints [scanned] N file(s) so the two cases can never print identically again. Also corrects the recorded exit code for this defect: it was 1, not 0, which moves it from "no signal at all" to "the signal was there and the message contradicted it". Verified against a fixture whose call graph is known by construction (docs/tool-verification/fixtures/make_fixture_dex.py, committed as the evidence): the .dex and the equivalent smali tree both return 3/2/2, matching what was written. And against the real target: Ljava/lang/String;->length gives 356 refs in 2.3 s where it previously gave 0; a five-dex directory gives 9,472; a typo path exits 2. Docs updated with it: F9 records the opcode-table finding the fix exposed, F2/F3 and TOOL-VERDICTS.md are corrected, and dex-patching.md now tells the reader to look at the [scanned] line before the count.
This commit is contained in:
@@ -48,12 +48,18 @@ warning earns its place.
|
||||
the script's stated purpose — deciding whether a hardening symbol exists in a library — the wrong
|
||||
answer is indistinguishable from the right one.
|
||||
|
||||
**`find_refs.py` returns a clean empty result for input it cannot read.** Its docstring advertises
|
||||
"a smali tree or a directory of dex files"; `iter_files()` accepts only `.smali`. Given the dex
|
||||
directory it printed `[total refs] 0`, exited **0**, and emitted no warning. This is worse than a
|
||||
**`find_refs.py` returned a clean empty result for input it could not read.** Its docstring advertised
|
||||
"a smali tree or a directory of dex files"; `iter_files()` accepted only `.smali`. Given the dex
|
||||
directory it printed `[total refs] 0`, exited **1**, and emitted no warning. This is worse than a
|
||||
crash: it is the exact input to "nothing references this, safe to patch", produced by the very tool
|
||||
recommended for judging blast radius before patching.
|
||||
|
||||
Re-measured later: the exit code recorded in this section was **0** and it is **1** — the `total == 0`
|
||||
path has always returned 1. The correction matters because it moves the defect from "no signal at
|
||||
all" to "the signal was there and the text contradicted it": the note diagnosed the *needle* and never
|
||||
named the *input form*, so a caller reading the message was still sent the wrong way. Fixed and
|
||||
re-verified; see F9 for what measuring it turned up underneath.
|
||||
|
||||
Both, plus `dart_pprefs.py`'s silently dropped double constants, share one property: **the failure is
|
||||
one-sided and quiet.** None of the three reports reduced confidence in its own output.
|
||||
|
||||
@@ -63,7 +69,7 @@ one-sided and quiet.** None of the three reports reduced confidence in its own o
|
||||
|
||||
| # | Script | Defect | Consequence | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `find_refs.py` | `iter_files()` matches only `.smali`; dex dirs yield 0 results, no error, exit 0 | silent false "no references" on the tool whose job is blast-radius | **cause identified, not fixed** |
|
||||
| 1 | `find_refs.py` | `iter_files()` matched only `.smali`; a dex dir yielded 0 results and a direct dex was regex-scanned as text | silent false "no references" on the tool whose job is blast-radius | **fixed, re-verified against a fixture and the real target** |
|
||||
| 2 | `elf_plt.py` | loop bound `off < fsz - 16` misses the final stub slot | false negative for one real symbol per library, conditionally | **fixed on a copy, re-verified** |
|
||||
| 3 | `dart_pprefs.py` | only the GP register file is decoded; `ldr dD,[x27,#imm]` (`0xFD400000`) and all double constants lost | under-reports pool references (302 displacements / 1,802 sites here), which breaks the "count refs to judge importance" advice in `dart-aot.md` §9/§10 — one site went `[0 refs]` → `[79 refs]` | **fixed on a copy; agrees with capstone 0/0** |
|
||||
| 4 | `so_constpatch.py` | rebuild drops the zip `extra` field, destroying 4 KB alignment | for `extractNativeLibs=false` APKs the output will not load; masked here because this target is DEFLATED + `extractNativeLibs=true` | **cause identified, proven by fixture; not fixed** |
|
||||
@@ -177,3 +183,66 @@ in `classes2.dex` and `classes3.dex` but the classes that declare it are third-p
|
||||
did not trace the call chain. Whether this plumbing is a benign logging SDK, part of the Flutter
|
||||
plugin bootstrap, or a light-weight loader contributing to the abort is **unverified**. Stating it
|
||||
as established would be exactly the kind of overreach this report is trying to document.
|
||||
|
||||
---
|
||||
|
||||
## F9 — Fixing F2 exposed a deeper one: the dex opcode table was shifted, and nine widths were wrong
|
||||
|
||||
Fixing `find_refs.py`'s dex path means depending on `dexutil.decode()`'s boundaries being right, so
|
||||
that was measured rather than assumed. It was not right: on a real 8.9 MB dex, **65 of 50,791 method
|
||||
bodies did not decode to their declared boundary**, and **32 carried an operand index outside the
|
||||
table it indexes**.
|
||||
|
||||
The cause is a run of `OP_NAMES` and `insn_units` shifted by one from `0x16` to `0x2C`:
|
||||
|
||||
| opcode | specification says | the repository had | width: spec vs repo |
|
||||
|---|---|---|---|
|
||||
| `0x17` | `const-wide/32` (31i) | `const-wide/16` | **3 vs 2** |
|
||||
| `0x18` | `const-wide` (51l) | `const-wide/32` | **5 vs 3** |
|
||||
| `0x1B` | `const-string/jumbo` (31c) | `const-class` | **3 vs 2** |
|
||||
| `0x1C` | `const-class` (21c) | `monitor-enter` | **2 vs 1** |
|
||||
| `0x1E` | `monitor-exit` (11x) | `check-cast` | **1 vs 2** |
|
||||
| `0x20` | `instance-of` (22c) | `array-length` | **2 vs 1** |
|
||||
| `0x21` | `array-length` (12x) | `new-instance` | **1 vs 2** |
|
||||
| `0x23` | `new-array` (22c) | `filled-new-array` | **2 vs 3** |
|
||||
| `0x26` | `fill-array-data` (31t) | `throw` | **3 vs 1** |
|
||||
| `0x2C` | `sparse-switch` (31t) | *(absent)* | **3 vs 2** |
|
||||
|
||||
Plus `0xFA`-`0xFF` (`invoke-polymorphic`, `invoke-custom`, `const-method-handle`,
|
||||
`const-method-type`), absent from the width logic and defaulting to one unit each where the
|
||||
specification has 4, 4, 3, 3, 2, 2.
|
||||
|
||||
The `goto` family went with it: `branch_target()` computed a branch target for `0x27`, which is
|
||||
`throw`, and read the offset width one opcode early for the rest. `dex_find_insn.py`'s kind map
|
||||
carried the same shift in its `goto` and `switch` entries, so a semantic search for a branch matched
|
||||
the wrong instructions.
|
||||
|
||||
That this survived an earlier "fix instruction widths" pass is not surprising in hindsight:
|
||||
`insn_units` ended with `if op == 0x29: # measured: this slot is goto/16 (20t)` — the shape of a
|
||||
repair made one observation at a time, where the width of `0x29` was corrected without checking the
|
||||
opcode's *name* against a specification. Name and width were wrong together and consistently, which
|
||||
is exactly why the decode still looked plausible.
|
||||
|
||||
**How it was settled.** Not by reading the table again, but by two measurements that do not depend on
|
||||
this project's code:
|
||||
|
||||
1. **Self-consistency at scale.** A real dex is a stream ART can execute, so a correct decoder must
|
||||
land every method body exactly on `insns_off + insns_size*2`. 65 did not. After the fix,
|
||||
**50,791 of 50,791 do, with 0 illegal operand indices.**
|
||||
2. **A second decoder.** `androguard` 4.1.4 decoded the same dex independently. Aligned by method
|
||||
order, the per-instruction width sequences are **identical for all 50,791 bodies — 0 differing.**
|
||||
|
||||
Names and widths now come from the format specification's table, and width is a lookup (`OP_UNITS`)
|
||||
rather than a chain of range tests, which is the shape that made this class of error possible.
|
||||
|
||||
**Two things this pass did not fix, recorded so they are not mistaken for clean:**
|
||||
|
||||
- **`dexutil.string()` decodes MUTF-8 with `errors="replace"`.** Identifiers containing non-BMP
|
||||
characters decode to U+FFFD — this sample has them, and one crashed a `print` with a
|
||||
`UnicodeEncodeError` on a non-BMP codepoint. A class name read from the dex may therefore not
|
||||
compare equal to the same name read from another tool. It does not affect instruction decoding or a
|
||||
descriptor-based needle, but it is why the cross-check above had to align by order rather than by
|
||||
key.
|
||||
- `OP_NAMES` had no entry for roughly 150 valid opcodes (`aget`/`aput`, the `cmp` family, the
|
||||
`neg`/`not`/conversion unops, the arithmetic and `lit8`/`lit16` families). They decoded correctly by
|
||||
fallback but printed as `op_XX`. The regenerated table covers 224 opcodes.
|
||||
|
||||
@@ -181,3 +181,55 @@ full check runs; and running `check_repo.py` with `PYTHONIOENCODING=utf-8` set p
|
||||
failure (exit 1, one script reported as crashed) because its `subprocess.run(..., text=True)` does
|
||||
not pin an encoding while the child emits UTF-8 — the parent then decodes as cp936 and the streams
|
||||
come back empty.
|
||||
|
||||
---
|
||||
|
||||
## A later pass: fixing the `find_refs.py` defect, and what measuring it exposed
|
||||
|
||||
The three defects recorded above as "documented but not fixed" were left alone for a stated reason:
|
||||
the repository ships nothing unrun. One of them — `find_refs.py` and `.dex` input — was reported again
|
||||
from outside with a clean reproduction, and that reproduction is correct. It is now fixed, together
|
||||
with a deeper defect the fix exposed.
|
||||
|
||||
### `find_refs.py`
|
||||
|
||||
Accepts a `.dex`, a directory of `.smali` and/or `.dex`, an `.apk`, or a smali tree. Dex input is
|
||||
decoded through `dexutil`, so baksmali is not required — which matters because the machine where the
|
||||
defect was found had no baksmali, jadx or apktool, and the tool was therefore completely unusable for
|
||||
that target. Input it cannot read is **refused with exit 2 and a reason**, and every run prints
|
||||
`[scanned] N file(s)` before the count, so "nothing was readable" can no longer print the same line as
|
||||
"read N files and found nothing".
|
||||
|
||||
### `dexutil.py` — the part that could not be skipped
|
||||
|
||||
`find_refs.py`'s dex path depends on `decode()`'s instruction boundaries, so those were measured
|
||||
instead of assumed. They were wrong: the opcode table was shifted from `0x16` to `0x2C` with nine
|
||||
widths wrong (F9), and 65 of 50,791 method bodies on a real dex did not decode to their declared
|
||||
boundary.
|
||||
|
||||
Fixing `find_refs` while leaving that in place would have produced the same silent false zero the
|
||||
defect was reported for, one layer down. So names and widths were regenerated from the format
|
||||
specification's table, width became a lookup (`OP_UNITS`) rather than a chain of range tests, and the
|
||||
`goto` family was corrected in `branch_target()` and in `dex_find_insn.py`'s kind map.
|
||||
|
||||
### Verification
|
||||
|
||||
- `[scanned]`/`[total refs]` from a `.dex` and from the equivalent smali tree **agree with each other
|
||||
and with the count written into the fixture by construction** (3 / 2 / 2).
|
||||
- **50,791 of 50,791** method bodies decode to their declared boundary (65 did not before), and **0**
|
||||
operand indices fall outside their table (32 did before).
|
||||
- **androguard 4.1.4**, a decoder sharing no code with this project, produces **identical
|
||||
per-instruction width sequences for all 50,791 bodies**.
|
||||
- Real target: `Ljava/lang/String;->length` → 356 references in 2.3 s; a five-dex directory → 9,472
|
||||
across 2 files; a typo'd path → exit 2.
|
||||
- The four repo gates still pass: `check_repo.py` 0 problems, `check_refs.py` 0 dangling,
|
||||
`build_scripts.py` 0 findings, `npx skills add … --list` lists the skill.
|
||||
|
||||
### Why the fixture is in the tree
|
||||
|
||||
`docs/tool-verification/fixtures/make_fixture_dex.py` is committed because it is the evidence for the
|
||||
counts above. Without it, "3 references" is a claim a later reader cannot check — which is exactly the
|
||||
evidentiary gap F5 describes, and the repository's rule that nothing ships unrun applies to claims as
|
||||
much as to code. It is dependency-free (stdlib only) so it reproduces on a machine with no Android
|
||||
toolchain, which is the machine the defect was found on. It carries no target data: the dex is
|
||||
synthesised from constants in the file.
|
||||
|
||||
@@ -27,7 +27,7 @@ of whether the tool was ever able to answer it.
|
||||
| `so_constpatch.py` | **verified on real target** for equal-length rewriting; **broken — cause identified, not fixed** for one sealed serious defect (zip alignment) |
|
||||
| `dex_strings.py` | **verified on real target** (multi-dex traversal confirmed) |
|
||||
| `dexutil.py` | **verified on real target** (used as the primary workhorse for the ad-chain analysis) |
|
||||
| `find_refs.py` | **broken — cause identified, not fixed**, and it fails *silently* |
|
||||
| `find_refs.py` | **broken — fixed and re-verified**; reads a `.dex` directly now |
|
||||
| `dart_pool_strings.py` | **verified on real target** |
|
||||
| `dart_pprefs.py` | **broken — fixed** (defect proven; corrected copy agrees with capstone to zero) |
|
||||
| `dart_disasm.py` | **verified on real target** |
|
||||
@@ -269,24 +269,69 @@ The workhorse of the ad-chain analysis: symbol resolution, branch-target back-an
|
||||
`ended cleanly` verdict per method. Coverage: 175,829 method bodies with code, 213,180 `method_id`s.
|
||||
It is the tool whose output the zero-reference conclusion rests on.
|
||||
|
||||
Two defects, both found by working at this scale:
|
||||
Three defects, all found by working at this scale:
|
||||
|
||||
- `decode()` does not resolve operands — string and method indices must be decoded by the caller.
|
||||
- **Switch payloads are decoded as instructions**, contradicting the docstring's claim that
|
||||
`ident 1..3` are recognized. This is the root cause of `saveToLocal` being reported as
|
||||
- **Switch payloads were decoded as instructions**, contradicting the docstring's claim that
|
||||
`ident 1..3` are recognized. This was one root cause of `saveToLocal` being reported as
|
||||
"desync likely".
|
||||
- **The opcode table was shifted from 0x16 to 0x2C, and nine widths were wrong with it** — fixed.
|
||||
`0x1C` carried the name `monitor-enter` and one unit; the format specification has `const-class`
|
||||
there, at two units. `0x17` was `const-wide/16`/2 where the spec has `const-wide/32`/3; `0x18` was
|
||||
3 where the spec has `const-wide`/5; `goto` was read one slot early, so `throw` was treated as a
|
||||
branch. The plausible "same family, same width" reading of that run is wrong at nine opcodes, and
|
||||
each wrong entry shifts every instruction after it inside that method.
|
||||
|
||||
## `find_refs.py` — broken — cause identified, not fixed, and it fails silently
|
||||
Proven by measurement rather than by re-reading the table: on a real 8.9 MB dex, **65 method
|
||||
bodies did not decode to their declared boundary and 32 carried an operand index outside its
|
||||
table**; after the fix, **50,791 of 50,791 land exactly on `insns_off + insns_size*2` with 0
|
||||
illegal indices**. Independently cross-checked against androguard's decoder, which shares no code
|
||||
with this project: same method count (50,791), and **0 differing width sequences** across all of
|
||||
them.
|
||||
|
||||
The docstring says it accepts "a smali tree or a directory of dex files". It does not:
|
||||
`iter_files()` yields only files ending in `.smali`. Given a dex directory it returns **0 files, no
|
||||
error, exit code 0**.
|
||||
Names and widths now come from the format specification's table, and widths are a lookup
|
||||
(`OP_UNITS`) rather than a chain of range tests. The `goto` family in `branch_target()` and in
|
||||
`dex_find_insn.py`'s kind map moved with it.
|
||||
|
||||
## `find_refs.py` — broken — fixed and re-verified
|
||||
|
||||
The docstring said it accepts "a smali tree or a directory of dex files". It did not:
|
||||
`iter_files()` yielded only files ending in `.smali`. A direct `.dex` was yielded, then read as UTF-8
|
||||
text and regex-matched against smali syntax, which cannot match; a directory holding only dexes
|
||||
yielded nothing at all. Both printed `[total refs] 0` and a note blaming the needle, so a real dex, a
|
||||
dex directory and a typo'd path were **indistinguishable in the output**.
|
||||
|
||||
This is the worst shape a defect can take in this particular tool, because `find_refs.py` is the
|
||||
script recommended for judging blast radius *before* patching. Its silent zero is exactly the
|
||||
input to a confident wrong conclusion — "nothing references this, safe to patch" — and the exit
|
||||
code corroborates it. Because no baksmali/jadx is installed on this machine, it was **completely
|
||||
unusable** for this target, and the analysis had to be built on `dexutil.py` instead.
|
||||
input to a confident wrong conclusion — "nothing references this, safe to patch". Because no
|
||||
baksmali/jadx is installed on this machine, it was **completely unusable** for this target, and the
|
||||
analysis had to be built on `dexutil.py` instead.
|
||||
|
||||
Two corrections to the record above, both from re-measuring rather than re-reading:
|
||||
|
||||
- The exit code was **1**, not 0 — the `total == 0` path has always returned 1, so a caller checking
|
||||
status could already catch it. What misled was the *text*: the note diagnosed the needle and never
|
||||
named the input form.
|
||||
- The dex-as-text path also read the whole file into memory to regex-scan binary for nothing, then
|
||||
dropped it.
|
||||
|
||||
Fixed in three ways at once, because all three were the same omission — the tool did not tell the
|
||||
difference between "found nothing" and "read nothing":
|
||||
|
||||
- A `.dex` is decoded through `dexutil` (no baksmali needed), a directory is walked for `.smali` and
|
||||
`.dex` alike, and an archive is opened for its `classes*.dex`.
|
||||
- Unsupported or unreadable input is **refused with exit 2** and a reason, instead of being reported
|
||||
as zero references.
|
||||
- Every run prints `[scanned] N file(s)` first, so the two cases can never print identically again.
|
||||
|
||||
Re-verified twice, in both directions:
|
||||
|
||||
- Against a fixture whose call graph is known **by construction** (`make_fixture_dex.py` writes two
|
||||
classes with 3, 2 and 2 references): the `.dex` and the equivalent smali tree return the same
|
||||
counts, and they match what was written — 3 / 2 / 2.
|
||||
- Against the real 8.9 MB target: `Ljava/lang/String;->length` → **356 references in 2.3 s**; a
|
||||
five-dex directory → 9,472 across 2 files; a typo'd path → `[error] no such file or directory`,
|
||||
exit 2.
|
||||
|
||||
**Correction to a related assumption in this environment:** `readelf` on `PATH` resolves to
|
||||
pyelftools' `readelf.py` and is *not* binutils — `--dyn-syms` and `-W` are rejected as unrecognized
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build a minimal, structurally valid dex whose reference counts are known.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Verifying a dex reader against a real APK only shows that two readers agree.
|
||||
Verifying it against a program whose call graph was written down in advance shows
|
||||
that the *number* is right. This fixture encodes exactly two method bodies:
|
||||
|
||||
Lcom/example/Caller;->go()V invoke-static Lcom/example/Target;->run()V x2
|
||||
Lcom/example/Target;->run()V invoke-virtual Ljava/lang/String;->length()I x3
|
||||
|
||||
so the answers a blast-radius check must return are 3, 2 and 2. The same call
|
||||
graph is also written out as a smali tree, because the point of the fix this
|
||||
fixture verifies is that a `.dex` and a smali tree answer a needle identically.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python make_fixture_dex.py out.dex [--smali DIR]
|
||||
|
||||
# then, from the skill's scripts directory:
|
||||
python find_refs.py out.dex 'Ljava/lang/String;->length' # expect 3
|
||||
python find_refs.py out.dex 'Lcom/example/Target;->run' # expect 2
|
||||
python find_refs.py out.dex 'Lcom/example/Target;' # expect 2
|
||||
python find_refs.py DIR 'Ljava/lang/String;->length' # expect 3
|
||||
|
||||
Dependency-free (stdlib only) on purpose: the fixture must be reproducible on a
|
||||
machine with no Android toolchain, which is where the defect it verifies was found.
|
||||
|
||||
Nothing here is target data. The dex is synthesised from the constants below.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
CALLER = 'Lcom/example/Caller;'
|
||||
TARGET = 'Lcom/example/Target;'
|
||||
OBJECT = 'Ljava/lang/Object;'
|
||||
STRING = 'Ljava/lang/String;'
|
||||
|
||||
SMALI_CALLER = """.class public Lcom/example/Caller;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.method public static go()V
|
||||
.registers 0
|
||||
invoke-static {}, Lcom/example/Target;->run()V
|
||||
invoke-static {}, Lcom/example/Target;->run()V
|
||||
return-void
|
||||
.end method
|
||||
"""
|
||||
|
||||
SMALI_TARGET = """.class public Lcom/example/Target;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.method public static run()V
|
||||
.registers 1
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
return-void
|
||||
.end method
|
||||
"""
|
||||
|
||||
EXPECTED = (('Ljava/lang/String;->length', 3),
|
||||
('Lcom/example/Target;->run', 2),
|
||||
('Lcom/example/Target;', 2))
|
||||
|
||||
|
||||
def uleb(n):
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
out.append(b | (0x80 if n else 0))
|
||||
if not n:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def p16(v):
|
||||
return struct.pack('<H', v)
|
||||
|
||||
|
||||
def p32(v):
|
||||
return struct.pack('<I', v)
|
||||
|
||||
|
||||
def string_data(s):
|
||||
raw = s.encode('utf-8')
|
||||
return uleb(len(raw)) + raw + b'\x00'
|
||||
|
||||
|
||||
def code_item(registers, outs, insns):
|
||||
body = b''.join(p16(u) for u in insns)
|
||||
return (p16(registers) + p16(0) + p16(outs) + p16(0)
|
||||
+ p32(0) + p32(len(insns)) + body)
|
||||
|
||||
|
||||
def invoke(op, method_idx, count=0, c=0, g=0):
|
||||
"""35c layout: op(G<<8)(count<<12), index, then the register nibbles."""
|
||||
return [op | (g << 8) | (count << 12), method_idx, c & 0xF]
|
||||
|
||||
|
||||
def class_data(entries):
|
||||
"""entries: [(method_idx, access_flags, code_off)], ascending by method_idx."""
|
||||
out = uleb(0) + uleb(0) + uleb(len(entries)) + uleb(0)
|
||||
prev = 0
|
||||
for mi, acc, co in entries:
|
||||
out += uleb(mi - prev) + uleb(acc) + uleb(co)
|
||||
prev = mi
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
strings = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V', 'go', 'run', 'length'})
|
||||
sidx = {s: i for i, s in enumerate(strings)}
|
||||
types = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V'}, key=lambda t: sidx[t])
|
||||
tidx = {t: i for i, t in enumerate(types)}
|
||||
|
||||
protos = [('I', 'I'), ('V', 'V')] # (shorty, return type)
|
||||
protos.sort(key=lambda p: tidx[p[1]])
|
||||
pidx = {shorty: i for i, (shorty, _r) in enumerate(protos)}
|
||||
|
||||
methods = [(CALLER, 'go', 'V'), (TARGET, 'run', 'V'), (STRING, 'length', 'I')]
|
||||
methods.sort(key=lambda m: (tidx[m[0]], sidx[m[1]], pidx[m[2]]))
|
||||
midx = {(c, n): i for i, (c, n, _r) in enumerate(methods)}
|
||||
|
||||
insns_go = invoke(0x71, midx[(TARGET, 'run')]) * 2 + [0x000E]
|
||||
insns_run = invoke(0x6E, midx[(STRING, 'length')], count=1) * 3 + [0x000E]
|
||||
|
||||
off = 0x70
|
||||
string_ids_off = off
|
||||
off += 4 * len(strings)
|
||||
type_ids_off = off
|
||||
off += 4 * len(types)
|
||||
proto_ids_off = off
|
||||
off += 12 * len(protos)
|
||||
method_ids_off = off
|
||||
off += 8 * len(methods)
|
||||
class_defs_off = off
|
||||
off += 32 * 2
|
||||
data_off = off
|
||||
|
||||
data = bytearray()
|
||||
|
||||
def align(n):
|
||||
while len(data) % n:
|
||||
data.append(0)
|
||||
|
||||
str_offs = []
|
||||
for s in strings:
|
||||
str_offs.append(data_off + len(data))
|
||||
data += string_data(s)
|
||||
|
||||
align(4)
|
||||
code_go_off = data_off + len(data)
|
||||
data += code_item(0, 0, insns_go)
|
||||
align(4)
|
||||
code_run_off = data_off + len(data)
|
||||
data += code_item(1, 1, insns_run)
|
||||
align(4)
|
||||
|
||||
cd_go_off = data_off + len(data)
|
||||
data += class_data([(midx[(CALLER, 'go')], 0x0009, code_go_off)])
|
||||
cd_run_off = data_off + len(data)
|
||||
data += class_data([(midx[(TARGET, 'run')], 0x0009, code_run_off)])
|
||||
align(4)
|
||||
|
||||
map_off = data_off + len(data)
|
||||
|
||||
def map_item(kind, size, offset):
|
||||
return p16(kind) + p16(0) + p32(size) + p32(offset)
|
||||
|
||||
items = [
|
||||
(0x0000, 1, 0), # header_item
|
||||
(0x0001, len(strings), string_ids_off),
|
||||
(0x0002, len(types), type_ids_off),
|
||||
(0x0003, len(protos), proto_ids_off),
|
||||
(0x0005, len(methods), method_ids_off),
|
||||
(0x0006, 2, class_defs_off),
|
||||
(0x2002, len(strings), str_offs[0]), # string_data_item
|
||||
(0x2001, 2, code_go_off), # code_item
|
||||
(0x2000, 2, cd_go_off), # class_data_item
|
||||
(0x1000, 1, map_off), # map_list
|
||||
]
|
||||
items.sort(key=lambda it: it[2])
|
||||
data += p32(len(items)) + b''.join(map_item(*it) for it in items)
|
||||
|
||||
string_ids = b''.join(p32(str_offs[i]) for i in range(len(strings)))
|
||||
type_ids = b''.join(p32(sidx[t]) for t in types)
|
||||
proto_ids = b''.join(p32(sidx[shorty]) + p32(tidx[ret]) + p32(0)
|
||||
for shorty, ret in protos)
|
||||
method_ids = b''.join(p16(tidx[c]) + p16(pidx[r]) + p32(sidx[n])
|
||||
for c, n, r in methods)
|
||||
|
||||
def class_def(class_idx, super_idx, class_data_off):
|
||||
return (p32(class_idx) + p32(0x0001) + p32(super_idx) + p32(0)
|
||||
+ p32(0xFFFFFFFF) + p32(0) + p32(class_data_off) + p32(0))
|
||||
|
||||
classes = sorted([(CALLER, cd_go_off), (TARGET, cd_run_off)], key=lambda c: tidx[c[0]])
|
||||
class_defs = b''.join(class_def(tidx[c], tidx[OBJECT], cdo) for c, cdo in classes)
|
||||
|
||||
h = bytearray(0x70)
|
||||
h[0:8] = b'dex\n035\x00'
|
||||
h[32:36] = p32(data_off + len(data)) # file_size
|
||||
h[36:40] = p32(0x70) # header_size
|
||||
h[40:44] = p32(0x12345678) # endian_tag
|
||||
h[52:56] = p32(map_off)
|
||||
h[56:60] = p32(len(strings))
|
||||
h[60:64] = p32(string_ids_off)
|
||||
h[64:68] = p32(len(types))
|
||||
h[68:72] = p32(type_ids_off)
|
||||
h[72:76] = p32(len(protos))
|
||||
h[76:80] = p32(proto_ids_off)
|
||||
h[80:84] = p32(0) # field_ids_size
|
||||
# There are no fields. Size 0 is correct; the offset points at the next
|
||||
# section rather than 0 only because a zero offset reads as "out of range"
|
||||
# to a structural check that treats every offset as present.
|
||||
h[84:88] = p32(method_ids_off)
|
||||
h[88:92] = p32(len(methods))
|
||||
h[92:96] = p32(method_ids_off)
|
||||
h[96:100] = p32(2) # class_defs_size
|
||||
h[100:104] = p32(class_defs_off)
|
||||
h[104:108] = p32(len(data))
|
||||
h[108:112] = p32(data_off)
|
||||
|
||||
blob = bytearray(bytes(h) + string_ids + type_ids + proto_ids + method_ids
|
||||
+ class_defs + bytes(data))
|
||||
blob[12:32] = hashlib.sha1(bytes(blob[32:])).digest() # signature first
|
||||
blob[8:12] = (zlib.adler32(bytes(blob[12:])) & 0xFFFFFFFF).to_bytes(4, 'little')
|
||||
return bytes(blob), len(strings), len(types), len(methods)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
ap.add_argument('out', nargs='?', default='fixture.dex')
|
||||
ap.add_argument('--smali', metavar='DIR',
|
||||
help='also write the same call graph as a smali tree')
|
||||
a = ap.parse_args()
|
||||
|
||||
blob, n_str, n_ty, n_m = build()
|
||||
with open(a.out, 'wb') as fh:
|
||||
fh.write(blob)
|
||||
print('wrote %s (%d bytes, %d strings, %d types, %d methods, 2 classes)'
|
||||
% (a.out, len(blob), n_str, n_ty, n_m))
|
||||
|
||||
if a.smali:
|
||||
d = os.path.join(a.smali, 'com', 'example')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, 'Caller.smali'), 'w', newline='\n') as fh:
|
||||
fh.write(SMALI_CALLER)
|
||||
with open(os.path.join(d, 'Target.smali'), 'w', newline='\n') as fh:
|
||||
fh.write(SMALI_TARGET)
|
||||
print('wrote smali tree under %s' % a.smali)
|
||||
|
||||
print('expected: ' + ' '.join('%s=%d' % kv for kv in EXPECTED))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user