0
0
0

Android改造者之路 08 · 打通任督二脉

文章摘要
|

第 08 章 打通任督二脉

续写章节|沿用「原木」系列的武侠讲解体,非原作者(dayeve)作品

前情提要

我在藏经阁待到天黑,连十分之一都没看完。

临走时师傅叫住我:“明天带你的电脑来。”

“干嘛?”

“看了一天别人的东西,该自己写一个了。”

我有点紧张:“我写?”

“你不是一直想知道 Hook 到底是怎么做到的吗?光看别人写,你永远只会用。自己写一遍,那才是你的。”

剧情片段

导演: 藏经阁是"看",下一章是"做"。

导演: 叫打通任督二脉。

剧情片段

前情提要


一、师傅的第一个问题

第二天我把电脑搬过去,打开,等着。

师傅没让我打开目标 app,也没让我打开 jadx。

他问了一个我没料到的问题。

“先写一个 app。”

“……啊?”

"写一个小 app。"他说,“功能随便,但要有这三样:”

  1. 一个按钮
  2. 一个"会员"判断(点按钮时判断是不是会员)
  3. 一句"试用剩余 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 用的是一个很巧的办法:

  1. 改 Zygote —— Zygote 是 android 里所有 app 进程的"母体",每个 app 都是从它 fork 出来的。Xposed 替换了系统的 app_process,让 Zygote 一启动就加载 Xposed 的库 → 于是所有 app 一出生就带着 Xposed
  2. 把目标方法标记为 native —— 修改 ArtMethod 的访问标志,加上 kAccNative
  3. 把入口指向 Xposed 的桥接函数
  4. 当方法被调用时,走进 Xposed 的 handleHookedMethod() → 依次回调你的 beforeHookedMethod → 原方法(如果你没拦截)→ afterHookedMethod
你调用 B()
    ↓
跳到 Xposed 的桥接函数
    ↓
beforeHookedMethod(param)   ← 你能在这儿改参数
    ↓
真正的 B() 执行(除非你 param.setResult 拦截了)
    ↓
afterHookedMethod(param)    ← 你能在这儿改返回值
    ↓
返回

这就解释了 Xposed 的一个特点:它需要 root + 改系统,因为 Zygote 是系统进程。所以 Xposed/LSPosed 必须通过 Magisk 装。

Frida 是怎么做的

Frida 路子不同:

  1. 注入 —— 把 frida-agent.so 注入进目标进程(通过 ptrace 或 /proc/self/mem 等方式)
  2. 起一个 JS 引擎 —— agent 里带了个 JavaScript 运行时
  3. 你写 JS,它去改方法入口 —— Java.use() 时它帮你找到类,implementation = 时它帮你替换入口
  4. 你在 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);          // 改返回值
                    }
                });
    }
}

第五步:安装激活

  1. Build → 装到手机
  2. 打开 LSPosed 管理器 → 模块 → 勾选你的模块
  3. 在模块的作用域里勾选目标 app
  4. 重启(或强制停止目标 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 不到

解法:

  1. Hook 调用它的上层方法(A),而不是被内联的(B)
  2. 尽早 hook —— 用 frida -f(spawn 模式)在 app 刚启动时就注入,抢在编译优化之前
  3. 换更底层/更稳定的 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!! 这条过。

助理: 导演,下一章是……

导演: 该来了总会来。

导演: 兵器给完了,心法也教完了,最后要考的——不是技术。

导演: 下一章,叫抉择。

剧情片段

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论

欢迎来到我的博客!