Android应用程序的逆向工程通常被认为很容易,因为它能够检索应用程序代码的Java表示形式。攻击者通过了解这些代码版本并收集应用程序信息来发现漏洞。如今,大多数Android应用程序编辑器都意识到了这一点,并尽最大努力使逆向工程变得不那么容易。由于JavaNativeInterface(简称JNI),攻击者通常依赖于集成混淆策略或将敏感功能从Java/Kotlin端转移到本地代码。但是当他们决定同时使用两者(即混淆本机代码)时,逆向工程过程变得更加复杂。因此,静态查看本机库的反汇编结果既乏味又耗时。幸运的是,运行时检查仍然是可能的,并提供了一种方便的方式来有效地掌握应用程序的内部机制,甚至避免混淆。JNI(JavaNativeInterface)Java本地接口,又称为Java本机接口。它允许Java调用C/C++代码,也允许在C/C++中调用Java代码。JNI可以理解为连接Java和底层的桥梁。其实按照字面意思,JNI是Java层和Native层之间的一个接口,Native层就是C/C++层。由于针对传统调试器的保护在流行的应用程序中很普遍,因此使用Frida等动态二进制检测(DBI)框架仍然是进行彻底检查的理想选择。从技术上讲,除其他强大功能外,Frida还允许用户在原生函数的开头和结尾插入自己的代码,或者替换整个实现。然而,Frida在某些时候缺乏粒度,尤其是在检查指令规模的执行时。在这种情况下,Quarkslab开发的DBI框架QBDI可以帮助Frida确定在调用给定的原生函数时执行了哪些代码部分。首先,我们必须正确设置测试环境。我们假设设备已获得root权限并且Frida服务器已在运行并准备好使用。除了Frida,我们还需要安装QBDI。我们可以从源代码编译它或者下载安卓的发行版,使用说明可以直接从官方页面获取。解压后,我们必须将共享库libQBDI.so推送到设备上的/data/local/tmp中。除此之外,我们还可以注意到frida-qbdi.js中定义的QBDI绑定,它负责提供QBDI函数的接口。换句话说,它充当了QBDI和Frida之间的桥梁。注意必须先关闭SELinux,否则Frida会因为某些限制规则无法将QBDI共享库加载到内存中。这将显示一条明确的错误消息,告诉用户权限被拒绝。在大多数情况下,只需以root权限运行此命令行即可完成工作:setenforce0现在我们具备了编写基于Frida和QBDI的脚本的所有要求。跟踪本机函数在对JNI共享库进行逆向工程时,检查JNI_OnLoad()总是值得的。事实上,这个函数在库加载后立即被调用并负责初始化。它能够与Java端进行交互,比如设置类的属性,调用Java函数,通过几个JNI函数注册其他本地方法。攻击者通常依靠这些属性来隐藏一些敏感的检查和秘密的内部机制。接下来,假设我们要分析一个流行的Android应用程序,比如Whatsapp,其包名为com.whatsapp,是目前Android上使用最广泛的即时通讯解决方案。它嵌入了一堆共享库,其中一个是libwhatsapp.so。不过请注意,该库不在常规的lib/目录中,因为在运行时有一种解包机制可以将其从存档中提取出来并将其加载到内存中,我们的目标是弄清楚它的初始化函数在做什么。利用Frida/***frida-Ufcom.whatsapp--no-pause-lscript.js*/functionprocessJniOnLoad(libraryName){constfuncSym="JNI_OnLoad";constfuncPtr=Module.findExportByName(libraryName,funcSym);console.log("[+]Hooking"+funcSym+"()@"+funcPtr+"...");//jintJNI_OnLoad(JavaVM*vm,void*reserved);varfuncHook=Interceptor.attach(funcPtr,{onEnter:function(args){constvm=args[0];constreserved=args[1];console.log("[+]"+funcSym+"("+vm+","+reserved+")调用");},onLeave:function(retval){console.log("[+]\t="+retval);}});}functionwaitForLibLoading(libraryName){varisLibLoaded=false;Interceptor.attach(Module.findExportByName(null,"android_dlopen_ext"),{onEnter:function(args){varlibraryPath=Memory.readCString(args[0]);if(libraryPath.includes(libraryName)){console.log("[+]Loadinglibrary"+libraryPath+"...");isLibLoaded=true;}},onLeave:函数(args){if(isLibLoaded){processJniOnLoad(libraryName);isLibLoaded=false;}}});}Java.perform(function(){constlibraryName="libwhatsapp.so";waitForLibLoading(libraryName);});首先,借助于Frida提供的便捷的API,我们可以轻松地hook我们想要研究的函数,但是由于Android应用中嵌入的库是通过System.loadLibrary()动态加载的,它调用了原生的android_dlopen_ext()在后台,我们需要等待目标库被放入进程内存中。使用这个脚本,我们只能访问函数的输入(参数)和输出(返回值),也就是说,我们是在函数层面。这是非常有限的,单凭这一点基本不足以准确掌握里面的情况。因此,在这种情况下,我们希望在较低级别对功能进行大修。利用Frida和QBDIQBDI提供的导入功能可以帮助我们克服上述问题。事实上,DBI框架允许用户通过跟踪执行的指令来进行细粒度的分析。这对我们非常有用,因为我们可以深入了解我们的目标函数。这个想法是,不是让JNI_OnLoad()在常规启动期间运行,而是在具有条件上下文的基本块/指令范围内执行,以便准确知道已执行的内容。由于我们可以结合这两个DBI框架,我们可以在我们之前编写的Frida脚本之上集成这个全新的部分。然而,我们使用的Interceptor.attach()函数只允许我们定义onEnter和onLeave回调。这意味着无论你的入口回调应该做什么,真正的功能总是被执行。因此,初始化函数将执行两次:第一次通过QBDI,然后正常执行。这是有问题的,因为根据情况,可能会出现一些意外的运行时错误,因为这个函数只需要调用一次。幸运的是,我们可以利用Frida的拦截器模块带来的另一个功能,其中包括替换原生功能的实现。这样做,我们能够设置QBDI上下文,执行检测函数并像往常一样无缝地将返回值转发给调用者,防止应用程序崩溃,这是一种旨在使进程足够稳定以恢复正常执行的技术。但是,我们仍然面临一个问题,即初始函数已被我们自己的新实现完全覆盖。换句话说,函数的代码不是原始代码,而是Frida之前检测过的。所以在我们的代码中,我们必须在使用QBDI执行函数之前恢复到真实版本。修改脚本后,processJniOnLoad()函数如下所示:初始化现在让我们编写负责在QBDI上下文中执行此函数的函数,首先,我们需要初始化一个VM,实例化其关联状态(通用寄存器),然后分配一个将在函数执行期间使用的伪堆栈。那么我们就要将QBDI的上下文与当前上下文进行同步,即将实际CPU寄存器的值放入到将要使用的QBDI中。现在我们可以决定要检测代码的哪些部分。我们可以显式定义一个任意地址范围,或者我们可以要求DBI检测函数地址所在模块的整个地址空间。为方便起见,本例将使用后者。回调函数设置我们必须指定我们想要的回调函数的种类,接下来,我们要跟踪每条已经执行的指令,所以我要放一个预指令代码回调,这意味着每一个执行我的函数是在指令执行之前调用。此外,我们可以添加几个事件回调函数,以便在执行转移到或从未被QBDI检测的代码部分返回时收到事件通知。当代码与系统库(libc.so、libart.so、libbinder.so等)等其他模块交互时,此功能很有用。请注意,其他几种回调类型可能会有所帮助,具体取决于您要监视的内容。函数调用现在我们准备通过QBDI调用目标函数,当然,我们需要传递预期的参数,在我们的例子中是一个指向JavaVM对象的指针和一个空指针。然后,我们可以根据使用的调用约定在特定的QBDI寄存器或虚拟堆栈中检索返回值。该值必须从我们之前编写的本机替换函数转发和返回。否则,应用程序很可能由于JNI版本检查不满意而停止运行,JNI_OnLoad()应该返回JNI版本。我们可以选择使用QBDI的CPU来还原真实的CPU上下文。constqbdi=require("/path/to/frida-qbdi");qbdi.import();functionqbdiExec(ctx,funcPtr,funcSym,args,postSync){varvm=newQBDI();//createaQBDIVMvarstate=vm.getGPRState();varstack=vm.allocateVirtualStack(state,0x10000);//allocateavirtualstackstate.synchronizeContext(ctx,SyncDirection.FRIDA_TO_QBDI);//setupQBDI'scontextvm.addInstrumentedModuleFromAddr(funcPtr);varicbk=vm.newInstCallback(function(vm,gpr,fpr,数据){varinst=vm.getInstAnalysis();console.log("0x"+inst.address.toString(16)+""+inst.disassembly);returnVMAction.CONTINUE;});variid=vm.addCodeCB(InstPosition.PREINST,icbk);//注册预指令回调varvcbk=vm.newVMCallback(function(vm,evt,gpr,fpr,data){constmodule=Process.getModuleByAddress(evt.basicBlockStart);constoffset=ptr(evt.basicBlockStart-module.base);if(evt.event&VMEvent.EXEC_TRANSFER_CALL){console.warn("->transfercallto0x"+evt.basicBlockStart.toString(16)+"("+module.name+"@"+offset+")");}if(evt.event&VMEvent.EXEC_TRANSFER_RETURN){console.warn("<-transferreturnfrom0x"+evt.basicBlockStart.toString(16)+"("+module.name+"@"+offset+")");}returnVMAction.CONTINUE;});varvid=vm.addVMEventCB(VMEvent.EXEC_TRANSFER_CALL,vcbk);//registertransfercallbackvarvid2=vm.addVMEventCB(VMEvent.EXEC_TRANSFER_RETURN,vcbk);//registerreturncallbackconstjavavm=);constrserved=ptr(args[1]);console.log([+]Executing"+funcSym+"("+javavm+","+reserved+")throughQBDI...");vm.call(funcPtr,[javavm,reserved]);varretVal=state.getRegister(0);//x86soreturnvalueisstoredon$eaxconsole.log("[+]"+funcSym+"()returned"+retVal);if(postSync){state.synchronizeContext(ctx,SyncDirection.QBDI_TO_FRIDA);}returnretVal;}最终,此脚本必须使用frida-compile进行编译,以便正确包含QBDI绑定。包含QBDI绑定的官方frida-qbdi.js文档页面详细解释了编译过程。生成覆盖文件包含所有i的迹线执行的指令是必要的,但不方便进行逆向工程。事实上,我们无法一眼看出执行的路径。为了正确呈现捕获的痕迹,集成到反汇编器中可能是个好主意。这样,就可以准确地看到完整的路径。然而,大多数反汇编器本身并不提供这样的选项。对我们来说幸运的是,各种插件都提供了这样的选项。在此示例中,我们分别为IDAPro和Ghidra使用Lighthouse和Dragondance。这些插件可以通过导入drcov格式的代码覆盖率文件轻松配置,DynamioRIO使用该格式存储有关代码覆盖率的信息。drcov格式非常简单:除了头域之外,还必须指定一个描述进程内存布局的模块表,为每个模块分配一个唯一的ID。从那时起,就出现了所谓的基本块表。该表包含在执行过程中命中的每个基本块。基本块由三个属性定义:它的起始(相对)地址、它的大小和它所属的模块的ID。由于我们能够在每个基本块的开头放置回调,因此我们可以确定这些值,从而生成我们自己的文件。我们现在需要检索基址和所有已执行基本块的大小,而不是按指令大小工作。实际上,我们必须定义一个BASIC_BLOCK_NEW类型的QBDI事件回调函数,它负责收集这些信息。每次QBDI将要执行一个新的基本程序块时,我们的函数都会被调用,目前未知。在这个例子中,我们不仅要打印关于这个基本块的一些有趣的值,还要创建一个代码覆盖率文件,稍后可以在反汇编程序中导入。但是,在Frida脚本的上下文中,我们无法操作文件。因此,我们不得不停止使用frida命令行实用程序,直接依赖Frida提供的消息系统从底层Python脚本运行我们的JS脚本。这样做允许我们在JS和Python端之间进行通信,然后执行我们需要的所有文件系统操作。varvcbk=vm.newVMCallback(function(vm,evt,gpr,fpr,data){constmodule=Process.getModuleByAddress(evt.basicBlockStart);constbase_addr=ptr(evt.basicBlockStart-module.base);//addressmustberelativetothemodule'sstartconstsize=evt.basicBlockEnd-evt.basicBlockStart;send({"bb":1},getBBInfo(base_addr,size,module));//将新发现的basicblock发送到Python端returnVMAction.CONTINUE;});varvid=vm.addVMEventCB(VMEvent.BASIC_BLOCK_NEW,vcbk);请注意,getBBInfo()函数仅在发送消息之前序列化有关基本块的信息。显然,Python端必须处理这些消息,将与执行相关的东西保存在内存中,最后以正确的格式生成相应的代码覆盖文件,如上所示。如果一切顺利,输出文件可以加载到IDAPro或Ghidra中,这要归功于其相应的代码覆盖插件。所有执行的基本块都将突出显示,现在我们可以更清楚地遵循执行流程并只关注代码的相关部分。总结Java/Kotlin逆向工程的易用性使得Android应用开发者可以使用C/C++来实现某些漏洞级别的操作。因此,本文所描述的方法就是让逆向工程师的逆向工程过程变得困难。因此,将QBDI与Frida结合使用是一个非常好的选择,尤其是在研究那些本机函数时。这种组合确实提供了一种方法来确定函数在不同级别(即函数、基本块和指令大小)上的作用。此外,QBDI的执行传输事件可用于解决对系统库的外部调用,或跟踪内存访问,然后了解执行的整体情况。为了有效地协助逆向工程师,可以将收集到的信息明智地集成到一些现有的面向逆向工程的工具中,以改进它们的静态分析。除了生成执行流的可视化表示之外,从运行时获取此类反馈对于其他与安全相关的目的(例如模糊测试)也很有价值。还值得注意的是,如果功能很重要,Frida和QBDI都提供C/C++API。本文翻译自:https://blog.quarkslab.com/why-are-frida-and-qbdi-a-great-blend-on-android.html如有转载请注明出处:
