# 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 ` 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: `), 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.