Add patch-audit reference; extend pitfalls to P30; unify script docs in English

- references/patch-audit.md (new): two independent questions for any self-made patch —
  did it land, and is it legal. Length-vs-bytes comparison and the ~800-false-positive
  signal that makes byte diffing useless; the equal-length-replacement blind spot that
  hides patches from length-only audits (and why tier-2 checking must be driven by the
  patch set's own declared targets, not by tier-1 output); verifier-level legality
  (move-result must immediately follow its producer) checked as a set-difference so it
  runs before the device; text-matching patch traps (whitespace, line endings,
  instruction names, anchor uniqueness, re-emitted old fragments, one block per doc);
  and auditing the patch set rather than a patch.
- pitfalls P28-P30: stale events-buffer records that read as this build's crash;
  a patch that lands and changes nothing because the edited data is not the data the UI
  consumes; two observations that cannot distinguish the hypotheses reported as a result.
- verification.md: point the known code-item blind spot at the new reference.
- Unify the four remaining script docstrings (and their inline comments) into English,
  and generalise a sample-specific class name out of one of them.
This commit is contained in:
inliver233
2026-09-19 22:38:54 +08:00
parent 98dd0b13c4
commit e50c826d95
9 changed files with 335 additions and 49 deletions
+4
View File
@@ -61,6 +61,10 @@ references/ loaded on demand, one topic each
tls-and-cert.md feature-scoped network failures: expired certs, dual trust chains
third-party-builds.md auditing a "cracked"/"modded" APK before trusting it
dex-patching.md patch-layer table + dexlib2 technique in depth
patch-audit.md proving a patch *landed* and is *legal*: length-vs-bytes
comparison, the equal-length-replacement blind spot, verifier-level
legality (move-result adjacency) checked statically, text-matching
patch traps, and reporting a missing patch
repack-and-sign.md repack rules, unpack-and-repack, signing, post-install hazards
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf; when the app
rewrites your edit, and decoding a value that looks encrypted
+1
View File
@@ -99,6 +99,7 @@ Load only what the current step needs.
| `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
| `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool |
| `references/patch-audit.md` | Proving a patch **landed**, or that it is **legal**: length-vs-bytes comparison, the equal-length blind spot, verifier-level legality (`move-result*` adjacency), text-matching patch traps, and how to report a missing patch |
| `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves |
| `references/signature-derived-keys.md` | The app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs and runs but every signed request fails (`sign`/`_p`/`uth` empty or `-1`) |
| `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens — **or your data edit keeps being reverted, or a stored value looks encrypted** |
+151
View File
@@ -0,0 +1,151 @@
# Auditing your own patch — proving it landed, and proving it is legal
Two independent questions, and they fail independently:
1. **Did the edit land?** Did the bytes you intended actually reach the artifact?
2. **Is the result legal?** Will the runtime's verifier accept it?
A patch can land and be illegal (the class fails to load). It can also fail to land while every
build step reports success. Both are silent, and each needs its own check.
## 1. Did it land? Compare *length*, not bytes
The naive check is a byte-level diff of the target method against the original.
**It produces a flood of false positives.** A disassemble→reassemble round-trip rebuilds the
string pool — the assembler **de-duplicates identical strings** — which shifts every later
string index by a small delta. Hundreds of methods then look "changed" while their behaviour is
identical.
Observed shape of that noise: **~800 methods reported as differing, 84% of them by only 1–4
bytes**, all of them constant-pool index shifts, with the method byte-length unchanged.
**Use instruction-stream length as the first-order predicate.** A real patch that changes control
flow or constant structure changes the method's `code_item.insns_size`. Pure index shifts and
debug-info reordering do not.
### The blind spot, and how to close it
**An equal-length replacement changes nothing measurable by length.**
Examples that bite: `move-result vX` → `const/4 vX, 0x0` (both one code unit); swapping one
`const` for another of the same width; any edit that keeps the instruction count identical.
Length-only auditing therefore **silently under-reports**: an audit reported 20 changed methods
on a build whose patch set actually touched 24 — the four missing ones were all equal-length
replacements.
**Two-tier rule:**
1. **Tier 1 (length):** scan every method. This yields candidates with essentially zero false
positives.
2. **Tier 2 (opcode-level):** compare the two versions opcode by opcode **for every method the
patch set declares as a target** — not merely the Tier-1 candidates.
**Tier 2's input cannot come from Tier 1's output.** Equal-length replacements produce no Tier-1
candidate at all, so the only reliable index of "what to check at Tier 2" is the patch set's own
declarations.
⇒ **Corollary, which is really a documentation requirement: every patch must declare its target
method signature.** A patch that is both equal-length and undeclared is invisible to both tiers;
finding it requires opcode-comparing every class, which is possible but expensive.
### A sharper Tier-2 shape for equal-length replacements
Compare the **position set of one opcode** inside the method:
```
target method, `move-result` (0x0a) position sets
original : [25, 43, 66, 72, 216, ... , 899] (16 entries)
patched : [25, 43, 72, 216, ... , 899] (15 entries) <- exactly one removed, pc=66
```
This proves two things at once: the intended site was hit, **and** the other same-shaped call
sites in that method were not. Asking "did anything differ" cannot distinguish those.
## 2. Is it legal? The verifier is a layer static checks cannot see
An assembler accepting your smali proves the file is **syntactically** valid dex. It does not run
the bytecode verifier. A build can pass assembly, pass every class-table check, and still be
rejected at class load:
```
java.lang.VerifyError: Verifier rejected class X: void X.<clinit>() failed to verify:
[0x1C8] copyRes1 v0<- result0 type=Undefined
```
**Root cause in the observed case:** padding instructions were inserted **between** a producer and
its `move-result`. DEX requires `move-result*` to **immediately follow** the instruction that
produces the result (`invoke-*`, `filled-new-array*`). The padding broke adjacency, the result
type became `Undefined`, and the whole method — and therefore the class — failed to verify.
The length arithmetic ("the shorter form plus two padding units preserves the total") was
**correct** and still produced an illegal method.
**Four layers, cheapest first:**
| Layer | Check | Catches |
|---|---|---|
| 1 | assembler exit code | syntax, register overflow, bad labels |
| 2 | class-table diff (`scripts/dex_classdiff.py`) | class-set / access-flag drift |
| 3 | **predecessor legality of `move-result*`** | result consumers detached from their producer |
| 4 | device: first execution of that method | everything above, plus real semantics |
**Layer 3 is a set-difference, not an absolute count.**
- For both builds, collect every `move-result*` whose immediately preceding instruction is **not**
a producer → the "offenders" set.
- Compare the sets. **New offenders must be 0.**
- **Never use the absolute size as a signal.** A perfectly healthy build can show hundreds of
offenders under this definition — payload pseudo-instructions (`packed-switch-payload`,
`fill-array-data-payload`, …) are normally read as ordinary instructions by a naive linear
scan — so the absolute number is dominated by scanner artifacts. Only the delta means anything.
Worked consequence: a build whose sole new offender was one `move-result` following padding
crashed at class load on device. After moving the padding to **after** the `move-result`, the
delta returned to 0 and the same build ran. **The delta predicted the device result before the
device was touched.**
## 3. When patches are applied by text matching
Some toolchains apply patches by literal match against a text form (smali) rather than through a
dex API. The failure modes are textual and unforgiving:
- **Whitespace is content.** A fragment indented with 5 spaces will not match a file using 4.
There is no fuzzy matching; one misplaced space is a zero-hit.
- **Line endings.** If the reader normalises CRLF→LF and the writer does not restore it, the
patched file becomes LF-only. A later byte-level comparison against the CRLF source then shows
the *entire file* as changed. Diff with an ignore-CR-at-EOL option, or compare structure.
- **Instruction names must be complete.** `move-result vX` and `move-result-object vX` are
different instructions; an anchor missing the suffix matches the wrong thing or nothing.
- **Operand punctuation matters.** `instance-of p3, p0, LType` is not the source text when the
source reads `LType;`.
- **Uniqueness is a hard requirement.** A matcher that finds several hits must fail loudly rather
than patch the first. When a one-line anchor is not unique, extend it with a second unique line;
**when the *combination* is still not unique, add a third anchor that discriminates the
homographs** — usually the branch target label, since identical call shapes often differ in
where they jump.
- **A re-emitted old fragment is not a failed write.** A patch may legitimately re-emit the old
text inside the new one (inserting a guard *before* the lines it matched). A read-back check
asserting "the old text is gone" then reports failure on that patch forever. Assert instead
that the **new, unique** part of the replacement is present.
- **One patch document, one patch block.** A parser that treats every fenced block as a candidate
will apply an *example* diff as a real patch — silently cancelling the real one, or editing
unrelated code. Keep examples in prose, never in the same fence format.
## 4. Audit the patch set, not a patch
For a multi-patch build:
```
expected = every method declared as a target by the patch set
tier1 = methods whose insns_size changed
tier2 = methods whose opcode stream differs (run over `expected`)
missing = expected - (tier1 U tier2) # must be empty
```
**`missing` is the number that matters.** A build can be green on every build step and still be
missing a patch. Report `missing` explicitly; do not report the total as if the total were the
goal.
**And re-verify the artifact, not the intent.** If the tool says "applied" but the build on the
device is unchanged, you have a stale build, a second copy, or the wrong file
(`pitfalls.md` P24). A patch tool's success message describes the tool, not the artifact.
+104
View File
@@ -690,3 +690,107 @@ runtime value (777 bytes) yields a build in which every signing parameter is `-1
5. **Check the OAID/device-id path too** — it frequently hashes the same certificate separately.
Full treatment: `references/signature-derived-keys.md`.
---
## P28. `logcat -c` does not clear the events buffer, so the previous build's crash looks like this build's
**Symptom**
After installing a fix, a search for the crash signature returns a hit. It looks like the fix did
not work — and the "hit" is convincing, because it is the exact exception and the exact frame you
were fixing.
**Root cause**
`adb logcat -c` clears `main`, `system` and `crash` **by default — not `events`**. The
system's own crash record (`am_crash`, `am_proc_died`) lives in `events`. Reading `-b all`
therefore pulls in the previous build's crash, which was never cleared.
**How to tell it apart**
Correlate **PID and wall-clock time** against the current process:
- The PID in the record belongs to a process that is no longer running.
- The timestamp **predates** the current process's start.
Both together mean history, not a finding. (In the observed case the stale record's PID was the
previous build's, and its timestamp was minutes before the current process started.)
**Do instead**
Pick one, and say which you used:
- Bound the query by timestamp — accept only records after the current process started, or
- Judge per buffer — `crash` is a fresh window after `-c`; `events` requires PID/time correlation.
**The evidence window is itself a claim that needs support.** An unexamined stale window turns
"fixed" into "still broken", and this one is invisible in the log's own text.
---
## P29. The patch landed and changed nothing, because the static data it edited is not the data the UI consumes
**Symptom**
Build is green. The patch is verifiably present in the artifact. The behaviour is unchanged.
**Root cause**
**The same type had two construction sites** — one for a static/default template, one inside the
runtime conversion of a server response — and only the second one feeds the UI. Editing the first
is a no-op that verifies cleanly and audits cleanly.
**How to see it before shipping**
From the type's constructor, count the call sites (`scripts/find_refs.py`), then ask of each:
> **Is this one on the path the UI actually reads?**
"It is constructed here" is not "it is consumed here". Two constructors of the same type can have
completely different fates.
**Do instead**
- Patch the **consumer** — the loop that converts response items into the UI model — not the
static table.
- Prefer a predicate over a **semantic discriminator** (a business code, a task type) rather than
a display string. The literal in the template is **not** necessarily the literal the server
sends: the string you want to match may not exist anywhere in the response.
- If you cannot confirm the server-side value, **predicate on both candidate fields** rather than
betting on one.
---
## P30. Two observations that cannot distinguish the hypotheses, reported as a result
**Symptom**
A verification step "passes" or "fails" while having tested nothing.
**Root cause**
The observable is identical under both hypotheses, so the measurement carries no information.
Real instance: a promotional banner was absent in the logged-in state, and that run was used to
"verify" its removal — but the same banner is also absent in the logged-out state. Neither run
could distinguish an effective patch from a no-op, and the second one was skipped as
"uninformative" — correctly, since it could not have been informative either.
**Do instead**
Before running a verification, ask:
> **What would this look like if the patch were absent?**
If the answer is the same, the measurement is void. Record it as **"not applicable under this
condition"**, never as "passed".
Two close variants of the same error:
- Treating **"the entry point is unreachable"** as proof of removal. The honest test is whether
the **request is still issued**: a UI element that no longer renders can still fire its network
call from elsewhere. Verify at the layer the behaviour actually lives on.
- Treating **"the screenshot hash changed"** as proof that something rendered. A hash proves
*change*, never *what* changed — and it never proves *absence*. Look at the images: a status-bar
clock tick or a line of text reflowing changes the hash while a full-screen overlay would not
have been missed if the frames had actually been inspected.
+2
View File
@@ -48,6 +48,8 @@ access_flags diff (same interface-ness): 0
Caveat, and it is an important one: **this check cannot detect code-item damage.** A whole-tree smali round-trip can pass every table check and still crash with `IncompatibleClassChangeError` (`pitfalls.md` P3). Passing this check is necessary, not sufficient.
The checks that *can* see further — instruction-length auditing, the equal-length-replacement blind spot, and verifier-level legality (`move-result*` adjacency) — are in `references/patch-audit.md`. Use them before claiming a patch landed.
Also confirm you changed what you intended and nothing more:
- Reverse the patched dex and inspect the target method.
- Compare unmodified classes byte-for-byte against the original where feasible.
+9 -4
View File
@@ -1,11 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对比两个 dex 的 class_defs:类名 + access_flags(重点看 ACC_INTERFACE 0x200)。
"""Diff two dex files at the class_defs level: class names + access_flags.
The interesting flag is ACC_INTERFACE (0x200).
用途:验证 baksmali -> smali 整树往返是否损伤了 dex 的 interface/class 关系。
ICCE "Found interface X, but class was expected" 就是这类损伤的典型症状。
Use it to prove that a disassemble -> reassemble round-trip did not damage the dex's
interface/class relationships. The classic symptom of that damage is
IncompatibleClassChangeError ("Found interface X, but class was expected") at runtime.
用法:python dex_classdiff.py <a.dex> <b.dex> [过滤子串]
Important limitation: this check compares tables only. It cannot see code-item damage —
see references/patch-audit.md for the checks that can.
Usage: python dex_classdiff.py <a.dex> <b.dex> [filter-substring]
"""
import struct
import sys
+27 -18
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""字节级定点 patch dex 里的字符串常量,完全不做 smali 往返。
"""Byte-level, in-place patch of a string constant inside a dex — no smali round-trip.
为什么不用 smali 往返:实测 classes7.dex 经 baksmali->smali 整树重建后,
`io.ktor.client.engine.HttpClientEngine` 的 synthetic access bridge 被破坏,
运行时报 IncompatibleClassChangeError(Found interface, but class was expected)。
class_def 的 access_flags 看不出问题,所以只能改用"只改字节"的方式。
Why not a smali round-trip: a disassemble -> reassemble of a whole tree can damage
R8-optimized output in ways the class table does not show. Observed symptom: a synthetic
access bridge lost its interface/class relationship, producing
IncompatibleClassChangeError ("Found interface X, but class was expected") at runtime even
though class_defs and access_flags looked untouched. See references/pitfalls.md P3.
本脚本做法(对 dex 结构零改动):
1. 把目标字符串替换成**等长**的另一个字符串 —— 长度相同则 string_data_item
的 uleb128 长度前缀不变,dex 的偏移、索引、表全部不变;
2. 重算 dex header 的 signature(SHA-1, 从 offset 32 起) 与 checksum(adler32, 从 offset 12 起)。
What this script does instead (zero structural change to the dex):
1. Replaces the target string with another of **exactly equal byte length**. Equal length
keeps the string_data_item uleb128 length prefix unchanged, so offsets, indices and
every table stay exactly where they were.
2. Recomputes the dex header signature (SHA-1, from offset 32) and checksum (adler32,
from offset 12).
用法:python dex_strpatch.py <in.dex> <out.dex> <old_str> <new_str>
要求 len(new) == len(old),且 old 在 dex 中出现次数恰好为 1。
Use only when the replacement can be equal-length. Otherwise use a method-level dex API
rewrite (references/dex-patching.md, technique 1).
Usage: python dex_strpatch.py <in.dex> <out.dex> <old_str> <new_str>
Requires len(new) == len(old) in UTF-8 bytes, and exactly one occurrence of old in the dex.
"""
import hashlib
import struct
@@ -46,11 +52,12 @@ def _read_strings(data):
def _order_ok(data, off, ob, nb):
"""检查把 ob 换成 nb 后是否仍满足 string_ids 的字典序约束。
"""Check whether replacing `ob` with `nb` still satisfies the string_ids ordering rule.
注意:string_ids 里存的是 string_data_item 的偏移,也就是**长度前缀**的位置
(在 UTF-8 数据前若干字节),所以不能直接拿字符串数据偏移去匹配 ——
这里改为按内容匹配目标条目。
Note: string_ids stores the offset of each string_data_item — i.e. the position of the
**length prefix** (a few bytes ahead of the UTF-8 payload), not of the text itself — so
matching by string-data offset directly is not possible; this matches the entry by
content instead.
"""
entries = _read_strings(data)
idx = None
@@ -89,9 +96,11 @@ def main():
return 1
off = data.find(ob)
# 关键:dex 规范要求 string_ids 表按字符串内容有序。等长替换虽然不动偏移,
# 但会改变该条目在表中的字典序位置 —— 一旦越界,整个 dex 会被 ClassLoader
# 拒绝(表现为 ClassNotFoundException: <Application>)。所以这里先做区间校验。
# The dex spec requires string_ids to be ordered by string content. An equal-length
# replacement moves no offset, but it does change where this entry sits in that ordering
# — and once it lands out of order the whole dex is rejected by the ClassLoader
# (symptom: ClassNotFoundException naming the Application class). So validate the
# interval before writing.
if not _order_ok(data, off, ob, nb):
print('[FAIL] replacement would break string_ids ordering. '
'Pick a string that stays between its two neighbours.')
+18 -13
View File
@@ -1,24 +1,29 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方法级 smali 补丁器。
Method-level smali patcher.
设计要点(踩坑后确定):
- 只替换「.method 声明行的下一行」到「.end method 之前」的指令体,
保留原 .method / .end method / .annotation 段不动,避免破坏修饰符与注解。
- 补丁清单要求显式给出自洽的 .registers 值:smali 汇编器会校验寄存器不越界,
registers 必须 >= max(参数寄存器数) 且 >= 指令里用到的最大寄存器编号 + 1。
参数寄存器数:static 方法 = 参数个数;非 static = 参数个数 + 1(this)。
- 补丁清单是 JSON 列表,每条:
Design notes (each one earned the hard way):
- Only the instruction body is replaced: from the line after the `.method` declaration up to
(but not including) `.end method`. The `.method` / `.end method` / `.annotation` sections are
left untouched, so modifiers and annotations survive.
- The patch manifest must state a self-consistent `.registers` value: the assembler rejects
out-of-range registers, so `registers` must be >= the number of parameter registers and
>= the highest register index used by any instruction + 1. Parameter registers:
static method = number of parameters; instance method = number of parameters + 1 (this).
- The manifest is a JSON list; each entry:
{
"file": "com/example/Helper.smali", # path relative to smali tree root
"file": "com/example/Helper.smali", # path relative to the smali tree root
"method": ".method public final show(Landroid/app/Activity;)V", # exact .method line
"registers": 3,
"body": ["invoke-interface {p3}, ...;", "return-void"],
"note": "干掉插屏广告" # 可选,仅用于报告
"note": "why this patch exists" # optional, report only
}
用法:
A literal-match tool: whitespace, operand punctuation and instruction names must match the
source exactly, and the anchor must be unique. See references/patch-audit.md section 3.
Usage:
python patch_smali.py <smali_tree> <patch.json> [--dry-run]
"""
import json
@@ -28,7 +33,7 @@ import sys
def split_methods(text):
"""把 smali 文本切成 [(method_decl, start_idx, end_idx)],按行索引。"""
"""Split smali text into [(method_decl, start_idx, end_idx)] using line indices."""
lines = text.split('\n')
out = []
start = None
@@ -53,7 +58,7 @@ def patch_one(path, decl_wanted, registers, body):
if len(hits) > 1:
return False, 'ambiguous (%d matches): %s' % (len(hits), decl_wanted)
_, start, end = hits[0]
# 保留 .method 行;重写内部:.registers + body
# Keep the .method line; rewrite the interior: .registers + body
new_inner = [' .registers %d' % registers, '']
for b in body:
new_inner.append(' ' + b)
+19 -14
View File
@@ -1,22 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用于给「无网络」Android 设备供网的极简 HTTP/HTTPS 代理。
Minimal HTTP/HTTPS forward proxy for giving an offline Android device network access.
场景:手机 WiFi DHCP 失败 -> 无默认网络 -> app 所有请求失败。
解法:手机没有可用网络接口也没关系 —— adb reverse 会在**设备本机的 loopback**
上开监听,不需要任何网络接口。于是:
1) 电脑跑本代理 (0.0.0.0:8080)
Situation: the device has no working network interface (DHCP failure, no default route), so
every request the app makes fails.
Why this works: it does not need a network interface on the device at all. `adb reverse`
opens a listener on the **device's own loopback**, which exists regardless:
1) run this proxy on the host (0.0.0.0:8080)
2) adb -s <serial> reverse tcp:8080 tcp:8080
3) 设备端 su -c "settings put global http_proxy 127.0.0.1:8080"
4) app 的 HTTP/HTTPS 流量经 USB 到电脑,由电脑代发
3) on the device: settings put global http_proxy 127.0.0.1:8080
4) the app's HTTP/HTTPS traffic travels over USB and is sent by the host
支持:
- CONNECT 隧道(HTTPS 主用)
- 明文 HTTP 绝对 URI 转发
仅做转发,不做 MITM(不解密 TLS,也不改流量),因此不干扰证书校验。
Supports:
- CONNECT tunnelling (what HTTPS uses)
- absolute-URI plain HTTP forwarding
用法:python usb_net_proxy.py [listen_port] [logfile]
Forwards only: it does not MITM, does not decrypt TLS and does not modify traffic, so
certificate validation is unaffected. See references/environment.md.
Usage: python usb_net_proxy.py [listen_port] [logfile]
"""
import socket
import sys
@@ -39,7 +44,7 @@ def log(msg):
def pump(a, b):
"""a -> b 单向搬运,直到任一端关闭。"""
"""Copy a -> b in one direction until either end closes."""
try:
while True:
r, _, _ = select_select([a], [], [], 30)
@@ -105,7 +110,7 @@ def handle(client, addr):
t.start()
pump(remote, client)
else:
# 明文 HTTP:target 一般是绝对 URI
# Plain HTTP: target is normally an absolute URI
if target.startswith('http://'):
without = target[len('http://'):]
hostport = without.split('/', 1)[0]