多年来,我一直梦想着让 Frida 超越用户空间软件,把插桩能力扩展到操作系统内核和裸机系统,甚至微控制器……

微控制器

今年早些时候,我家的猫门坏了。与零售商沟通并反复检查安装等问题后,它每次只能正常工作一小会儿,最终还是会再次故障。

这显然让家里的猫很不开心:

cat-door-fail

它们自然会闹出很大动静,而我不得不起床手动放它们进来,也就很难睡个好觉。

最后我又买了一扇猫门,果然再也没出问题。旧猫门吃灰了一阵子,但我一直在想:能不能调试它,甚至扩展它的软件,让它做更多有用的事情。

我越来越想拆开它研究里面的电子元件,最终还是动手了:

cat-door-pcb

看起来这是 STM32F030C6T6,一款基于 ARM Cortex M0 的 MCU。我的第一个念头是能否转储闪存,再做静态分析。

快速浏览 MCU 文档并用万用表做了一番探测后,我弄清了 JP12 焊盘的定义:

PAD 1/2     PAD 7/8
BOOT0 USART1 RX SWDIO GND
VDD USART1 TX   SWCLK

这样就能轻松将 BOOT0 拉高,让 MCU 启动其内部引导程序,而不是用户代码。

将 USB 转 3.3V TTL 设备接到 USART1 焊盘后,我就能转储闪存:

$ ./stm32flash -r firmware.bin /dev/ttyUSB0
stm32flash 0.7

http://stm32flash.sourceforge.net/

Interface serial_posix: 57600 8E1
Version      : 0x31
Option 1     : 0x00
Option 2     : 0x00
Device ID    : 0x0444 (STM32F03xx4/6)
- RAM        : Up to 4KiB  (2048b reserved by bootloader)
- Flash      : Up to 32KiB (size first sector: 4x1024)
- Option bytes  : 16b
- System memory : 3KiB
Memory read
Read address 0x08008000 (100.00%) Done.

然后进行一些静态分析: cat-door-firmware

另外两个焊盘连接到串行线调试(SWD)所用的 SWDIO 和 SWCLK,所以下一步自然是接上 Raspberry Pi Debug Probe。配置完成后,我启动了 OpenOCD:

$ openocd -f interface/cmsis-dap.cfg -f target/stm32f0x.cfg
Open On-Chip Debugger 0.11.0-g8e3c38f7-dirty (2023-05-05-14:25)
Licensed under GNU GPL v2
For bug reports, read
	http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : Using CMSIS-DAPv2 interface with VID:PID=0x2e8a:0x000c, serial=E6614103E78B482F
Info : CMSIS-DAP: SWD  Supported
Info : CMSIS-DAP: FW Version = 2.0.0
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : SWCLK/TCK = 0 SWDIO/TMS = 0 TDI = 0 TDO = 0 nTRST = 0 nRESET = 0
Info : CMSIS-DAP: Interface ready
Info : clock speed 1000 kHz
Info : SWD DPIDR 0x0bb11477
Info : stm32f0x.cpu: hardware has 4 breakpoints, 2 watchpoints
Info : starting gdb server for stm32f0x.cpu on 3333
Info : Listening on port 3333 for gdb connections

我酝酿已久的想法是为 Frida 增加一个新后端,其中唯一可附加的进程是 PID 0。加载到其中的脚本实际上在本地运行,并实现大家熟悉的 JavaScript API。任何访问内存的 API,例如用 ptr(‘0x80000’).readInt() 解引用 int *,最终都会查询目标;在上述场景中就是通过 SWD 完成。

起初我设想让后端通过 telnet 接口与 OpenOCD 守护进程通信,但很快意识到,与其兼容 GDB 的远程桩通信更好。这样,Frida 就能对任何提供远程桩的目标进行插桩,无论是 OpenOCD、Corellium(iOS 内核插桩!)、QEMU,还是其他环境。

至于 Interceptor,我计划用断点实现基础功能,但仅在用户提供 JavaScript 回调时如此。如果提供的是函数指针,则可以执行内联 Hook,使目标运行时无需与主机之间反复触发陷阱和往返通信。这意味着它甚至可用于观察和修改操作系统内核或 MCU 固件中的热点代码。

完成初步实现后,我已经能运行下面的脚本:

Interceptor.breakpointKind = 'hard';

const THUMB_BIT = 1;

const initRest = ptr('0x0800306a').or(THUMB_BIT);
Interceptor.attach(initRest, {
  onEnter(args) {
    console.log('>>> init_rest()',
        JSON.stringify(this.context, null, 2));
  },
  onLeave(retval) {
    console.log(`<<< init_rest() retval=${retval}`);
  }
});

通过 Frida REPL 运行:

$ frida -D barebone -p 0 -l demo.js
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)

[Remote::SystemSession ]-> $gdb.continue()
[Remote::SystemSession ]-> >>> init_rest() {
  "r7": "0xffffffff",
  "pc": "0x800306a",
  "r8": "0xffffffff",
  "xPSR": "0x41000000",
  "r9": "0xffffffff",
  "sp": "0x20000578",
  "r0": "0x0",
  "r10": "0xffffffff",
  "lr": "0x8003069",
  "r1": "0x40021008",
  "r11": "0xffffffff",
  "r2": "0xffffffff",
  "r12": "0xffffffff",
  "r3": "0xffffffff",
  "r4": "0xffffffff",
  "r5": "0xffffffff",
  "r6": "0xffffffff"
}
<<< init_rest() retval=0x1

这里有几点需要注意:

  • 我们将 Interceptor.breakpointKind 设为 hard,因为目标代码位于闪存中,软件断点无法工作。如果使用 J-Link 或类似 SWD 接口,则不必这样做;它们会在添加软件断点时透明地重新刷写闪存。
  • 后端尚不会自动恢复运行,因此我们通过 $gdb.continue() 手动恢复。它属于这个内部 API,将 GDB.Client 的大部分能力暴露给 JavaScript。随着新后端逐渐成熟,这会成为内部实现细节;目前不应把它视为稳定 API。
  • 我们设置最低有效位,告诉 Interceptor 目标函数采用 Thumb 指令编码。若你曾在 32 位 ARM 上使用 Frida 的传统后端,应该已经熟悉这一点。
  • 新的 Barebone 后端默认连接 127.0.0.1:3333 上兼容 GDB 的远程桩,这与 OpenOCD 的常见默认值一致;也可通过 FRIDA_BAREBONE_ADDRESS 环境变量覆盖。

操作系统内核

有趣的猫门支线任务很适合测试规模较小的一端,但支持更大型系统同样潜力巨大。

其中最酷的用例之一无疑是 Corellium,因为这意味着我们可以对 iOS 内核插桩。借助 Tamarin Cable,甚至应该能在可利用 checkm8 的实体设备上实现。

不过在讨论它之前,先看看能否在 QEMU 和运行中的 Linux 内核上把功能跑起来。

Linux

首先启动一台可供实验的虚拟机:

$ pip install arm_now
$ arm_now start aarch64 --add-qemu-options='-gdb tcp::9000'
...
Welcome to arm_now
buildroot login:

接着用 Frida REPL 四处查看:

$ export FRIDA_BAREBONE_ADDRESS=127.0.0.1:9000
$ frida -D barebone -p 0
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)

[Remote::SystemSession ]-> Process.arch
"arm64"
[Remote::SystemSession ]-> Process.enumerateRanges('r-x')
[
    {
        "base": "0xffffff8008080000",
        "protection": "r-x",
        "size": 4259840
    }
]
[Remote::SystemSession ]-> $gdb.state
"stopped"
[Remote::SystemSession ]-> $gdb.exception
{
    "breakpoint": null,
    "signum": 2,
    "thread": {}
}
[Remote::SystemSession ]-> $gdb.exception.thread.readRegisters()
{
    "cpsr": 1610613189,
    "pc": "0xffffff8008096648",
    "sp": "0xffffff80085f3f10",
    "x0": "0x0",
    "x1": "0xffffff80085e6b78",
    "x10": "0x880",
    "x11": "0xffffffc00e877180",
    "x12": "0x0",
    "x13": "0xffffffc00ffe1f30",
    "x14": "0x0",
    "x15": "0xfffffff8",
    "x16": "0xffffffbeff000000",
    "x17": "0x0",
    "x18": "0xffffffc00ffe17e0",
    "x19": "0xffffff80085e0000",
    "x2": "0x40079f5000",
    "x20": "0xffffff80085f892c",
    "x21": "0xffffff80085f88a0",
    "x22": "0xffffff80085ffe80",
    "x23": "0xffffff80085ffe80",
    "x24": "0xffffff80085d5028",
    "x25": "0x0",
    "x26": "0x0",
    "x27": "0x0",
    "x28": "0x405a0018",
    "x29": "0xffffff80085f3f10",
    "x3": "0x30c",
    "x30": "0xffffff800808492c",
    "x4": "0x0",
    "x5": "0x40079f5000",
    "x6": "0x1",
    "x7": "0x1c0",
    "x8": "0x2",
    "x9": "0xffffff80085f3e80"
}
[Remote::SystemSession ]->

你可能想知道我们如何实现 Process.enumerateRanges()。目前这部分仅在 arm64 上实现,方式是解析页表。(如果连接 Corellium 的远程桩,我们会使用厂商特定的 monitor 命令,从而省去大量网络往返。)

既然已经能窥探运行中的内核,我们可能会想查找内部函数和数据结构。这正是内存扫描 API 的用武之地:

for (const r of Process.enumerateRanges('r-x')) {
  console.log(JSON.stringify(r, null, 2));
  const matches = Memory.scanSync(r.base, r.size,
      '7b2000f0 fa03082a 992480d2 : 1f00009f ffffffff 1f00e0ff');
  console.log('Matches:', JSON.stringify(matches, null, 2));
}

这里通过匹配前三条指令来寻找 Linux 内核的 arm64 系统调用处理程序。我们使用掩码功能忽略 ADRP 和 MOV 指令(第一条和第三条指令)的立即数。

来实际运行一下:

$ frida -D barebone -p 0 -l scan.js
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)
Attaching...
{
  "base": "0xffffff8008080000",
  "size": 4259840,
  "protection": "r-x"
}
Matches: [
  {
    "address": "0xffffff8008082f00",
    "size": 12
  }
]
[Remote::SystemSession ]->

现在我们已经动态检测到了内核内部的系统调用处理程序!🚀

重新实现内存扫描是我个人最喜欢的部分之一,@hsorbo 和我通过结对编程完成它,过程非常愉快。其思路与我们面向未越狱 iOS 的 Fruity 后端以及新的 Linux 注入器非常相似:无需把数据传回主机再搜索,只需传输搜索算法,让它在目标上运行。

内存扫描器实现使用 Rust 编写,也为本文稍后介绍的一项新功能奠定了基础。

现在知道 Linux 内核系统调用处理程序的位置后,就能用 Interceptor 安装指令级 Hook:

const el0Svc = ptr('0xffffff8008082f00');
Interceptor.attach(el0Svc, function (args) {
  const { context } = this;
  const scno = context.x8.toUInt32();
  console.log(`syscall! scno=${scno}`);
});

在运行中的虚拟机上试一试:

$ frida -D barebone -p 0 -l kernhook.js
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)

[Remote::SystemSession ]-> $gdb.continue()
[Remote::SystemSession ]-> syscall! scno=63
syscall! scno=64
syscall! scno=73
syscall! scno=63
syscall! scno=64
syscall! scno=73
syscall! scno=63
syscall! scno=64
syscall! scno=56
syscall! scno=62
syscall! scno=64
syscall! scno=57
syscall! scno=29
syscall! scno=134
...

就是这样——我们正在监控整个系统的系统调用!💥

Rust

尝试上面的示例时,你首先可能会注意到系统明显变慢。这是因为指定 JavaScript 函数作为回调时,Interceptor 会使用断点。

不过无需担心。如果用机器码编写回调并传入 NativePointer,Interceptor 会采用另一种策略:修改目标机器码,将执行流重定向到跳板,再由跳板调用我们指定地址处的函数。

这样很好,我们只需把机器码放进内存。有些人可能熟悉 CModule API。新的 Barebone 后端尚未实现它(最终会实现!),但我们有更棒的方案:RustModule:

const kernBase = ptr('0xffffff8008080000');
const procPidStatus = kernBase.add(0x15e600);

const m = new RustModule(`
#[no_mangle]
pub unsafe extern "C" fn hook(ic: &mut gum::InvocationContext) -> () {
    let regs = &mut ic.cpu_context;
    println!("proc_pid_status() was called with x0={:#x} x1={:#x}",
        regs.x[0],
        regs.x[1],
    );
}
`);

Interceptor.attach(procPidStatus, m.hook);

RustModule 使用假定已位于 PATH 中的本地 Rust 工具链,把提供的代码编译成自包含的 no_std ELF。它会重定位 ELF 并写入目标内存,同时解析 MMU 页表并插入新条目,使上传的代码成为虚拟地址空间的一部分,相关页面具有读、写、执行权限。

本例会在运行中的 Linux 内核里 Hook proc_pid_status()。

注意,可使用 File.readAllText(),避免在 JavaScript 中内联 Rust 代码。这里为了简洁采用内联代码。

现在来运行这个由 Rust 驱动的 Agent:

$ frida -D barebone -p 0 -l kernhook2.js
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)

Error: to enable this feature, set FRIDA_BAREBONE_HEAP_BASE to the physical base address to use, e.g. 0x48000000
    at <eval> (/home/oleavr/src/demo/kernhook2.js:13)
    at evaluate (native)
    at <anonymous> (/frida/repl-2.js:1)

[Remote::SystemSession ]->

糟糕!还没成功。新后端仍缺少一块拼图:我们还没有“内核桥接器”来自动识别已知内核的内部结构,以找到可用的合适内存分配器。实现 Process.enumerateModules() 等 API 也需要它,这样才能列出已加载的内核模块或 kext。我们还可以定位内核进程列表并实现 enumerate_processes(),让 frida-ps 工作。这些只是少数例子……还可以把 frida-gadget 注入用户空间进程;对于不想修改闪存的嵌入式系统,这会非常有用。话题扯远了 😊

因此,在 MCU 和未知内核上,如果要使用 RustModule、Interceptor 内联 Hook 模式、Memory.alloc() 等侵入式功能,就必须告诉 Frida 哪一段物理内存可以覆盖使用。

了解这一点后,再次尝试示例;这次设置 FRIDA_BAREBONE_HEAP_BASE 环境变量:

$ export FRIDA_BAREBONE_HEAP_BASE=0x48000000
$ frida -D barebone -p 0 -l kernhook2.js
     ____
    / _  |   Frida 16.1.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to GDB Remote Stub (id=barebone)

[Remote::SystemSession ]-> m
{
    "hook": "0xffffff80080103e0"
}
[Remote::SystemSession ]-> $gdb.continue()

成功了!🎉 现在在运行 QEMU 的终端中访问三次 /proc/$pid/status,让被 Hook 的函数得到调用:

# head -3 /proc/self/status
Name:	head
Umask:	0022
State:	R (running)
# head -3 /proc/self/status
Name:	head
Umask:	0022
State:	R (running)
# head -3 /proc/self/status
Name:	head
Umask:	0022
State:	R (running)

回到 REPL,应该能看到 hook() 被命中三次:

proc_pid_status() was called with x0=0xffffffc00d4bca00 x1=0xffffff8008608758
proc_pid_status() was called with x0=0xffffffc00d4bc780 x1=0xffffff8008608758
proc_pid_status() was called with x0=0xffffffc00d4bc780 x1=0xffffff8008608758

成功了!🥳

不过有一点很重要:示例使用了 println!(),它实际上会让目标命中断点,以便主机读出传递的消息,并像 JavaScript 的 console.log() 一样向上传递。因此这个功能只应临时用于调试;若处在热点代码路径上,还应限制调用频率。

接下来你可能想把外部符号传入 RustModule,例如从 Rust 代码调用内核内部函数。可按下面的方式声明:

extern "C" {
    fn frobnicate(data: *const u8, len: usize);
}

然后在构造 RustModule 时通过第二个参数传入:

const m = new RustModule(source, {
  frobnicate: ptr('0xffffff8008084320'),
});

熟悉 CModule API 的读者会发现,这部分完全相同。还可使用 NativeCallback 在主机端用 JavaScript 实现部分逻辑,但需要谨慎处理,以免形成性能瓶颈。反过来,也可使用 NativeFunction 从 JavaScript 调用 Rust 代码。

最后,你可能还想从 crates.io 导入现有 Rust crate。这同样受支持:

const m = new RustModule(source, {}, {
  dependencies: [
    'cstr_core = { version = "0.2.6", default-features = false }',
  ]
});

Corellium

令人兴奋的是,上述所有 Linux 功能在 Corellium 上也能直接工作。只需将 FRIDA_BAREBONE_ADDRESS 指向 Corellium 界面中“Advanced Options”→“gdb”显示的端点。

感谢 Corellium 团队在此过程中的支持。他们甚至实现了新的协议功能来提升互操作性 🔥

未来

目前这个新后端仍应视为 alpha 质量,但它已经能完成许多有用的事情,继续让它留在分支里实在可惜。

你可能会注意到,目前实现的 JS API 只是一个子集,而且非 arm64 目标还不能使用所有功能。随着后端成熟,这些都会逐步改善。(非常欢迎提交 Pull Request!)

再分享一个有趣的例子:这是 Frida 附加到 BeOS 内核的画面:

beos-kernel

结语

此外还有许多令人兴奋的变化,请务必查看下面的变更日志。

尽情体验吧!

变更日志

  • 新增 Barebone 后端。(上文已详细介绍。)
  • objc:处理修饰符,使类型解析更可靠,特别是在处理经常带有“atomic”修饰符的 ivar 时;此前修饰符会被视为未知类型并抛出异常。感谢 @mrmacete!
  • android:修复 Android 14 支持。感谢 @gsingh93!也感谢 @jayluxferro 修复我合并 @gsingh93 的 PR 时犯下的错误。
  • gum-graft:新增链式导入支持。感谢 @mrmacete!
  • gumjs:新增 NativePointer#readVolatile(),可安全读取可能在途中被取消映射或改变内存保护的内存。感谢 @hsorbo!
  • darwin:改进 tvOS 支持,使其覆盖 frida-server。感谢 @tmm1!
  • darwin:修复后备 kill() 逻辑中的内存泄漏。感谢 @tmm1!
  • fruity:处理获取 dyld 符号失败的情况。
  • compiler:将 frida-compile 升级到 16.2.2;现在也会打包依赖项的 source map。感谢 @vfsfitvnm!
  • compiler:将 @types/frida-gum 升级到 18.3.2,并改进 hexdump() 的类型定义。
  • gdb:从 Fruity 的 LLDB.Client 中提取核心实现,新增 GDB.Client,并加入多项协议增强和互操作性修复。
  • elf-module:改进 API 并实现跨平台;支持从 blob 加载、公开重定位信息,并提升整体稳健性。
  • capstone:修复使用 MSVC 构建时 x86 上的崩溃。