171 lines
7.1 KiB
Python
171 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Tier-3 verifier check: does any branch target a `move-result*` instruction?
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
`move-result*` is not an ordinary instruction. The verifier requires it to be the
|
|
immediate successor of the invoke that produced the value it copies. A branch that
|
|
lands on a `move-result` therefore bypasses the producer, and the class fails to
|
|
load with:
|
|
|
|
VerifyError: ... copyRes1 v11 <- result0 type=Undefined
|
|
|
|
This matters specifically for byte-level patching. Redirecting a branch
|
|
(`if-*` -> `goto`) is the intuitive way to force one side of a condition, and it
|
|
is the way that silently creates this violation. Replacing the branch with a pair
|
|
of `nop`s cannot: it removes a control-flow edge instead of adding one.
|
|
|
|
The check must be done on the control-flow graph, not linearly. A "is the
|
|
previous instruction the producer?" scan over the instruction list reports clean
|
|
on a method that has a branch jumping straight onto a `move-result`, because the
|
|
producer does sit immediately before it in the stream. Only asking "does ANY
|
|
branch target this offset" sees the problem.
|
|
|
|
Usage
|
|
-----
|
|
python dex_check_verifier.py app.apk # whole image
|
|
python dex_check_verifier.py app.apk --class 'Lcom/ex/Foo;'
|
|
python dex_check_verifier.py before.dex after.dex # compare two builds
|
|
|
|
Exit codes: 0 = no findings, 1 = findings present, 2 = usage/parse error. The
|
|
non-zero-on-findings convention is deliberate so a pipeline can gate on it.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from dexutil import IF_TEST, IF_TESTZ, MOVE_RESULT_OPS, load_dex # noqa: E402
|
|
|
|
|
|
def analyse_method(dex, code_off):
|
|
"""Return (findings, clean, detail) for one method body.
|
|
|
|
findings: list of (branch_offset, move_result_offset, is_conditional)
|
|
clean: whether the decode consumed exactly insns_size units
|
|
detail: human string when not clean
|
|
|
|
ONLY conditional branches are reported. An unconditional `goto` that lands on
|
|
a `move-result` is a normal compiler-generated merge point: the producer runs
|
|
on the incoming path and the goto is a *forward* jump to the shared epilogue,
|
|
so nothing is bypassed. Reporting those as violations makes the tool unusable
|
|
-- on a real sample they outnumber genuine findings by 20:1 and are pure
|
|
compiler output.
|
|
|
|
The real hazard is narrower: redirecting a CONDITIONAL branch so that it lands
|
|
directly on a `move-result`. That skips the producing invoke on the taken path.
|
|
"""
|
|
insns = list(dex.decode(code_off))
|
|
info = dex.code_info(code_off)
|
|
expected = info["insns_off"] + info["insns_size"] * 2
|
|
ended = (insns[-1]["off"] + insns[-1]["units"] * 2) if insns else info["insns_off"]
|
|
clean = (ended == expected)
|
|
detail = "" if clean else ("decode ended at 0x%x, method body ends at 0x%x"
|
|
% (ended, expected))
|
|
|
|
move_results = {i["off"] for i in insns if i["op"] in MOVE_RESULT_OPS}
|
|
findings = []
|
|
for i in insns:
|
|
if i["op"] not in IF_TEST and i["op"] not in IF_TESTZ:
|
|
continue
|
|
t = dex.branch_target(i)
|
|
if t is not None and t in move_results:
|
|
findings.append((i["off"], t, True))
|
|
return findings, clean, detail
|
|
|
|
|
|
def scan_image(path, entry, cls_filter):
|
|
dex, entry_name = load_dex(path, entry)
|
|
problems = dex.check()
|
|
if problems:
|
|
for p in problems:
|
|
print(" [structure] %s" % p)
|
|
raise RuntimeError("structurally broken: %s" % path)
|
|
|
|
total_methods = 0
|
|
total_findings = 0
|
|
unclean = 0
|
|
for fqcn in dex.class_names():
|
|
if cls_filter and cls_filter not in 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
|
|
total_methods += 1
|
|
findings, clean, detail = analyse_method(dex, code_off)
|
|
if not clean:
|
|
unclean += 1
|
|
if findings:
|
|
total_findings += len(findings)
|
|
print(" VIOLATION %s.%s%s" % (fqcn, name, desc))
|
|
for b_off, mr_off, _cond in findings:
|
|
b_insn = dex.insn_at(code_off, b_off)
|
|
mr_insn = dex.insn_at(code_off, mr_off)
|
|
print(" conditional branch 0x%x (%s)"
|
|
% (b_off, dex.describe(b_insn)))
|
|
print(" -> move-result 0x%x (%s)"
|
|
% (mr_off, dex.describe(mr_insn)))
|
|
print(" the producing invoke is bypassed on the taken "
|
|
"path: the class will fail to load")
|
|
return {"methods": total_methods, "findings": total_findings,
|
|
"unclean": unclean, "entry": entry_name}
|
|
|
|
|
|
def main(argv):
|
|
ap = argparse.ArgumentParser(
|
|
description="Detect branch-into-move-result verifier violations.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__.split("Usage")[-1][:800])
|
|
ap.add_argument("targets", nargs="+", help="one .dex/.apk, or two to compare")
|
|
ap.add_argument("--entry", help="archive member (default: first classes*.dex)")
|
|
ap.add_argument("--class", dest="cls", help="substring filter on the class name")
|
|
args = ap.parse_args(argv[1:])
|
|
|
|
results = []
|
|
for path in args.targets:
|
|
print("== %s" % path)
|
|
try:
|
|
stats = scan_image(path, args.entry, args.cls)
|
|
except Exception as exc:
|
|
print(" error: %s" % exc)
|
|
return 2
|
|
results.append(stats)
|
|
print(" methods checked: %d violations: %d unclean decodes: %d"
|
|
% (stats["methods"], stats["findings"], stats["unclean"]))
|
|
print("")
|
|
|
|
if len(results) >= 2:
|
|
before, after = results[0], results[1]
|
|
print("== comparison")
|
|
print(" violations %d -> %d" % (before["findings"], after["findings"]))
|
|
if after["findings"] > before["findings"]:
|
|
print(" REGRESSION: your patch introduced %d new violation(s). A "
|
|
"branch now lands on a move-result." % (after["findings"] - before["findings"]))
|
|
print(" Fix: replace the branch with `nop`s (removes an edge) instead "
|
|
"of redirecting it (adds one).")
|
|
return 1
|
|
if before["findings"] and after["findings"] == before["findings"]:
|
|
print(" note: violations exist in BOTH builds, so they are pre-existing "
|
|
"and not caused by the patch. Do not 'fix' them as part of this task.")
|
|
|
|
if any(r["unclean"] for r in results):
|
|
print("== WARNING: unclean decodes reported above. Offsets from those "
|
|
"methods are unreliable -- widen the instruction format table or "
|
|
"cross-check against a disassembler before trusting a patch offset.")
|
|
|
|
if any(r["findings"] for r in results):
|
|
return 1
|
|
print("== no branch-into-move-result violations")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|