#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Apply equal-length byte patches to a dex, with structural and verifier checks. WHY THIS EXISTS --------------- The cheap way to change behaviour is to rewrite a few bytes in place rather than rebuild a method body. It is cheap for a reason: nothing moves, so no try/catch block, debug-info pointer or branch displacement can be invalidated by construction. But it has four failure modes that all produce a build which looks fine and misbehaves, and this tool exists to make each one impossible to skip: 1. **Same-length is not enough** -- the replacement must decode to a valid instruction sequence of exactly the same length. A 4-byte branch replaced by a 4-byte branch is fine; a 2-byte branch replaced by a 4-byte instruction corrupts the next instruction. 2. **Polarity** -- the overwhelmingly common error is patching the wrong side of a condition. So a spec can *require* the instruction that follows the edit, and this tool aborts if it is not the one you named. Name the fall-through you expect and the mistake cannot survive. 3. **Verifier legality** -- a branch whose target is a `move-result*` bypasses the producing invoke and the class fails to load (`VerifyError`). Checked here before writing. 4. **dex header staleness** -- the checksum and signature fields must be recomputed, signature FIRST (the checksum covers it). Skipping this makes Android fall back to interpreting the dex, which surfaces as an unrelated ClassNotFoundException at startup. Spec format (JSON; a list, or {"patches": [...]}): [{ "name": "human label", "reason": "why this edit is correct", "class": "Lpkg/Name;", "method": "someMethod", "desc": "(Lpkg/Arg;)V", // optional: disambiguates overloads "match": {"kind": "if-testz", "target_reads_field_of": "Lpkg/Cfg;", "target_field": "enabled"}, "expect_next": {"kind": "invoke", "target_method": "goHome"}, "replace": {"kind": "nops"} // or {"bytes": "28060000"} }] `match` selects the instruction; `expect_next` (optional but strongly advised) asserts what immediately follows, which is what pins the polarity; `replace` gives the bytes. Run with --dry-run first: it prints the match, the neighbours, the verifier verdict and the predicted byte diff without writing anything. Exit codes: 0 ok, 1 failure (structure, match, verifier, or self-verify), 2 usage. """ import argparse import json import os import struct import sys import zipfile 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, RETURN_OPS, fix_dex_header, load_dex, verify_dex_header, ) def _kind_of(insn): """Coarse semantic kind used by spec matching.""" op = insn["op"] if op in IF_TEST: return "if-test" if op in IF_TESTZ: return "if-testz" if op in INVOKE_OPS: return "invoke" if op in RETURN_OPS: return "return" if op in MOVE_RESULT_OPS: return "move-result" if op == 0x12: return "const4" return insn["name"] def _describe_neighbours(dex, code_off, target_off, span=4): """Render up to `span` instructions before and after an offset.""" insns = list(dex.decode(code_off)) idx = None for i, insn in enumerate(insns): if insn["off"] == target_off: idx = i break if idx is None: return [], None, [] before = insns[max(0, idx - span):idx] after = insns[idx + 1:idx + 1 + span] return before, insns[idx], after def _matches(dex, insn, crit, code_off=None): """Does one instruction satisfy a match/expect criterion?""" if crit is None: return True want = crit.get("kind") if want and _kind_of(insn) != want: return False if want in ("if-test", "if-testz"): treg = crit.get("target_reg") if treg is not None: regs, _ = dex.branch_regs(insn) if treg not in regs: return False # The usual way to pin "this is the gate for config X": require that the # branch TARGET instruction reads a field of a named class. This is what # distinguishes the gate you want from the other branches in the method. fcls = crit.get("target_reads_field_of") fname = crit.get("target_field") if fcls or fname: if code_off is None: return False tgt = dex.branch_target(insn) t_insn = dex.insn_at(code_off, tgt) if t_insn is None: return False if not (0x52 <= t_insn["op"] <= 0x58): return False fidx = struct.unpack_from(" 7: val -= 16 if val != lit: return False if want == "invoke": midx = struct.unpack_from("%s%s" % (label, cls, name, desc)) section, midx, code_off = found else: overloads = dex.find_methods_named(cls, name) if len(overloads) != 1: raise RuntimeError( "[%s] %s->%s has %d overloads; give \"desc\" to disambiguate: %s" % (label, cls, name, len(overloads), [d for _s, _i, d, _c in overloads])) section, midx, dsc, code_off = overloads[0] desc = dsc report.update({"class": cls, "method": name, "desc": desc, "section": section, "method_idx": midx, "code_off": code_off}) site, _insns = _find_site(dex, code_off, spec) if site is None: raise RuntimeError("[%s] no instruction matched spec['match']" % label) if isinstance(site, tuple) and site and site[0] == "ambiguous": cands = ", ".join("0x%x" % h["off"] for h in site[1]) raise RuntimeError("[%s] match is ambiguous (%d hits: %s); tighten it or " "set match.ordinal" % (label, len(site[1]), cands)) before, cur, after = _describe_neighbours(dex, code_off, site["off"]) report.update({ "site_off": cur["off"], "old_bytes": cur["raw"].hex(), "old_text": dex.describe(cur), "before": [dex.describe(i) for i in before], "after": [dex.describe(i) for i in after], }) # polarity pin: what must immediately follow? exp = spec.get("expect_next") if exp is not None: nxt = after[0] if after else None if nxt is None or not _matches(dex, nxt, exp, code_off): raise RuntimeError( "[%s] expect_next not satisfied at 0x%x. Expected %r, found %r.\n" " This is the polarity check: the instruction after the branch " "is not the one your spec assumes, so the branch points at the " "wrong side." % (label, cur["off"], exp, dex.describe(nxt) if nxt else None)) report["expect_next_ok"] = True new = _apply_replace(data, cur, spec.get("replace", {"kind": "nops"})) if len(new) != len(cur["raw"]): raise RuntimeError( "[%s] replacement is %d bytes but the instruction is %d. Equal length " "is required: a longer replacement overwrites the next instruction." % (label, len(new), len(cur["raw"]))) report.update({"new_bytes": new.hex(), "size_delta": 0}) # verifier: simulate the new instruction to see if it adds a branch edge fake = Dex(bytes(data), dex.name) fake_probe = dict(cur) fake_probe["raw"] = new fake_probe["op"] = new[0] report["new_text"] = fake.describe(fake_probe) if dry_run: report["ok"] = True return report, data data[cur["off"]:cur["off"] + len(new)] = new # Re-read the patched dex and confirm the edit landed. # # Compare the BYTE RANGE, not a decoded instruction: replacing a 4-byte branch # with a nop pair yields two 1-unit instructions, so "the instruction at this # offset has length 4" is false even though the patch is perfectly correct. # The byte range is what the patch actually owns. span = slice(cur["off"], cur["off"] + len(new)) if bytes(data[span]) != new: raise RuntimeError( "[%s] bytes at 0x%x are %s, expected %s" % (label, cur["off"], bytes(data[span]).hex(), new.hex())) patched = Dex(bytes(data), dex.name) insns_after = list(patched.decode(code_off)) info = patched.code_info(code_off) expected_end = info["insns_off"] + info["insns_size"] * 2 ended = (insns_after[-1]["off"] + insns_after[-1]["units"] * 2 if insns_after else info["insns_off"]) report["post_instruction_count"] = len(insns_after) report["post_decode_clean"] = (ended == expected_end) landed = [i for i in insns_after if cur["off"] <= i["off"] < cur["off"] + len(new)] report["post_text"] = " | ".join(patched.describe(i) for i in landed) # the next instruction boundary after the replaced span must still be the # instruction that followed the original one nxt_after = patched.insn_at(code_off, cur["off"] + len(new)) report["post_next_text"] = patched.describe(nxt_after) if nxt_after else None if not report["post_decode_clean"]: raise RuntimeError( "[%s] the method no longer decodes to exactly insns_size units after " "the edit -- the replacement changed the instruction stream length. " "Use an equal-length replacement." % label) edges = _verifier_report(patched, code_off) report["verifier_edges"] = len(edges) if edges: raise RuntimeError( "[%s] verifier: a branch targets a move-result at %s; the class will " "fail to load (VerifyError). Use a nop pair instead of a branch." % (label, ", ".join("0x%x->0x%x" % e for e in edges))) report["ok"] = True return report, data def main(argv): ap = argparse.ArgumentParser( description="Apply equal-length byte patches to a dex, with structural, " "polarity and verifier checks.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("Spec format")[-1][:1200] if __doc__ else None) ap.add_argument("dex", help="input .dex, or an APK/zip (use --entry to pick)") ap.add_argument("--entry", help="archive member to read (default: classes.dex)") ap.add_argument("--spec", required=True, help="JSON spec file") ap.add_argument("-o", "--out", help="output dex path") ap.add_argument("--dry-run", action="store_true", help="report matches and diffs without writing") ap.add_argument("--report", help="write a machine-readable JSON report here") args = ap.parse_args(argv[1:]) dex, entry = load_dex(args.dex, args.entry) if os.path.exists(args.dex) else (None, None) if dex is None: # allow raw bytes piped through a path that does not exist yet: fail clearly print("error: no such file: %s" % args.dex) return 2 problems = dex.check() if problems: print("structural problems in %s (%s):" % (args.dex, entry)) for p in problems: print(" - %s" % p) print("refusing to patch a source that does not parse cleanly") return 1 with open(args.spec, encoding="utf-8") as fh: spec_doc = json.load(fh) specs = spec_doc["patches"] if isinstance(spec_doc, dict) else spec_doc ok_before = verify_dex_header(dex.d) print("== source: %s (%s, %d bytes)" % (args.dex, entry, len(dex.d))) print(" header before: checksum_ok=%s signature_ok=%s" % ok_before) if not all(ok_before): print(" note: the SOURCE dex header is already inconsistent. Some") print(" producers ship a zeroed signature field; the patch will fix it.") data = bytearray(dex.d) reports = [] for i, spec in enumerate(specs): rep, data = process_one(dex, data, spec, i, args.dry_run) reports.append(rep) print("\n-- [%s] %s.%s%s" % (rep["name"], rep["class"], rep["method"], rep["desc"])) print(" site 0x%x %s" % (rep["site_off"], rep["old_text"])) for line in rep["before"][-2:]: print(" (before) %s" % line) print(" -> %s" % rep.get("new_text")) for line in rep["after"][:2]: print(" (after) %s" % line) if rep.get("expect_next_ok"): print(" polarity: expect_next satisfied") if args.dry_run: print("\n== dry run: nothing written") if args.report: with open(args.report, "w", encoding="utf-8") as fh: json.dump(reports, fh, indent=2) print(" report: %s" % args.report) return 0 header = fix_dex_header(data) ok_after = verify_dex_header(data) print("\n== header checksum 0x%08x -> 0x%08x" % (header["before_checksum"], header["after_checksum"])) print(" signature %s -> %s" % (header["before_signature"].hex()[:16], header["after_signature"].hex()[:16])) print(" self-verify: checksum_ok=%s signature_ok=%s" % ok_after) if not all(ok_after): print("FATAL: header does not self-verify; nothing written") return 1 out = args.out if not out: root, ext = os.path.splitext(args.dex) out = root + ".patched" + (ext or ".dex") with open(out, "wb") as fh: fh.write(bytes(data)) print("== wrote %s (%d bytes, delta %d)" % (out, len(data), len(data) - len(dex.d))) if args.report: with open(args.report, "w", encoding="utf-8") as fh: json.dump(reports, fh, indent=2) print(" report: %s" % args.report) print("\nNext: repack (scripts/repack.py) then sign with apksigner, then run " "the device + screen checks in references/verification.md.") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))