Android 逆向实战:模拟调用解决 Native+LLVM 混淆
今天我们来看练习平台的最后一个 APP,也是最难的一个。
APP9
APP9 地址:https://app9.scrape.center/
APP9 说明:
核心加密算法在 Native 层实现,同时添加了 LLVM 混淆,适合做 so 模拟或者逆向分析。
LLVM 介绍
看说明,核心算法在 Native
层实现,相比上一道题新增了 LLVM 混淆,我们先了解一下 LLVM 混淆是什么。
LLVM 混淆是一种基于 LLVM 编译器框架的代码保护技术,通过修改程序中间表示(LLVM IR)的逻辑结构和指令模式,增加逆向工程难度 。其核心原理是通过自定义的 LLVM Pass(编译器优化模块)在编译阶段对代码进行变形处理,典型应用包括:
控制流混淆:将函数内的分支逻辑转换为状态机模式,破坏原始执行顺序(如 Obfuscator-LLVM 的
-mllvm -fla
参数),插入无效分支和冗余跳转,干扰逆向分析。指令级混淆:将单一指令替换为等效但更复杂的指令序列(如
ADD
替换为SUB + XOR
),加密字符串和常量,仅在运行时动态解密。
简单来说,LLVM 就是将代码编译为一种中间代码,并且对中间代码的执行流程或者指令进行混淆,之后再通过编译器编译为机器码来执行。这种方式可以很方便的将简单的代码编译为强混淆的代码,而且支持很多种语言,不用为每种语言开发混淆代码,一套代码可以通用。
了解了 LLVM 是什么之后,我们来看看 APP9,Java 层就不看了,应该和之前的 APP8 差不多,直接看 APP9 的 so 文件。使用 ida 打开:



一眼望去,满眼都是 label
跳转,比 switch
跳转还要乱,来看看调用关系图:

再来对比一下 APP8 的 so 文件,

是不是 APP8 的特别清晰,APP9 因为用了控制流混淆,所以它的调用关系被打乱了,通过大大小小的分发器将原有的流程拆成了 n
多个小块,然后不停地在各个流程之间跳来跳去,最终完整整个流程的执行。
模拟调用
那遇到这种情况怎么办呢?有两种方案:
so 模拟调用
去混淆
先来说第一种方案,模拟调用。什么是模拟调用呢?模拟调用就是使用虚拟机来加载 so 文件,并且在脱离 APP 的情况下调用 so 中的代码,有点类似于 JS 的扣代码 +nodejs 的方式。模拟调用使用到的工具是:unidbg。
Unidbg 是一款专注于逆向工程的开源工具,用于在桌面环境(无需真机或移动应用)中模拟执行 Android/iOS 的加密 SO 文件,直接调用其内部函数以还原算法逻辑。其核心功能包括:基于 Unicorn 引擎或 Dynarmic 动态编译模拟 ARM 指令集,加载并解密 SO 文件的代码段;通过虚拟内存映射和补全 JNI(Java Native Interface)环境,绕过加密算法对 Java 层的依赖;支持 Hook 关键函数(如系统调用、加密接口)监控执行流程,并集成调试工具追踪寄存器与内存变化。开发者可通过编写 Java 或 Python 脚本,主动调用目标函数(如生成签名、解码数据),无需逆向分析复杂的加密逻辑。Unidbg 的典型应用场景包括黑盒调用闭源 SDK、快速验证加密协议,以及对抗 SO 文件的反调试保护,但其性能低于真实设备,且对复杂环境(如多线程、硬件依赖)的模拟仍需手动适配。
在开始前需要准备环境,我们需要一个 jdk8 的 Java 环境,配置好环境变量,并且使用 idea clone unidbg 的代码,因为 unidbg 需要使用代码来启动模拟器,Hook 函数,加载 so 等操作。
clone unidbg 的代码:
clone 下来以后,使用 idea 打开它,正常来说应该是这样的:

直接打开它的 test 文件夹,里面有给我们的示例文件,我们可以使用这些示例来学习如何使用 unidbg 的高级功能。
回到正题,我们开始写代码,调用 so,生成 token。
以上代码我加了部分注释,基本上代码都是固定的模版,不同的 so 文件只需要改不同的部分就可以了。
别忘了改一下 so 的路径为你本机的路径,运行以上代码,可以获取到 encrypt
加密后的结果:

可以看到在输出结果之前,还打印了一些日志,首先是发现了一个静态注册的函数,函数名和对应的地址都打印出来了,还有就是 so 的 encrypt
函数在执行的时候,还调用了 Java 的 GetStringUftChars
方法并且传入了一个路径参数,并且调用了 NewStringUFT
方法传入了一个结果参数,最终将加密后的结果返回。我们试试结果是不是对的:

结果是可用的,但是里面有几个点需要注意:
方法签名


callStaticJniMethodObject
方法的第二个参数,encrypt(Ljava/lang/String;I)Ljava/lang/String
这个东西叫 Java 方法的签名,可能看起来比较奇怪,但是这是 Smali 语法,一种用于描述 Android Dalvik
字节码的反汇编格式,Android 中实际执行的就是它。那如何获取呢?直接在反编译页面按一下 Tab 键就可以了,jadx-gui 会自动显示原始的 smali
代码,红框就是我们需要的方法签名了。
虚拟机设置
别忘记设置对应的系统位数,如果你是 32 位的 so 文件,别忘了将
.for64Bit()
改成 32 位,要和 so 对应上才可以。别忘记在创建虚拟机的时候传入原始的 apk 文件,虚拟机会自动帮我们做一部分签名校验的工作,防止出现奇怪的问题。
最好打印一下日志,可以更好的观察 so 的执行情况。
com.goldze.mvvmhabit.utils.NativeUtils
为什么要使用这个类呢,简单来说就是哪个类加载的 so 文件,就填哪个类就可以
RPC 调用
好了,现在我们已经可以通过 unidbg 模拟调用获取到正确的结果了,但是因为 unidbg 是另外一套系统,我们如何在 Python 中实时获取到加密后结果呢?
这个时候就要使用 RPC 调用了。在 unidbg 中起一个 Spring boot 服务,暴露一个接口出去,然后在接口中获取一个字符串参数和整型参数,调用刚才的方法调用 so 计算结果,然后返回给 Python。
要想使用 RPC 调用 so 的话可以使用 unidbg-boot-server 来实现,它是一个开源的 Github 项目,自带 unidbg 依赖,我们只需要实现对应的接口即可。
最终代码见:https://github.com/libra146/learnscrapy/tree/main/Android/APP9
总结
APP9 使用了 LLVM 进行混淆,我们选择一种比较简单的模拟调用的方式来实现数据爬取,有点投机取巧了,不过对于复杂的或者自己不会分析的 so 文件,这种方案是相对比较高效率的方式,也不失为一种比较好的方案。
其实实际的 so 模拟调用有的时候会比这复杂许多,包括前面提到的有的时候 so 可能会有签名校验,我们要能找到或者去除校验,有的时候可能会遇到 so 中调用 Java 的某些自定义的方法来获取一些值,我们要通过补环境的方法来补充上,让 so 能拿到正确的值,从而继续往下走正常的流程。
说到这里其实大家可能看出来了,很像 nodejs 的扣代码和补环境对吧,所以这种方法的优缺点也和 nodejs 的扣代码和补环境类似。
速度快是一个优点,只要找到函数调用的地方,正确传入参数就能调用。缺点也类似,可能会有大量的环境检测,一个个补环境的话可能会耗费大量的时间,包括有些我们可以通过 Java 方法补的环境,可能还会有一些大厂的 so 会针对性的检测 unidbg,例如通过多线程或者其他方式检测,如果遇到了也会很头疼哈哈。
本文章首发于个人博客 LLLibra146's blog
本文作者:LLLibra146
更多文章请关注公众号 (LLLibra146):
![]()
版权声明:本博客所有文章除特别声明外,均采用 © BY-NC-ND 许可协议。非商用转载请注明出处!严禁商业转载!
版权声明: 本文为 InfoQ 作者【LLLibra146】的原创文章。
原文链接:【http://xie.infoq.cn/article/51af487e8cb730fee0106eecd】。未经作者许可,禁止转载。
评论