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
+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]