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:
@@ -0,0 +1,170 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user