Android改造者之路 08 · 打通任督二脉
第 08 章 打通任督二脉
续写章节|沿用「原木」系列的武侠讲解体,非原作者(dayeve)作品
前情提要
我在藏经阁待到天黑,连十分之一都没看完。
临走时师傅叫住我:“明天带你的电脑来。”
“干嘛?”
“看了一天别人的东西,该自己写一个了。”
我有点紧张:“我写?”
“你不是一直想知道 Hook 到底是怎么做到的吗?光看别人写,你永远只会用。自己写一遍,那才是你的。”
剧情片段
导演: 藏经阁是"看",下一章是"做"。
导演: 叫打通任督二脉。
剧情片段
前情提要
一、师傅的第一个问题
第二天我把电脑搬过去,打开,等着。
师傅没让我打开目标 app,也没让我打开 jadx。
他问了一个我没料到的问题。
“先写一个 app。”
“……啊?”
"写一个小 app。"他说,“功能随便,但要有这三样:”
- 一个按钮
- 一个"会员"判断(点按钮时判断是不是会员)
- 一句"试用剩余 7 天"的文案
“写它干嘛?”
"写着。"他端着茶不理我了。
我花了两个小时写完,装到手机上。屏幕上就一个按钮,点下去弹出「试用剩余 7 天」。
师傅看了一眼:“好。现在——把它改成 99 天,并且让会员判断永远通过。”
我愣了一下,笑了:“这有什么难的。”
我打开 MT管理器,找到 smali,改 0x7 为 0x63,加一行 const/4 v0, 0x1。五分钟搞定。
“改完了。”
"嗯。"师傅点点头,“现在告诉我——你刚才改的那两行,为什么一定在那个位置?”
我张了张嘴。
“……因为我写的。”
"对。"师傅说,“因为你写的,所以你知道它在哪儿,知道它为什么会在那儿,知道改了以后会怎样。”
他把我的手机拿过去,点了几下。
“现在换一个 app——一个你没写过的。你能在五分钟内找到同样的位置吗?”
我沉默了。
"不能。"师傅替我回答,“因为你不知道那个人写代码的时候在想什么。”
“这就是我今天要你写 app 的原因——”
“**你不是在开发。你是在给自己造一把尺子:**以后每看到一个陌生的 app,你就能拿’如果是我写,我会放哪儿’去量它。”
二、Hook 到底是什么
"好了,说正事。"师傅在纸上画了两个方框。
正常调用: A ──────────────> B(原方法)
返回原结果
Hook 之后: A ──> [你的代码] ──> B(原方法)
↓
改参数 / 看结果 / 改结果
↓
返回给 A
“Hook 就是——在方法被调用的路上,插进去一个你。”
“你可以只偷看(打印参数),也可以改参数,也可以拦下返回值,甚至可以不让原方法执行(直接返回你要的结果)。”
“那它是怎么做到的?”
师傅放下笔。
“这个要讲到 android 的心脏里去了。”
三、ART 层:Hook 究竟动了什么
每个 Java 方法,在底层都是一个结构体
Android 5.0 之后,虚拟机从 Dalvik 换成了 ART。
在 ART 里,你写的每一个 Java 方法,在 native 层都对应一个叫 ArtMethod 的结构体。这个结构体里存着这个方法的各种信息,其中有一个字段特别关键:
ArtMethod
├── declaring_class_ ← 属于哪个类
├── ...
└── entry_point_from_quick_compiled_code_ ← ★ 代码入口地址
最后那个字段,是一个指针——指向这个方法真正要执行的机器码在哪里。
Hook 的本质
"所以 Hook 干的事,说白了就一句话——"师傅在纸上写:
把
entry_point_from_quick_compiled_code_这个指针,改成指向你自己的函数。
改之前: entry_point ──> B 的机器码
改之后: entry_point ──> 你的函数 ──> B 的机器码(可选)
系统调用这个方法时,会从这个字段取地址然后跳过去。你把这个地址换成自己的,调用就先到你这儿来了。
Xposed 是怎么做的
Xposed 用的是一个很巧的办法:
- 改 Zygote —— Zygote 是 android 里所有 app 进程的"母体",每个 app 都是从它 fork 出来的。Xposed 替换了系统的
app_process,让 Zygote 一启动就加载 Xposed 的库 → 于是所有 app 一出生就带着 Xposed - 把目标方法标记为 native —— 修改
ArtMethod的访问标志,加上kAccNative - 把入口指向 Xposed 的桥接函数
- 当方法被调用时,走进 Xposed 的
handleHookedMethod()→ 依次回调你的beforeHookedMethod→ 原方法(如果你没拦截)→afterHookedMethod
你调用 B()
↓
跳到 Xposed 的桥接函数
↓
beforeHookedMethod(param) ← 你能在这儿改参数
↓
真正的 B() 执行(除非你 param.setResult 拦截了)
↓
afterHookedMethod(param) ← 你能在这儿改返回值
↓
返回
这就解释了 Xposed 的一个特点:它需要 root + 改系统,因为 Zygote 是系统进程。所以 Xposed/LSPosed 必须通过 Magisk 装。
Frida 是怎么做的
Frida 路子不同:
- 注入 —— 把
frida-agent.so注入进目标进程(通过 ptrace 或/proc/self/mem等方式) - 起一个 JS 引擎 —— agent 里带了个 JavaScript 运行时
- 你写 JS,它去改方法入口 ——
Java.use()时它帮你找到类,implementation =时它帮你替换入口 - 你在 JS 里调用
this.xxx()就是调原方法
┌─────────────┐ ┌──────────────────┐
│ 你的电脑 │ USB │ 手机 │
│ frida CLI │ <─────> │ frida-server │
│ 跑 hook.js │ │ ↓ 注入 │
└─────────────┘ │ 目标进程 + agent │
└──────────────────┘
Frida 的优势:不用改 apk、不用重启(可以 attach 已运行的进程)、跨平台。劣势:要开着电脑和 frida-server,且容易被检测到(第五章的照妖君)。
四、动手:写第一个 Frida 脚本
"知道了原理,就自己写一个。"师傅把我的电脑转过来。
第一步:准备环境
# 电脑上(注意版本要和手机端一致)
pip install frida-tools
frida --version # 记下这个版本号
# 手机上:下载对应版本 + 对应 CPU 架构的 frida-server
# 例:frida-server-16.x.x-android-arm64.xz
# 解压后推到手机并运行(需 root)
adb push frida-server-16.x.x-android-arm64 /data/local/tmp/fs
adb shell chmod 755 /data/local/tmp/fs
adb shell /data/local/tmp/fs &
# 验证
frida-ps -U # 能列出手机进程就成功了
第二步:写脚本
目标:我自己写的那个 demo,包名 com.demo.trial,有个方法:
public class VipManager {
public boolean isVip() {
return false; // 永远不是会员
}
public int getTrialDays() {
return 7; // 试用 7 天
}
}
hook.js:
Java.perform(function () {
var VipManager = Java.use("com.demo.trial.VipManager");
// 1. 偷看 + 改返回值
VipManager.isVip.implementation = function () {
var original = this.isVip(); // 先调原方法,看看原本返回什么
console.log("[*] isVip() 原本返回: " + original);
return true; // 强制改成 true
};
// 2. 改数值
VipManager.getTrialDays.implementation = function () {
var original = this.getTrialDays();
console.log("[*] getTrialDays() 原本返回: " + original);
return 99;
};
});
第三步:运行
# -U 表示 USB 设备;-f 表示重新启动(spawn)目标 app
frida -U -f com.demo.trial -l hook.js
# 如果 app 已经在跑,用 attach 方式
frida -U com.demo.trial -l hook.js
点一下按钮——
[*] isVip() 原本返回: false
[*] getTrialDays() 原本返回: 7
界面上变成:「尊享会员,试用剩余 99 天」。
我看着屏幕,忽然有点上头。
这比改 smali 快太多了。
第四步:主动调用(不只是等它被调用)
Hook 是"等它来",有时候你想"主动喊它":
Java.perform(function () {
// 从内存里找出已存在的实例
Java.choose("com.demo.trial.VipManager", {
onMatch: function (instance) {
console.log("[*] 找到实例: " + instance);
var ret = instance.getTrialDays(); // 主动调用
console.log("[*] 主动调用结果: " + ret);
},
onComplete: function () {
console.log("[*] 搜索完毕");
}
});
});
还可以用
Java.use("...").$new()直接 new 一个实例出来,或者$new带构造参数。
五、动手:写第一个 Xposed 模块
"Frida 好是好,但它得连着电脑。"师傅说,“想做一个能长期留在手机上的东西,得写模块。”
第一步:准备
- 手机已 root(Magisk)
- 装了 LSPosed(Magisk 模块)
- Android Studio 新建项目
第二步:AndroidManifest 里声明"我是 Xposed 模块"
<application>
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="我的第一个模块:改写试用天数" />
<meta-data
android:name="xposedminversion"
android:value="93" />
</application>
第三步:告诉 Xposed 从哪儿开始
在 assets/ 下建一个文件 xposed_init,内容就一行——你的入口类全名:
com.example.myhook.HookMain
第四步:写代码
package com.example.myhook;
import de.robv.android.xposed.*;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class HookMain implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(final XC_LoadPackage.LoadPackageParam lpparam) {
// 只处理目标 app
if (!lpparam.packageName.equals("com.demo.trial")) return;
XposedBridge.log("模块已加载: " + lpparam.packageName);
// Hook isVip()
XposedHelpers.findAndHookMethod(
"com.demo.trial.VipManager",
lpparam.classLoader,
"isVip",
new XC_MethodReplacement() { // 完全替换,不调原方法
@Override
protected Object replaceHookedMethod(MethodHookParam param) {
XposedBridge.log("isVip() 被拦截,返回 true");
return true;
}
});
// Hook getTrialDays()
XposedHelpers.findAndHookMethod(
"com.demo.trial.VipManager",
lpparam.classLoader,
"getTrialDays",
new XC_MethodHook() { // 前钩 + 后钩
@Override
protected void afterHookedMethod(MethodHookParam param) {
param.setResult(99); // 改返回值
}
});
}
}
第五步:安装激活
- Build → 装到手机
- 打开 LSPosed 管理器 → 模块 → 勾选你的模块
- 在模块的作用域里勾选目标 app
- 重启(或强制停止目标 app 再打开)
三种回调,记住区别
| 回调 | 行为 | 什么时候用 |
|---|---|---|
beforeHookedMethod |
原方法执行前 | 改参数:param.args[0] = "xxx" |
afterHookedMethod |
原方法执行后 | 改返回值:param.setResult(...) |
replaceHookedMethod |
完全不执行原方法 | 整个逻辑换掉 |
这是 Xposed 和改 smali 最大的区别:模块不改 apk 一个字节,所以签名校验、CRC 校验全都过得了——因为你压根没动它。
我写完之后,装上运行,看着那个「尊享会员,试用剩余 99 天」,有点恍惚。
“我……完全没改 apk?”
"一个字节都没改。"师傅说,“这就是 Hook 的价值:不动对方,只改结果。”
“那第五章那六道关——”
"还记得云端君吗?"师傅笑了,“前五关,Hook 基本都能绕。唯一绕不过的,还是那一个。”
六、四个一定会踩的坑
"你自己写的时候会遇到。"师傅伸出四根手指。
坑一 · 方法被内联(inlining)
现象:Hook 明明写对了,App 也确实跑了那个方法,但你的回调一次都没进来。
原因:ART 编译时,会把很短的方法直接"融进"调用它的方法里(内联优化)。这样那个方法就不再是一次独立调用了——你 hook 它,自然收不到。
优化前: A() { ... B(); ... } B 是一次调用
优化后: A() { ... <B的代码> ... } B 没了,没了自然 hook 不到
解法:
- Hook 调用它的上层方法(A),而不是被内联的(B)
- 尽早 hook —— 用
frida -f(spawn 模式)在 app 刚启动时就注入,抢在编译优化之前 - 换更底层/更稳定的 hook 点(比如去 hook 系统 API)
坑二 · 混淆后找不到类
现象:Java.use("com.demo.VipManager") 报 “Class not found”。
解法:遍历已加载的类,模糊匹配:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("VipManager") >= 0) { // 关键字匹配
console.log("[*] 找到: " + name);
}
},
onComplete: function () { console.log("[*] 完成"); }
});
});
坑三 · 类不在默认 ClassLoader 里
现象:类确实存在(jadx 里能看到),但 Java.use 找不到。
原因:app 有多个 dex / 用了自定义 ClassLoader(加固壳尤其常见),你要的类不在默认那个里。
解法:切换 ClassLoader:
Java.perform(function () {
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
if (loader.findClass("com.demo.trial.VipManager")) {
Java.classFactory.loader = loader; // ★ 切过去
console.log("[*] 已切换 ClassLoader");
}
} catch (e) { /* 这个 loader 里没有,继续找 */ }
},
onComplete: function () {}
});
// 切换之后再 Java.use
var VipManager = Java.use("com.demo.trial.VipManager");
});
这个坑和第七章的
dexElements是同一件事的两面——多 dex、多 ClassLoader,是加固和插件化留下的痕迹。
坑四 · 重载(overload)没指定
现象:方法存在,但报 “has more than one overload”。
解法:明确指定参数类型:
// 这个 sayHello 有两个版本
VipManager.sayHello.overload("java.lang.String").implementation = function (s) { ... };
VipManager.sayHello.overload("int", "boolean").implementation = function (i, b) { ... };
// 无参的也要写
VipManager.reset.overload().implementation = function () { ... };
七、最好的学习方法
天快黑的时候,师傅让我把我写的那个 demo 拆了。
“拆了?”
"重做一遍。"他说,“这次按这个顺序来:”
| 步骤 | 你做什么 | 你会理解什么 |
|---|---|---|
| 1 | 正常写一个 demo | 开发者的思路 |
| 2 | 加一个会员判断 | 判断逻辑会放哪儿 |
| 3 | 给它加一个签名校验 | 验身君是怎么写出来的 |
| 4 | 给它开 ProGuard 混淆 | 迷魂君是怎么工作的 |
| 5 | 自己用 jadx 反编译它 | 混淆后的样子 |
| 6 | 自己改 smali 破掉它 | 第四章的招式 |
| 7 | 自己写 Hook 破掉它 | 今天的招式 |
| 8 | 对比两条路线的差别 | 什么时候该用哪种 |
"你既是出题人,又是答题人。"师傅说,“这一圈走下来,你对’改造’的理解会跟现在完全不一样。”
“因为你会同时看见——矛是怎么造的,盾是怎么造的。”
八、尾声
我把 demo 重做完,已经是第三天夜里。
界面上那个按钮点下去,弹出「尊享会员,试用剩余 99 天」。
我用三种方式各实现了一遍:改 smali、写 Frida 脚本、写 Xposed 模块。
三种都成功了。
我把手机递给师傅看,有点得意。
他接过去,点了点,然后问我一句话。
“现在,你会去改别人的 app 吗?”
我愣住了。
"我……"我张了张嘴,“我可以改。”
"我知道你可以。"师傅把手机还给我,“我问的是——你会吗?”
我答不上来。
师傅沉默了一会儿,忽然说:“明天有个客人来。”
“客人?”
“嗯。来请你改东西的。”
我心里咯噔一下。
"他给的价钱,"师傅顿了顿,“是你想象不到的那种。”
剧情片段
导演: cut!! 这条过。
助理: 导演,下一章是……
导演: 该来了总会来。
导演: 兵器给完了,心法也教完了,最后要考的——不是技术。
导演: 下一章,叫抉择。
剧情片段


