feat: apk-reverse Agent Skill

Progressive-disclosure skill for Android APK reverse engineering:
recon, ad removal, membership limits, dex surgical patching, repack/sign,
runtime data, Frida, environment setup, and a hard-won failure catalogue.
This commit is contained in:
inliver233
2026-09-19 21:25:35 +08:00
commit c66658b6b3
33 changed files with 3713 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import org.jf.dexlib2.DexFileFactory;
import org.jf.dexlib2.Opcode;
import org.jf.dexlib2.Opcodes;
import org.jf.dexlib2.iface.ClassDef;
import org.jf.dexlib2.iface.Method;
import org.jf.dexlib2.iface.MethodImplementation;
import org.jf.dexlib2.builder.MutableMethodImplementation;
import org.jf.dexlib2.immutable.ImmutableMethod;
import org.jf.dexlib2.writer.pool.DexPool;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。
*
* 为什么不用 baksmali/smali 整树往返:实测本项目的 classes7.dex 往返重建后运行时报
* IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
* but class was expected
* (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item,
* 其余类/方法/字符串表原样保留,因此不会有这类损伤。
*
* 用法: java PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>
* 例: java PatchMethod classes7.dex out.dex Lx6; d V
*/
public class PatchMethod {
public static void main(String[] args) throws Exception {
if (args.length < 5) {
System.err.println("usage: PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>");
System.exit(2);
}
String inPath = args[0], outPath = args[1], desc = args[2], mName = args[3], ret = args[4];
org.jf.dexlib2.iface.DexFile dex =
DexFileFactory.loadDexFile(new File(inPath), Opcodes.forApi(34));
List<ClassDef> outClasses = new ArrayList<ClassDef>();
int patched = 0;
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<Method>();
for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<Method>();
for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean isTargetClass = cd.getType().equals(desc);
boolean changed = false;
if (isTargetClass) {
for (int pass = 0; pass < 2; pass++) {
List<Method> list = (pass == 0) ? direct : virtual;
for (int i = 0; i < list.size(); i++) {
Method m = list.get(i);
if (!m.getName().equals(mName) || !m.getReturnType().equals(ret)) continue;
// 用 return-void 替换整个方法体。
// 寄存器数必须保留原值:Compose/协程方法体里可能仍被其它指令引用同一寄存器窗口,
// 且 dex 校验要求 registers >= 参数寄存器数。
int regs = m.getImplementation() != null ? m.getImplementation().getRegisterCount() : 0;
MethodImplementation impl = new org.jf.dexlib2.immutable.ImmutableMethodImplementation(
regs,
java.util.Collections.singletonList(
new org.jf.dexlib2.immutable.instruction.ImmutableInstruction10x(Opcode.RETURN_VOID)),
null,
null);
Method nm = new ImmutableMethod(
m.getDefiningClass(), m.getName(), m.getParameters(), m.getReturnType(),
m.getAccessFlags(), m.getAnnotations(), m.getHiddenApiRestrictions(),
impl);
list.set(i, nm);
patched++;
changed = true;
System.out.println("[patch] " + desc + "->" + mName + m.getReturnType()
+ " registers=" + regs);
}
}
}
if (!changed) {
outClasses.add(cd);
} else {
outClasses.add(new org.jf.dexlib2.immutable.ImmutableClassDef(
cd.getType(), cd.getAccessFlags(), cd.getSuperclass(),
cd.getInterfaces(), cd.getSourceFile(), cd.getAnnotations(),
cd.getStaticFields(), cd.getInstanceFields(), direct, virtual));
}
}
DexFileFactory.writeDexFile(outPath,
new org.jf.dexlib2.immutable.ImmutableDexFile(Opcodes.forApi(34), outClasses));
System.out.println("[done] patched=" + patched + " -> " + outPath);
if (patched == 0) {
System.err.println("[warn] no method matched!");
System.exit(1);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import org.jf.dexlib2.DexFileFactory;
import org.jf.dexlib2.Opcode;
import org.jf.dexlib2.Opcodes;
import org.jf.dexlib2.iface.ClassDef;
import org.jf.dexlib2.iface.Method;
import org.jf.dexlib2.iface.instruction.Instruction;
import org.jf.dexlib2.immutable.ImmutableClassDef;
import org.jf.dexlib2.immutable.ImmutableDexFile;
import org.jf.dexlib2.immutable.ImmutableMethod;
import org.jf.dexlib2.immutable.ImmutableMethodImplementation;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction11x;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction22c;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction35c;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction51l;
import org.jf.dexlib2.immutable.reference.ImmutableFieldReference;
import org.jf.dexlib2.immutable.reference.ImmutableMethodReference;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* classes7.dex 定点改动(单次读写,绝不要连续重写两次)。
*
* 只做一件事:把免广告到期时间的读取 lambda 改成恒返回"未过期"。
*
* VideoDataStore$currentAdFreeExpiresAt$$inlined$map$1$2.emit(Object; Continuation)Object
* -> 无条件向下游 collector 发一个超大 Long
*
* 效果等价于 DataStore 里 ad_free_expires_at 永远没有过期,
* 「看广告领特权」弹窗因此不再出现,而且这个改动写死在代码里,
* 全新安装同样生效(DataStore 那份是运行时数据,随 APK 分发不了)。
*
* ⚠️ 不要顺手 patch Lx6;->d:那是通用的图片卡片 Composable,
* 内容和广告共用,改成 return-void 会导致正常图片/播放链路一起失效。
*/
public class PatchClasses7v2 {
private static final String AD_CLASS =
"Lcyc/data/datastore/VideoDataStore$currentAdFreeExpiresAt$$inlined$map$1$2;";
private static final long BIG = 4102444800000L; // ~2100-01-01 (ms)
public static void main(String[] args) throws Exception {
String in = args[0], out = args[1];
org.jf.dexlib2.iface.DexFile dex =
DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(34));
List<ClassDef> outClasses = new ArrayList<ClassDef>();
int p = 0;
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<Method>();
for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<Method>();
for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean touched = false;
if (cd.getType().equals(AD_CLASS)) {
for (int pass = 0; pass < 2; pass++) {
List<Method> list = (pass == 0) ? direct : virtual;
for (int i = 0; i < list.size(); i++) {
Method m = list.get(i);
if (!m.getName().equals("emit")
|| !m.getReturnType().equals("Ljava/lang/Object;")
|| m.getImplementation() == null) {
continue;
}
List<Instruction> body = new ArrayList<Instruction>();
// v0 = collector, v1:v2 = long, p0=3 p1=4 p2=5
body.add(new ImmutableInstruction22c(Opcode.IGET_OBJECT, 0, 3,
new ImmutableFieldReference(AD_CLASS, "b", "Lkw2;")));
body.add(new ImmutableInstruction51l(Opcode.CONST_WIDE, 1, BIG));
body.add(new ImmutableInstruction35c(Opcode.INVOKE_STATIC, 2, 1, 2, 0, 0, 0,
new ImmutableMethodReference("Ljava/lang/Long;", "valueOf",
Collections.singletonList("J"), "Ljava/lang/Long;")));
body.add(new ImmutableInstruction11x(Opcode.MOVE_RESULT_OBJECT, 1));
body.add(new ImmutableInstruction35c(Opcode.INVOKE_INTERFACE, 3, 0, 1, 5, 0, 0,
new ImmutableMethodReference("Lkw2;", "emit",
Arrays.asList("Ljava/lang/Object;", "Lwj1;"),
"Ljava/lang/Object;")));
body.add(new ImmutableInstruction11x(Opcode.MOVE_RESULT_OBJECT, 0));
body.add(new ImmutableInstruction11x(Opcode.RETURN_OBJECT, 0));
list.set(i, new ImmutableMethod(m.getDefiningClass(), m.getName(),
m.getParameters(), m.getReturnType(), m.getAccessFlags(),
m.getAnnotations(), m.getHiddenApiRestrictions(),
new ImmutableMethodImplementation(6, body, null, null)));
p++;
touched = true;
System.out.println("[patch] emit -> BIG=" + BIG + " (list=" + pass + ")");
}
}
}
if (!touched) outClasses.add(cd);
else outClasses.add(new ImmutableClassDef(
cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
cd.getInstanceFields(), direct, virtual));
}
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(34), outClasses));
System.out.println("[done] patched=" + p);
if (p == 0) System.exit(1);
}
}
+78
View File
@@ -0,0 +1,78 @@
# dexlib2 method-level patcher
The **preferred** way to change app behavior. It replaces only a target method's
implementation and writes a new dex, leaving every other class, method, string and
reference untouched.
Why not whole-tree smali round-trip: `baksmali` → `smali` rebuilds misrepresent R8's
synthetic access bridges. The class table still looks perfect — same class count, zero
`ACC_INTERFACE` mismatches — but at runtime you get
`IncompatibleClassChangeError: Found interface X, but class was expected`.
See `../../references/pitfalls.md` P3.
## Build
Needs the same eight jars `smtool.py` uses:
```
smali-2.5.2.jar antlr-runtime-3.5.2.jar stringtemplate-3.2.1.jar
baksmali-2.5.2.jar util-2.5.2.jar jcommander-1.64.jar
guava-27.1-android.jar dexlib2-2.5.2.jar
```
```bash
# point at your jars (or reuse ../smali_cp.txt)
CP=$(cat ../smali_cp.txt | grep -v '^#' | tr '\n' ';' | sed 's/;$//')
javac -encoding UTF-8 -cp "$CP" PatchMethod.java
```
**Always pass `-encoding UTF-8`** if the source has non-ASCII comments — `javac`
otherwise reads them with the platform default encoding and fails.
## Run
```bash
java -cp "$CP;." PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>
# example: make Lx6;->d(...)V a no-op
java -cp "$CP;." PatchMethod classes7.dex out.dex 'Lx6;' d V
```
`PatchMethod` replaces the body with `return-void`. It scans **both** `directMethods`
and `virtualMethods` — a method you cannot find is often in the other list
(`static`/`private`/`<init>` are direct; everything else, including `public final`,
is virtual).
Use `PatchMethodExample.java` as the template for anything non-trivial (returning a
constant, emitting a fixed value from a Flow lambda, applying **several** edits to the
same dex in one pass).
## Rules that decide success
1. **One dex, one write pass.** Never chain two patchers over the same dex — the second
serialization drops metadata and ART then refuses to start the process
(`Failure starting process`, no Java stack). Combine all edits into one program.
2. **Preserve the register count.** `registers` must be `>=` the parameter register
count. With `registers = N` and `k` parameters (counting `this` for non-static),
`p0 = N - k`. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;`
on an instance method, `k = 3`, so with `registers = 6` you get `p0=3, p1=4, p2=5`.
3. **`const-wide` needs the 64-bit instruction form** (`ImmutableInstruction51l`), not
the 32-bit one.
4. **`invoke` operand order in `35c`** is `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`.
`invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
5. **End every body with a return of the correct type.**
6. **Check the blast radius first** — `../find_refs.py`. A generic helper with dozens of
callers is not your patch point (`../../references/pitfalls.md` P6).
## Verify
```bash
python ../dex_classdiff.py <original.dex> <patched.dex>
python ../smtool.py d <patched.dex> <tmp_tree> # confirm it parses
# then read the target method in tmp_tree to confirm it says what you intended
```
Expect `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch: 0`. Then install and
launch — table checks cannot see code-item damage, so runtime verification is
mandatory (`../../references/verification.md`).