Close the byte-patching gap, add the server-config path, fix two script crashes

Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.

New references
  byte-level-patching.md
    The technique that was missing entirely. Equal-length in-place edits beat
    method rebuilding (measured on a real sample: method rewrite grew debug_info
    924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
    silent: same-length is not enough, branch polarity, verifier legality, and
    stale dex header fields. Covers locating an instruction's exact offset (why
    listings and byte-pattern guesses both fail), the instruction width table and
    the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
    and the header's signature-then-checksum order.
  server-config-and-updates.md
    The most common shape of "ad" in a modern app, and the one usually
    mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
    (local fallback then remote override) that proves it, finding the config DTOs
    through the field names data classes keep, why you patch the decision and not
    the data, deciding the scope of "remove", and remote re-enable / cached config
    durability.

New scripts
  dexutil.py             dependency-free dex reader: structural walk, exact
                         instruction decode, dex header recompute/verify in the
                         correct order, branch-target and operand helpers
  dex_find_insn.py       locate an instruction by decoded semantics, print its exact
                         byte offset with context and BOTH sides of any branch
  dex_patch_bytes.py     equal-length byte patches from a JSON spec: semantic match,
                         polarity pin via expect_next, equal-length enforcement,
                         verifier check, header recompute, re-decode to prove it landed
  dex_check_verifier.py  does any conditional branch target a move-result (bypassing
                         its producer)? Compares builds and separates pre-existing
                         findings from regressions the patch introduced
  coldstart.py           cold-launch capture: timed screenshot burst + logcat signals
                         + installed-build facts + launch timing, and a warning when
                         the foreground activity is not the app
  check_repo.py          maintenance check: every script parses and answers --help,
                         every referenced path resolves, every file is documented

Fixes found while running the above
  repack.py       wrote archives with a plain zipfile writer, which cannot satisfy
                  the Android R+ install gate: resources.arsc must be STORED *and*
                  4-byte aligned, and a zip extra record cannot express a 1-3 byte
                  pad (a stored filler entry is inserted instead). Also fixes the
                  central-directory pointer and adds an alignment gate to the output.
  usb_net_proxy.py, dex_strpatch.py
                  crashed on --help with a traceback instead of printing usage.

Updated
  toolchain.md            ddc added (single-binary dex->Java with query subcommands,
                          with measured timings and its real limits); "not on PATH is
                          not not-installed" with the search commands; which signer
                          to use and why jarsigner breaks alignment
  repack-and-sign.md      alignment requirement and its arithmetic; dex header
                          recompute; jarsigner hazard; vendor install interception
                          ([N] with no symbolic constant -> root pm install); the
                          installer can keep owning the screen after an install
  recon.md                resolve package identity from a tool, not from hand-read
                          AXML strings (a wrong package sends all later device queries
                          nowhere); snapshot the config surface during recon
  long-task-discipline.md captures you never looked at are not evidence (incl. the
                          consistent-but-wrong-window variant); long-context decay and
                          the record shape that survives it
  dynamic-frida.md        when live attach cannot work at all: static instrumentation,
                          static read, and self-recorded evidence, with the control
                          build caveat each one carries
  ad-removal.md           server-driven config is usually the EASIEST case, not the
                          hardest; a three-way zero result is a positive finding;
                          scope note on local brand splash vs promotional content
  pitfalls.md             P37 stale dex header checksum, P38 [-124] alignment,
                          P39 bare-numeric install failure, P40 wrong-window evidence,
                          P41 inverted branch polarity, P42 decode desynchronisation
  SKILL.md, README.md     symptom index, reference/script indexes and requirements
                          updated for all of the above
This commit is contained in:
inliver233
2026-09-20 02:05:41 +08:00
parent d1349b3cfa
commit a552157f1b
20 changed files with 3179 additions and 52 deletions
+278
View File
@@ -0,0 +1,278 @@
#!/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": (0x27, 0x28, 0x29),
"switch": (0x2A, 0x2B),
"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))