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
11 KiB
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_itemmember indices are diffs.field_idx,method_idxare 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 notnops. dalvik encodes switch/array payloads as00 <ident> <size>withidentin 1..3. A plain00 00is an ordinary one-unitnop. Treating every00as 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 arefile_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:
- Locate by structure + decoded semantics, never by a hard-coded offset alone.
- Assert the old bytes before replacing, and abort on mismatch. A silent mismatch means the sample changed or your understanding did.
- Equal length in, equal length out. Assert
len(new) == len(old). - Recompute both header fields, then assert
adler32(data[12:]) == header_checksumandsha1(data[32:]) == header_signatureon the bytes you are about to write. - 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.
- 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.
VerifyErroris 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.logcatshows it asE/dalvikvmorE/artwith 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.mdP2 on the string-id ordering guard); different-length values needdex_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.