Files
apk-reverse/references/byte-level-patching.md
T
inliver233 a552157f1b 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
2026-09-20 02:05:41 +08:00

11 KiB
Raw Blame History

Byte-level dex patching — equal-length edits, and the traps that make them fail

dex-patching.md covers rewriting a method body with dexlib2. This file covers the other technique: rewriting a few bytes in place. Reach for it whenever the change can be expressed as "make this branch do the other thing" or "make this constant a different constant", because it is strictly safer than rebuilding a method — but only if you respect the constraints below, which are not optional and are not obvious.

Why equal-length, in numbers

A method-level rebuild is not free. Measured on one R8-processed sample (4.32 MB single dex):

original after a 2-method dexlib2 rewrite
dex size 4.32 MB 7.73 MB
debug_info table 924 bytes 22,828 bytes
class set / access flags — identical (zero drift)

The class table is unchanged, so the rebuild is correct — but dexlib2 does not reuse R8's shared debug_info entries and the file grows ~80%. That is the real cost of "surgical" method rewriting, and it matters because it changes the byte layout of everything after the edited methods.

An equal-length edit changes only the bytes you chose. No offset moves, so no try/catch block, no debug-info pointer, and no branch displacement anywhere in the file can be invalidated by construction. That property is worth more than the convenience of writing smali.

Decision rule: if the change fits in an existing instruction slot or a constant, patch bytes. If it genuinely needs new instructions or a different register allocation, rebuild the method — and do it in a copy so you can diff the tables.

Locating the exact byte offset of an instruction

This is the part that eats hours if you improvise. Three approaches, worst to best.

Do not reconstruct offsets from a .line listing. baksmali emits one .line per source line, and one source line can be attributed to several consecutive instructions. The .line values therefore repeat and do not map 1:1 to offsets. A listing walker built on .line silently produces zero candidates for a branch you can see with your eyes.

Do not match on a guessed byte sequence. Instruction encodings vary with register numbers and operand widths; a pattern that looks right (38 xx) can be a different instruction than you think (0x38 is if-test, 22t and 4 bytes — not if-testz).

Do this instead: walk the dex structure to the method, then decode. Resolve class → class_data_item → method (code_off) → code_item → instruction stream, and decode forward with a complete format table. Then match on decoded semantics ("an iget-boolean p1 immediately followed by if-nez p1 whose target reads field X") rather than on bytes. scripts/dex_find_insn.py does this; the decoder in scripts/dexutil.py is the reusable part.

Three details in the walk that silently produce wrong answers:

  • class_data_item member indices are diffs. field_idx, method_idx are encoded as deltas against the previous entry in the same list and must be accumulated. Reading a raw uleb as an absolute index gives you real-looking wrong members — you will "find" a method that is not the one you asked for and not notice.
  • Payload nops are not nops. dalvik encodes switch/array payloads as 00 <ident> <size> with ident in 1..3. A plain 00 00 is an ordinary one-unit nop. Treating every 00 as a payload swallows the next instruction and desynchronises the whole rest of the method.
  • The header field order is not what you remember. After magic(8) + checksum(4) + signature(20), the offsets are file_size 0x20, header_size 0x24, endian 0x28, link_size 0x2C, link_off 0x30, map_off 0x34, string_ids_size 0x38, string_ids_off 0x3C, type_ids 0x40/0x44, proto_ids 0x48/0x4C, field_ids 0x50/0x54, method_ids 0x58/0x5C, class_defs 0x60/0x64, data 0x68/0x6C. Print the parsed sizes and compare against the file before trusting anything downstream.

Instruction format table (the part that must be right)

An operand width wrong by one byte desynchronises the decode from that point on, and the failure looks like "the instruction I want does not exist". These are the groups that matter in practice:

opcode range format code units
01 04 07 0A 0B 0C 0D 0E 0F 10 11 12 12x / 11x / 11n / 10x 1
13 15 16 17 19 1B 20 21 23 24 21s/21h/21c/22c/35c/3rc 2
14 18 1A 22 25 27 28 29 2A 2B 31i/31c/31t/30t/20t/switch 3
02 05 08 22x 2
03 06 09 32x 3
2C–31 23x 2
32–37 22t if-test (2 regs + int16) 2
38–3D 21t if-testz (1 reg + int16) 2
44–51 23x aget/aput 2
52–5F 22c iget/iput 2
60–6D 21c sget/sput 2
6E–72 / 74–78 35c / 3rc invoke 3
7B–8F 12x unop 1
90–AF 23x binop 2
B0–CF 12x binop/2addr 1
D0–D7 22s binop/lit16 2
D8–E2 22b binop/lit8 2

Self-check that costs nothing: decode the whole method and assert you land exactly on insns_off + insns_size*2. If you overshoot or undershoot, the table is wrong somewhere and every offset you derived is suspect. Also scan the decoded register numbers: a method whose registers count is 12 cannot use v13, so any v13+ in the output is proof of desync.

Long conditional branches are two instructions. A conditional jump has a 16-bit displacement, but when you see if-nez vX, :label in smali and :label is far away, the assembler may have emitted if-* followed by a separate goto. When hunting a branch, do not assume the pattern is "branch → target"; confirm by decoding.

Neutralise a branch, do not redirect it

Once you have decided which side of a condition you want, the safest edit is to remove the branch, not to point it somewhere else.

Edit When it is right Risk
if-* → nop pair (preferred) You want the fall-through path None. No new control-flow edge is created
if-* → goto (+ same displacement) You want the branch-taken path Introduces an edge. It may land on a move-result*, which the verifier rejects
flip the sense (if-nez → if-eqz) Polarity is the only problem Same edge as above, different destination

The move-result rule: move-result* must be immediately preceded by its producing invoke in the instruction stream. A branch whose target is a move-result bypasses the producer and the class fails to load with VerifyError: ... copyRes vN <- result0 type=Undefined. A linear "is the previous instruction the producer" check does not catch this — you need the CFG view: does any branch target this move-result? scripts/dex_check_verifier.py answers that question directly.

A nop pair cannot create that problem, which is why it is the default choice.

Polarity is the other silent killer. Field names lie about intent often enough that you must read the branch structure, not the name. In one real case the config field was enabled, the smali was if-nez v, +6, and the fall-through was startActivity(main) while the branch target was the countdown block. So enabled == true was the value that skipped the promo — the opposite of the literal reading. Always decode both sides of the branch and name what each one does before editing. Getting this backwards produces a build that does the exact opposite of the goal and still starts cleanly, so it survives casual testing.

The dex header has two integrity fields, and order matters

Any edit to a dex body invalidates both header integrity fields. Recompute them in this order:

bytes 12..32 = sha1(data[32:])        # signature first
bytes  8..12 = adler32(data[12:])     # checksum last — it covers the signature bytes

Writing them in the reverse order produces a header that looks plausible and never verifies, because the adler32 was taken while the signature field was still zeroed.

Why this is dangerous rather than merely broken: Android logs Failure to verify dex file ...: Bad checksum (computed, expected) — note that the real adler32 appears as the "expected" value, which reads backwards — and then falls back to interpreting the dex. The process may still start, but the class loader can fail to resolve ordinary classes (ClassNotFoundException: <your Application class>), so the symptom is "the APK I built is broken" rather than "two header fields are stale".

Aggravating detail: some producers ship a dex whose signature field is all zeros (a known R8/optimiser artifact). On such a file the wrong order is self-consistent until the first patch, so the bug is invisible until you edit something — and then looks like your edit caused it.

scripts/dex_patch_bytes.py recomputes both fields in the correct order and refuses to write if the result does not self-verify.

Worked shape of a patch script

Keep these properties; they are what make the edit auditable a month later:

  1. Locate by structure + decoded semantics, never by a hard-coded offset alone.
  2. Assert the old bytes before replacing, and abort on mismatch. A silent mismatch means the sample changed or your understanding did.
  3. Equal length in, equal length out. Assert len(new) == len(old).
  4. Recompute both header fields, then assert adler32(data[12:]) == header_checksum and sha1(data[32:]) == header_signature on the bytes you are about to write.
  5. Re-decode the patched method and print the instruction you changed, plus confirm the instruction that should follow it still does. Two instructions of output is enough to catch the "landed on the wrong site" class.
  6. Report the size delta (should be 0) and before/after hashes of both the dex and the APK.

Verification specific to byte patches

patch-audit.md covers the general audit. Two checks matter more here:

  • Assert the instruction count and every branch target are unchanged. Compare a decoded listing of the whole method before and after: same instruction count, same offsets, same targets. If the count changed, you replaced a 4-byte instruction with a 4-byte pair and the extra decode boundary is expected — but say so explicitly rather than letting a diff tool surprise the next reader.
  • VerifyError is a class-load failure, not a startup failure. If the app starts and one screen is dead, or a secondary class is missing, suspect a verifier violation in the method you edited. logcat shows it as E/dalvikvm or E/art with the class name; it does not always surface as a crash dialog.

When not to use this technique

  • The change needs new instructions, a new register, or a different call — go to method rewriting.
  • The change is a short string constant whose new value is a different length — you cannot grow the string in place. Equal-length string swaps work (pitfalls.md P2 on the string-id ordering guard); different-length values need dex_strpatch-class tooling or a rebuild.
  • The target is a multi-dex app and you intend to move code between dexes. Byte patching stays inside one dex by definition.