Frida 是一个知名的 Hook 框架
接下来我们主要以 Android Hook 为主题进行讲解

环境配置

分别在 PC 和目标 Android 上配置 Frida:

1
2
3
4
5
# 我使用 UV 管理 Python,因此没有一个全局的 Python
# uv venv --seed --clear venv
# venv\Scripts\activate

pip install frida frida-tools

记住 Frida 版本:

1
2
3
4
$ pip show frida

Name: frida
Version: 17.5.1

在 ADB shell 中查看 Android 设备架构:

1
2
3
$ uname -m

aarch64
Note

一般来讲,模拟器采用 x86-64,真机采用 arm64(即此处的 aarch64)

Releases · frida/frida 下载对应版本的 Frida-server,例如此处应下 frida-server-17.5.1-android-arm64.xz。

解压得到的文件传输到 Android 上:

1
adb push frida-server-17.5.1-android-arm64 /data/local/tmp/

在 Android 上启动服务端:

1
2
3
4
5
6
adb shell
su # 需要在 Root 下运行,否则 SELinux 会报错
cd /data/local/tmp
chmod +x ./frida-server-17.5.1-android-arm64 # 赋予执行权限
./frida-server-17.5.1-android-arm64
 # 无报错即服务端正常运行

现在就可以用 Frida 了 😊

Frida 调试基础

Android 每个 APP 都运行在自己独立的虚拟机和沙盒中,如果每个 APP 的运行时环境都不共享,那么资源消耗是很恐怖的。而 Android 提供了 Zygote 进程,所有 APP 都是由 Zygote fork 出来的:

1
2
3
4
5
init 
└── Zygote(已预加载所有 Framework 类)
    ├── system_server
    ├── com.tencent.mm
    └── com.example.yourapp  ← 你要分析的 APP

Zygote 的作用是:提前把 Android Framework 所有的类加载好,每次 fork 新进程时直接继承,不用重新初始化。这样做可以实现极速启动和节省内存.

两种模式:Spawn(生成)和 Attach(附加)。Spawn 模式是从头启动应用并调试,Attach 模式是在应用运行时才开始调试。Spawn 模式下,Frida 利用 Zygote 控制进程创建,可以在业务代码运行前完成 Hook;Attach 模式下,APP 的 Application.onCreate 等初始化逻辑已经执行完,注入太晚。因此对抗加固的场景大多用 Spawn 模式。

但 Spawn 模式也可以被某些 APP 检测到。

Spawn 模式:

1
frida -U -f <TARGET> -l <SCRIPT>
  • TARGET:APP 包名或可执行文件名
  • SCRIPT:用于调试的 Python, JS 或 TS 脚本。自己写或者用已有的。如果不附带脚本,则进入交互式调试。

TARGET 需要的包名可以通过 frida-ps -Ua(运行中的应用)/frida-ps -Uai(安装的应用)得到。

Spawn 方式启动后,会立刻暂停运行。需要继续运行,需要手动输入:%resume

旧版本有一个 --no-pause 会自动运行,但新版本已不支持。


Attach 模式:

1
frida -U -N TARGET -l SCRIPT

怎么写好一个调试脚本

脚本主要有三类内容:

  • Interceptor(拦截器):实现 Hook 函数、打印参数等调试操作。每次匹配到(对应函数),都会触发执行代码。
  • ApiResolver(解析器):模糊查找相关函数。只有第一次(解析时)匹配到,才会执行。
  • Stalker(跟踪器):跟踪代码的实际运行的过程。期间可以打印和查看对应的值,便于实现调试真正代码运行的逻辑。

对于 JS 脚本,一个简单的 CTF 示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 所有 Java API 调用必须在 Java.perform() 回调中执行
Java.perform(function () {
  // 获取目标类的引用
  var Vault = Java.use("com.heroctf.freeda1.utils.Vault");

  // 尝试调用目标方法并处理异常。延迟 1000ms 执行以减少 bug
  setTimeout(function () {
    try {
      var flag = Vault.get_flag();
      console.log("[SUCCESS] Flag: " + flag);
    } catch (e) {
      console.log("[ERROR] " + e);
    }
  }, 1000);
});

这个的脚本的目的是直接获取 Vault 类下的 get_flag() 方法的输出,得到 flag。

一些常用方法:

API 名称描述
Java.use(className)获取指定的 Java 类并使其在 JavaScript 代码中可用。
Java.perform(callback)确保回调函数在 Java 的主线程上执行。
Java.choose(className, callbacks)枚举指定类的所有实例。
Java.cast(obj, cls)将一个 Java 对象转换成另一个 Java 类的实例。
Java.enumerateLoadedClasses(callbacks)枚举进程中已经加载的所有 Java 类。
Java.enumerateClassLoaders(callbacks)枚举进程中存在的所有 Java 类加载器。
Java.enumerateMethods(targetClassMethod)枚举指定类的所有方法。

绕过检测

要用 Frida-server 调试的前提就是 Root,但显然大部分应用都不希望自己被 Frida 或者 Root 调戏,因此会做 Root 检测,一检测到特征就停止程序运行。

但是,代码总是有执行顺序的,只要我在检测前就把特征检测方法给 Hook 掉,你不就检查不出来了吗?另一种就是 “打入内部”,将 Frida-gadget 植入程序,可以实现免 Root 的 Hook。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.heroctf.freeda2.utils;

import android.content.Context;
import com.scottyab.rootbeer.RootBeer;

/* compiled from: r8-map-id-baa4c77f810b701c3077d4ce68a3d5b79ee91034c03e266e8ba1aed7e464c1a1 */
/* loaded from: classes.dex */
public final class Security {
    private Security() {
    }

    public static boolean detectRoot(Context context) {
        return new RootBeer(context).isRooted();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Java.perform(function () {
  console.log("[+] Bypassing root detection...");

  // 绕过 RootBeer
  var RootBeer = Java.use("com.scottyab.rootbeer.RootBeer");
  RootBeer.isRooted.implementation = function () {
    console.log("[+] RootBeer.isRooted() bypassed");
    return false;
  };

  // 绕过 Vault 构造函数检测
  var Vault = Java.use("com.heroctf.freeda2.utils.Vault");
  Vault.$init.implementation = function () {
    console.log("[+] Vault constructor bypassed");
    // Don't call original constructor
  };

  setTimeout(function () {
    try {
      var flag = Vault.get_flag();
      console.log("[SUCCESS] Flag: " + flag);
    } catch (e) {
      console.log("[ERROR] " + e);
    }
  }, 1000);
});

RootBeer 是一个简单的 Root 权限检查库。作者也在 README 中坦陈 RootBeer 可以被绕过(查看 Bypassing Android’s RootBeer Library — Part 1

本题仅使用 RootBeer 的综合检测方法 isRooted() 来检测 Root,这倒是为我们省去了不少麻烦,因为我们只需要 Hook isRooted() 就行了。

Native Hook

与 Java 层不同,Native 层的函数是分散在 so 文件中的。在 Hook 前,Frida 需要找到目标函数,因为 so 是动态加载的。