Files
inliver233 5fa80a5bea 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.
2026-09-21 18:19:23 +08:00

263 lines
8.9 KiB
Python

#!/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()