利用 Frida 和 QBDI 动态分析 Android Native 的各项函数
2020-09-24 11:20:00 Author: www.4hou.com(查看原文) 阅读量:318 收藏

这篇文章将介绍如何通过充分利用Frida和QBDI来更好地理解Android Native 的各项函数。

由于可以检索应用程序代码的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()函数如下所示:

3.png

初始化

现在让我们编写负责在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中。所有已执行的基本块都将突出显示,现在我们可以更清楚地遵循执行流程,而只关注代码的相关部分。

graph.png

总结

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


文章来源: https://www.4hou.com/posts/AAxP
如有侵权请联系:admin#unsafe.sh