Android 逆向之 Uni Debug 全面解析

目录

Android 逆向之 Uni Debug 全面解析

一、Uni Debug 概述

(一)Unicorn 框架

(二)Uni Debug 简介

二、Uni Debug 的使用场景、优缺点

(一)使用场景

(二)优点

(三)缺点

三、Uni Debug 的使用步骤

(一)配置资源文件

(二)文件结构解析

(三)案例分析与代码示例

四、Uni Debug 的 API 介绍

(一)Emulator 常用 API

(二)内存常用 API

(三)VN 常用 API

五、Uni Debug 的 Hook 机制

(一)内置第三方 Hook 框架

(二)原生 Hook 方式

六、Uni Debug 的 Console Debug 功能

七、Uni Debug 的 Patch 功能


在 Android 逆向工程领域,理解和掌握相关工具及技术至关重要。本文将深入探讨 Uni Debug,为大家详细介绍其原理、使用方法及应用场景。

一、Uni Debug 概述

(一)Unicorn 框架

Unicorn 是由新加坡南洋理工大学团队在 2015 年开源的 CPU 模拟器框架。它支持多种架构,如 X86、X64、arm、m64 等。其主要特点包括:

  • 多架构支持:能适应不同的硬件架构需求,为多种类型的代码模拟提供基础。
  • 高性能:在代码模拟执行过程中具备较高的效率,减少运行延迟。
  • 丰富接口与多语言绑定:提供了多种语言的绑定,包括 Python、Java、C# 等,方便开发者在不同的编程环境中使用。例如,在 Python 环境中,开发者可以利用其接口快速构建模拟测试场景。
  • 灵活的 hook 设置:允许用户在模拟过程中设置 hook,能够拦截和处理特定的指令和内存访问,这对于深入分析代码执行流程非常有帮助。比如在分析加密算法时,可以通过 hook 关键函数来获取加密过程中的数据。

(二)Uni Debug 简介

Uni Debug 是一个开源的轻量级模拟器,主要用于执行安卓平台上的 native 代码,由凯神在 2019 年开源,基于 Unicorn 构建,用 Java 语言编写,可在 IDE 打开和运行。它在 Android 逆向分析中具有重要地位:

  • 强大的分析能力:能够让逆向工程师和安全研究人员分析和理解二进制文件的运行行为,通过模拟执行,深入探究代码内部逻辑。
  • 系统调用与 JNI 支持:支持模拟系统调用和 JNI 调用,使得在模拟环境中可以执行依赖这些调用的代码,这对于处理复杂的 Android 应用程序逻辑至关重要。

其优势在于提供了隐蔽的监控手段,可模拟复杂的 native 环境,且由于开源特性,得到社区广泛支持和持续更新,成为安卓 native 逆向分析领域的有力工具。

二、Uni Debug 的使用场景、优缺点

(一)使用场景

  • 模拟执行 SO 文件函数:可以模拟执行目标 so 文件中用户关注的函数,获取和真机相等的结果,从而避免在真机上进行复杂的测试和分析。例如,在分析某个应用的加密算法所在的 so 文件时,Uni Debug 可以准确模拟函数执行过程,获取加密结果,帮助开发者理解算法原理。
  • 监控环境信息:能够监控观察样本对环境的信息获取与修改,包括系统调用、库函数、JNI 调用和文件读写等所有类型的外部信息访问。在检测恶意软件行为时,可以通过 Uni Debug 监控其对系统资源的访问,及时发现潜在的安全威胁。
  • 辅助算法分析与还原:结合时间旅行调试器,提供 hook、debug 等分析能力,可辅助算法分析和还原。对于一些复杂的算法,如自定义的加密或压缩算法,Uni Debug 可以帮助开发者逐步分析代码执行过程,还原算法逻辑。

(二)优点

  • 成本效益高:无需购买大量真机或云手机,降低了开发和研究成本。对于小型开发团队或个人开发者来说,这是一个非常重要的优势。
  • 灵活性强:可以模拟和代理所有的函数调用接口,方便模拟设备环境变化。在测试不同环境下的应用行为时,开发者可以通过 Uni Debug 快速调整模拟环境,提高测试效率。
  • 强大的监控与分析能力:能监控 native 层的详细执行流,结合时间旅行调试器可提供强大的算法分析和还原能力,为逆向工程提供了有力的支持。

(三)缺点

  • 学习成本高:尤其是补环境部分,如果处理不好,即使代码运行,结果也可能错误。需要开发者花费大量时间学习和实践,掌握正确的配置和使用方法。
  • 执行速度慢:基于 Unicorn 的模拟执行速度比真机慢很多,尽管有后端架构优化,但可能会牺牲部分辅助算法还原能力。在处理大规模数据或复杂算法时,可能会导致较长的等待时间。
  • 功能受限:没有为特定场景做专门优化,缺乏配置管理功能,且对所有系统调用的模拟不够完善,需要进行补环境操作。在一些特殊的应用场景下,可能无法满足开发者的全部需求。
  • 扩展性差:作为 Java 项目,无法作为 IDA 或 GDA 的插件,难以轻松嵌入到其他项目中,不如 Python 项目灵活,限制了其在一些复杂项目中的应用。

三、Uni Debug 的使用步骤

(一)配置资源文件

  1. 下载 IDA 社区版,可从官方网站获取相应安装包进行安装。IDA 是一款强大的反汇编工具,在 Uni Debug 分析过程中起到重要作用。
  2. 下载 Uni Debug 源码,可从其官方开源仓库获取。
  3. 使用 IDEA 打开源码,并配置好 SDK。SDK 的配置可参考以往课程或 IDEA 的官方文档,确保项目能够正确编译和运行。

(二)文件结构解析

  • 项目包含项目介绍和使用指南、开源许可证等文件,以及配置文件、脚本文件和资源文件等。这些文件共同构成了 Uni Debug 的项目结构,其中资源文件存放模拟过程中需要的资源,如后端实现、核心接口和抽象类模块等。
  • 对于安卓相关部分,主要关注构建文件、SRC 目录(包含 main 和 test 文件夹)。main 文件夹中有核心的 Java 源代码,如核心模拟器文件系统、虚拟键组件和 so 文件格式解析实现等;test 文件夹中有单元测试代码,可供开发者参考和学习。

(三)案例分析与代码示例

以下是一个基于 Uni Debug 的简单案例,用于分析一个安卓应用中的 native 函数执行过程。

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
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;

public class UniDebugExample {
    public static void main(String[] args) {
        // 创建 32 位安卓模拟器实例
        AndroidEmulator emulator = AndroidEmulatorBuilder.for32Bit().build();
        // 获取内存管理接口
        Memory memory = emulator.getMemory();
        // 设置系统库版本为 23(对应安卓 6.0)
        memory.setLibraryResolver(new AndroidResolver(23));
        // 创建 Dalvik 虚拟机实例
        VM vm = emulator.createDalvikVM(null);
        // 设置是否详细输出 JNI 调用日志
        vm.setVerbose(true);

        // 加载指定路径的 so 文件,这里假设 so 文件名为 libexample.so
        Module module = vm.loadLibrary(new File("path/to/libexample.so"), false);

        // 以下是模拟调用 native 函数的示例
        DvmClass dvmClass = vm.resolveClass("com/example/MyClass");
        DalvikModule dalvikModule = vm.getDalvikModule("com.example.MyClass");
        // 假设要调用的 native 函数名为 nativeMethod,其签名为 (Ljava/lang/String;)Z
        Object result = dalvikModule.callStaticJniMethod(emulator, "nativeMethod", "input_string");

        System.out.println("Function call result: " + result);

        // 释放资源
        emulator.close();
    }
}

在上述代码中:

  • 首先创建了一个 32 位的安卓模拟器实例,并获取其内存管理接口,设置系统库版本。
  • 接着创建了 Dalvik 虚拟机实例,并根据需要设置 JNI 调用日志的输出级别。
  • 然后加载指定的 so 文件,通过获取相应的 Dvm 类和 Dalvik 模块,模拟调用了名为 nativeMethod 的 native 函数,并传入一个字符串参数 input_string
  • 最后输出函数调用的结果,并关闭模拟器实例,释放资源。

四、Uni Debug 的 API 介绍

(一)Emulator 常用 API

  • 获取内存接口:通过 emulator.getMemory() 可以获取内存管理接口,用于后续的内存操作,如内存分配、读取和写入等。
  • 获取进程 IDemulator.getPid() 可获取当前模拟器进程的 ID,在多进程分析场景中具有重要作用。
  • 创建虚拟机emulator.createDalvikVM() 用于创建 Dalvik 虚拟机实例,为执行安卓应用的代码提供运行环境。
  • 获取已创建虚拟机对象emulator.getVM() 可以获取已经创建的虚拟机对象,方便对虚拟机进行进一步的操作和管理。
  • 获取当前寄存器emulator.getCurrentRegisters() 能够获取当前寄存器的值,对于分析代码执行状态和数据流向非常有帮助。
  • 获取后端 CPU 进程名emulator.getBackendProcessName() 可获取后端 CPU 进程的名称,有助于了解代码执行的底层环境。

(二)内存常用 API

  • 设置版本memory.setLibraryResolver() 用于设置内存的系统库版本,确保模拟环境与目标应用的兼容性。
  • 获取当前指针值memory.getPointer() 可以获取当前内存指针的值,在内存操作中起到关键作用。
  • 获取内存对象memory.getMemoryObject() 可获取内存对象,通过该对象可以进行更详细的内存操作,如内存映射、模块地址查找等。
  • 获取内存映射情况memory.getMemoryMap() 用于获取内存的映射情况,帮助开发者了解内存的分配和使用状态。
  • 加载 so 文件memory.loadLibrary() 可加载指定的 so 文件到内存中,为函数调用和代码执行提供必要的库支持。

(三)VN 常用 API

  • 创建虚拟机 APK 文件vm.createApk() 可创建与虚拟机关联的 APK 文件,在分析应用的 APK 相关资源和逻辑时非常有用。
  • 设置 JNI 运行日志输出vm.setVerbose() 用于设置是否输出 JNI 的运行日志,方便开发者在调试过程中查看详细的 JNI 调用信息。
  • 加载 so 模块vm.loadLibrary() 加载 so 模块到虚拟机中,确保相关函数和代码能够在模拟环境中正确执行。
  • 获取 JNIENV 指针vm.getJNIEnv() 可获取 JNIENV 指针,这是进行 JNI 调用和操作的关键。
  • 获取 Java VN 指针vm.getJavaVM() 用于获取 Java VN 指针,在 Java 与 native 代码交互过程中起到重要作用。

五、Uni Debug 的 Hook 机制

(一)内置第三方 Hook 框架

Uni Debug 内置了一些第三方的 Hook 框架,如 Doby(前身是 Hook JJ)、While 和 X Hook。

  • 优点
    • 提供多种 Hook 方式,如 inline Hook、PLT Hook 等,满足不同的 Hook 需求。例如,在分析函数调用流程时,可以使用 inline Hook 直接修改函数的执行逻辑,获取更详细的执行信息。
    • 具有简洁的 API 接口,便于快速上手。开发者可以通过简单的函数调用实现 Hook 功能,减少开发时间和成本。
  • 缺点
    • 由于是第三方框架,可能存在被检测的风险。在一些安全防护较强的应用中,使用这些框架可能会触发应用的反调试机制。
    • Inline Hook 在短函数和相邻地址的函数中可能出现问题,影响 Hook 的准确性和稳定性;PLT Hook 无法 Hook 非导出的函数,限制了其应用范围。

(二)原生 Hook 方式

Uni Debug 基于 Unicorn 引擎开发了原生的 Hook 方式,如利用 Unicorn 本身实现指令集的 Hook、块级内存读写异常 Hook 等,并封装了 Console Debug。

  • 优点
    • 隐蔽性强,是原生的 Hook 方式,相对难以被检测。在进行安全研究和逆向分析时,能够更好地避免被目标应用发现。
    • 可以对任意代码位置进行 Hook,没有特定的限制,提供了更强大的分析能力。
  • 缺点
    • 使用复杂度高,需要开发者深入理解底层机制。在实现 Hook 功能时,需要对底层的指令集、内存管理等有深入的了解,增加了开发难度。
    • 配置起来相对复杂,需要花费更多的时间和精力进行设置和调试。

以下是一个使用 Uni Debug 原生 Hook 方式的示例代码:

import com.github.unidbg.Emulator;
import com.github.unidbg.hook.HookContext;
import com.github.unidbg.hook.ReplaceCallback;
import com.github.unidbg.hook.hookzz.HookZz;
import com.github.unidbg.pointer.UnicornPointer;

public class UniDebugHookExample {
    public static void main(String[] args) {
        Emulator emulator = // 创建模拟器实例的代码

        // 使用 HookZz 进行函数 Hook
        HookZz hookZz = HookZz.getInstance(emulator);
        hookZz.wrap(module.base + 0x1234, new ReplaceCallback() {
            @Override
            public void preCall(HookContext context, UnicornPointer[] args) {
                // 在函数调用前的操作,例如获取参数值
                int arg1 = args[0].getInt(0);
                System.out.println("Before function call, arg1: " + arg1);
            }

            @Override
            public void postCall(HookContext context, UnicornPointer[] args, UnicornPointer retval) {
                // 在函数调用后的操作,例如获取返回值
                int returnValue = retval.getInt(0);
                System.out.println("After function call, return value: " + returnValue);
            }
        });

        // 执行相关代码,触发 Hook 操作

        emulator.close();
    }
}

在上述代码中:

  • 首先创建了模拟器实例,然后通过 HookZz.getInstance() 获取 HookZz 实例。
  • 使用 wrap 方法对指定地址(module.base + 0x1234)的函数进行 Hook,通过实现 ReplaceCallback 接口的 preCall 和 postCall 方法,分别在函数调用前和调用后执行自定义的操作,如获取参数值和返回值,并进行输出。

六、Uni Debug 的 Console Debug 功能

Console Debug 是 Uni Debug 提供的强大工具,允许用户在模拟执行过程中设置断点、单步调试、查看和修改内存以及寄存器的操作,从而深入分析目标程序的行为。

以下是一个 Console Debug 的使用示例:

import com.github.unidbg.Emulator;
import com.github.unidbg.Debugger;
import com.github.unidbg.Module;

public class UniDebugConsoleExample {
    public static void main(String[] args) {
        Emulator emulator = // 创建模拟器实例的代码

        // 获取 Debugger 实例
        Debugger debugger = emulator.getDebugger();
        // 附加到目标进程或模块
        debugger.attach(module);
        // 添加断点,假设断点地址为 0x5678
        debugger.addBreakpoint(0x5678);

        // 运行模拟器
        emulator.run();

        // 当程序断在断点处时,可以进行以下操作
        // 查看寄存器值,例如查看 X0 寄存器的值
        int x0Value = debugger.readRegister("X0");
        System.out.println("X0 register value: " + x0Value);
        // 查看内存值,假设查看地址 0x1234 处的内存值
        byte[] memoryValue = debugger.readMemory(0x1234, 4);
        System.out.println("Memory value at 0x1234: " + Arrays.toString(memoryValue));

        // 继续执行程序
        debugger.continueExecution();

        emulator.close();
    }
}

在上述代码中:

  • 首先创建模拟器实例后获取 Debugger 实例,通过 attach 方法附加到目标模块。
  • 使用 addBreakpoint 方法在指定地址添加断点,然后运行模拟器。
  • 当程序断在断点处时,可以使用 readRegister 方法读取寄存器的值,使用 readMemory 方法读取指定地址的内存值,并进行输出。最后可以使用 continueExecution 方法继续执行程序。

七、Uni Debug 的 Patch 功能

Uni Debug 的 Patch 功能主要用于对二进制文件进行修改,有两种形式:一是在文件系统中修改二进制文件,二是在内存中修改。其应用场景广泛,在某些情况下比传统的 Patch 方法更具优势。

以下是一个简单的 Patch 示例代码:

import com.github.unidbg.Emulator;
import com.github.unidbg.Module;
import com.github.unidbg.patcher.Patch;
import com.github.unidbg.patcher.PatchContext;

public class UniDebugPatchExample {
    public static void main(String[] args) {
        Emulator emulator = // 创建模拟器实例的代码

        // 创建 Patch 实例并进行配置
        Patch patch = new Patch() {
            @Override
            public void patch(PatchContext context) {
                // 假设要将地址 0x8765 处的指令修改为特定的汇编指令
                context.writeMemory(0x8765, new byte[]{0x12, 0x34, 0x56});
            }
        };

        // 应用 Patch
        patch.apply(emulator);

        // 加载目标模块,假设目标模块名为 libtarget.so
        Module module = emulator.loadLibrary("libtarget.so", false);

        // 获取目标函数并执行,假设目标函数名为 targetFunction
        // 这里需要根据实际情况获取函数签名和参数等信息
        // 以下是一个示例,实际可能需要调整
        Object result = module.callFunction("targetFunction", "input_param");

        // 输出结果以查看 Patch 效果
        System.out.println("Function call result after patch: " + result);

        emulator.close();
    }
}

在上述补充代码中:

  • 首先通过 emulator.loadLibrary 加载目标 so 文件模块,确保目标函数所在的模块已被正确加载到模拟器环境中。
  • 然后使用 module.callFunction 调用目标函数 targetFunction,并传入一个示例参数 input_param。这里的函数名和参数需要根据实际被 Patch 的目标函数情况进行修改。
  • 最后将函数调用的结果输出,通过观察结果来判断 Patch 是否达到了预期的效果,即是否改变了目标函数的行为。

请注意,在实际使用中,需要根据具体的目标二进制文件和要修改的内容准确地设置 Patch 的地址和指令,以及正确地调用相关函数和处理参数。

你可能感兴趣的:(逆向)