Files

279 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Locate an instruction by decoded semantics and report its exact byte offset.
WHY THIS EXISTS
---------------
You know what the instruction *does* ("a branch on the config boolean whose target
starts the countdown") but you need the *byte offset* to patch it. Two shortcuts
fail:
* baksmali listings give smali line numbers, not code offsets. `.line` tracks
source lines, breaks 1:N, and cannot be converted reliably.
* a hard-coded offset dies the moment the sample is rebuilt, and a wrong offset
in an equal-length patch corrupts the neighbouring instruction silently.
So this tool decodes the method and filters on semantics. It always prints the
surrounding instructions so you can see which side of a branch you are about to
change -- the polarity mistake is the most common way a patch does the exact
opposite of the goal and still starts cleanly.
Filter language (all optional; combined with AND, comma = OR within a repeated
flag):
NAME substring match on the class, method or string operand
@Member field or method name operand equals/contains Member
=literal a const/4 literal equals this integer
#Class/member a branch whose TARGET reads this field of this class
>kind instruction kind: if-test if-testz invoke return
move-result const4 nop goto switch field sfield
$name the ENCLOSING method name must contain this
Examples
--------
# a boolean gate whose taken path reads Cfg.enabled
python dex_find_insn.py app.apk --class 'Lcom/example/SplashActivity;' \\
--method u --filter '#Lcom/example/Cfg;*enabled'
# every invoke of a method named like goHome, with 3 lines of context
python dex_find_insn.py app.apk --filter '@goHome' --kind invoke -C 3
# where a specific literal is compared
python dex_find_insn.py app.apk --class 'Lcom/example/PlayerActivity;' --filter '=1' --kind if-testz
Exit codes: 0 = at least one hit, 1 = no hits (a finding: say so, do not retry the
same filter), 2 = usage error.
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dexutil import ( # noqa: E402
Dex, IF_TEST, IF_TESTZ, INVOKE_OPS, MOVE_RESULT_OPS, load_dex,
)
_KIND_MAP = {
"if-test": IF_TEST,
"if-testz": IF_TESTZ,
"invoke": INVOKE_OPS,
"return": (0x0F, 0x10, 0x11),
"move-result": MOVE_RESULT_OPS,
"const4": (0x12,),
"const16": (0x13,),
"goto": (0x28, 0x29, 0x2A),
"switch": (0x2B, 0x2C),
"nop": (0x00,),
}
_FIELD_READ = tuple(range(0x52, 0x59))
_FIELD_WRITE = tuple(range(0x59, 0x60))
_SFIELD = tuple(range(0x60, 0x6E))
_KIND_MAP["field"] = _FIELD_READ
_KIND_MAP["field-write"] = _FIELD_WRITE
_KIND_MAP["sfield"] = _SFIELD
class Filter(object):
"""One compiled filter expression."""
def __init__(self, text):
self.raw = text
self.kind = text[1:] if text.startswith(">") else None
self.member = text[1:] if text.startswith("@") else None
self.literal = int(text[1:]) if text.startswith("=") else None
self.target_field = None
self.target_class = None
self.enclosing = text[1:] if text.startswith("$") else None
self.text = None
if text.startswith("#"):
spec = text[1:]
if "/" in spec and ("*" in spec):
cls, mem = spec.split("*", 1)
self.target_class, self.target_field = cls, mem
elif "*" in spec:
self.target_field = spec.split("*", 1)[1]
else:
self.target_class = spec
elif not any(text.startswith(p) for p in ("@", "=", ">", "$")):
self.text = text
def describe(self):
return self.raw
def _operand_text(dex, insn):
"""All names this instruction mentions (class, method, field, string)."""
parts = []
op, pos = insn["op"], insn["off"]
if op in INVOKE_OPS:
cls, nm, ds = dex.method(int.from_bytes(insn["raw"][2:4], "little"))
parts += [cls, nm, ds]
elif op in _FIELD_READ + _FIELD_WRITE + _SFIELD:
cls, nm, ty = dex.field(int.from_bytes(insn["raw"][2:4], "little"))
parts += [cls, nm, ty]
elif op in (0x19, 0x1A):
parts.append(dex.string_safe(int.from_bytes(insn["raw"][2:4], "little")))
return parts
def _matches(dex, insn, filters, code_off):
"""All filters must hold (AND)."""
for f in filters:
if f.kind:
ops = _KIND_MAP.get(f.kind)
if ops is None:
raise ValueError("unknown kind: %s" % f.kind)
if insn["op"] not in ops:
return False
if f.literal is not None:
if insn["op"] != 0x12:
return False
val = insn["raw"][1] & 0xF
if val > 7:
val -= 16
if val != f.literal:
return False
if f.member:
if insn["op"] not in INVOKE_OPS:
return False
_cls, nm, _ds = dex.method(int.from_bytes(insn["raw"][2:4], "little"))
if f.member not in nm:
return False
if f.target_class or f.target_field:
tgt = dex.branch_target(insn)
if tgt is None:
return False
t_insn = dex.insn_at(code_off, tgt)
if t_insn is None or not (0x52 <= t_insn["op"] <= 0x58):
return False
cls, nm, _ty = dex.field(int.from_bytes(t_insn["raw"][2:4], "little"))
if f.target_class and f.target_class != cls:
return False
if f.target_field and f.target_field not in nm:
return False
if f.text:
hay = " ".join(_operand_text(dex, insn))
if f.text.lower() not in hay.lower():
return False
return True
def scan(dex, filters, kind_only=None, class_pattern=None, method_pattern=None,
limit=None):
"""Yield (fqcn, method_name, descriptor, code_off, insn, all_insns, idx)."""
hits = 0
cls_re = re.compile(class_pattern) if class_pattern else None
mth_re = re.compile(method_pattern) if method_pattern else None
for fqcn in dex.class_names():
if cls_re and not cls_re.search(fqcn):
continue
try:
methods = list(dex.methods_of(fqcn))
except Exception:
continue
for section, midx, _c, name, desc, code_off in methods:
if code_off == 0:
continue
if mth_re and not mth_re.search(name):
continue
for f in filters:
if f.enclosing and f.enclosing not in name:
break
insns = list(dex.decode(code_off))
# a decode that does not land on the method end means offsets here
# cannot be trusted; report the method rather than silent misses
info = dex.code_info(code_off)
expected = info["insns_off"] + info["insns_size"] * 2
clean = (insns[-1]["off"] + insns[-1]["units"] * 2 == expected) if insns else False
for i, insn in enumerate(insns):
if kind_only and insn["op"] not in _KIND_MAP.get(kind_only, ()):
continue
if filters and not _matches(dex, insn, filters, code_off):
continue
yield (fqcn, name, desc, code_off, insn, insns, i, clean)
hits += 1
if limit and hits >= limit:
return
def main(argv):
ap = argparse.ArgumentParser(
description="Find an instruction by decoded semantics; print its exact "
"byte offset and surrounding context.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split("Examples")[-1][:1500])
ap.add_argument("target", help=".dex or .apk/.zip (use --entry to pick a dex)")
ap.add_argument("--entry", help="archive member (default: first classes*.dex)")
ap.add_argument("--class", dest="cls", help="regex over the class descriptor")
ap.add_argument("--method", help="regex over the method name")
ap.add_argument("--filter", action="append", default=[],
help="filter expression, repeatable (AND across, OR within a flag "
"list). See the module docstring for the language.")
ap.add_argument("--kind", help="restrict to one instruction kind (see docstring)")
ap.add_argument("-C", "--context", type=int, default=2,
help="instructions of context to print (default 2)")
ap.add_argument("--limit", type=int, default=40, help="max hits (default 40)")
ap.add_argument("--show-unclean", action="store_true",
help="also print hits from methods whose decode did not align")
args = ap.parse_args(argv[1:])
dex, entry = load_dex(args.target, args.entry)
problems = dex.check()
if problems:
print("structural problems in %s (%s):" % (args.target, entry))
for p in problems:
print(" - %s" % p)
return 2
filters = [Filter(a) for a in args.filter]
print("== %s (%s) %d classes" % (args.target, entry,
dex.header["class_defs_size"]))
if filters:
print(" filters: %s" % ", ".join(f.describe() for f in filters))
found = 0
for fqcn, name, desc, code_off, insn, insns, idx, clean in scan(
dex, filters, args.kind, args.cls, args.method, args.limit):
if not clean and not args.show_unclean:
print("\n!! %s.%s%s decode did not align; offsets unreliable, "
"skipping (use --show-unclean to see anyway)"
% (fqcn, name, desc))
continue
found += 1
print("\n-- %s.%s%s" % (fqcn, name, desc))
print(" code_off=0x%x insns_off=0x%x (decode clean: %s)"
% (code_off, code_off + 16, clean))
lo = max(0, idx - args.context)
hi = min(len(insns), idx + args.context + 1)
for j in range(lo, hi):
marker = " <== HIT" if j == idx else ""
print(" %s%s" % (dex.describe(insns[j]), marker))
print(" >>> file offset of the instruction: 0x%x" % insn["off"])
if insn["op"] in IF_TEST or insn["op"] in IF_TESTZ:
tgt = dex.branch_target(insn)
t_insn = dex.insn_at(code_off, tgt)
print(" >>> branch TAKEN target 0x%x: %s"
% (tgt, dex.describe(t_insn) if t_insn else "?"))
nxt = insn["off"] + insn["units"] * 2
n_insn = dex.insn_at(code_off, nxt)
print(" >>> branch FALL-THROUGH 0x%x: %s"
% (nxt, dex.describe(n_insn) if n_insn else "?"))
print(" >>> decide which side you want BEFORE patching; a wrong "
"polarity still starts cleanly")
print("\n== %d hit(s)" % found)
if not found:
print(" no match. Do not retry the same filter: either the decode is "
"unclean (see --show-unclean) or the behaviour lives in another "
"layer (native / another dex / another class).")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))