准备迎接一次重大升级。这次我们在一个版本中解决了三个存在时间最久的限制。

限制一:fork()

简单回顾一下:这个传统 UNIX API 会克隆整个进程,向父进程返回子进程 ID,并向子进程 返回零。子进程获得父进程地址空间的独立副本;由于采用写时复制,开销通常很小。

涉及多线程后,处理起来就很棘手。子进程中只有调用 fork() 的线程会存活,因此如果其他 线程恰好持有锁,这些锁在子进程中仍处于持有状态,却再也没有线程会释放它们。

这意味着,同时使用 fork 和多线程的应用必须经过非常谨慎的设计。虽然大多数会 fork 的 应用是单线程的,但 Frida 注入 agent 后实际上会把它们变成多线程。文件描述符又是共享的, 同样需要小心管理。

我非常高兴地宣布,我们终于能检测到 fork() 即将发生,临时停止自身线程、暂停通信 通道,并在之后重新启动。这样就能在让子进程继续运行前,按需对它应用插桩。

限制二:execve()、posix_spawn()、CreateProcess() 等

通俗地说,就是程序启动其他程序:要么像 execve() 那样完全替换自身,要么像不带 POSIX_SPAWN_SETEXEC 的 posix_spawn() 那样启动子进程。

与 fork() 后的情况一样,现在可以在子进程执行第一条指令前应用插桩,并控制它何时开始运行。

限制三:处理进程突然终止

过去一个令人困惑的问题是:把数据交给 Frida 的 send() API 后,如果进程即将终止, 这些数据可能实际上无法到达另一端。

此前推荐的解决方案一直是挂钩 exit()、abort() 等函数,通过 send() 加 recv().wait() 的往返过程刷新仍在传输的数据。现在看来这并不理想,因为很难在多个 平台上都正确实现。如今我们有了更好的方案。

下面介绍新的 API 和功能。

子进程门控

这是解决前两个限制的方法。提供 create_script() 的 Session 对象现在也提供 enable_child_gating() 和 disable_child_gating()。Frida 默认行为仍与以前相同; 必须调用 enable_child_gating() 才会启用新行为。

从那以后,所有子进程都会处于挂起状态,你需要使用其 PID 调用 resume()。Device 对象还新增名为 delivered 的信号,应为其连接回调,以便在出现新子进程时收到通知。 此时应先按需应用插桩,再调用 resume()。Device 还新增 enumerate_pending_children() 方法,可获取全部待处理子进程。进程会一直保持挂起并 留在列表中,直到被你恢复或最终被终止。

理论就是这样。下面通过 Frida 的 Python 绑定查看一个实际示例:

import frida
from frida.application import Reactor
import threading

class Application(object):
    def __init__(self):
        self._stop_requested = threading.Event()
        self._reactor = Reactor(run_until_return=lambda _:
            self._stop_requested.wait())

        self._device = frida.get_local_device()
        self._sessions = set()

        self._device.on("delivered", lambda child:
            self._reactor.schedule(
                lambda: self._on_delivered(child)))

    def run(self):
        self._reactor.schedule(lambda: self._start())
        self._reactor.run()

    def _start(self):
        argv = ["/bin/sh", "-c", "cat /etc/hosts"]
        print("✔ spawn(argv={})".format(argv))
        pid = self._device.spawn(argv)
        self._instrument(pid)

    def _stop_if_idle(self):
        if len(self._sessions) == 0:
            self._stop_requested.set()

    def _instrument(self, pid):
        print("✔ attach(pid={})".format(pid))
        session = self._device.attach(pid)
        session.on("detached", lambda reason:
            self._reactor.schedule(lambda:
                self._on_detached(pid, session, reason)))
        print("✔ enable_child_gating()")
        session.enable_child_gating()
        print("✔ create_script()")
        script = session.create_script("""
Interceptor.attach(Module.getExportByName(null, 'open'), {
  onEnter(args) {
    send({
      type: 'open',
      path: Memory.readUtf8String(args[0])
    });
  }
});
""")
        script.on("message", lambda message, data:
            self._reactor.schedule(
                lambda: self._on_message(pid, message)))
        print("✔ load()")
        script.load()
        print("✔ resume(pid={})".format(pid))
        self._device.resume(pid)
        self._sessions.add(session)

    def _on_delivered(self, child):
        print("⚡ delivered: {}".format(child))
        self._instrument(child.pid)

    def _on_detached(self, pid, session, reason):
        print("⚡ detached: pid={}, reason='{}'"
            .format(pid, reason))
        self._sessions.remove(session)
        self._reactor.schedule(self._stop_if_idle, delay=0.5)

    def _on_message(self, pid, message):
        print("⚡ message: pid={}, payload={}"
            .format(pid, message["payload"]))


app = Application()
app.run()

运行效果如下:

$ python3 example.py
✔ spawn(argv=['/bin/sh', '-c', 'cat /etc/hosts'])
✔ attach(pid=42401)
✔ enable_child_gating()
✔ create_script()
✔ load()
✔ resume(pid=42401)
⚡ message: pid=42401,
↪payload={'type': 'open', 'path': '/dev/tty'}
⚡ detached: pid=42401, reason='process-replaced'
⚡ delivered: Child(pid=42401, parent_pid=42401,
↪path="/bin/cat", argv=['cat', '/etc/hosts'],
↪envp=['SHELL=/bin/bash', 'TERM=xterm-256color', …],
↪origin=exec)
✔ attach(pid=42401)
✔ enable_child_gating()
✔ create_script()
✔ load()
✔ resume(pid=42401)
⚡ message: pid=42401,
↪payload={'type': 'open', 'path': '/etc/hosts'}
⚡ detached: pid=42401, reason='process-terminated'
$

退出前刷新

对于第三个限制,即处理进程突然终止,Frida 现在会拦截最常见的进程终止 API,并代为 刷新所有待处理数据。

不过,有些高级 agent 会缓冲数据并仅定期执行 send() 来优化吞吐量;现在它们可以在 进程终止或脚本卸载时运行自定义代码。只需定义一个名为 dispose 的 RPC 导出,例如:

rpc.exports = {
  dispose() {
    send(bufferedData);
  }
};

结语

基于 Frida 全新的 fork() 处理,我们还彻底重写了 Android 应用启动实现。 frida-loader-{32,64}.so 辅助 agent 已被移除,幕后的 Zygote 插桩现在利用全新的 子进程门控完成所有繁重工作。这意味着你也可以按自己的需要对 Zygote 插桩。只需记得 调用 enable_child_gating(),并对不关心的子进程调用 resume()。

本次发布大致就是这些。尽情享用吧!