|
|
本帖最后由 正己 于 2025-11-9 12:23 编辑

一、课程目标
1. 理解文件访问补环境的 `IOResolver` 机制,并掌握不同 `FileIO` 的应用场景
2. 掌握 `/proc/self/maps`、`/proc/self/status` 等关键文件的核心处理策略
3. 理解补齐库函数的原理,掌握使用 `Dobby` Hook `__system_property_get` 等底层函数以伪造系统属性的方法
二、工具
1.教程 Demo
2.IDEA
3.IDA
三、课程内容
一. 补文件访问
#一、文件访问补环境的核心概念
文件访问 (File Access) 补环境是 Unidbg 应用中仅次于 JNI 补环境的重要环节。当 Unidbg 模拟执行的原生库(. So 文件)尝试通过文件 I/O 操作访问文件系统时——例如读取设备信息 (`/proc/cpuinfo`)、检测运行环境 (`/proc/self/maps`、`/xbin/su`) 或校验自身完整性 (APK 文件)——Unidbg 必须能够对这些访问请求作出响应。在一个纯净的 Unidbg 环境中,这些被访问的目标文件通常是不存在的。如果一个关键的文件访问失败或返回了不符合预期的内容,原生库可能会改变其执行逻辑、触发风控机制或直接中断执行。因此,文件访问补环境的目的就是通过 Unidbg 的 `IOResolver` 责任链机制拦截这些文件系统调用,并提供一个内容、权限都符合原生库逻辑预期的虚拟文件或目录。
#二、核心机制:责任链模式与 `IOResolver`
Unidbg 并未将所有文件处理逻辑写死,而是采用了一种灵活的**责任链模式**。当代码尝试访问一个文件时,请求会依次通过一个处理器链,直到被成功处理或最终失败。
这个处理链的顺序通常是:
1. **用户自定义的 `IOResolver`**:你添加的文件处理器,后添加的拥有更高优先级。
2. **`AndroidResolver`**:Unidbg 内置的默认处理器。
3. **虚拟文件系统(VFS)**:处理映射到文件系统的真实文件。
这样的设计使得我们可以注入自定义逻辑来处理文件访问,而无需修改 Unidbg 源码,保证了项目的可移植性和环境的整洁性。
`添加自定义文件处理器:`
你可以通过 `emulator.getSyscallHandler().addIOResolver()` 来添加一个文件处理器。这个操作必须在加载 SO 库(loadLibrary)之前完成才能生效。- // BiliIOResolver.java - 独立的处理器类
- public class BiliIOResolver implements IOResolver<AndroidFileIO> {
- @Override
- public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
- // 打印所有文件访问请求,无论是否处理
- System.out.println("File open request: " + pathname);
- // 在这里添加具体的文件处理逻辑
- return null; // 返回null表示未处理,交由下一个处理器
- }
- }
- // Main.java - 在主类中添加处理器
- public class Bili extends AbstractJni {
- public Bili() {
- // ...模拟器初始化代码...
- emulator.getSyscallHandler().addIOResolver(new BiliIOResolver());
- // ...加载SO和后续操作...
- }
- }
复制代码
#三、文件处理的三种返回结果
在 `resolve` 方法中,你可以返回一个 `FileResult` 对象,它有三种状态:
4. `FileResult.success(FileIO)`: 表示文件访问成功,返回一个 `FileIO` 对象。这是最常用的方式。
5. `FileResult.failed(errno)`: 表示文件访问失败,并返回一个指定的错误码,如 `UnixEmulator.EACCES` (权限不足)。
6. `FileResult.fallback()`: 表示回退,只有当其他所有处理器都无法处理时,才会由它来处理。
#四、常规文件处理与 `FileIO` 对象
处理文件访问需要结合三方面的知识:Linux/Android 文件系统知识、对业务(风控/检测)的经验以及 Unidbg 的实现方法。
##1. 使用 `SimpleFileIO` 处理物理文件
当需要返回一个真实存在的文件时,`SimpleFileIO` 是最佳选择。你需要将文件从真机上导出,并放置在你的项目路径下。
**案例:补 `boot_id` 和 APK 文件**
`boot_id` 常用于生成设备指纹,而 APK 文件访问常用于签名校验或资源读取,两者都必须处理。
`boot_id` 文件在开机时生成,在设备关机前不会改变内容。我们将这个文件从真机上 push 出来- C:\Users\zhengji>adb shell
- * daemon not running; starting now at tcp:5037
- * daemon started successfully
- vermeer:/ $ cat /proc/sys/kernel/random/boot_id
- c7de54b2-f238-481d-b8e1-41c05413b2cd
- vermeer:/ $ cp /proc/sys/kernel/random/boot_id /sdcard
- vermeer:/ $ exit
- C:\Users\zhengji>adb pull /sdcard/boot_id D:\unidbg-master\unidbg-android\src\test\resources
- /sdcard/boot_id: 1 file pulled, 0 skipped. 0.0 MB/s (37 bytes in 0.012s)
复制代码- @Override
- public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
- switch (pathname) {
- // 注意:这里的路径需要和样本访问的完全一致
- case "/proc/sys/kernel/random/boot_id":{
- return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/cpu/boot_id"), pathname));
- }
- }
- return null;
- }
复制代码
##2. 使用 `ByteArrayFileIO` 动态生成文件内容
当文件内容需要动态生成或随机化时(例如躲避基于设备指纹的风控),`ByteArrayFileIO` 非常有用。它直接接收一个字节数组作为文件内容。
**案例:随机化 `boot_id` 和补 CPU 频率**- @Override
- public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
- switch (pathname) {
- case "/proc/sys/kernel/random/boot_id": {
- // 动态生成UUID作为boot_id
- String randomBootId = UUID.randomUUID().toString();
- return FileResult.<AndroidFileIO>success(new ByteArrayFileIO(oflags, pathname, randomBootId.getBytes(StandardCharsets.UTF_8)));
- }
- case "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq": {
- // 直接返回一个字符串作为文件内容
- return FileResult.<AndroidFileIO>success(new ByteArrayFileIO(oflags, pathname, "1766400".getBytes()));
- }
- }
- return null;
- }
复制代码
> **注意**:`ByteArrayFileIO` 不支持写入操作,如果样本需要写入文件,使用它会抛出 `UnsupportedOperationException`。
##4. 使用 `DirectoryFileIO` 和虚拟文件系统处理目录
* **`DirectoryFileIO`**: 当样本明确访问一个目录时,可以使用它,并传入一个本地文件夹。
* **虚拟文件系统(VFS)**: 这是处理目录访问更好的选择。你可以在初始化模拟器时设置一个根目录,然后将需要的文件按真实路径结构放置其中。这对于样本需要遍历目录(如 `/system/fonts`)的场景非常高效。
##4. 处理失败的文件访问
多数情况下,对于环境检测类的文件(如 `su` 文件),我们什么都不做,让它自然失败即可。但在某些特殊场景,需要手动返回失败。
**案例:模拟无 Root 权限**
有些 Root 检测会尝试访问 `/data` 目录的权限。由于 Unidbg 的虚拟文件系统会自动创建 `/data` 目录导致访问成功,我们需要手动拦截并返回“权限不足”。- @Override
- public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
- if ("/data".equals(pathname)) {
- return FileResult.failed(UnixEmulator.EACCES); // EACCES = 13, Permission denied
- }
- return null;
- }
复制代码
#五、关键点
`/proc` 目录下的文件访问频率极高,且容易出错,需要特殊对待。
##1. `/proc/self/cmdline`
* **用途**:获取当前进程名,常用于反调试或环境检测。
* **处理要点**:
* 必须补,否则可能导致关键逻辑失败。
* 由于进程 ID (pid) 每次运行都可能变化,硬编码 `/proc/12345/cmdline` 会失效。因此,**最佳实践是直接拦截符号链接路径 `/proc/self/cmdline`**,这样可以无需关心动态的 pid。
* 返回的进程名字符串末尾应包含一个 `\0` (NULL) 字符,这是 cmdline 的格式规范,不加的话在解析时存在出错的可能性。
*- case "/proc/self/cmdline":
- return FileResult.success(new ByteArrayFileIO(oflags, pathname, PACKAGE_NAME.getBytes()));
复制代码
##2. `/proc/self/status`
* **用途**:获取进程状态,尤其是 `TracerPid` 字段,用于检测是否被调试(gdb/ida)。
* **处理要点**:
* 最简单的处理是只返回 `TracerPid: 0`。
* 更稳妥的方式是返回一个从真机 `status` 文件中复制的、内容完整模板。
*- case "/proc/self/status":
- // 返回一个包含 "TracerPid: 0" 的文件内容,表示未被调试
- String statusContent = "Name:\t" + PACKAGE_NAME + "\n" +
- "Umask:\t0077\n" +
- "State:\tS (sleeping)\n" +
- "Tgid:\t12345\n" +
- "Pid:\t12345\n" +
- "PPid:\t1\n" +
- "TracerPid:\t0\n"; // 关键行
- return FileResult.success(new ByteArrayFileIO(oflags, pathname, statusContent.getBytes()));
复制代码
##3. `/proc/net/*` (tcp, udp, unix)
* **建议**:**一律不要补!**
* **原因**:
1. Android 10 及以上版本已禁止普通应用访问此目录,不补文件符合高版本系统的行为。
2. 这些文件常用于检测网络代理、Frida/IDA 等工具的端口。如果从真机直接拷贝,可能会把这些危险信息带入模拟环境,反而导致检测失败。
##4\. `/proc/self/maps` (重中之重)
`maps` 文件记录了进程的内存映射,是所有文件访问中处理起来最复杂也最重要的一项。原生库通过读取它来检查环境中是否存在 Frida、Xposed 等 Hook 框架的特征模块,或者获取自身 APK 文件的路径以进行签名校验。
* **Unidbg 的默认处理 (`fakeMaps`)**:如果你不处理 `maps` 访问,Unidbg 会默认返回一个 `MapsFileIO` 对象。这个 `fakeMaps` 只包含当前加载的 SO 及其依赖库的内存信息,非常干净简洁。
* **优点**:可以完美绕过对 `frida-agent.so`, `xposed.so` 等风险模块的检测,因为 `fakeMaps` 中根本没有它们。
* **`fakeMaps` 的问题**:`fakeMaps` 是真实 `maps` 的一个**子集**。它缺少很多信息,例如:
* 缺少 APK 文件的映射,导致基于 `maps` 寻找 APK 路径并进行签名校验的逻辑失败。
* 缺少其他 SDK 或系统库的映射,导致检测特定 SDK 是否加载的逻辑失败。
* **真实 `maps` 的问题**:直接使用从真机导出的 `maps` 文件虽然能解决上述问题,但会引入新的、更严重的问题。如果样本解析 `maps` 文件获取内存地址并尝试读取该地址的内容(如进行 inline hook 检测、扫描内存特征),将会导致 Unidbg 崩溃。因为真实 `maps` 中的内存地址在 Unidbg 的模拟环境中绝大部分是不存在的。这无异于“**拿着北京的地图在南京开车**”。
**处理 `maps` 的三种核心策略:**
面对 `maps` 的复杂性,理论上的最佳方案是完全逆向分析其所有检测点,然后构造一份完美的 `maps` 文件。但在实践中,我们可以采用以下三种更高效的策略,并根据情况灵活选择。
###策略一:使用 Unidbg 默认 `fakeMaps`
* **做法**:在你的 `IOResolver` 中不添加任何对 `/proc/self/maps` 的处理逻辑。
* **优点**:最简单,能绕过所有基于 `maps` 内容的风险模块(Frida 等)检测。
* **缺点**:缺少 APK 等关键路径信息,如果 App 会校验 `maps` 中的 APK 路径,此方法会失效。
* **适用场景**:
1. 确定 App 的 `maps` 检测逻辑比较简单,不关心 APK 路径。
2. 当其他策略导致未知错误时,退回此策略作为保底方案。
###策略二:使用真实 `maps` 文件
* **做法**:从与你模拟环境相近的真机上,抓取目标 App 运行时的 `maps` 文件 (`cat /proc/<pid>/maps`),并用 `SimpleFileIO` 返回它。
* **优点**:信息最全,能够应对需要检查各种库(如特定 SDK)是否加载的场景。
* **缺点**:**风险极高**。一旦 App 根据 `maps` 中的地址去读取内存,几乎必然导致 Unidbg 因访问非法地址而崩溃。
* **适用场景**:
1. 作为一种快速试探手段,当你怀疑 App 仅仅是简单地搜索 `maps` 文件中的某些字符串,而不会进行后续的内存操作时。
2. **出现内存异常时,必须立即切换**:如果在使用了真实 `maps` 后,程序执行中出现内存访问异常(Bad Address, segmentation fault 等),应立刻放弃此策略。
###策略三:按需构造极简 `maps` 内容
* **核心思想**:**“它要什么,就给什么”**。通过日志或简单逆向,分析出 App 访问 `maps` 文件的真实意图,然后用 `ByteArrayFileIO` 动态构造一个只包含它所关心的那几行内容的 `maps` 字符串。
* **做法**:假设我们分析出 App 读取 `maps` 只是为了找到 `base.apk` 的路径,那么我们的处理如下:
- // 在 IOResolver 的 resolve 方法中
- case "/proc/self/maps": {
- final String APK_PATH = "/data/app/com.zj.wuaipojie-1/base.apk";
- String maps = "7fbe852000-7fbe853000 r-xp 00000000 00:00 0 " + APK_PATH + "\n";
- return FileResult.success(new ByteArrayFileIO(oflags, pathname, maps.getBytes(StandardCharsets.UTF_8)));
- }
-
复制代码
* **优点**:
* **精准绕过**:提供了 App 关心的 APK 路径,成功绕过校验。
* **安全无风险**:由于没有提供任何真实的内存地址,从根本上杜绝了 App 进行非法内存读写导致崩溃的可能。
* **环境干净**:`maps` 内容完全由我们控制,干净且不含任何风险模块特征。
* **适用场景**:绝大多数需要处理 `maps` 的情况。它只需要很小的分析成本,就能获得稳定且安全的结果。
二. 补库函数
#1. 什么是“库函数”?
首先,一个应用程序(例如一个 App 的 `.so` 文件)通常不会自己从零开始实现所有功能。它会依赖大量由操作系统或第三方提供的**共享库(Shared Libraries)**,在 Android/Linux 中,这些库通常是 `.so` 文件(Shared Object)。
**库函数**就是这些共享库中提供的、可供应用程序调用的、预先编译好的函数。
- **例子**:
- `libc.so` (C 标准库): 提供了 `fopen` (打开文件), `printf` (打印字符串), `malloc` (分配内存), `gettid` (获取线程 ID) 等基础功能。
- `liblog.so` (安卓日志库): 提供了 `__android_log_print` (打印 logcat) 功能。
- `libz.so` (压缩库): 提供了 `compress`, `uncompress` 等数据压缩功能。
当你的 so 文件调用 `fopen` 时,它实际上是在请求操作系统加载 `libc.so`,并执行其中的 `fopen` 函数代码。
#2. 案例讲解
**案例 1:补 getProperty**
**`__system_property_get` 函数详解**
在 Android 系统中,App 获取设备信息(如型号、品牌、系统版本等)最常用、最底层的手段之一就是调用 `libc.so` 库中的 `__system_property_get` 函数。理解并控制这个函数,是 Unidbg 环境模拟的重中之重。
**1. 它是做什么的?**
`__system_property_get` 是一个 C 函数,原型如下:- int __system_property_get(const char* name, char* value);
复制代码
- **功能**:根据传入的属性**名称**(`name`,一个字符串,例如 `"ro.product.model"`),查询对应的属性**值**,并将结果存入调用者提供的内存缓冲区(`value`)。
- **返回值**:
- 如果成功找到属性,函数返回属性值的**长度**。
- 如果属性不存在,函数返回 `0`。
当一个 App 的 Java 代码调用 `android.os.SystemProperties.get("ro.product.model")` 时,其底层最终就会调用到这个 Native 函数来完成查询。
**2. 为什么它在补环境中如此重要?**
几乎所有的风控和反调试检测都会利用这个函数来“审问”当前的运行环境。它们会查询一系列的属性,来判断设备是否真实、是否被 Root、是否处于调试状态。常见的检测点包括:
- **设备标识**:`ro.product.model`, `ro.product.brand`, `ro.build.fingerprint`
- **硬件信息**:`ro.board.platform`, `ro.hardware`
- **调试状态**:`ro.debuggable` (值应为 `0`), `ro.secure` (值应为 `1`)
- **ABI/CPU 类型**:`ro.product.cpu.abi`
如果不对这个函数进行处理,Unidbg 环境下的默认返回值可能为空或不符合预期,导致 App 识别出模拟器环境,从而触发风控或直接退出。
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
|