好,我们先来简单了解一下编译的过程,再讲一下 LLVM 怎么解决编译中的问题。
编译的过程就像是翻译:
-
前端(Frontend):就像你写的文章,它会先被检查有没有语法错误(就像老师检查你的作文有没有拼写错误、句子不通顺),然后会变成一种更简单的语言,这种语言就是中间代码(好比是翻译成了一种大家都能懂的语言,方便后面的步骤处理)。
-
优化(Optimizer):这一步就像是你检查自己写的文章,看看有没有可以删掉的部分,或者有没有能合并的句子。目标是让文章更加简洁明了,省时间。就像程序代码中删掉没用的部分,合并冗余的代码,提升运行速度。
-
后端(Backend):这一步是把你翻译的文章(代码)转化成机器能直接执行的语言(也就是计算机能读懂的代码)。比如你写的是 x86 语言的代码,计算机能直接跑。但如果要生成 ARM 语言的代码(比如给手机的处理器用),那就得换一种生成方法了。
问题来了:
你看,传统的编译器就是这样直接把代码转换成机器语言。如果我今天想让它运行在不同类型的计算机上(比如电脑和手机),就需要重新做一套不同的转化方式。比如用 x86 的机器语言做的代码,跑不了 ARM 的手机处理器;而且每次想用不同编程语言(比如 C 或 Fortran)时,前端也需要重新写一遍。
这个问题怎么办?
就像在网络中,电脑和手机想要互相通信,但它们之间需要中转站。直接连在一起太复杂了,于是我们引入了交换机来管理信息的传输,这样电脑和手机不用直接互相连接,而是通过交换机来连接不同的设备。这样连接就更简单、清晰了。
LLVM 如何解决这个问题?
LLVM 就像一个中转站,它把编译过程分成了几个层次:
-
前端(Frontend):它负责理解你写的程序,检查语法,生成中间代码。这个阶段你可以选择不同的语言来写,比如 C 或 Fortran。
-
优化(Optimizer):优化阶段会帮你把代码优化得更高效。
-
后端(Backend):不同的计算机(比如 x86 电脑和 ARM 手机)需要不同的机器码,LLVM 提供了一种方法,让我们可以根据不同的硬件生成不同的机器码,后端只需要接收中间代码,生成适合不同设备的代码。
所以,通过 LLVM 的设计,我们可以解决前面提到的问题,不需要每次编译不同的语言时都重新做一遍,LLVM 的中间代码就像一个标准的“语言”,让不同的设备都能理解。
OLLVM 的原理
OLLVM 是在 LLVM 的基础上增加了 混淆(obfuscation)的功能。简单来说,混淆就是让程序的代码变得更加难以理解,从而增加破解的难度。
为什么混淆加在优化阶段?
在 LLVM 中,优化阶段处理的是中间代码(LLVM IR)。这是一个非常适合做混淆的地方,因为:
- 在这个阶段,程序的语法已经不是人类写的那样了,而是一个通用的中间代码,更适合做各种“混淆”处理。
- 并且 LLVM 的优化阶段是一个独立的部分,不管你用的是哪种编程语言或硬件,优化的流程都是一样的。
OLLVM 怎么实现混淆?
OLLVM 提供了一些 API,可以对中间代码(LLVM IR)进行各种操作,比如:
- 加密字符串:通过混淆处理,将字符串内容进行加密,使得即使别人拿到了程序,也看不懂这些字符串。
例如,OLLVM 可以把某些字符串变成加密的样子,防止别人看到代码后直接读取到敏感信息。举个简单的例子,假设有一个字符串 Hello
,OLLVM 可以通过某种方式把它变成乱码或者加密后的数据,程序运行时再把它解密回来。这样,即使别人获得了代码,他们也无法轻易知道其中的敏感信息。
Instructions Substitution (指令替换)
指令替换 是一种将常见的简单指令(如加法、减法等)替换为更加复杂的等效形式的混淆方式。这种方式的目的是使得代码的逻辑变得更加难以理解,增加反向工程的难度。
示例:
假设原本有如下的加法指令:
a + b;
通过 指令替换,可以将加法转换为其它等效的操作,如:
(a ^ b) + (a & b);
这个表达式虽然看起来更复杂,但和原来的加法操作等价。通过这种替换,即使反编译后的代码看起来很复杂,但它的实际行为没有发生变化。
常见的指令替换:
- 加法替换:
a + b
→(a ^ b) + (a & b)
- 乘法替换:
a * b
→((a << 2) - a) * b
- 逻辑运算替换:
a && b
→!(a ^ b)
Bogus Control Flow (虚假控制流)
虚假控制流 是通过改变程序的控制流路径(如 if
、while
、for
等条件和循环语句)来引入一些不必要的代码分支。它的目的是增加程序的复杂性,让程序看起来有更多的路径和判断条件,但这些路径和条件并不会改变程序的实际功能。
示例:
假设原本的代码是:
if (x > 0) {
doSomething();
}
通过 虚假控制流,我们可以加入一些不执行的路径,使得控制流看起来更加复杂:
if (x > 0) {
doSomething();
} else {
// 虚假的控制流,不执行任何操作
if (false) {
doSomethingElse();
}
}
在这个例子中,else
分支中的 if (false)
语句永远不会被执行,但它增加了代码的复杂性,让程序在反编译时更难理解。
虚假控制流的常见做法:
- 引入永远不会执行的
if
或switch
语句。 - 在循环中加入无意义的判断条件,使得代码看起来有更多的循环路径。
- 使用
goto
语句跳转到一些没有实际意义的地方。
Control Flow Flattening(控制流扁平化)
控制流扁平化 是一种代码混淆技术,目的是打乱原本清晰的代码执行顺序,使得逆向分析更加困难。它的主要做法是将代码中的 顺序执行、条件分支、循环等控制流结构 统一变成 一个大 switch
语句 + 状态变量 的形式,让代码执行路径变得不直观。
如何工作?
正常情况下,代码的执行流程通常是 顺序执行 或 基于 if
/ switch
语句进行跳转,例如:
void example() {
step1();
if (condition) {
step2();
}
step3();
}
这个代码的执行流程很清晰:
step1()
- 如果
condition
为true
,执行step2()
- 执行
step3()
控制流扁平化后,代码变成这样:
void example() {
int state = 0; // 用一个变量控制流程
while (true) {
switch (state) {
case 0:
step1();
state = 1; // 进入下一个状态
break;
case 1:
if (condition) {
state = 2;
} else {
state = 3;
}
break;
case 2:
step2();
state = 3;
break;
case 3:
step3();
return;
}
}
}
在这个版本中:
- 原来的代码逻辑被拆分成多个状态 (
state
) - 执行顺序完全由
state
控制,而不是自然的代码块执行 - 整个代码都包裹在一个
while
循环和switch
语句中 - 真实的控制流被隐藏,使得反编译后代码的执行顺序变得难以理解
1. 破解字符串加密(Hook 内存解密后的明文)
OLLVM 的字符串混淆常使用 异或加密,然后在 init_array
里解密。虽然静态分析看不到明文字符串,但执行过程中解密后的字符串 已经存入内存,我们可以通过 Frida 直接 读取内存中的明文!
Frida Hook 解密后的字符串
function hook_native() {
var base_hello_jni = Module.findBaseAddress("libhello-jni.so");
if (base_hello_jni) {
// OLLVM 先在 init_array 里解密字符串,我们 hook 运行时的明文
var addr_37070 = base_hello_jni.add(0x37070);
console.log("addr_37070:", ptr(addr_37070).readCString()); // 直接读取明文
}
}
✅ 思路
- 找到
libhello-jni.so
在内存中的基址 - 计算字符串的绝对地址(基址 + 偏移
0x37070
) - 直接读取字符串的明文内容
- 打印输出
👉 这样,即使代码混淆了,运行时的字符串还是可以被直接读取!
2. 破解动态注册的 JNI 方法
在 Android 里,JNI(Java Native Interface)允许 Java 调用 Native 代码(C/C++)。OLLVM 可能会混淆 JNI 方法名,但我们可以 Hook RegisterNatives
,找到 Java 层方法和 Native 层方法的映射关系。
Frida Hook RegisterNatives
Interceptor.attach(addr_RegisterNatives, {
onEnter: function (args) {
console.log("RegisterNatives - JNI 方法映射:");
// 获取 JNI 方法数组的地址
var jniArrayAddr = args[2];
// 读取 Java 层方法名
var javaMethodName = ptr(jniArrayAddr).readPointer().readCString();
console.log("Java 方法名:", javaMethodName);
// 读取 方法签名 (参数)
var methodSig = ptr(jniArrayAddr).add(Process.pointerSize).readPointer().readCString();
console.log("方法签名:", methodSig);
// 读取 Native 方法地址
var nativeFuncAddr = ptr(jniArrayAddr).add(Process.pointerSize * 2);
console.log("Native 方法地址:", nativeFuncAddr);
}
});
✅ 思路
- Hook
RegisterNatives
,在 JNI 方法注册时拦截数据 - 找到 Java 方法名
- 找到方法的签名
- 找到 Native 方法的地址
- 打印出来,帮助我们分析 OLLVM 代码
👉 这样,即使 OLLVM 混淆了 JNI 方法名,我们依然可以拿到 Java 层和 Native 层的对应关系!
总结
-
字符串解密:
- OLLVM 只是在
.so
里加密了字符串,运行时依然会解密。 - 动态 Hook 读取解密后的字符串即可破解!
- OLLVM 只是在
-
JNI 方法映射:
- OLLVM 混淆可能会影响
JNI
方法名,但 注册过程必须暴露这些信息。 - Hook
RegisterNatives
,打印 Java 层与 Native 层的方法关系!
- OLLVM 混淆可能会影响
-
Frida Hook 是破解 OLLVM 的关键!
- 字符串解密 → Hook 内存
- 方法混淆 → Hook JNI 注册
- 控制流混淆 → 运行时动态调试