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 打开:

image-20250217211201308

image-20250217211217268

image-20250217211229173

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

image-20250217212824405

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

image-20250217212818749

是不是 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 的代码:

1
git clone https://github.com/zhkl0228/unidbg.git

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

image-20250217214456111

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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 加密后的结果:

image-20250217221747767

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

image-20250217222017604

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

方法签名

image-20250217221430910

image-20250217221522406

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):LLLibra146

版权声明:本博客所有文章除特别声明外,均采用 © BY-NC-ND 许可协议。非商用转载请注明出处!严禁商业转载!

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