frida(底层)

一、frida介绍

frida是一款便携的、自由的、支持全平台的hook框架,可以通过编写JavaScript、Python代码来和frida_server端进行交互,还记得当年用xposed时那种写了一大堆代码每次修改都要重新打包安装重启手机、那种调试调到头皮发麻的痛苦,百分之30的时间都是在那里安装重启安装重启。

二、frida的代码结构

1
2
3
4
5
6
7
8
9
frida-core: Frida 核心库
frida-gum: inline-hook 框架
bindings:
frida-python: python
frida-node: Node.js
frida-qml: Qml
frida-swift: Swift
frida-tools: CLI tools
capstone: instruction disammbler

Frida的核心是c编写的有多种语言绑定例如 Node.js、 Python、 Swift、 .NET、 Qml。

一般我们都使用js去编写frida脚本因为js的异常处理机制非常棒相比于其他语言更高效好用。

frida-core

frida-core的功能有进程注入、进程间通信、会话管理、脚本生命周期管理等功能,屏蔽部分底层的实现细节并给最终用户提供开箱即用的操作接口。而这一切的实现都在 frida-core 之中。

正如名字所言,这其中包含了 frida 相关的大部分关键模块和组件,比如 frida-server、frida-gadget、frida-agent、frida-helper、frida-inject 以及之间的互相通信底座。

frida-gum

frida-gum是基于inline-hook实现的他还有很多丰富的功能比如用于代码跟踪 Stalker、用于内存访问监控的MemoryAccessMonitor,以及符号查找、栈回溯实现、内存扫描、动态代码生成和重定位等。

Interceptor

Interceptor 是 inline-hook 的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GumInterceptor * interceptor;
GumInvocationListener * listener;
gum_init ();
interceptor = gum_interceptor_obtain ();
//GumInvocationListener*的接口
listener = g_object_new (EXAMPLE_TYPE_LISTENER, NULL);

// 开始 hook `open` 函数
gum_interceptor_begin_transaction (interceptor);
gum_interceptor_attach_listener (interceptor,
GSIZE_TO_POINTER (gum_module_find_export_by_name (NULL, "open")),
listener,
GSIZE_TO_POINTER (EXAMPLE_HOOK_OPEN));
gum_interceptor_end_transaction (interceptor);

// 测试 hook 效果
close (open ("/etc/hosts", O_RDONLY));

// 结束 hook
gum_interceptor_detach_listener (interceptor, listener);
g_object_unref (listener);
g_object_unref (interceptor);

Stalker

潜行者又称为尾行痴汉,可以实现指定线程中所有函数、所有基本块、甚至所有指令的跟踪但是有很大的缺点比如在32位或者thumb下问题很大,一般想使用指令跟踪都是使用内存断点或者unidbg模拟执行so但是有很多问题,内存断点的反调试倒是很容易解决但是性能是一个很大的缺陷代码触发断点后会先中断到内核态,然后再返回到用户态(调试器)执行跟踪回调,处理完后再返回内核态,然后再回到用户态继续执行,这来来回回的黄花菜都凉了。

但Unidbg的使用门槛动不动就补环境,龙哥说样本和Unidbg之间摩擦出的火花才是最迷人的。或者说人话——“Unidbg怎么又报错了,我该怎么办?”

Stalker的简单使用

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
Interceptor.attach(addr, {
onEnter: function (args) {
this.args0 = args[0];
this.tid = Process.getCurrentThreadId();
//跟随
Stalker.follow(this.tid, {
events: {//事件
call: true,//呼叫
ret: false,//返回
exec: true,//执行
block: false,//块
compile: false//编译
},
//接收
onReceive(events){
for (const [index,value] of Stalker.parse(events)) {
console.log(index,value);
//findModuleByAddress {"name":"libc.so","base":"0x7d1f0af000","size":3178496,"path":"/apex/com.android.runtime/lib64/bionic/libc.so"}
//console.log("tuzi",Process.findModuleByAddress(0x7d1f13adb8));

}
}
// onCallSummary(summay){
//console.log("onCallSummary"+JSON.stringify(summay));
// },
});
}, onLeave: function (retval) {
Stalker.unfollow(this.tid);
}
});

Stalker也可以用来还原ollvm混淆 记录函数的真实执行地址结合ida反汇编没执行的代码都nop掉可以很大程度上帮助辅助混淆算法分析当然可能不太准确但也是一种非常棒的思路。

Stalker的功能实现,在线程即将执行下一条指令前,先将目标指令拷贝一份到新建的内存中,然后在新的内存中对代码进行插桩,如下图所示:

img

这其中使用到了代码动态重编译的方法,好处是原本的代码没有被修改,因此即便代码有完整性校验也不影响,另外由于执行过程都在用户态,省去了多次中断内核切换,性能损耗也达到了可以接受的水平。由于代码的位置发生了改变,如前文 Interceptor 一样,同样要对代码进行重定位的修复。

内存监控

MemoryAccessMonitor可以实现对指定内存区间的访问监控,在目标内存区间发生读写行为时可以触发用户指定的回调函数。

通过阅读源码发现这个功能的实现方法非常简洁,本质上是将目标内存页设置为不可读写,这样在发生读写行为时会触发事先注册好的中断处理函数,其中会调用到用户使用 gum_memory_access_monitor_new 注册的回调方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
//C 代码
gboolean
gum_memory_access_monitor_enable (GumMemoryAccessMonitor * self,
GError ** error)
{
if (self->enabled)
return TRUE;
// ...
self->exceptor = gum_exceptor_obtain ();
gum_exceptor_add (self->exceptor, gum_memory_access_monitor_on_exception,
self);
// ...
}
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
//js代码
function read_write_break(){
function hook_dlopen(addr, soName, callback) {
Interceptor.attach(addr, {
onEnter: function (args) {
var soPath = args[0].readCString();
if(soPath.indexOf(soName) != -1) hook_call_constructors();
}, onLeave: function (retval) {
}
});
}
var dlopen = Module.findExportByName("libdl.so", "dlopen");
var android_dlopen_ext = Module.findExportByName("libdl.so", "android_dlopen_ext");
hook_dlopen(dlopen, "libaes.so", set_read_write_break);
hook_dlopen(android_dlopen_ext, "libaes.so", set_read_write_break);

function set_read_write_break(){
//实现一个异常回调 处理好这个异常就可以正常返回
Process.setExceptionHandler(function(details) {
console.log(JSON.stringify(details, null, 2));
console.log("lr", DebugSymbol.fromAddress(details.context.lr));
console.log("pc", DebugSymbol.fromAddress(details.context.pc));
Memory.protect(details.memory.address, Process.pointerSize, 'rwx');
console.log(Thread.backtrace(details.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
return true;
});
var addr = Module.findBaseAddress("libaes.so").add(0x6666);
Memory.protect(addr, 8, '---'); //修改内存页的权限
/**
* 比如有一个地址是0x12345678 我想看一下是那个代码去访问了这个地址
* 我只需要把这个内存地址置空 有函数去访问这个地址时 就会触发非法访问异常
* 比较鸡肋这种方法 这种方法会一次修改一个内存页 并且触发一次就无效了
*/
}
}

hook原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1.注入进程
ptrace
dlopen
2.hook 目标函数
2.1 Java Hook
Static Field Hook:静态成员hook
Method Hook:函数hook
2.2 Native So Hook
GOT Hook:全局偏移表hook
SYM Hook:符号表hook
Inline Hook:函数内联hook
执行自身代码
获取敏感信息
修改返回值
etc.

frida注入的主要思路就是找到目标进程,使用ptrace跟踪目标进程获取mmap,dlpoen,dlsym等函数库的便宜获取mmap在目标进程申请一段内存空间将在目标进程中找到存放[frida-agent-32/64.so]的空间启动执行各种操作由agent去实现。

补充:frida注入之后会在远端进程分配一段内存将agent拷贝过去并在目标进程中执行代码,执行完成后会 detach 目标进程,这也是为什么在 frida 先连接上目标进程后还可以用gdb/ida等调试器连接,而先gdb连接进程后 frida 就无法再次连上的原因(frida在注入时只会ptrace一下下注入完毕后就会结束ptrace所以ptrace占坑这种反调试使用spawn方式启动即可)。frida-agent 注入到目标进程并启动后会启动一个新进程与 host 进行通信,从而 host 可以给目标进行发送命令,比如执行代码,激活/关闭 hook,同时也能接收到目标进程的执行返回以及异步事件信息等。

hook java层

frida 的 hook 区分了 art 模式和 dalvik 模式。

dalvik 模式

把 java 函数变成 native 函数,然后修改入口信息为自定义函数信息。

将Java函数转换为Native函数并修改入口信息为自定义函数信息的过程具有一定的意义和用途,例如:

  1. 性能优化和加速:Native函数通常以原生代码实现,可以直接调用底层系统功能或优化算法,相比Java函数在某些场景下具有更高的执行效率和更低的延迟。通过将关键的Java函数转换为Native函数,可以提高应用程序的性能和响应速度。
  2. 与底层系统交互:Native函数可以直接与底层系统进行交互,调用底层库、操作系统接口或硬件功能。这对于需要与底层系统进行紧密集成的应用程序或需要访问底层资源的任务非常有用。
  3. 逆向工程和代码修改:通过将Java函数转换为Native函数并修改入口信息,可以实现对已编译代码的修改和定制。这在逆向工程、代码注入、破解游戏或应用程序等方面具有应用场景。
  4. 调试和分析:通过将Java函数转换为Native函数,可以在调试过程中更容易地跟踪和分析代码。Native函数的调试通常比Java函数更直接且更容易理解,可以更深入地研究和解决问题。

img

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
struct Method {  
ClassObject* clazz;/* method所属的类publicnative等*/
u4 accessFlags; /* 访问标记 */
u2 methodIndex; //method索引
//三个size为边界值,对于native函数,这3个size均等于参数列表的size
u2 registersSize; /* ins + locals */
u2 outsSize;
u2 insSize;
const char* name;//函数名称
/*
* Method prototype descriptor string (return and argument types)
*/
DexProto prototype;
/* short-form method descriptor string */
const char* shorty;
/*
* The remaining items are not used for abstract or native methods.
* (JNI is currently hijacking "insns" as a function pointer, set
* after the first call. For internal-native this stays null.)
*/
/* the actual code */
const u2* insns; /* instructions, in memory-mapped .dex */
/* cached JNI argument and return-type hints */
int jniArgInfo;
/*
* Native method ptr; could be actual function or a JNI bridge. We
* don't currently discriminate between DalvikBridgeFunc and
* DalvikNativeFunc; the former takes an argument superset (i.e. two
* extra args) which will be ignored. If necessary we can use
* insns==NULL to detect JNI bridge vs. internal native.
*/
DalvikBridgeFunc nativeFunc;
/*
* Register map data, if available. This will point into the DEX file
* if the data was computed during pre-verification, or into the
* linear alloc area if not.
*/
const RegisterMap* registerMap;

};




//为空就直接返回
function replaceDalvikImplementation (fn) {
if (fn === null && dalvikOriginalMethod === null) {
return;
}


//备份原来的method,
if (dalvikOriginalMethod === null) {
dalvikOriginalMethod = Memory.dup(methodId, DVM_METHOD_SIZE);
dalvikTargetMethodId = Memory.dup(methodId, DVM_METHOD_SIZE);
}


if (fn !== null) {
//自定的代码
//使用自定义函数 (fn) 实现需要替换的函数 (f),并将结果保存在变 量 implementation 中。
implementation = implement(f, fn);



//计算参数的总大小 (argsSize)。根据参数类型 (argTypes) 遍历累加每个参数的大小 (size),并在实例方法 (type === INSTANCE_METHOD) 的情况下增加 1。
let argsSize = argTypes.reduce((acc, t) => (acc + t.size), 0);
if (type === INSTANCE_METHOD) {
argsSize++;
}

// 把method变成native函数
/*
* make method native (with kAccNative)
* insSize and registersSize are set to arguments size
*/



//将方法的访问标记 (accessFlags) 设置为 Native 函数标记 (kAccNative)。将寄存器大小 (registersSize)、输出大小 (outsSize) 和输入大小 (insSize) 设置为参数大小 (argsSize)。 const accessFlags=(Memory.readU32(methodId.add(DVM_METHOD_OFFSET_ACCESS_FLAGS)) | kAccNative) >>> 0;
const registersSize = argsSize;
const outsSize = 0;
const insSize = argsSize;
Memory.writeU32(methodId.add(DVM_METHOD_OFFSET_ACCESS_FLAGS), accessFlags);
//是一个用于将无符号 32 位整数写入内存地址的函数。在给定的代码中,它用于修改指定内存地址上的值。
Memory.writeU16(methodId.add(DVM_METHOD_OFFSET_REGISTERS_SIZE), registersSize);
Memory.writeU16(methodId.add(DVM_METHOD_OFFSET_OUTS_SIZE), outsSize); Memory.writeU16(methodId.add(DVM_METHOD_OFFSET_INS_SIZE), insSize);


//计算 JNI 参数信息 (jniArgInfo) 并将其写入方法结构体的相应字段
Memory.writeU32(methodId.add(DVM_METHOD_OFFSET_JNI_ARG_INFO), computeDalvikJniArgInfo(methodId));


//调用dvmUseJNIBridge为这个Method设置一个Bridge,本质上是修改结构体中的nativeFunc为自定义的implementation函数
//调用 dvmUseJNIBridge 函数将自定义函数的指针设置为 Method 结构体的 nativeFunc 字段,从而将其作为 Native 函数的入口
api.dvmUseJNIBridge(methodId, implementation);


//将修改后的函数 (f) 添加到 patchedMethods 集合中,表示已经进行了修改。
patchedMethods.add(f);
} else {
patchedMethods.delete(f);
//如果传入的自定义函数 (fn) 为 null,则表示需要恢复原始函数的状态:从备份的 dalvikOriginalMethod 中恢复原始函数的内容,将其复制回 methodId。
Memory.copy(methodId, dalvikOriginalMethod, DVM_METHOD_SIZE);
//将实现函数 (implementation) 设置为 null
implementation = null;
}
}

//这样,通过调用 replaceDalvikImplementation 函数,可以将 Java 函数转换为 Native 函数,并将其入口信息设置为自定义函数的信息。

art 模式

art模式也是需要将java 函数变成 native 函数但是不同于dalvik,art下有两种解释器一种汇编解释器一种smali解释器。

1
2
quick code 模式:执行 arm 汇编指令
Interpreter 模式:由解释器解释执行 Dalvik 字节码

img

1.如果函数已经存在quick code, 则指向这个函数对应的 quick code的起始地址,而当quick code不存在时,它的值则会代表其他的意义。

2.当一个 java 函数不存在 quick code时,它的值是函数artQuickToInterpreterBridge 的地址,用以从 quick 模式切换到 Interpreter 模式来解释执行 java 函数代码。

3.当一个 java native(JNI)函数不存在 quick code时,它的值是函数 art_quick_generic_jni_trampoline 的地址,用以执行没有quick code的 jni 函数。

所以 frida 要将 java method 转为 native method,需要将ARTMethod 结构进行如下修改:

1
2
3
4
5
6
7
8
9
10
patchMethod(methodId, {
//jnicode入口entry_point_from_jni_改为自定义的代码
'jniCode': implementation,
//修改为access_flags_为native
'accessFlags': (Memory.readU32(methodId.add(artMethodOffset.accessFlags)) | kAccNative | kAccFastNative) >>> 0,
//art_quick_generic_jni_trampoline函数的地址
'quickCode': api.artQuickGenericJniTrampoline,
//artInterpreterToCompiledCodeBridge函数地址
'interpreterCode': api.artInterpreterToCompiledCodeBridge
});