学习笔记-FRIDA脚本系列(四)

更新篇:几个主要机制的大更新

最近沉迷学习,无法自拔,还是有一些问题百思不得骑姐,把官网文档又翻了一遍,发现其实最近的几个主要版本,更新还是挺大的,遂花了点时间和功夫,消化吸收下,在这里跟大家分享。

进程创建机制大更新

存在的问题:无法为新进程准备参数和环境

当我们使用Frida Python的binding的时候,一般会这么写:

pid = device.spawn(["/bin/cat", "/etc/passwd"])

或者在iOS平台上,会这样写:

pid = device.spawn(["com.apple.mobilesafari"])

目前来看貌似用这个API只能这么写,这么写其实是存在很多问题的,比如说没有考虑完整参数列表的问题,或者说新进程的环境是继承自绑定的host机环境还是设备client环境?再比如想要实现定制功能,比如以关闭ASLR模式打开safari,这些都没有考虑进去。

问题产生的原因(一):当初源码中就没有实现

我们先来看看这个API在frida-core里是如何实现的:

namespace Frida {

public class Device : GLib.Object {

public async uint spawn (string path,
string[] argv, string[] envp)
throws Frida.Error;
public uint spawn_sync (string path,
string[] argv, string[] envp)
throws Frida.Error;
}

}

这段代码是用vala语言写的,frida-core都是用vala写的,vala看起来跟C#很像,并且最终会被编译成C代码。从代码可以看出,第一个方法——spawan是异步的,调用者调用一遍就可以去干其他事情了,不用等待调用完成,而第二个方法——spawn_sync则需要等到调用完全结束并返回。

这两个方法会被编译成如下的C代码:

void frida_device_spawn (FridaDevice * self,
const gchar * path,
gchar ** argv, int argv_length,
gchar ** envp, int envp_length,
GAsyncReadyCallback callback, gpointer user_data);
guint frida_device_spawn_finish (FridaDevice * self,
GAsyncResult * result, GError ** error);
guint frida_device_spawn_sync (FridaDevice * self,
const gchar * path,
gchar ** argv, int argv_length,
gchar ** envp, int envp_length,
GError ** error);

前两个函数组成了spawn()的过程,首先调用第一个获得一个回调,当获得回调之后就会调用第二个函数——spawn_finish(),将回调的返回值将会作为GAsyncResult的参数。最终的返回值就是PID,当然如果有error的话就会返回error no。

第三个函数——spawn_sync()上面也解释了,是完全同步的,Frida Python用的其实就是这个。Frida nodejs用的其实是前两个,因为nodejs里的绑定默认就是异步的。当然以后其实应该也考虑将Frida Python的绑定迁移到异步的模式中来,利用Python 3.5版本引入的async/await机制。

回到上一小节那两个例子,可以发现其实调用的格式跟我们写的API并不完全一致,仔细看源码就会发现,像envp字符串列表并没有暴露给上层API,如果查看Frida Python的绑定过程的话,就可以发现其实后来在绑定里是这样写的:

envp = g_get_environ ();
envp_length = g_strv_length (envp);

也就是说最终我们传递给spawn()函数的是调用者的Python环境,这明显是不对的,host的Python环境跟client的Python肯定是不一样的,比如像client是iOS或Android的情况。

当然我们在frida-server里做了设定,在spawn()安卓或者iOS的进程的时候,envp会被默认忽略掉,这或多或少减少了问题的产生。

问题产生的原因(二):spawn()的历史遗留问题

还有一个问题就是spawn()这个古老的API的定义——string[] envp,这个定义意味着不能为空(如果写成string[]? envp的话其实就可以为空了),也就是说其实无法从根本上区别“用默认的环境配置”和“不使用任何环境配置”。

进程创建机制更新(一):参数、目录、环境均可设置

既然决定要修这个API,那就干脆顺便把跟这个API相关的问题都来看下:

  • 如何给命令提供一些额外的环境参数
  • 设置工作目录
  • 自定义标准输入流
  • 传入平台特定的参数

修正完以上bug之后,最终代码会变成下面这样:

namespace Frida {

public class Device : GLib.Object {

public async uint spawn (string program,
Frida.SpawnOptions? options = null)
throws Frida.Error;
public uint spawn_sync (string program,
Frida.SpawnOptions? options = null)
throws Frida.Error;
}

public class SpawnOptions : GLib.Object {
public string[]? argv { get; set; }
public string[]? envp { get; set; }
public string[]? env { get; set; }
public string? cwd { get; set; }
public Frida.Stdio stdio { get; set; }
public GLib.VariantDict aux { get; }

public SpawnOptions ();
}

}

最后,我们回到开头的那段示例代码,本来我们是这么写的:

device.spawn(["com.apple.mobilesafari"])

现在得这样写了:

device.spawn("com.apple.mobilesafari")

第一个参数是要被spawn的命令,后面可以加上argv的字符串列表,argv就会被用来设定参数的命令,比如:

device.spawn("/bin/busybox", argv=["/bin/cat", "/etc/passwd"])

如果想要将默认环境替换成自己的设定的话:

device.spawn("/bin/ls", envp={ "CLICOLOR": "1" })

只更改环境变量里的一个参数:

device.spawn("/bin/ls", env={ "CLICOLOR": "1" })

更改命令的工作目录:

device.spawn("/bin/ls", cwd="/etc")

重定向标准输入流:

device.spawn("/bin/ls", stdio="pipe")

stdin默认的输入是inherit,加上stdio="pipe"这个选项之后,就变成管道了。

进程创建机制更新(二):利用aux机制实现平台特定功能

到这里我们几乎覆盖了spawn()的所有选项,还剩下最后一个选项——aux,该选项的本质是平台特定参数的一个字典。可以用Python绑定来设置这个参数,任何无法被前面参数捕获的键值对,都会直接放在命令行的最后面。

比如,打开Safari并且通知它去打开特定的URL:

device.spawn("com.apple.mobilesafari", url="https://bbs.pediy.com")

再比如以关闭ASLR的模式执行一个命令:

device.spawn("/bin/ls", aslr="disable")

再比如用特定的Activity来打开一个安卓的App:

spawn("com.android.settings", activity=".SecuritySettings")

aux机制让命令行可以轻松定制,这可比为每个平台单独写代码方便多了。事实上,底层代码一行都没变 ^.<

最后来看下这个API修改完成之后的效果,逗号后面的第二个参数就是带属性的对象,后面无法被是别的参数则全部进aux字典。

const pid = await device.spawn('/bin/sh', {
argv: ['/bin/sh', '-c', 'ls /'],
env: {
'BADGER': 'badger-badger-badger',
'SNAKE': true,
'MUSHROOM': 42,
},
cwd: '/usr',
stdio: 'pipe',
aslr: 'auto'
});

当然,修改完成之后,子进程的路径、参数和环境都可以置空了,这个置空已经可以区分“用默认的环境配置”和“不使用任何环境配置”了。

子进程插装机制大更新

存在的问题:子进程多线程机制混乱容易崩

首先来回顾一下,传统的fork()函数本来的操作是这样婶儿的,它会克隆完整的父进程空间给子进程,这个过程通常开销不大,因为有着copy-on-write机制,然后将子进程的进程ID返回给父进程,将0返回给子进程。

而当涉及到多线程的时候,情况就会变得复杂起来,只有调用fork()函数的线程,可以“存活”到子进程里面,而如果其他线程碰巧有线程锁,这些锁在子进程里将永远不会被解开。

所以说App如果要同时进行多线程和fork操作的话,必须得非常谨慎,当然大多数App都在fork进程时都是使用的单线程设计,可是在注入我们的frida-gum之后,该进程就变成了多线程,所以程序经常会崩溃或失去响应。还有一种情况就是拥有共享属性的文件描述符,处理的时候也需要非常非常谨慎。

在这个版本中作者花了大力气,最终解决了这个问题。作者非常鸡冻的宣布,现在FRIDA可以检测到即将运行fork()函数,临时暂停FRIDA的线程,暂停通讯通道,并随着fork()的过程一起备份,备份完成之后恢复运行。也就是说在子进程开始运行之前,我们就把想要实施的插装操作应用到子进程上了。

当然不仅仅是fork(),还有execve(), posix_spawn(), CreateProcess()等系列子进程操作函数,这么说吧,只要是对进程实施的操作,不管是像execve()一样替换自身进程的,还是像posix_spawn()一样另起一个进程的,都会像fork()函数一样,由FRIDA先实施好插桩之后,再开始运行。

解决的方法:引入新的子进程控制API:Child gating

前两个问题主要就是由这个新引入的“子进程控制”的API来解决的,我们为拥有create_script()方法的Session对象全新加入了enable_child_gating()和disable_child_gating()这两个方法,在不显示调用新API的情况下,Frida的机制还是会跟从前一样,我们需要手动调用enable_child_gating()方法来切换到子进程控制的模式。

进入子进程控制模式之后,所有的子进程都会先暂停,等我们一顿操作完成之后,再对子进程的PID调用resume()来恢复子进程的运行。Device对象有一个叫做delivered的信号,我们可以在这个信号上装一个回调callback,这样有新的进程被产生出来的时候就会得到通知,得到通知之后立刻对新进程进行插桩等操作即可,然后调用resume()函数就可以恢复新进程的运行。Device对象还有一个新的enumerate_pending_children()的方法,用来列出即将产生的子进程列表,所有即将产生的子进程都会在这个表里,直到用户运行resume()函数恢复其运行,或者直接被kill掉。

理论讲完了,接下来实际操作一遍。下面是host端的py代码:

from __future__ import print_function
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 reactor: 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("""'use strict';

Interceptor.attach(Module.findExportByName(null, 'open'), {
onEnter: function (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'
$

我们重构了子进程的hook机制,也顺便重构了Android App的启动机制,移除了之前的frida-loader-{32,64}.so,全新的Zygote插桩机制会在后台承担所有的子进程控制工作,这也意味着可以对Zygote进行任意的插桩工作,当然得记好要调用enable_child_gating()来开启这这个功能,对于不需要进行插桩的子进程立即使用resume()来恢复其运行。

退出(崩溃)消息机制大更新

存在的问题:程序崩溃时消息来不及发出

另外一个一直以来存在的问题就是,当进程快要意外崩溃的时候,进程传给FRIDA的send()的API的数据,可能会来不及发出去,虽然民间也有一种解决的办法就是可以hook一些exit()或abort()函数,然后在hook的语句里进行send()和recv().wait()的client-host结对操作,虽然不是很优雅,但针对特定平台也是有效的。

解决的方法:对各大平台的停止进程API进行插装

针对程序意外崩溃的情况,Frida目前已经可以介入各大系统平台常用的停止进程的API,为用户做好进程崩溃时的清理工作,包含把数据发送出去。

有些脚本会把想要输出的数据在本地做个持久化然后定期通过send()传出去,这种情况下需要在进程即将崩溃的时候显式地将数据传输出去,我们为这种情况定制了一个RPC,导出名为dispose:

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

几个大的机制的更新先介绍到这里,应该还会有下一篇,介绍一些小的但是***钻的,或者是理念式的变化,不要小看这些变化,对于代码来讲,every line matters 。

 

 

点击关注,共同学习!
[安全狗的自我修养](https://mp.weixin.qq.com/s/E6Kp0fd7_I3VY5dOGtlD4w)


[github haidragon](https://github.com/haidragon)


https://github.com/haidragon

posted @ 2022-11-11 10:11  syscallwww  阅读(388)  评论(0编辑  收藏  举报