安卓逆向(OLLVM)

好,我们先来简单了解一下编译的过程,再讲一下 LLVM 怎么解决编译中的问题。

编译的过程就像是翻译:

  1. 前端(Frontend):就像你写的文章,它会先被检查有没有语法错误(就像老师检查你的作文有没有拼写错误、句子不通顺),然后会变成一种更简单的语言,这种语言就是中间代码(好比是翻译成了一种大家都能懂的语言,方便后面的步骤处理)。

  2. 优化(Optimizer):这一步就像是你检查自己写的文章,看看有没有可以删掉的部分,或者有没有能合并的句子。目标是让文章更加简洁明了,省时间。就像程序代码中删掉没用的部分,合并冗余的代码,提升运行速度。

  3. 后端(Backend):这一步是把你翻译的文章(代码)转化成机器能直接执行的语言(也就是计算机能读懂的代码)。比如你写的是 x86 语言的代码,计算机能直接跑。但如果要生成 ARM 语言的代码(比如给手机的处理器用),那就得换一种生成方法了。

问题来了:

你看,传统的编译器就是这样直接把代码转换成机器语言。如果我今天想让它运行在不同类型的计算机上(比如电脑和手机),就需要重新做一套不同的转化方式。比如用 x86 的机器语言做的代码,跑不了 ARM 的手机处理器;而且每次想用不同编程语言(比如 C 或 Fortran)时,前端也需要重新写一遍。

这个问题怎么办?

就像在网络中,电脑和手机想要互相通信,但它们之间需要中转站。直接连在一起太复杂了,于是我们引入了交换机来管理信息的传输,这样电脑和手机不用直接互相连接,而是通过交换机来连接不同的设备。这样连接就更简单、清晰了。

LLVM 如何解决这个问题?

LLVM 就像一个中转站,它把编译过程分成了几个层次:

  1. 前端(Frontend):它负责理解你写的程序,检查语法,生成中间代码。这个阶段你可以选择不同的语言来写,比如 C 或 Fortran。

  2. 优化(Optimizer):优化阶段会帮你把代码优化得更高效。

  3. 后端(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 (虚假控制流)

虚假控制流 是通过改变程序的控制流路径(如 ifwhilefor 等条件和循环语句)来引入一些不必要的代码分支。它的目的是增加程序的复杂性,让程序看起来有更多的路径和判断条件,但这些路径和条件并不会改变程序的实际功能。

示例:

假设原本的代码是:

if (x > 0) {
    doSomething();
}

通过 虚假控制流,我们可以加入一些不执行的路径,使得控制流看起来更加复杂:

if (x > 0) {
    doSomething();
} else {
    // 虚假的控制流,不执行任何操作
    if (false) {
        doSomethingElse();
    }
}

在这个例子中,else 分支中的 if (false) 语句永远不会被执行,但它增加了代码的复杂性,让程序在反编译时更难理解。

虚假控制流的常见做法:

  • 引入永远不会执行的 ifswitch 语句。
  • 在循环中加入无意义的判断条件,使得代码看起来有更多的循环路径。
  • 使用 goto 语句跳转到一些没有实际意义的地方。

Control Flow Flattening(控制流扁平化)

控制流扁平化 是一种代码混淆技术,目的是打乱原本清晰的代码执行顺序,使得逆向分析更加困难。它的主要做法是将代码中的 顺序执行、条件分支、循环等控制流结构 统一变成 一个大 switch 语句 + 状态变量 的形式,让代码执行路径变得不直观。

如何工作?

正常情况下,代码的执行流程通常是 顺序执行基于 if / switch 语句进行跳转,例如:

void example() {
    step1();
    if (condition) {
        step2();
    }
    step3();
}

这个代码的执行流程很清晰:

  1. step1()
  2. 如果 conditiontrue,执行 step2()
  3. 执行 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()); // 直接读取明文
    }
}

思路

  1. 找到 libhello-jni.so 在内存中的基址
  2. 计算字符串的绝对地址(基址 + 偏移 0x37070
  3. 直接读取字符串的明文内容
  4. 打印输出

👉 这样,即使代码混淆了,运行时的字符串还是可以被直接读取!

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);
    }
});

思路

  1. Hook RegisterNatives,在 JNI 方法注册时拦截数据
  2. 找到 Java 方法名
  3. 找到方法的签名
  4. 找到 Native 方法的地址
  5. 打印出来,帮助我们分析 OLLVM 代码

👉 这样,即使 OLLVM 混淆了 JNI 方法名,我们依然可以拿到 Java 层和 Native 层的对应关系!

总结

  1. 字符串解密

    • OLLVM 只是在 .so 里加密了字符串,运行时依然会解密。
    • 动态 Hook 读取解密后的字符串即可破解!
  2. JNI 方法映射

    • OLLVM 混淆可能会影响 JNI 方法名,但 注册过程必须暴露这些信息
    • Hook RegisterNatives,打印 Java 层与 Native 层的方法关系!
  3. Frida Hook 是破解 OLLVM 的关键!

    • 字符串解密 → Hook 内存
    • 方法混淆 → Hook JNI 注册
    • 控制流混淆 → 运行时动态调试

android逆向奇技淫巧十:OLLVM原理、常见破解思路和hook代码 - 第七子007 - 博客园

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

奶龙牛牛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值