写点什么

Android 逆向实战:模拟调用解决 Native+LLVM 混淆

作者:LLLibra146
  • 2025-02-18
    北京
  • 本文字数:4232 字

    阅读完需:约 14 分钟

今天我们来看练习平台的最后一个 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 的代码:


git clone https://github.com/zhkl0228/unidbg.git
复制代码


clone 下来以后,使用 idea 打开它,正常来说应该是这样的:



直接打开它的 test 文件夹,里面有给我们的示例文件,我们可以使用这些示例来学习如何使用 unidbg 的高级功能。


回到正题,我们开始写代码,调用 so,生成 token。


package com;
import com.github.unidbg.AndroidEmulator;import com.github.unidbg.Module;import com.github.unidbg.arm.backend.DynarmicFactory;import com.github.unidbg.arm.backend.HypervisorFactory;import com.github.unidbg.arm.backend.KvmFactory;import com.github.unidbg.arm.backend.Unicorn2Factory;import com.github.unidbg.linux.android.AndroidEmulatorBuilder;import com.github.unidbg.linux.android.AndroidResolver;import com.github.unidbg.linux.android.dvm.AbstractJni;import com.github.unidbg.linux.android.dvm.DalvikModule;import com.github.unidbg.linux.android.dvm.DvmClass;import com.github.unidbg.linux.android.dvm.VM;import com.github.unidbg.memory.Memory;import com.github.unidbg.virtualmodule.android.AndroidModule;
import java.io.File;
/** * @author b * @date 2025/2/17 */public class CallStaticMethodAPP9 extends AbstractJni { private final VM vm; private CallStaticMethod53.AsyncTCP asyncTCP; private Module module; private AndroidEmulator emulator; private Memory memory; private DalvikModule dm;
CallStaticMethodAPP9() { // 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验 emulator = AndroidEmulatorBuilder .for64Bit() .setProcessName("com.goldze.mvvmhabit") .addBackendFactory(new Unicorn2Factory(true)) .addBackendFactory(new HypervisorFactory(true)) .addBackendFactory(new DynarmicFactory(true)) .addBackendFactory(new KvmFactory(true)) .build(); // 获取内存 memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作 // 如果不传入APK,那么样本在JNI_OnLoad中所做的签名校验,就需要手动补环境校验 vm = emulator.createDalvikVM(new File("/Users/xxx/Downloads/scrape-app9.apk")); // 打印日志 vm.setVerbose(true); new AndroidModule(emulator, vm).register(memory);
} public void start() { vm.setJni(this); DalvikModule dm = vm.loadLibrary(new File("/Users/xxx/Downloads/libnative.so"), true); // 调用JNI OnLoad,可以看到JNI中做的事情,比如动态注册以及签名校验等。 dm.callJNI_OnLoad(emulator); // 获取本SO模块的句柄 Module module = dm.getModule(); DvmClass dvmClass = vm.resolveClass("com.goldze.mvvmhabit.utils.NativeUtils"); Object result=dvmClass.callStaticJniMethodObject(emulator,"encrypt(Ljava/lang/String;I)Ljava/lang/String","/api/movie",0); System.out.println(result); } public static void main(String[] args) { new CallStaticMethodAPP9().start(); }}
复制代码


以上代码我加了部分注释,基本上代码都是固定的模版,不同的 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 许可协议。非商用转载请注明出处!严禁商业转载!

本文链接https://blog.d77.xyz/archives/3f187237.html

发布于: 13 小时前阅读数: 2
用户头像

LLLibra146

关注

还未添加个人签名 2018-09-17 加入

还未添加个人简介

评论

发布
暂无评论
Android逆向实战:模拟调用解决Native+LLVM混淆_Android 逆向_LLLibra146_InfoQ写作社区