Gadget

Gadget 是 Frida 提供的一个共享库。当没办法使用注入模式时,可以将该库加载到需要调试的程序中。

通常有几个办法可以把Gadget嵌入到目标程序中,比如:

  • 修改程序源码
  • 给目标程序或目标程序的某个库打补丁。比如使用 insert_dylib 类似的工具
  • 借助动态链接特性,如 LD_PRELOADDYLD_INSERT_LIBRARIES

动态链接器在执行Gadget的构造器后就会立刻开始工作。

Gadget 可以根据你的需求提供四种不同的交互模式。其中默认交互模式为 监听 模式. 你可以在配置文件中修改默认模式。 这个配置文件命名应该和Gadget二进制文件一模一样,除了后缀名是.config。 举个例子,如果你的Gadget名字为FridaGadget.dylib,那么对应的配置文件名就应该是FridaGadget.config

注意,Gadget二进制文件的名字可以随意设定,这样可以规避一些反Frida引擎的检测。

使用Xcode向iOS应用中添加.config的时候需要注意,你可能会倾向于将FridaGadget.dylib放到一个称为"Frameworks"的子目录下, 然后将".config"文件放到父目录中,也就是和app以及其他资源文件同级的目录。处于这个原因,Gadget也会在其父目录名字为"Framework"时,在父目录里寻找.config文件。

在Android上,对一个非debug模式的应用,包管理器只会在满足以下条件之一的情况下拷贝/lib文件:

  • 名字前缀为 lib
  • 名字后缀为 .so
  • 名字为 gdbserver

因此Frida会很智能的检测如下的配置文件名称:

lib
└── arm64-v8a
    ├── libgadget.config.so
    ├── libgadget.so

更多详情,查阅这篇 文章.

配置文件应该是使用UTF-8编码的JSON文本文件。支持四种根键:

  • interaction: 指明交互模式的对象。默认为 监听 交互.

  • teardown: 字符串 minimalfull, 指明该库卸载时,需要清理的干净程度 默认值为 minimal, 也就是不关闭内部线程,也不释放已分配的内存和系统资源。 这么做没啥问题,因为Gadget的生命周期是和程序绑定的。如果你想在某个时间点完全卸载该库,你可以设置为full

  • runtime: 字符串 default, qjs, 或 v8 。默认使用的JS运行时。

  • code_signing: 字符串 optionalrequired , 设置为 required,将允许Gadget在已越狱iOS设备上不实用调试器附加的情况下运行。 默认值为 optional, 这时Gadget会假定拥有修改内存中已存在代码的权限和运行未签名代码的权限,而且做这两件事不会被内核杀死。将该值设置为 required 也意味着拦截API是不可行的。因此,在已越狱的iOS设备上,使用拦截API的唯一方式是在Gadget加载完毕前附加上调试器。需要注意的是,启动应用时附加调试器就够了, 没有必要一直附加,因为代码签名状态是黏性的,设置后就不会再变了。

支持的交互模式

  1. 监听
  2. 连接
  3. 脚本
  4. 脚本目录

被动监听模式

默认的交互模式。 Gadget 会将 frida-server 接口暴露出来, 默认在 localhost:27042 监听。唯一的区别是,正在运行的进程列表和已安装app列表里只包函一个程序。程序名永远为Gadget,以及app的标识符永远为 re.frida.Gadget

为了实现早期调试,我们会让Gadget的构造器阻塞,直到你调用attach()附加到进程,或者在使用spawn() -> attach()-> …apply instrumentation…过程中 调用resume()。 这也就意味着原本就存在工具(比如 frida-trace)可以仍按照器原本的方式运作。

如果你不想阻塞,想让程序立刻启动起来,或者你更想在不同的接口上监听,你可以在配置文件里自定义。

默认的配置文件:

{
  "interaction": {
    "type": "listen",
    "address": "127.0.0.1",
    "port": 27042,
    "on_port_conflict": "fail",
    "on_load": "wait"
  }
}

支持的配置键有:

  • address: 字符串,指明监听的接口。 支持 IPv4 和 IPv6。 默认值为 127.0.0.1. 设置为 0.0.0.0 表明在所有IPv4上监听。 :: 为在所有IPv6上监听。

  • port: 数字,指明默认监听的端口号. 默认值为 27042.

  • certificate: 设置该键用于支持TLS。 其值应当为PEM编码的公钥和私钥 可以是包函多行PEM数据的字符串,或者是指明文件位置的路径。服务器接受所有客户端侧的任何证书。

  • token: 设置该键用于打开身份认证。 其值应为客户端访问时应该提供的密码。

  • on_port_conflict: 字符串 failpick-next, 指明监听端口冲突时的行为。 默认 fail, 也就是端口冲突时,Gadget会启动失败。 设置为 pick-next 时,Gadget 会在端口冲突时逐个检测下个端口,直到找到一个可用端口。

  • on_load: 字符串 resumewait, 指明Gadget加载完毕后的行为。 默认值为wait, 也就是等待你连接并通知它恢复运行 设置为 resume 时,程序会立刻启动,当你想晚一点附加进程的时候,你可以使用这个选项。

  • origin: 设置该值用来保护未认证的跨源访问,设置后将只接受匹配头的请求。

  • asset_root: 设置该值以启用HTTP/HTTPS托管静态文件。所有目录内的文件都将暴露出来。默认情况下不托管任何文件。

主动连接模式

这是一个和 “监听模式” 对立的模式。监听模式下,Gadget会监听一个TCP接口,等待连接,而连接模式下,Gadget会主动连接到一个运行的frida-portal,然后 成为其集群的一个进程节点。 这就是所谓的cluster接口。 而Portal通常也会暴露一个control接口,该接口和frida-server使用相同的协议。这允许任何已连接的控制器,就像在本地机器上的进程一样运行enumerate_processesattach()

为了实现早期调试,我们会让Gadget的构造器阻塞,直到一个控制器发出resume()请求(前提启用了 spawn-gating,可以通过Device.enable_spawn_gating()启用)。 这意味着启动后,Gadget会阻塞,直到其连接的Portal或集群发出命令。

默认的配置文件:

{
  "interaction": {
    "type": "connect",
    "address": "127.0.0.1",
    "port": 27052
  }
}

支持的配置键有:/p>

  • address: 字符串,指明要连接的主机,或暴露的集群接口。 支持 IPv4 和 IPv6。 默认值为 127.0.0.1

  • port: 数字,指明要连接的TCP端口。 默认为 27052

  • certificate: 若 Portal启用了TLS,则必须设置该值。也就是PEM编码的公钥。 和前面一样,可以是多行的PEM数据,也可以是指明公钥位置的字符串。这个公钥应该来自被信任的CA,也就是服务端证书匹配或者继承来的。

  • token: 若 Portal 集群启用了身份认证,就必须设置该值。该值会在连接到 Portal 时提供给服务器用于身份验证。 该键的具体值取决于Portal的实现。可能是固定的密码,也可能是任何验证方式。该验证服务接口是可外部集成定制的。

  • acl: 字符串数组,用于指明存取控制列表。该值限定了可以和发现并和该进程的控制器。比如 ["team-a", "team-b"],任何来自"team-a"或"team-b"的控制器都将获取控制权限。 只有 Portal 通过API实例化,且需要提供身份认证的时候,才需要设置该键。

高级用法Advanced users

为了更好的控制,比如定制的身份验证,节点ACLs,以及应用协议信息。你也可以自己实例化 PortalService 对象,而不是运行 frida-portal 的CLI程序。

脚本模式

有时,从本地直接加载脚本,并在程序入口前执行全面的调试非常有必要。

这里有一个简单的必要配置:

{
  "interaction": {
    "type": "script",
    "path": "/home/oleavr/explore.js"
  }
}

其中 explore.js 包含如下的框架:

rpc.exports = {
  init(stage, parameters) {
    console.log('[init]', stage, JSON.stringify(parameters));

    Interceptor.attach(Module.getExportByName(null, 'open'), {
      onEnter(args) {
        const path = args[0].readUtf8String();
        console.log('open("' + path + '")');
      }
    });
  },
  dispose() {
    console.log('[dispose]');
  }
};

其中 rpc.exports 不是必须的, 但在你的脚本需要知晓生命周期的时候很有用。

Gadget 会调用你的 init() 方法,然后等待其返回,这部分发生在程序进入其入口时发生。 如果你同时还想做点别的什么事,你可以返回一个Promise。比如 Socket.connect(), 然后保证不要错过早期的调用。 第一个参数, stage, 是一个字符串,取值为 earlylate, 该参数在知晓Gadget是否是刚加载时十分有用,或在脚本重新加载时也很有用。 后面的主题会介绍更多。 第二个参数, parameters, 是一个对象,该对象为配置文件中的对象。如果配置文件中没配置,则默认为空。 如果你想参数化你的脚本,这个参数将会很重要。

如果你需要显式地在脚本卸载后做一些清理工作,那么你需要暴露dispose() 方法。 通常在进程退出,Gadget卸载时,或者你的脚本发生变动,旧版本退出时调用。

你可以使用 console.log(), console.warn(), 和 console.error() 进行调试, 这些信息会打印到 stdout/stderr

支持的配置键有:

  • path: 字符串,指明需要需要加载的脚本路径。相对路径时则是相对于Gadget二进制文件。 在 iOS 上,Gadget 首先会查看相对于 app 文档目录的路径。这意味着你可以通过 iTunes 文件共享来上传和更新脚本。这个功能和"on_change": "reload"一起使用时非常有用。该键没有默认值,而且必须提供。

  • parameters: 对象,包含任意的配置数据。该对象会传递给 init() RPC 方法。默认为空。

  • on_change: 字符串 ignorereload, 其中 ignore 意味着脚本只会加载一次, 而 reload 意味着 Gadget 会监视脚本文件,并在脚本变动时立刻重新加载脚本 默认值为 ignore, 但在开发期间,强烈建议使用 reload

脚本目录模式

有时你可能会想在系统层面篡改程序和库,但是不用自己的脚本逻辑去筛选程序,而是想尽可能少的作筛选, 并且根据当前运行Gadget的程序选择不同的脚本注入。你可能甚至不需要过滤,就可以将每个脚本当作独立的插件。 在 GNU/Linux 系统里,类似的脚本甚至可以来自于包,通常这些脚本用来安装并微调已存在的应用。

这里有一个简单的必要配置:

{
  "interaction": {
    "type": "script-directory",
    "path": "/usr/local/frida/scripts"
  }
}

支持的配置键有:

  • path: 字符串,用于指明包含脚本的目录。也可以是相对Gadget二进制文件的相对目录。 该键没有默认值,必须提供。脚本必须以.js作为其后缀名,而且每个脚本都可以有一个对应的.config文件。也就是说,twitter.js会将twitter.config识别为其配置文件。

  • on_change: 字符串,值为 ignorerescan, 其中 ignore 意味着目录只会扫描一次,而 rescan 意味着 Gadget 会监视目录,并在有变动时重新扫描。 默认值为 ignore, 但在开发期间,强烈建议使用 rescan

每个脚本可选的配置文件可以包含以下键:

  • filter: 对象,包含了若干加载目标脚本的条件。这下加载条件中只有一个会被匹配到,所以如果需要复杂的过滤逻辑,最好还是用脚本实现。支持的匹配方式如下:

    • executables: 字符串数组,指明可执行文件的名称
    • bundles: 字符串数组,指明一系列标识符
    • objc_classes: 字符串数组,用于指明Objectiv-C的类名
  • parameters: 对象,包含任意的配置数据。该对象会传递给 init() RPC 方法。默认为空。

  • on_change: 字符串,取值为 ignorereload,其中 ignore 意味着脚本只会加载一次,而reload 意味着 Gadget 会监视脚本文件,并在脚本变动时立刻重新加载脚本。 默认值为 ignore , 不过强烈建议开发阶段使用 reload

比如你想在Twitter的macOS应用中写一个延迟脚本,你可以编写这样一个文件 twitter.js,将其放在/usr/local/frida/scripts,内容为:

const { TMTheme } = ObjC.classes;

rpc.exports = {
  init(stage, parameters) {
    console.log('[init]', stage, JSON.stringify(parameters));

    ObjC.schedule(ObjC.mainQueue, () => {
      TMTheme.switchToTheme_(TMTheme.darkTheme());
    });
  },
  dispose() {
    console.log('[dispose]');

    ObjC.schedule(ObjC.mainQueue, () => {
      TMTheme.switchToTheme_(TMTheme.lightTheme());
    });
  }
};

然后为了确保该脚本只会加载到特定的app中,你需要再创建一个配置文件twitter.config,其中包含:

{
  "filter": {
    "executables": ["Twitter"],
    "bundles": ["com.twitter.twitter-mac"],
    "objc_classes": ["Twitter"]
  }
}

这个例子里,配置文件说明了这样一些事:

  • 可执行文件名字为 Twitter, 或
  • 其打包标识符是 com.twitter.twitter-mac, 或
  • 检测到名为 Twitter的 Objective-C class被加载。

这个例子里,你可能只会用 bundle ID作为过滤条件,因为通常来说这个是最稳定的标识。不过如果需要的话,还是多一些检查更好。

接下来你可能想再声明一些其他键,比如 parameterson_change, 这些配置和前面 脚本 的配置一模一样。