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

189 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `nop`s are not `nop`s.** 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.