动态钩子工具Frida的入门学习

Frida是一款轻量级hook框架,可用于多平台上,例如Android、Windows、IOS、 GNU/Linux等。Frida分为两部分,服务端运行在目标机上,通过注入进程的方式来实现劫持应用函数,另一部分运行在自己操作的主机上。Frida上层接口支持js、python、c等。

简介

Frida

Frida 是一个用于在运行时分析、修改和控制应用程序的动态插桩工具,主要用于逆向工程、安全研究和应用程序测试。Frida 提供了多平台支持,包括 Android、iOS、Windows、macOS 和 Linux。它的主要特点包括:

  1. 动态插桩: Frida 允许你在运行时动态地插入 JavaScript 代码到目标应用程序中。这意味着你可以在不重新编译或重新启动应用的情况下,直接与运行中的代码交互。
  2. 多平台支持: Frida 提供了广泛的平台支持,使其可以应用于多种操作系统和设备,包括 Android、iOS、Windows、macOS 和 Linux。
  3. 脚本编写: 使用 JavaScript 编写 Frida 脚本,这使得它易于学习和使用。你可以通过脚本来执行各种任务,如函数挂钩、内存操作、函数参数修改等。
  4. 功能强大的 API: Frida 提供了强大的 API,使得你能够直接与目标应用程序进行交互。你可以访问和修改内存、调用函数、挂钩函数等。JavaScript API | Frida • A world-class dynamic instrumentation toolkit
  5. 应用场景: Frida 在安全研究和逆向工程中广泛应用,用于分析和修改应用程序的行为,破解加密算法,绕过安全机制等。它还可用于应用程序测试,以检查应用程序的安全性和漏洞。
  6. 社区支持: Frida 拥有活跃的社区,提供文档、示例代码和支持论坛,方便用户学习和解决问题。

使用 Frida 的基本步骤包括安装 Frida 工具、运行 Frida Server(在移动设备上)、编写 Frida 脚本并将其注入到目标应用程序中。这使得研究人员和安全专业人员能够在运行时动态地分析和修改应用程序的行为,以便更好地理解其内部机制,发现潜在的漏洞,并进行安全评估。

动态插桩技术

使用动态二进制插桩有两种主要方式,其中第一个方式是最常见的,是在动态二进制系统的控制下从头到尾执行程序。当我们想要实现完整的系统模拟或仿真时,由于需要完全控制并进行代码覆盖,就要在动态二进制系统的控制下从头到尾执行程序。第二个方式就是动态二进制系统可以被附加到一个已经运行的程序中,且以完全相同的方式被调试器从正在运行的程序中附加或分离。如果我们想知道某个程序在特定时刻正在做什么,那么第二个方式就会非常有用。

此外,大多数动态二进制插桩框架都有三种执行模式:解释模式( Interpretation mode)、探测模式(probe mode)和JIT模式(just-in-time mode)。JIT模式是最常见的实现方式,也是最常用的模式,在JIT模式下,原始二进制文件或可执行文件实际上从未被修改或执行过,此时二进制文件被视为数据,修改后的二进制文件副本将在新的内存区域中生成(但只针对二进制文件的执行部分,而不是整个二进制文件),此时执行的就是这个修改后的文件副本。而在解释模式中,二进制文件也被视为数据,每条指令都被用作具有相应功能的替代指令的查找表(由用户实现) 。在探测模式中,二进制文件实际上是通过使用新指令来覆盖旧的指令,来达到修改目的的,不过这会导致运行开销增大,但在某些体系结构(如x86)中,该方式很好用。

无论采用哪种执行模式,一旦我们通过动态二进制插桩框架控制了程序的执行,就能够将插桩添加到执行程序中。我们可以在代码块之前和之后插入想要的代码,甚至也可以完全替换它们。

动态插桩执行过程

安装使用

Installation | Frida • A world-class dynamic instrumentation toolkit

Android端

需要Root,本文直接使用的模拟器

下载对应Android版本的server版本Releases · frida/frida (github.com),具体架构可以使用adb shell连接到设备之后,使用cat /proc/cpuinfo 或者uname -a查看;或者直接使用adb shell getprop ro.product.cpu.abi查看

下载之后使用adb将文件上传至Android:adb push frida-server /data/local/tmp/

然后使用adb shell连接到Android设备,打开/data/local/tmp/目录,将frida-server文件设置为可执行,使用chmod 777 frida-server 即可,然后./frida-server即可执行服务

桌面端

安装Python3之后执行下面pip命令安装frida库

1
2
3
4
5
# 安装 frida库 
pip install frida

# 安装frida-tools工具
pip install frida-tools

安装完成后可以使用frida-ps -U查看是否展示了任务列表,如果可以环境就配置完成了。

下面官方文档有很多相关的tools的介绍:

frida-ps | Frida • A world-class dynamic instrumentation toolkit

Android相关frida脚本编写指南

JavaScript API | Frida • A world-class dynamic instrumentation toolkit

Android | Frida • A world-class dynamic instrumentation toolkit

一个简单的应用实例

准备工作

CTF简单小demo的apk下载链接:https://pan.baidu.com/s/1eq9AGt8p_cG7fbRa4Pn8ig?pwd=elze 提取码:elze

Jadx仓库Github地址:skylot/jadx: Dex to Java decompiler (github.com)

APP分析

在模拟器中安装APP,可以看到如下界面:

显然不想使用模拟点击507904次的方法去得到Flag,我们可以看一下逻辑。

小demo应用没有加壳处理,可以直接拖到Jadx中进行反编译,得到的主要的Java代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.ctf.test.ctf_100;

import android.os.Bundle;
import android.os.Debug;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import java.util.Random;

/* loaded from: classes.dex */
public class MainActivity extends AppCompatActivity {
public int has_gone_int;
public int to_reach_int;

public native String get_flag(int i);

/* JADX INFO: Access modifiers changed from: protected */
@Override // android.support.v7.app.AppCompatActivity, android.support.v4.app.FragmentActivity, android.support.v4.app.BaseFragmentActivityDonut, android.app.Activity
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button bt = (Button) findViewById(R.id.button2);
bt.setClickable(false);
this.has_gone_int = 0;
Random random = new Random();
this.to_reach_int = random.nextInt();
while (true) {
if (this.to_reach_int < 0) {
this.to_reach_int *= -1;
}
if (5 >= this.to_reach_int) {
this.to_reach_int = random.nextInt();
} else {
this.to_reach_int %= 32;
this.to_reach_int *= 16384;
TextView tv = (TextView) findViewById(R.id.data_to_reach);
tv.setText("" + this.to_reach_int);
TextView tv_result = (TextView) findViewById(R.id.tvResult);
tv_result.setText("");
return;
}
}
}

public void Btn_up_onclick(View v) {
this.has_gone_int++;
String data = "" + this.has_gone_int;
TextView tv = (TextView) findViewById(R.id.data_has_gone);
tv.setText(data);
if (this.to_reach_int <= this.has_gone_int) {
Button bt = (Button) findViewById(R.id.button2);
bt.setClickable(true);
}
}

public void btn2_onclick(View v) {
TextView tv_result = (TextView) findViewById(R.id.tvResult);
tv_result.setText("{Flag:" + get_flag(this.to_reach_int) + "}");
}

static {
if (!Debug.isDebuggerConnected()) {
System.loadLibrary("ctf");
}
}
}

逻辑很简单,步数是伪随机生成的,达到步数要求后按钮变为可点击状态,点击按钮调用get_flag函数即可得到flag。

编写frida脚本

根据上面的分析,最直观的一个拿到flag的思路就是寻找到MainActivity的实例,然后调用本地实例方法get_flag即可直接绕过逻辑直接获取Flag。

编写脚本时需要确定attach的进程名,因此需要使用frida列出运行中的进程:

1
2
3
4
5
# -*- coding: utf-8 -*-
import frida

process = frida.get_usb_device(-1).enumerate_processes()
print(process)

结果如下,我们可以看到该应用进程的名字是CTF_100,pid=2862

1
[Process(pid=1, name="init", parameters={}), Process(pid=1068, name="init", parameters={}), Process(pid=1069, name="init", parameters={}), Process(pid=1070, name="ueventd", parameters={}), Process(pid=1427, name="logd", parameters={}), Process(pid=1430, name="servicemanager", parameters={}), Process(pid=1431, name="hwservicemanager", parameters={}), Process(pid=1432, name="vndservicemanager", parameters={}), Process(pid=1500, name="vold", parameters={}), Process(pid=1550, name="netd", parameters={}), Process(pid=1551, name="zygote64", parameters={}), Process(pid=1552, name="zygote", parameters={}), Process(pid=1553, name="android.hidl.allocator@1.0-service", parameters={}), Process(pid=1554, name="healthd", parameters={}), Process(pid=1555, name="android.hardware.audio@2.0-service", parameters={}), Process(pid=1556, name="android.hardware.bluetooth@1.0-service.btlinux", parameters={}), Process(pid=1558, name="android.hardware.cas@1.0-service", parameters={}), Process(pid=1559, name="android.hardware.configstore@1.1-service", parameters={}), Process(pid=1560, name="android.hardware.dumpstate@1.0-service", parameters={}), Process(pid=1561, name="android.hardware.light@2.0-service", parameters={}), Process(pid=1562, name="android.hardware.memtrack@1.0-service", parameters={}), Process(pid=1563, name="android.hardware.power@1.0-service", parameters={}), Process(pid=1564, name="android.hardware.usb@1.0-service", parameters={}), Process(pid=1565, name="android.hardware.wifi@1.0-service", parameters={}), Process(pid=1566, name="local_opengl", parameters={}), Process(pid=1567, name="local_gps", parameters={}), Process(pid=1568, name="vinput", parameters={}), Process(pid=1569, name="audioserver", parameters={}), Process(pid=1570, name="lmkd", parameters={}), Process(pid=1571, name="surfaceflinger", parameters={}), Process(pid=1572, name="thermalserviced", parameters={}), Process(pid=1573, name="adbd", parameters={}), Process(pid=1574, name="noxd", parameters={}), Process(pid=1576, name="sh", parameters={}), Process(pid=1582, name="cameraserver", parameters={}), Process(pid=1583, name="drmserver", parameters={}), Process(pid=1584, name="incidentd", parameters={}), Process(pid=1585, name="installd", parameters={}), Process(pid=1586, name="keystore", parameters={}), Process(pid=1587, name="mediadrmserver", parameters={}), Process(pid=1588, name="media.extractor", parameters={}), Process(pid=1589, name="media.metrics", parameters={}), Process(pid=1590, name="mediaserver", parameters={}), Process(pid=1591, name="statsd", parameters={}), Process(pid=1592, name="storaged", parameters={}), Process(pid=1593, name="wificond", parameters={}), Process(pid=1594, name="media.codec", parameters={}), Process(pid=1595, name="rild", parameters={}), Process(pid=1596, name="gatekeeperd", parameters={}), Process(pid=1597, name="tombstoned", parameters={}), Process(pid=1601, name="mdnsd", parameters={}), Process(pid=1618, name="iptables-restore", parameters={}), Process(pid=1619, name="ip6tables-restore", parameters={}), Process(pid=1714, name="system_server", parameters={}), Process(pid=1856, name="sdcard", parameters={}), Process(pid=1870, name="com.android.systemui", parameters={}), Process(pid=1907, name="webview_zygote", parameters={}), Process(pid=1974, name="com.android.phone", parameters={}), Process(pid=1984, name="wpa_supplicant", parameters={}), Process(pid=2001, name="设置", parameters={}), Process(pid=2058, name="android.ext.services", parameters={}), Process(pid=2267, name="android.process.media", parameters={}), Process(pid=2282, name="com.vphone.launcher", parameters={}), Process(pid=2291, name="com.android.printspooler", parameters={}), Process(pid=2334, name="com.android.keychain", parameters={}), Process(pid=2459, name="com.android.packageinstaller", parameters={}), Process(pid=2478, name="com.android.providers.calendar", parameters={}), Process(pid=2509, name="com.android.traceur", parameters={}), Process(pid=2525, name="com.google.android.webview:webview_service", parameters={}), Process(pid=2547, name="com.google.android.webview:sandboxed_process0", parameters={}), Process(pid=2597, name="com.android.inputservice", parameters={}), Process(pid=2684, name="android.hardware.camera.provider@2.4-service", parameters={}), Process(pid=2752, name="su", parameters={}), Process(pid=2862, name="CTF_100", parameters={}), Process(pid=3055, name="sh", parameters={}), Process(pid=3058, name="frida-server-16.1.8-android-x86_64", parameters={}), Process(pid=3060, name="logcat", parameters={}), Process(pid=3074, name="memfd:frida-helper-32 (deleted)", parameters={})]

或者使用frida-ps -U,如果是一些中文名称的name,还可以使用frida-ps -U -a获取Identifier

然后就可以编写Python脚本将js代码注入,选择寻找MainActivity的实例,然后调用实例方法打印返回值:

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
27
28
# -*- coding: utf-8 -*-
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))


jscode = """
Java.perform(function x() {
Java.choose('com.ctf.test.ctf_100.MainActivity', {
onMatch: function (instance) {
console.log(instance.get_flag(507904));
},
onComplete: function () {
console.log('Done');
}
});
});
"""

process = frida.get_usb_device(-1).attach('CTF_100')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] Running CTF')
script.load()

我们就可以在控制台看到如下结果:

1
2
3
[*] Running CTF
268796A5E68A25A1
Done

Flag就是268796A5E68A25A1

不过这个get_flag的本地方法貌似不会校验传入的参数具体数值,换几个数字都可以拿到Flag,因此我们也可以使用另一种思路,就是直接将button2设置为可以点击:

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
27
# -*- coding: utf-8 -*-
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))


jscode = """
Java.perform(function x() {
Java.choose('com.ctf.test.ctf_100.MainActivity', {
onMatch: function (instance) {
instance.findViewById(0x7f0c0056).setClickable(true);
},
onComplete: function () {
console.log('Done');
}
});
});
"""
process = frida.get_usb_device(-1).attach('CTF_100')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] Running CTF')
script.load()

其中0x7f0c0056就是R.id.button2的实际值。运行上面的脚本,然后点击看FLAG的按钮就可以展示出Flag。

Hook native函数

假设我们有个需求,修改get_flag()函数的返回值,在native函数上挂钩子应该怎么处理呢?

小插曲

如果是JNI非动态注册,参考官方文档和一些博客很容易可以写出如下代码:

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
27
28
29
30
31
32
33
34
35
# -*- coding: utf-8 -*-
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))


jscode = """
Java.perform(function x() {
//下面这代码是指定要Hook的so文件名和要Hook的函数名
var func_addr = Module.findExportByName("libctf.so","xxxx_get_flag");
send("func_addr="+func_addr);
Interceptor.attach(func_addr, {
//onEnter: function(args)顾名思义就是进入该函数前要执行的代码,其中args是传入的参数,一般so层函数第一个参数都是JniEnv,第二个参数是jclass,从第三个参数开始才是我们java层传入的参数
onEnter: function(args) {
send("Hook start");
send("args[2]=" + args[2]); //打印我们java层第一个传入的参数
},
onLeave: function(retval){ //onLeave: function(retval)是该函数执行结束要执行的代码,其中retval参数即是返回值
send("return:"+retval); //打印返回值
var env = Java.vm.getEnv(); //获取env对象,也就是native函数的第一个参数
var jstrings = env.newStringUtf("test"); //因为返回的是字符串指针,使用我们需要构造一个newStringUtf对象,用来代替这个指针
retval.replace(jstrings); //替换返回值
}
});
});
"""
process = frida.get_usb_device(-1).attach('CTF_100')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] Running CTF')
script.load()

正式开始分析

IDA分析之后查看导出表可以看出,其实libctf.so是动态JNI注册:JNI与动态注册介绍

先寻找so的地址:

1
2
var base_hello_jni = Module.findBaseAddress("libctf.so");
send("base_hello_jni="+base_hello_jni);

发现so的地址是null,然后我们打印出所有的so:

1
2
3
4
5
6
7
var modules = Process.enumerateModulesSync();
modules.forEach(function(module) {
console.log("Name:", module.name);
console.log("Base:", module.base);
console.log("Size:", module.size);
console.log("\n");
});

发现确实没有libctf.so,但是在我们在源代码反编译后的lib文件夹下确实只有libctf.so一个动态链接库。

尝试手工加载so:

1
2
3
4
Module.load("/data/data/com.ctf.test.ctf_100/lib/libctf.so");
Error: dlopen failed: "/data/app/com.ctf.test.ctf_100-DKPwuW3kmtpG18W0MdAxmA==/lib/arm/libctf.so" has unexpected e_machine: 40 (EM_ARM)
at value (frida/runtime/core.js:234)
at <eval> (<input>:1)

因为我们用的虚拟机,非ARM架构,无法加载这个so。。。

那为啥虚拟机能正常运行app呢?因为模拟了arm去执行吗?不死心,使用下面frida脚本查看加载的so(参考APP使用frida反调试检测绕过 - 树大招疯 - 博客园 (cnblogs.com)):

1
2
3
4
5
6
7
8
9
10
11
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("load " + path);
}
}
}
);
1
frida -U -f com.ctf.test.ctf_100 -l frida_script.js

结果frida在加载到arm的so文件时就会崩溃。。。下次用真机试试吧

后面好像没办法继续了,IDA看一下关键函数的逻辑吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
int v3; // r1
int v4; // [sp+0h] [bp-18h] BYREF
int v5[5]; // [sp+4h] [bp-14h] BYREF

v4 = 0;
v5[0] = (int)"get_flag";
v5[1] = (int)"(I)Ljava/lang/String;";
v5[2] = (int)sub_F90;
if ( (*vm)->GetEnv(vm, (void **)&v4, 65540) )
return -1;
v3 = (*(int (__fastcall **)(int, const char *))(*(_DWORD *)v4 + 24))(v4, "com/ctf/test/ctf_100/MainActivity");
if ( !v3 )
return -1;
(*(void (__fastcall **)(int, int, int *, int))(*(_DWORD *)v4 + 860))(v4, v3, v5, 1);
return 65540;
}

JNI_OnLoad函数中可以看出调用了sub_F90函数:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
int __fastcall sub_F90(int a1)
{
unsigned int *v2; // r2
const char *v3; // r3
unsigned int v4; // r0
unsigned int v5; // r1
char *v6; // r5
char *v7; // r3
int v8; // r0
int v9; // r1
int i; // r3
int j; // r2
unsigned int v12; // r3
unsigned int v13; // r0
unsigned int v14; // r3
char v15; // r0
int v16; // r0
char v17; // r3
int v19[2]; // [sp+0h] [bp-60h] BYREF
int v20[2]; // [sp+8h] [bp-58h] BYREF
unsigned int v21[2]; // [sp+10h] [bp-50h] BYREF
unsigned int v22; // [sp+18h] [bp-48h] BYREF
char v23[16]; // [sp+20h] [bp-40h] BYREF
char v24[20]; // [sp+30h] [bp-30h] BYREF

if ( dword_4004 == 1 )
exit(-1);
v2 = v21;
v3 = "my_test_ctf_flag";
do
{
v4 = *(_DWORD *)v3;
v3 += 8;
v5 = *((_DWORD *)v3 - 1);
*v2 = v4;
v2[1] = v5;
v2 += 2;
}
while ( v3 != "" );
v6 = v23;
v7 = (char *)&dword_2801;
do
{
v8 = *(_DWORD *)v7;
v7 += 8;
v9 = *((_DWORD *)v7 - 1);
*(_DWORD *)v6 = v8;
*((_DWORD *)v6 + 1) = v9;
v6 += 8;
}
while ( v7 != "get_flag" );
sub_F08(v21, (int)v23, v19);
sub_F08(&v22, (int)v23, v20);
for ( i = 0; i != 8; ++i )
*((_BYTE *)v19 + i) ^= *((_BYTE *)v20 + i);
for ( j = 0; j != 8; ++j )
{
v12 = *((unsigned __int8 *)v19 + j);
v13 = v12 >> 4;
v14 = v12 & 0xF;
if ( v13 > 9 )
v15 = v13 + 55;
else
v15 = v13 + 48;
v24[2 * j] = v15;
v16 = 2 * j;
if ( v14 > 9 )
v17 = v14 + 55;
else
v17 = v14 + 48;
v24[v16 + 1] = v17;
}
v24[16] = 0;
return (*(int (__fastcall **)(int, char *))(*(_DWORD *)a1 + 668))(a1, v24);
}

这段逻辑应该是生成最终Flag的逻辑,后面等学习IDA动态调试时再去分析吧。


动态钩子工具Frida的入门学习
https://chujian521.github.io/blog/2023/12/01/动态钩子工具Frida的入门学习/
作者
Encounter
发布于
2023年12月1日
许可协议