14

利用 Frida 和 QBDI 动态分析 Android Native 的各项函数

 3 years ago
source link: http://developer.51cto.com/art/202009/627098.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

6BFNzm7.jpg!mobile

由于可以检索应用程序代码的Java表示形式,因此通常认为Android应用程序的逆向工程比较容易。攻击者就是通过了解这些代码版本,收集应用程序信息,来发现漏洞的。如今,大多数Android应用程序编辑器已经意识到了这一点,并尽力使反向工程不再那么容易。由于Java本地接口(Java Native Interface,简称JNI),攻击者通常依靠集成混淆策略或将敏感函数从Java / Kotlin端转移到本机代码。但是,当他们决定同时使用两者时(即,混淆的本机代码),逆向工程过程变得更加复杂。结果,静态查看本机库的反汇编结果非常繁琐且耗时。幸运的是,运行时检查仍然是可能的,并且提供了一种便捷的方法来有效地掌握应用程序的内部机制,甚至避免混淆。JNI(Java Native Interface) Java本地接口,又叫Java原生接口。它允许Java调用C/C++的代码,同时也允许在C/C++中调用Java的代码。可以把JNI理解为一个桥梁,连接Java和底层。其实根据字面意思,JNI就是一个介于Java层和Native层的接口,而Native层就是C/C++层面。

由于针对常规调试器的保护在流行的应用程序中非常普遍,因此使用动态二进制工具(DBI)框架(例如Frida)仍然是进行全面检查的理想选择。从技术上讲,在其他强大函数中,Frida允许用户在本机函数的开头和结尾插入自己的代码,或替换整个实现。但是,Frida在某些时候缺乏粒度,特别是在以指令规模检查执行情况时。在这种情况下,Quarkslab开发的DBI框架QBDI可以帮助Frida在调用给定的本机函数时确定已执行了代码的哪些部分。

首先,我们必须正确设置测试环境。我们假设设备已经植根并且Frida服务器已经在运行并且可以使用。除了Frida,我们还需要安装QBDI。我们可以从源代码编译它或下载Android的发行版,使用说明可以直接从官方页面检索到。解压缩后,我们必须将共享库libQBDI.so推送到设备上的/ data / local / tmp中。除此之外,我们还可以注意到在frida-qbdi.js中定义的QBDI绑定,该文件负责提供QBDI函数的接口。换句话说,它充当QBDI和Frida之间的桥梁。

请注意,必须先关闭SELinux,否则由于某些限制规则,Frida无法将QBDI共享库加载到内存中。这将会显示一条明确的错误消息,告诉用户权限被拒绝。在大多数情况下,仅使用root特权运行此命令行即可完成此工作:

setenforce 0 

现在我们已经具备了基于Frida和QBDI编写脚本的所有要求。

跟踪本机函数

在对JNI共享库执行反向工程时,始终值得检查JNI_OnLoad()。确实,此函数在库加载后立即调用,并负责初始化。它能够与Java端进行交互,例如设置类的属性,调用Java函数以及通过几个JNI函数注册其他本机方法。攻击者通常依靠这些属性来隐藏一些敏感的检查和秘密的内部机制。

接下来,让我们假设我们要分析一个流行的Android应用程序,比如Whatsapp,其软件包名称为com.whatsapp,这是当前Android上最广泛的即时消息解决方案。它嵌入了一堆共享库,其中一个是libwhatsapp.so。不过要注意的是,该库并不位于常规的lib /目录中,因为在运行时存在一种解压缩机制,该机制可将其从存档中提取出来,然后将其加载到内存中,我们的目标是弄清楚它的初始化函数在做什么。

利用 Frida

/** * frida -Uf com.whatsapp --no-pause -l script.js */function processJniOnLoad(libraryName) { 
    const funcSym = "JNI_OnLoad"; 
    const funcPtr = Module.findExportByName(libraryName, funcSym); 
 
    console.log("[+] Hooking " + funcSym + "() @ " + funcPtr + "..."); 
    // jint JNI_OnLoad(JavaVM *vm, void *reserved); 
    var funcHook = Interceptor.attach(funcPtr, { 
        onEnter: function (args) { 
            const vm = args[0]; 
            const reserved = args[1]; 
            console.log("[+] " + funcSym + "(" + vm + ", " + reserved + ") called"); 
        }, 
        onLeave: function (retval) { 
            console.log("[+]\t= " + retval); 
        } 
    });}function waitForLibLoading(libraryName) { 
    var isLibLoaded = false; 
 
    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { 
        onEnter: function (args) { 
            var libraryPath = Memory.readCString(args[0]); 
            if (libraryPath.includes(libraryName)) { 
                console.log("[+] Loading library " + libraryPath + "..."); 
                isLibLoaded = true; 
            } 
        }, 
        onLeave: function (args) { 
            if (isLibLoaded) { 
                processJniOnLoad(libraryName); 
                isLibLoaded = false; 
            } 
        } 
    });}Java.perform(function() { 
    const libraryName = "libwhatsapp.so"; 
    waitForLibLoading(libraryName);}); 

首先,借助Frida提供的便捷API,我们可以轻松地挂接我们要研究的函数。但是,由于Android应用程序中嵌入的库是通过System.loadLibrary()动态加载的,该函数在后台调用了本机android_dlopen_ext(),因此我们需要等待将目标库放入进程的内存中。使用此脚本,我们可以只访问函数的输入(参数)和输出(返回值),也就是说,我们位于函数层。这是非常有限的,仅凭这一点基本上还不足以准确掌握内部的情况。因此,在这种精确的情况下,我们希望在较低级别上彻底检查该函数。

利用 Frida 和 QBDI

QBDI提供的导入函数可以帮助我们克服以上的问题,实际上,该DBI框架允许用户通过跟踪执行的指令来执行细粒度的分析。这对我们非常有用,因为我们可以深入了解我们的目标函数。

这样做的想法是,不是让JNI_OnLoad()在常规启动期间运行,而是在基本块/指令范围内通过有条件的上下文来执行它,以便确切地知道已执行了什么。由于我们可以将这两个DBI框架结合在一起,因此可以在我们之前编写的Frida脚本的基础上集成这一全新的部分。

但是,我们使用的Interceptor.attach()函数只允许我们定义onEnter和onLeave回调。它意味着真正的函数总是被执行,而不管你的条目回调应该做什么。因此,初始化函数将执行两次:首先通过QBDI执行,然后正常执行。这是有问题的,因为根据情况不同,可能会出现一些意外的运行时错误,因为这个函数只需要调用一次。

幸运的是,我们可以利用Frida的拦截器模块带来的另一个函数,该函数包括替换本机函数的实现。这样做,我们能够设置QBDI上下文,执行检测的函数并像往常一样无缝地将返回值转发给调用方,以防止应用程序崩溃,该技术旨在使过程足够稳定以恢复正常执行。

然而,我们仍然面临一个问题,初始函数已被我们自己的新实现完全覆盖。换句话说,该函数的代码不是原始代码,而是由Frida早些时候进行检测的。因此,在我们的代码中,我们必须在使用QBDI执行该函数之前恢复到真正的版本。

修改脚本后,processJniOnLoad()函数如下所示:

Rn6FJj2.png!mobile

初始化

现在让我们编写负责在QBDI上下文中执行该函数的函数,首先,我们需要初始化一个VM,实例化它的相关状态(通用寄存器),并分配一个伪堆栈,该堆栈将在函数执行期间使用。然后,我们必须将QBDI的上下文与当前上下文进行同步,也就是说,将实际CPU寄存器的值放入将要使用的QBDI。现在我们可以决定要检测代码的哪些部分。我们可以显式定义一个任意地址范围,也可以要求DBI检测函数地址所在模块的整个地址空间。为方便起见,在本示例中将使用后者。

回调函数设置

我们必须指定所需的回调函数的种类,接下来,我们要跟踪已执行的每条指令,因此我要放置一条预指令代码回调,这意味着将在位于目标模块中的每个已执行指令之前调用我的函数。

此外,我们还可以添加几个事件回调函数,以便在执行转移到QBDI未检测到的部分代码中或从中返回时通知该事件。当代码与其他模块(例如系统库)(libc.so,libart.so,libbinder.so等)进行交互时,此函数非常有用。请注意,根据您要监视的内容,其他几种回调类型可能会很有帮助。

函数调用

现在我们准备通过QBDI调用目标函数,当然,我们需要传递预期的参数,在我们的例子中是一个指向JavaVM对象的指针和一个空指针。然后,我们可以根据使用的调用约定在特定的QBDI寄存器或虚拟堆栈上检索返回值。这个值必须从我们之前编写的本机替换函数中被转发和返回。否则,应用程序很可能会因为对JNI版本的检查不满意而停止运行,JNI_OnLoad()应该返回JNI版本。

我们可以选择使用QBDI的CPU恢复真正的CPU上下文。

const qbdi = require("/path/to/frida-qbdi");qbdi.import();function qbdiExec(ctx, funcPtr, funcSym, args, postSync) { 
    var vm = new QBDI(); // create a QBDI VM 
    var state = vm.getGPRState(); 
    var stack = vm.allocateVirtualStack(state, 0x10000); // allocate a virtual stack 
    state.synchronizeContext(ctx, SyncDirection.FRIDA_TO_QBDI); // set up QBDI's context 
 
    vm.addInstrumentedModuleFromAddr(funcPtr); 
 
    var icbk = vm.newInstCallback(function (vm, gpr, fpr, data) { 
        var inst = vm.getInstAnalysis(); 
        console.log("0x" + inst.address.toString(16) + " " + inst.disassembly); 
        return VMAction.CONTINUE; 
    }); 
    var iid = vm.addCodeCB(InstPosition.PREINST, icbk); // register pre-instruction callback 
 
    var vcbk = vm.newVMCallback(function (vm, evt, gpr, fpr, data) { 
        const module = Process.getModuleByAddress(evt.basicBlockStart); 
        const offset = ptr(evt.basicBlockStart - module.base); 
        if (evt.event & VMEvent.EXEC_TRANSFER_CALL) { 
            console.warn(" -> transfer call to 0x" + evt.basicBlockStart.toString(16) + " (" + module.name + "@" + offset + ")"); 
        } 
        if (evt.event & VMEvent.EXEC_TRANSFER_RETURN) { 
            console.warn(" <- transfer return from 0x" + evt.basicBlockStart.toString(16) + " (" + module.name + "@" + offset + ")"); 
        } 
        return VMAction.CONTINUE; 
    }); 
    var vid = vm.addVMEventCB(VMEvent.EXEC_TRANSFER_CALL, vcbk); // register transfer callback 
    var vid2 = vm.addVMEventCB(VMEvent.EXEC_TRANSFER_RETURN, vcbk); // register return callback 
 
    const javavm = ptr(args[0]); 
    const reserved = ptr(args[1]); 
 
    console.log("[+] Executing " + funcSym + "(" + javavm + ", " + reserved + ") through QBDI..."); 
    vm.call(funcPtr, [javavm, reserved]); 
    var retVal = state.getRegister(0); // x86 so return value is stored on $eax 
    console.log("[+] " + funcSym + "() returned " + retVal); 
    if (postSync) { 
        state.synchronizeContext(ctx, SyncDirection.QBDI_TO_FRIDA); 
    } 
    return retVal;} 

最终,此脚本必须使用frida-compile进行编译,以便正确包含包含QBDI绑定的frida-qbdi.js。官方文档页对编译过程进行了详细说明。

生成一个覆盖文件

具有包含已执行的所有指令的跟踪是很有必要的,但对于反向工程来说并不方便。事实上,我们不能一眼就分辨出整个执行过程中的路径。为了正确地呈现捕获的轨迹,在反汇编器中集成可能是一个好主意。这样,人们就可以准确地看到全部的路径。然而,大多数反汇编器本身并没有提供这样的选项。对我们来说幸运的是,各种插件都提供了这样的选项。在本例中,我们使用Lighthouse和Dragondance分别用于IDA Pro和Ghidra。这些插件可以通过导入drcov格式的代码覆盖文件来轻松配置,DynamioRIO使用这种格式存储关于代码覆盖率的信息。

drcov格式非常简单:除了标头字段外,还必须指定描述进程的内存布局的模块表,为每个模块分配一个惟一的ID。此后,就有了所谓的基本块表。该表包含执行期间已命中的每个基本块,一个基本块由三个属性定义:它的开始(相对)地址,它的大小和它所属模块的ID。

由于我们能够在每个基本块的开头放置一个回调,因此我们可以确定这些值,从而生成我们自己的文件。现在,我们需要检索基地址和所有已执行的基本块的大小,而不是按指令规模工作。实际上,我们必须定义一个类型为BASIC_BLOCK_NEW 的QBDI事件回调函数,该函数负责收集此类信息。每当QBDI将要执行一个新的基本程序块时,我们的函数都会被调用,到目前为止尚不知道。在本示例中,我们不仅要打印有关此基本块的一些有趣的值,还要创建一个代码覆盖率文件,以后可以在反汇编器中将其导入。但是,在Frida脚本的上下文中,我们无法操作文件。结果,我们必须停止使用frida命令行实用程序,并直接依赖于Frida提供的消息传递系统从底层Python脚本运行我们的JS脚本。这样做使我们能够在JS和Python端之间进行通信,然后对所需的文件系统执行所有操作。

var vcbk = vm.newVMCallback(function (vm, evt, gpr, fpr, data) { 
    const module = Process.getModuleByAddress(evt.basicBlockStart); 
    const base_addr = ptr(evt.basicBlockStart - module.base); // address must be relative to the module's start 
    const size = evt.basicBlockEnd - evt.basicBlockStart; 
    send({"bb": 1}, getBBInfo(base_addr, size, module)); // send the newly discovered basic block to the Python side 
    return VMAction.CONTINUE;});var vid = vm.addVMEventCB(VMEvent.BASIC_BLOCK_NEW, vcbk); 

请注意,getBBInfo()函数仅在发送消息之前先序列化有关基本块的信息。显然,Python端必须处理此类消息,将与执行相关的内容保留在内存中,并最终以上述正确的格式相应地生成代码覆盖文件。如果一切顺利,由于其相应的代码覆盖插件,可以将输出文件加载到IDA Pro或Ghidra中。所有已执行的基本块都将突出显示,现在我们可以更清楚地遵循执行流程,而只关注代码的相关部分。

zU3qQbf.png!mobile

总结

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如若转载,请注明原文地址:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK