Frida Hook Android




2020-02-18

blog_main_img

把动态插桩、方法观测和 Python 驱动串起来。如果你是在调试自己的 Android 应用,或者想做一套更灵活的动态观测方案,`Frida` 非常适合干这类活:

Frida 到底在做什么

一句话概括,Frida 就是在目标进程里塞进一层可编程的运行时,让你用脚本在运行时观察、替换、增强函数行为。

你可以把它拆成三块来看:

  • 设备侧:目标进程里有 Frida 运行时
  • 脚本侧:你写 JavaScript 做 hook 和消息传递
  • 控制侧:你用 CLIPython 去启动、附加、收集输出

所以它不是传统意义上“改源码再重编译”,而更像“运行起来以后再插进去看”。

这就是它最迷人的地方。很多平时藏在运行时里的行为,比如参数是怎么变的、返回值哪里开始歪掉了、某个 native 调用是不是被走到了,用 Frida 都能很快摸出来。

什么时候 Frida 比日志更顺手

如果只是打印几行流程日志,直接改代码通常更快。但下面这些场景,Frida 会明显更省劲:

  • 你不想频繁改包、打包、安装
  • 问题只在某几个运行时分支里出现
  • Java 层和 Native 层要连起来一起看
  • 想快速确认某个方法的真实入参和出参
  • 你只想做临时观测,不想把调试代码留在工程里

说白了,它特别适合那种“我现在就想看看,代码到底跑成啥样了”的时刻。

一张够用的工作流

如果你要把 Frida 用顺,记住这条链就够了:

  1. 准备测试设备和自有调试包
  2. 让 Python 或 CLI 连上设备
  3. 启动或附加目标进程
  4. 注入 JavaScript 脚本
  5. 在脚本里 hook Java / Native 方法
  6. 把消息发回宿主侧做展示或保存

这条线跑通以后,你后面补的都只是细节。

Android 侧通常怎么接

最常见的方式有两类:

  • 在测试设备上跑 frida-server
  • 在自己的应用里接 Frida Gadget

前者比较适合开发调试机,后者更适合你想把运行时能力打进自己的测试包里。两种方式本质上都在解决一个问题:让 Frida 能进到目标进程里。

如果只是调自己的 debug 包,通常建议:

  • 版本对应别乱套,客户端和设备侧尽量匹配
  • 把环境固定在测试机,不要和正式环境混着用
  • 用清晰的包名和构建标记区分 debug / release

这听起来像废话,但真到现场,很多“为什么 attach 不上”的问题都出在环境没对齐。

Java 层 hook:先从最有手感的地方开始

Frida 在 Android 上最容易上手的一段,通常是 Java 层。因为类名、方法名、参数签名只要能确认下来,脚本就比较直接。

下面这个例子假设你在调试自己的应用 com.example.debugdemo,想看看某个格式化方法在运行时到底收到了什么:

Java.perform(function () {
  const DebugUtils = Java.use("com.example.debugdemo.DebugUtils");

  DebugUtils.formatProfile
    .overload("java.lang.String", "int")
    .implementation = function (name, level) {
      send({
        tag: "java-enter",
        method: "DebugUtils.formatProfile",
        args: {
          name: name,
          level: level
        }
      });

      const result = this.formatProfile(name, level);

      send({
        tag: "java-leave",
        method: "DebugUtils.formatProfile",
        result: result
      });

      return result;
    };
});

这段代码的重点不在“替换行为”,而在“把方法观测干净地插进去”:

  • 进入方法时打印参数
  • 调原始实现
  • 出来以后打印返回值

这类 hook 非常适合排查:

  • 某个字段在进入业务层前是不是就错了
  • UI 组装逻辑是否把数据拼坏了
  • ViewModel 到 Repository 之间有没有参数漂移

Java 与 Native 关系图

Native 层 hook:把 C/C++ 调用也看见

当问题落到 JNIso 里,Java 层日志通常就不够用了。这时候 Interceptor.attach 会很顺手。

下面还是以自有测试样例为前提,假设你自己的 libdemo.so 导出了一个 JNI 函数:

const target = Module.findExportByName(
  "libdemo.so",
  "Java_com_example_debugdemo_NativeBridge_add"
);

if (target) {
  Interceptor.attach(target, {
    onEnter(args) {
      this.left = args[2].toInt32();
      this.right = args[3].toInt32();
      send({
        tag: "native-enter",
        symbol: "NativeBridge.add",
        left: this.left,
        right: this.right
      });
    },
    onLeave(retval) {
      send({
        tag: "native-leave",
        symbol: "NativeBridge.add",
        result: retval.toInt32()
      });
    }
  });
}

这个思路非常朴素:

  • 进函数时记下参数
  • 出函数时拿到返回值
  • 把整条调用链补完整

如果 Java 层看到的是 5 + 7,Native 层出来却变成了一个奇怪结果,那问题范围瞬间就被你收紧了。

用 Python 把整套流程接起来

很多人第一次接触 Frida 时,只在终端里用命令行。其实 Python 一接上,体验会顺很多。你可以:

  • 自动连接测试设备
  • 自动 spawn / attach 指定包名
  • 统一收集 send() 回来的消息
  • 顺手把结果写文件、做过滤、分级展示

一个简洁的 Python 驱动脚本可以像这样:

import frida
import sys
from pathlib import Path

PACKAGE_NAME = "com.example.debugdemo"
SCRIPT_PATH = Path("observe_debugutils.js")


def on_message(message, data):
    if message["type"] == "send":
        payload = message["payload"]
        print(f"[hook] {payload}")
    elif message["type"] == "error":
        print("[error]", message["stack"])


def main():
    device = frida.get_usb_device(timeout=5)
    pid = device.spawn([PACKAGE_NAME])
    session = device.attach(pid)

    script = session.create_script(SCRIPT_PATH.read_text(encoding="utf-8"))
    script.on("message", on_message)
    script.load()

    device.resume(pid)
    sys.stdin.read()


if __name__ == "__main__":
    main()

这段代码虽然短,但已经把主链路串起来了:

  • 连接设备
  • 启动 App
  • 注入脚本
  • 接收消息
  • 保持会话

如果你想更工程化一点,还可以继续加:

  • 日志分级
  • JSON 落盘
  • 多脚本切换
  • 白名单类名过滤

Python 再往前走一步:批量观测和自动化采样

如果你的目标不是“看一眼”,而是“跑很多次看看稳定性”,Python 就更有用了。

比如你可以在宿主侧做一个轻量采样器,把某个方法的返回值分布简单统计出来:

from collections import Counter

counter = Counter()


def on_message(message, data):
    if message["type"] != "send":
        return

    payload = message["payload"]
    if payload.get("tag") == "java-leave":
        result = str(payload.get("result"))
        counter[result] += 1
        print("top results:", counter.most_common(5))

这种写法不炫,但很实用。尤其是你在排查“偶发格式化异常”或“同一批输入为什么会出不同输出”时,它能比肉眼盯日志舒服很多。

Python 驱动图

Hook 脚本写起来,真正容易踩的坑在哪

真正把 Frida 用到工程里以后,常见问题反而不是“能不能 hook”,而是“hook 完以后是不是还稳”。

几个特别常见的坑:

1. 签名没对上

Android 方法重载一多,overload() 选错参数类型,脚本看起来像加载了,实际上根本没进目标方法。

2. 时机太早或太晚

有些类还没加载,你就去 Java.use(),自然会报错。还有些场景是 App 逻辑已经跑过去了,你才 attach,上半段调用早没了。

3. 打印太猛

如果你对高频函数疯狂 send(),性能抖动会很明显,日志也会把自己淹没。观测点越精,脚本越耐用。

4. Native 参数理解错位

JNI 函数参数不是你肉眼看名字就能全猜对,尤其到复杂结构体、指针偏移这些地方,先确认签名比盲打重要得多。

5. 忘了它只是调试工具

Frida 很灵活,但它不是正式埋点系统,也不是线上观测平台。你拿它做动态诊断很香,拿它顶替完整工程方案就会开始别扭。

一个更稳的实践姿势

如果你想让这套东西别只停在“偶尔用一下”,可以按下面这个思路整理:

  • 给常用 hook 脚本按模块分类
  • 宿主侧 Python 做统一入口
  • 约定统一消息格式,比如 tag / method / args / result
  • 先观测,再决定是否临时改行为
  • 每次只盯一小段链路,不贪多

这样后面你查 UI、网络封装、JNI 桥接、序列化逻辑,基本都能沿着同一套姿势往前推。