Files

287 lines
10 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Find every reference to a method/field/class, so you can judge blast radius
BEFORE patching it.
This is the check that prevents the classic mistake: patching something that looked
ad-specific but is actually a general utility with dozens of callers.
Usage
-----
# a smali tree, a single .dex, a directory holding either, or an .apk
python find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;->showAd(Landroid/app/Activity;)V'
# a whole class (all of its members)
python find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;'
# a field
python find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;->count:I'
# just the counts, no listing
python find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;->showAd' --count-only
Reading the result
------------------
few callers, all inside one feature area -> safe to patch
many callers, or callers in unrelated pkgs -> general utility. DO NOT patch it.
Go one level up and patch the
specific caller instead.
The first line is part of the answer, not decoration. `[scanned] 0 file(s)` means
the input was never read -- an unsupported path, a typo, a directory with nothing
this script can open -- and it is a different result from "read N files and found
nothing". The two used to print identically, which put a false zero on exactly the
decision this script exists to protect. Input that cannot be read now exits 2 and
says why.
Input forms, precisely:
.smali file text scan (no decoder needed)
.dex file decoded with dexutil (no baksmali/jadx required)
directory every .smali and .dex found under it, recursively
.apk/.zip/... every classes*.dex inside the archive
anything else refused with an error, rather than reported as "no references"
Also inspect the signature. If it mentions Modifier / ContentScale / Shape / View /
ColorScheme or a content-generic model type, it is shared UI, not your target.
"""
import argparse
import os
import re
import sys
import zipfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dexutil import ( # noqa: E402
IFIELD_OPS, INVOKE_OPS, PFIELD_OPS, SFIELD_OPS, insn_units, load_dex, u16,
)
TEXT_EXT = ('.smali',)
DEX_EXT = ('.dex',)
ARCHIVE_EXT = ('.apk', '.zip', '.jar', '.xapk', '.apks', '.apkm')
REF_RE_TMPL = r'invoke[^\n]*%s|sget[^\n]*%s|sput[^\n]*%s|new-instance[^\n]*%s|check-cast[^\n]*%s'
FIELD_OPS = tuple(IFIELD_OPS) + tuple(PFIELD_OPS) + tuple(SFIELD_OPS)
# new-instance (0x22) and check-cast (0x1F) both carry a type index at byte 2,
# which is where the 21c format puts it.
TYPE_OPS = (0x1F, 0x22)
# classes.dex, classes2.dex, ... in an archive.
ARCHIVE_DEX_RE = re.compile(r'(?:^|/)classes\d*\.dex$')
EXIT_FOUND = 0
EXIT_NO_MATCH = 1
EXIT_BAD_INPUT = 2
def gather(root):
"""Return (smali_paths, dex_paths, archive_paths).
Raises ValueError for input this script cannot read, so that "nothing was
scanned" can never be mistaken for "nothing references it".
"""
if os.path.isdir(root):
smali, dexs = [], []
for dp, _dirs, files in os.walk(root):
for fn in sorted(files):
path = os.path.join(dp, fn)
if fn.endswith(TEXT_EXT):
smali.append(path)
elif fn.endswith(DEX_EXT):
dexs.append(path)
if not smali and not dexs:
raise ValueError(
'no .smali and no .dex files under %s.\n'
' A directory of .apk archives is not scanned; pass the '
'.apk itself, or disassemble first (%s).'
% (root, 'smtool.py'))
return smali, dexs, []
if not os.path.exists(root):
raise ValueError('no such file or directory: %s' % root)
low = root.lower()
if low.endswith(TEXT_EXT):
return [root], [], []
if low.endswith(DEX_EXT):
return [], [root], []
if low.endswith(ARCHIVE_EXT):
return [], [], [root]
raise ValueError(
'%s is not a .smali file, a .dex, a directory, or an archive.\n'
' Accepted: .smali, .dex, a directory holding either, '
'%s' % (root, '/'.join(ARCHIVE_EXT)))
def method_owner(text, idx):
"""Map a character offset to the enclosing .method declaration."""
head = text.rfind('.method ', 0, idx)
if head < 0:
return '?'
end = text.find('\n', head)
return text[head:end].strip()
def scan_smali(path, pat):
"""Return [(offset, owner, matched_text)] for one smali file."""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
text = fh.read()
except OSError:
return []
return [(m.start(), method_owner(text, m.start()), m.group(0).strip())
for m in pat.finditer(text)]
def walk_insns(dex, code_off):
"""Yield (offset, opcode) for one method body.
Deliberately not dex.decode(): this walks every instruction of every method
in the dex, and the per-instruction dict decode() builds is not needed to
read an operand index out of the raw bytes.
"""
info = dex.code_info(code_off)
pos = info['insns_off']
end = pos + info['insns_size'] * 2
while pos < end:
op = dex.d[pos]
units = insn_units(op, dex.d, pos, end)
if units < 1 or pos + units * 2 > end:
return
yield pos, op
pos += units * 2
def scan_dex(dex, needle):
"""Return [(insn_off, owner, rendered)] for references inside one dex.
The rendered form is the same string the smali side matches textually
(`Lcom/pkg/Helper;->showAd(Landroid/app/Activity;)V`), so both input forms
answer the same question the same way.
"""
out = []
for i in range(dex.header['class_defs_size']):
cd_off = dex.header['class_defs_off'] + i * 32
for _section, _midx, cls, name, desc, code_off in dex.methods_at(cd_off):
if not code_off:
continue
owner = '%s->%s%s' % (cls, name, desc)
for pos, op in walk_insns(dex, code_off):
if op in INVOKE_OPS:
cls2, nm, ds = dex.method(u16(dex.d, pos + 2))
rendered = '%s->%s%s' % (cls2, nm, ds)
elif op in FIELD_OPS:
cls2, nm, ty = dex.field(u16(dex.d, pos + 2))
rendered = '%s->%s:%s' % (cls2, nm, ty)
elif op in TYPE_OPS:
rendered = dex.type_(u16(dex.d, pos + 2))
else:
continue
if needle in rendered:
out.append((pos, owner, rendered))
return out
def dex_sources(path):
"""Return [(label, Dex)] for a .dex file or every classes*.dex in an archive."""
if path.lower().endswith(DEX_EXT):
dex, _entry = load_dex(path)
return [(path, dex)]
with zipfile.ZipFile(path) as z:
names = sorted(n for n in z.namelist() if ARCHIVE_DEX_RE.search(n))
if not names:
raise ValueError('no classes*.dex inside %s' % path)
out = []
for name in names:
dex, _entry = load_dex(path, entry=name)
out.append(('%s!%s' % (path, name), dex))
return out
def main():
ap = argparse.ArgumentParser(
description='Count and list references to a method/field/class, in a smali '
'tree, a .dex, a directory of either, or an .apk.')
ap.add_argument('root', help='.smali file, .dex file, directory, or archive')
ap.add_argument('needle', help='e.g. Lcom/pkg/Helper;->showAd(...)V, '
'Lcom/pkg/Helper;, or Lcom/pkg/Helper;->count:I')
ap.add_argument('--count-only', action='store_true')
ap.add_argument('--max-print', type=int, default=60)
a = ap.parse_args()
esc = re.escape(a.needle)
# match the needle plus one more char so we do not match a longer prefix
pat = re.compile(REF_RE_TMPL % (esc, esc, esc, esc, esc))
try:
smali_paths, dex_paths, archives = gather(a.root)
except ValueError as exc:
print('[error] %s' % exc)
return EXIT_BAD_INPUT
per_file = [] # (count, label, hits)
scanned = 0
unreadable = []
for path in smali_paths:
hits = scan_smali(path, pat)
scanned += 1
if hits:
per_file.append((len(hits), path, hits))
for path in dex_paths + archives:
try:
sources = dex_sources(path)
except (ValueError, OSError, zipfile.BadZipFile) as exc:
unreadable.append('%s (%s)' % (path, exc))
continue
for label, dex in sources:
problems = dex.check()
if problems:
unreadable.append('%s (%s)' % (label, problems[0]))
continue
hits = scan_dex(dex, a.needle)
scanned += 1
if hits:
per_file.append((len(hits), label, hits))
if scanned == 0:
print('[error] nothing readable was scanned under %s' % a.root)
for u in unreadable:
print(' unreadable: %s' % u)
print(' Input was refused rather than reported as zero references, '
'because the two are not the same answer.')
return EXIT_BAD_INPUT
total = sum(c for c, _l, _h in per_file)
per_file.sort(key=lambda row: row[0], reverse=True)
print('[scanned] %d file(s) (%d smali, %d dex)'
% (scanned, len(smali_paths), scanned - len(smali_paths)))
print('[total refs] %d across %d files' % (total, len(per_file)))
for u in unreadable:
print('[unreadable] %s' % u)
print()
printed = 0
for cnt, label, hits in per_file:
print('%-70s %d' % (label, cnt))
if a.count_only:
continue
for off, owner, rendered in hits:
if printed >= a.max_print:
print(' ... (truncated)')
return EXIT_FOUND
print(' in %s' % owner)
print(' %s' % rendered)
printed += 1
if total == 0:
print('[note] scanned %d file(s) and found no reference to %r. Check the '
'descriptor prefix "L...;", the exact obfuscated name, and whether the '
'behaviour lives in another dex or in native code.'
% (scanned, a.needle))
return EXIT_NO_MATCH
return EXIT_FOUND
if __name__ == '__main__':
sys.exit(main())