Frida工作原理介绍
2022-7-26 08:46:19 Author: 网络安全与取证研究(查看原文) 阅读量:23 收藏


frida介绍

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


frida的代码结构

frida-core: Frida 核心库frida-gum: inline-hook 框架bindings:frida-python: pythonfrida-node: Node.jsfrida-qml: Qmlfrida-swift: Swiftfrida-tools: CLI toolscapstone: 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 的封装。
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));
// 结束 hookgum_interceptor_detach_listener (interceptor, listener);g_object_unref (listener);g_object_unref (interceptor);

Stalker

潜行者又称为尾行痴汉,可以实现指定线程中所有函数、所有基本块、甚至所有指令的跟踪但是有很大的缺点比如在32位或者thumb下问题很大,一般想使用指令跟踪都是使用内存断点或者unidbg模拟执行so但是有很多问题,内存断点的反调试倒是很容易解决但是性能是一个很大的缺陷代码触发断点后会先中断到内核态,然后再返回到用户态(调试器)执行跟踪回调,处理完后再返回内核态,然后再回到用户态继续执行,这来来回回的黄花菜都凉了。
但Unidbg的使用门槛动不动就补环境,龙哥说样本和Unidbg之间摩擦出的火花才是最迷人的。或者说人话——“Unidbg怎么又报错了,我该怎么办?”
 
Stalker的简单使用
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的功能实现,在线程即将执行下一条指令前,先将目标指令拷贝一份到新建的内存中,然后在新的内存中对代码进行插桩,如下图所示:

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

内存监控

MemoryAccessMonitor可以实现对指定内存区间的访问监控,在目标内存区间发生读写行为时可以触发用户指定的回调函数。
 
通过阅读源码发现这个功能的实现方法非常简洁,本质上是将目标内存页设置为不可读写,这样在发生读写行为时会触发事先注册好的中断处理函数,其中会调用到用户使用 gum_memory_access_monitor_new 注册的回调方法中。
//C 代码gbooleangum_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);  // ...}
//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.注入进程ptracedlopen2.hook 目标函数2.1 Java HookStatic Field Hook:静态成员hookMethod Hook:函数hook2.2 Native So HookGOT Hook:全局偏移表hookSYM Hook:符号表hookInline 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 函数,然后修改入口信息为自定义函数信息。
struct Method {      ClassObject*    clazz;   /* method所属的类 public、native等*/    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) { //自定的代码 implementation = implement(f, fn);
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 */ 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); 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); Memory.writeU32(methodId.add(DVM_METHOD_OFFSET_JNI_ARG_INFO), computeDalvikJniArgInfo(methodId)); //调用dvmUseJNIBridge为这个Method设置一个Bridge,本质上是修改结构体中的nativeFunc为自定义的implementation函数 api.dvmUseJNIBridge(methodId, implementation);
patchedMethods.add(f); } else { patchedMethods.delete(f);
Memory.copy(methodId, dalvikOriginalMethod, DVM_METHOD_SIZE); implementation = null; }}
art 模式
art模式也是需要将java 函数变成 native 函数但是不同于dalvik,art下有两种解释器一种汇编解释器一种smali解释器。
quick code 模式:执行 arm 汇编指令Interpreter 模式:由解释器解释执行 Dalvik 字节码
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 结构进行如下修改:
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});
参考文献

https://evilpan.com/2022/04/05/frida-internal/

https://blog.drov.com.cn/2021/04/hook.html

https://bbs.pediy.com/thread-229215.htm

https://frida.re/docs/home/

https://www.youtube.com/watch?v=uc1mbN9EJKQ


文章来源: http://mp.weixin.qq.com/s?__biz=Mzg3NTU3NTY0Nw==&mid=2247484921&idx=1&sn=c6bf72b6fde48cbb75f82fdfb295d821&chksm=cf3e25c9f849acdf7284aacc8570e16e2cb8a9477fcfd47bff65d314e58069adc67443bf3061#rd
如有侵权请联系:admin#unsafe.sh