创建模拟器和加载模块

创建模拟器和加载模块

一、引言

本篇讨论两个过程中涉及到的知识和用法

二、创建模拟器

在创建模拟器时,我们通过建造者模式对模拟器的方方面面进行配置。

private static AndroidEmulator createARMEmulator() {
    return AndroidEmulatorBuilder.for32Bit()
            .setProcessName("com.sun.jna")
            .addBackendFactory(new DynarmicFactory(true))
            .build();
}

2.1 位数与架构

Unidbg 不支持 X86 和 Mips架构,所以 for32Bitfor64Bit 意味着是 ARM32 和 ARM64.

if (emulator.is32Bit() && elfFile.arch != ElfFile.ARCH_ARM) {
    throw new ElfException("Must be ARM arch.");
}

if (emulator.is64Bit() && elfFile.arch != ElfFile.ARCH_AARCH64) {
    throw new ElfException("Must be ARM64 arch.");
}

如果样本既提供了 ARM32,也提供了 ARM64 的动态库,那么你可以根据个人喜好做选择。从Unidbg 的角度讲,有两个因素你可以考虑

  1. 是执行速度,ARM64 相比 ARE32 会快 10% 上下。
  2. 是完善程度,在过去几年,出于节省包体积、兼容适配等原因,绝大多数样本只提供 ARM32 的动态库,导致 Unidbg 对 ARM32 的完善程度更好一些,这体现在 JNI 等多方面。

面向未来建议选 ARM64 ,面向当下选 ARM32。

2.2 进程名

setProcessName 用于设置 App 进程名,很多人会忽略这一设置,但最好设置一下,否则会带来风险。因为样本可以在代码中通过 getProgname 函数获取进程名。

// function in libc.so
__int64 getprogname()
{
  return _progname;
}

如果通过 setProcessName 设置了进程名,那么getProgname 会返回你所设置的进程名,而如果不设置进程名,Unidbg 会设置进程名为自身,这可不是很妙。

this.processName = processName == null ? "unidbg" : processName;

2.3 后端

addBackendFactory 用于设置后端执行引擎,一般计算机的结构如下图所示。
image.png

Unidbg 中结构如下
image.png

Unidbg 扮演的角色就是操作系统,它是一个微型、有限的 Linux 操作系统模拟器。

Backend 扮演的角色是处理器,承担模拟执行机器指令的任务。addBackendFactory 用于设置使用哪一个后端,因为 Unidbg 支持多个 Backend。Backend 的选择直接影响着 Unidbg 的能力边界和模拟执行的效率,所以这部分内容很重要,我们需要多做一些了解。

Unidbg 目前支持五个 Backend,分别是 Unicorn、Unicorn2、Dynarmic、Hypervisor、KVM。如果不添加BackendFactory,默认使用 Unicorn Backend,代码逻辑如下

public static Backend createBackend(Emulator<?> emulator, boolean is64Bit, Collection<BackendFactory> backendFactories) {
    if (backendFactories != null) {
        for (BackendFactory factory : backendFactories) {
            Backend backend = factory.newBackend(emulator, is64Bit);
            if (backend != null) {
                return backend;
            }
        }
    }
    // 默认使用 Unicorn后端
    return new UnicornBackend(emulator, is64Bit);
}

​ 事实上,Unicorn 也是 Unidbg 早先唯一的后端,Unidbg 这一命名甚至都可能是 Unicorn Debugger 的缩写。下面我们简单讨论 Unidbg Backend 的发展历史。

​ Unicorn 作为这些年最炙手可热的 CPU 模拟器,当之无愧是所有后端中功能最全面也是最强大的那个,在 Unicorn2 发布后,这一称号就到了 Unicorn2 头上。

​ 那么为什么 Unidbg 要引入其他后端?背后原因也很简单——Unicorn 执行速度太慢了。Unidbg 的设计初衷是利用 Unicorn 所提供的各种强大 Hook,辅助和加速 Android Native 算法分析,那么执行百万行汇编耗时 1s 还是 1ms 完全无足轻重,这个时候 Unidbg 的主要用处就是调试 SO,没有人在意调试器的速度。

​ 但在 Unidbg 开源并得到广泛关注后,这一情况发生了改变。Unidbg 可以很好的模拟执行目标函数,不依赖于真实设备,参数和设备信息可以轻松配置等特征,吸引了大批的爬虫乃至黑灰产从业人员,他们将 Unidbg 单纯用在跑算法上。相比基于真机的各种远程调用,既不需要购买大量真机/云手机,也不需要购买一键新机的软件,十分好用。

​ 这一用途很快成为 Unidbg 的最主要用途,那么相比于基于真机 RPC 的各种方案,Unidbg 的执行速度就成了最大的短板,快和慢的源头就是底层的指令执行引擎。Unicorn Backend 执行指令实在太慢了,甚至都比不过 Android 4.0 的老设备,这意味着在模拟执行复杂函数和流程时,单次执行耗时在数秒以上,这严重影响了 Unidbg 在这一用途上的使用。

​ 因此 Unidbg 引入了 Dynarmic 作为可选后端,它是另一款 CPU 模拟器,在执行速度上比 Unicorn 快了不止一个数量级。但它在可以对比的所有其他维度上都比 Unicorn 弱,比如不支持各种各样的 Hook,比如支持指令的范围、架构的广度等等。使用 Dynarmic 引擎,Unidbg 在 debugger 上的能力十不余一,辅助算法分析的能力大大降低。

​ 如果你使用 Unidbg 的意图是算法还原,那么建议使用 Unicorn2 后端。

emulator = AndroidEmulatorBuilder.for32Bit()
        .addBackendFactory(new Unicorn2Factory(false))
        .setProcessName("test")
        .build();

​ 各种 BackendFactory 的构造方法都要传入fallbackUnicorn参数,它表示如果这个后端创建失败时如何处理。

private Backend newBackend(Emulator<?> emulator, boolean is64Bit) {
    try {
        return newBackendInternal(emulator, is64Bit);
    } catch (Throwable e) {
        if (fallbackUnicorn) {
            return null;
        } else {
            throw e;
        }
    }
}

​ 如果设置为 false,那么 backend 创建失败时直接报错;设置为 true,返回 null,最终使用默认的 Unicorn Backend。

​ 相比较 Unicorn 后端,Unidbg 基于 Unicorn2 后端设计和处理了多线程的相关逻辑,因此在 Unicorn 和 Unicorn2 之间,应该选择 Unicorn 2。

​ 如果你使用 Unidbg 的意图是模拟执行,替代 RPC,那么你仍然应该先试 Unicorn2 后端,因为 Unicorn2 在模拟执行指令上的能力更强,如果 Unicorn2 最终能顺利跑通、跑出预期结果,再切换为 Dynarmic 看是否也能处理成功,如果 Unicorn2 跑不通,那么 Dynarmic 引擎也必然无法成功。需要强调的是,使用 Dynarmic 引擎时,不可使用基于 Unicorn 的 各种 Hook,而应该用内置适配的 HookZz、xHook 等框架。

emulator = AndroidEmulatorBuilder.for32Bit()
    //.addBackendFactory(new Unicorn2Factory(false))
    .addBackendFactory(new DynarmicFactory(true))
    .setProcessName("test")
    .build();

​ 关于执行速度和应用实践,这里再多谈一些。首先,Unicorn 和 Unicorn2 在模拟执行速度上差异不大,但因为多线程以及用新不用旧的原因,我们一般使用 Unicorn2。其次,Unicorn、Dynarmic、真机这三者在执行速度上差异有多大,读者应该有一个直观的感受。Unicorn 最慢,Dynarmic 比 Unicorn 快 50 - 100 倍,真机比 Dynarmic 快 5 - 30 倍,因此测试机性能差异很大。最后,为了提高效率,光用 Dynarmic 是不够的,还会搭配 unidbg-boot-server。

​ 说完 Unicorn/Unicorn2/Dynarmic 这三个 Backend,我们再简单介绍其他两个。KVM 和 Hypervisor 都是虚拟化而非模拟执行方案,所以对宿主机有较高要求,KVM 可用于 Raspberry Pi B4 等环境 ,速度快。Hypervisor 是 Mac M1上的虚拟化方案,是 Unidbg 上最快的后端。KVM 和 Hypervisor 都很快很快,但受制于宿主机设备限制,在 Unidbg 实际使用中不多见。

2.4根目录

setRootDir 用于设置虚拟文件系统的根目录,在作用上它等价于 Android 的根目录

emulator = AndroidEmulatorBuilder.for32Bit()
        .setProcessName(executable.getName())
        .setRootDir(new File("target/rootfs"))
        .addBackendFactory(new DynarmicFactory(true))
        .build();

​ 如果你认为目标函数可能会做文件访问或读写操作,那么建议主动设置根目录。因为如果不加以设置,Unidbg 会在本机某个临时目录下创建根目录,在将项目迁移到其他电脑上时就比较麻烦。我们一般将根目录设置为 target/rootfs 这个相对路径,使得潜在的文件依赖环境在当前 Unidbg 项目里。

image-20250719155217809

2.5 PID

在 Unidbg 中,进程的 pid 被默认设置为当前的 JVM 的 ID。约束其不大于 0x7FFF,这是一个相对老旧的规定,但无关痛痒。

String name = ManagementFactory.getRuntimeMXBean().getName();
String pid = name.split("@")[0];
this.pid = Integer.parseInt(pid) & 0x7fff;

理论上,Unidbg 最好在初始化时提供一个 setPid 方法,让使用者可以设置 pid,这会有助于处理进程相关的文件访问,但目前 Unidbg 并没有这么做。

2.6 开启多线程

在大多数情况下,开启多线程可以更好的处理程序逻辑。Unidbg 只在 Unicorn2 Backend 上实现了相对完善的多线程处理逻辑,除了将 Backend 设置为 Unicorn2 外,你还需要像下面代码这样,开启调度以及设置多少行切换一次

emulator = AndroidEmulatorBuilder.for32Bit()
        .addBackendFactory(new Unicorn2Factory(false))
        .setProcessName("test")
        .build();
// 设置执行多少条指令切换一次线程
emulator.getBackend().registerEmuCountHook(10000);
// 开启日志
emulator.getSyscallHandler().setVerbose(true);
// 开启线程调度器
emulator.getSyscallHandler().setEnableThreadDispatcher(true);

三、加载模块

3.1 加载模块

Unidbg 在创建虚拟机对象的时候,可以传入甚至是建议传入 apk 文件,

//创建虚拟机
VM dalvikVM = emulator.createDalvikVM();
//创建虚拟机并指定APK文件
VM dalvikVM = emulator.createDalvikVM(new File("apk file path"));

这一行让很多人误以为 Unidbg 不是一个 so 模拟器,而是一个 APK 模拟器,就像雷电或者夜神模拟器。实际上, Unidbg 加载 apk 并非要去执行 dex 甚至 apk 这样的大事,相反,它只是在做一些小而美的事。

一是解析 apk 信息,减少使用者在补 JNI 环境上的工作量。Unidbg 会解析版本名、版本号、包名等信息,如果样本 JNI 访问和获取这些信息,Unidbg 会替我们处理,不需要我们烦心。

// versionName
if ("android/content/pm/PackageInfo->versionName:Ljava/lang/String;".equals(signature) &&
        dvmObject instanceof PackageInfo) {
    PackageInfo packageInfo = (PackageInfo) dvmObject;
    if (packageInfo.getPackageName().equals(vm.getPackageName())) {
        String versionName = vm.getVersionName();
        if (versionName != null) {
            return new StringObject(vm, versionName);
        }
    }
}

// versionCode
if ("android/content/pm/PackageInfo->versionCode:I".equals(signature)) {
    return (int) vm.getVersionCode();
}

// packageName
case "android/app/Application->getPackageName()Ljava/lang/String;":
case "android/content/ContextWrapper->getPackageName()Ljava/lang/String;":
case "android/content/Context->getPackageName()Ljava/lang/String;": {
    String packageName = vm.getPackageName();
    if (packageName != null) {
        return new StringObject(vm, packageName);
    }
    break;
}

进一步,Unidbg 还会解析 APK 签名,处理签名校验相关的 JNI 调用。

@Override
public CertificateMeta[] getSignatures() {
    if (signatures != null) {
        return signatures;
    }

    try (net.dongliu.apk.parser.ApkFile apkFile = new net.dongliu.apk.parser.ApkFile(this.apkFile)) {
        List<CertificateMeta> signatures = new ArrayList<>(10);
        for (ApkSigner signer : apkFile.getApkSingers()) {
            signatures.addAll(signer.getCertificateMetas());
        }
        this.signatures = signatures.toArray(new CertificateMeta[0]);
        return this.signatures;
    } catch (IOException | CertificateException e) {
        throw new IllegalStateException(e);
    }
}

处理 JNI 中获取签名信息的 API (只举例一处)

if ("android/content/pm/PackageInfo->signatures:[Landroid/content/pm/Signature;".equals(signature) &&
        dvmObject instanceof PackageInfo) {
    PackageInfo packageInfo = (PackageInfo) dvmObject;
    if (packageInfo.getPackageName().equals(vm.getPackageName())) {
        CertificateMeta[] metas = vm.getSignatures();
        if (metas != null) {
            Signature[] signatures = new Signature[metas.length];
            for (int i = 0; i < metas.length; i++) {
                signatures[i] = new Signature(vm, metas[i]);
            }
            return new ArrayObject(signatures);
        }
    }
}

如果没有加载 apk,这些 JNI 调用就需要我们去补,平添不少工作量。

二是对资源文件的处理,如果加载了 apk,就可以在 Unidbg 中访问 apk assets 目录下的文件。

@Override
public byte[] openAsset(String fileName) {
    try (net.dongliu.apk.parser.ApkFile apkFile = new net.dongliu.apk.parser.ApkFile(this.apkFile)) {
        return apkFile.getFileData("assets/" + fileName);
    } catch (IOException e) {
        throw new IllegalStateException(e);
    }
}

样本通过 AAssetManager_open 等 API 访问 apk 的资源时,就依赖于这些 API 以及载入的 apk 文件。综上所述,建议创建虚拟机时选择加载 apk。

值得一提的,Unidbg 使用 apk-parser 完成相关解析工作,读者也可以在自己的项目里使用它来解析 apk。

<dependency>
    <groupId>net.dongliu</groupId>
    <artifactId>apk-parser</artifactId>
    <version>2.6.10</version>
</dependency>

3.2加载模块

如何加载 so 到 Unidbg 里?需要通过 loadLibrary API,它的函数原型如下:

//参数一: 动态库或可执行ELF文件
//参数二: 是否强制执行 init_proc、init_array 初始化系列函数
DalvikModule loadLibrary(File elfFile, boolean forceCallInit);

//参数一:动态库或可执行ELF文件名,比如 libkwsgmain.so 其名就是 kwsgmain,
//Unibdg 会在 apk lib 目录下找到和加载它
//参数二: 是否强制执行 init_proc、init_array 初始化系列函数
DalvikModule loadLibrary(String libname, boolean forceCallInit);

建议使用第二个重载,这个 API 在使用上近似于 Java 的 System.loadLibrary(soName),比如libkwsgmain.so 对应为 kwsgmain ,掐头去尾。

vm.loadLibrary("kwsgmain", true);

对应的加载代码如下

@Override
public final DalvikModule loadLibrary(String libname, boolean forceCallInit) {
    String soName = "lib" + libname + ".so";
    LibraryFile libraryFile = findLibrary(soName);
    if (libraryFile == null) {
        throw new IllegalStateException("load library failed: " + libname);
    }
    Module module = emulator.getMemory().load(libraryFile, forceCallInit);
    return new DalvikModule(this, module);
}

它需要加载 APK 才可以使用,然后在 apk 的 lib 目录下找你想要加载的 SO。

32 位代码如下

byte[] loadLibraryData(Apk apk, String soName) {
    byte[] soData = apk.getFileData("lib/armeabi-v7a/" + soName);
    if (soData != null) {
        if (log.isDebugEnabled()) {
            log.debug("resolve armeabi-v7a library: " + soName);
        }
        return soData;
    }
    soData = apk.getFileData("lib/armeabi/" + soName);
    if (soData != null && log.isDebugEnabled()) {
        log.debug("resolve armeabi library: " + soName);
    }
    return soData;
}

64 位代码如下

byte[] loadLibraryData(Apk apk, String soName) {
    byte[] soData = apk.getFileData("lib/arm64-v8a/" + soName);
    if (soData != null) {
        if (log.isDebugEnabled()) {
            log.debug("resolve arm64-v8a library: " + soName);
        }
        return soData;
    } else {
        return null;
    }
}

如果使用第一个重载,自己传入 SO 文件,不必加载 apk。

vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/armeabi-v7a/libsignutil.so"), true);

除了这两个,Unidbg 还有一个用于加载 SO 字节数组的重载方法。

loadLibrary(String libname, byte[] raw, boolean forceCallInit)

它的主要应用场景是使用 Unidbg 加载 dump memory,但事实上,这个 API 缺少维护,实际并不可用。

3.3 加载依赖模块

​ 这里我们要聊聊加载依赖 SO 的问题,目标 SO 总会依赖一些外部 SO,具体分为系统库和自身库。系统库比如 libc.so,liblog.so,它们提供了实现各种代码逻辑必不可少的基础函数。自身库则指的是目标函数可能依赖于自身另外一个库的导出函数或符号。

​ 在 IDA 的头部可以看到这些依赖库信息,或使用诸如 objdump、readelf 任意工具也可以获取这一信息。

image.png

​ 在真实 Android 系统的加载逻辑中,会解析依赖库,判断这些依赖库是否已加载到内存,如果没有加载到内存,就到诸如 /vendor/lib 这样的系统库路径以及 /data/app/packageName/base.apk!/lib/armeabi-v7a 这样的用户库路径下找所依赖的 SO 其文件。

​ 在 Unidbg 的逻辑里,处理也类似,但代码逻辑要简单非常多。我们只需要通过 setLibraryResolver 确认使用哪一种文件,参数可选 23 和 19。

​ 分别对应于 sdk23(Android 6) 和 sdk19(Android 4.4)。

​ 如果你设置为 64 位模拟器,那么 sdk19 无法满足你,因为 Android 4.4 的彼时没有对应 64 位的系统库。

​ 我们一般使用 sdk23,读者可能认为 Android 6.0 的运行库环境太低了,但事实上,对于 Native Runtime 而言,似乎已然够用,如果读者希望使用更高的版本,并非将真机的 SO 放到 Unidbg 就大功告成,Unidbg 为这两个 SDK 环境做了一些优化和 patch 处理,你也需要做对应的处理才能让新环境正常可用。

​ 寻找依赖 SO 的逻辑如下

LibraryFile neededLibraryFile = libraryFile.resolveLibrary(emulator, neededLibrary);
if (libraryResolver != null && neededLibraryFile == null) {
    neededLibraryFile = libraryResolver.resolveLibrary(emulator, neededLibrary);
}

​ 第二处 resolveLibrary 是寻找我们所设置的系统库路径下是否有对应的 SO。

protected static LibraryFile resolveLibrary(Emulator<?> emulator, String libraryName, int sdk, Class<?> resClass) {
    final String lib = emulator.is32Bit() ? "lib" : "lib64";
    String name = "/android/sdk" + sdk + "/" + lib + "/" + libraryName.replace('+', 'p');
    URL url = resClass.getResource(name);
    if (url != null) {
        return new URLibraryFile(url, libraryName, sdk, emulator.is64Bit());
    }
    return null;
}

​ 第一处是寻找用户库下是否有依赖 SO,如果加载目标 SO 时采用文件方式,那么它会在文件所处的文件夹下寻找。

@Override
public LibraryFile resolveLibrary(Emulator<?> emulator, String soName) {
    File file = new File(elfFile.getParentFile(), soName);
    return file.canRead() ? new ElfLibraryFile(file, is64Bit) : null;
}

如果加载目标 SO 时采用 apk 内部 lib 下寻找的方式,那么寻找依赖库时也会同理。

@Override
public LibraryFile resolveLibrary(Emulator<?> emulator, String soName) {
    byte[] libData = baseVM.loadLibraryData(apk, soName);
    return libData == null ? null : new ApkLibraryFile(baseVM, this.apk, soName, libData, packageName, is64Bit);
}

// 32 位
byte[] loadLibraryData(Apk apk, String soName) {
    byte[] soData = apk.getFileData("lib/armeabi-v7a/" + soName);
    if (soData != null) {
        if (log.isDebugEnabled()) {
            log.debug("resolve armeabi-v7a library: " + soName);
        }
        return soData;
    }
    soData = apk.getFileData("lib/armeabi/" + soName);
    if (soData != null && log.isDebugEnabled()) {
        log.debug("resolve armeabi library: " + soName);
    }
    return soData;
}

​ 简而言之,简直加载 apk,简直 loadlibrary 传入 SO 名而非 SO 文件,不管是内部逻辑还是实际场景上,都更稳妥好用。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1621925986@qq.com

💰

×

Help us with donation