Unidbg学习笔记(八):文件系统层补环境

admin 2026-04-21 04:20:19 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析Unidbg模拟器中文件系统层补环境的关键陷阱与解决方案。核心发现是文件访问失败常被忽略但会触发SO反检测代码,导致结果偏差。文章区分信息收集和环境检测两种文件用途,强调使用IOResolver的三种返回语义(success、null、failed)正确处理路径,特别指出反检测路径必须用FileResult.failed()断然否认。可操作建议包括分层注册Resolver、伪造合理设备状态及避免沉默失败。 综合评分: 87 文章分类: 移动安全,逆向分析,安全工具,恶意软件,技术标准


cover_image

Unidbg学习笔记(八):文件系统层补环境

原创

泡泡以安 泡泡以安

泡泡以安

2026年4月20日 09:07 浙江

在小说阅读器读本章

去阅读

文件访问是仅次于 JNI 的第二高频补环境场景,但它也是**最容易被”补漏”**的通道。因为 Android 真机上文件访问失败本来就很常见,你会下意识地觉得”失败没关系” —— 而恰恰是这种默认的沉默,让反检测代码在你眼皮底下通过。


上一篇把你留在了哪里

第七篇讲完 JNI 通道之后,你已经掌握了全部补环境工作量的 90%。这一篇开始的三篇(文件 / 系统调用 / 库函数)占剩下的 10%,但它们每一个都有独特的陷阱和高价值场景。

文件系统这一篇特别值得慢慢读 —— 不是因为它技术上复杂,而是因为它在反检测对抗里占有压倒性的权重。你会看到,很多 App 抓你 Unidbg 就是靠一个 /proc/self/maps 的读取成败判断。


一个让你警惕的现象:沉默的失败

先看一段几乎每个新手都写过的代码:

// 新手写法: 只 override 了 AbstractJni, 完全没碰文件系统
public class MyApp extends AbstractJni {
    public static void main(String[] args) {
&nbsp; &nbsp; &nbsp; &nbsp; Emulator<AndroidFileIO> emulator = AndroidEmulatorBuilder
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .for64Bit()
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .build();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ... 加载 SO, 调 native 方法
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 跑完了, 没报错, 以为通了
&nbsp; &nbsp; }
}

跑起来,没有 IllegalStateException: resolve failed,没有 UnsatisfiedLinkError,最终 native 方法也返回了一个结果 —— 看起来完全通了

但你把这个结果拿到真机上对照 Frida 抓的”标准答案”,发现不一致。于是你开始怀疑是不是 JNI 值写错了、是不是哪个参数对不上。查了半天全是对的。

真相往往是这样一段代码藏在 SO 里:

// SO 内部的反检测逻辑
int&nbsp;check_frida()&nbsp;{
&nbsp; &nbsp; FILE *fp = fopen("/proc/self/maps",&nbsp;"r");
&nbsp; &nbsp;&nbsp;if&nbsp;(fp ==&nbsp;NULL) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 文件打不开, 无法判断 -> 认为环境异常, 返回错误标记
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;0x1337DEAD;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;char&nbsp;line[256];
&nbsp; &nbsp;&nbsp;while&nbsp;(fgets(line,&nbsp;sizeof(line), fp)) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(strstr(line,&nbsp;"frida") ||&nbsp;strstr(line,&nbsp;"gum")) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fclose(fp);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;0x1337DEAD; &nbsp;// 发现 Frida, 返回错误标记
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; &nbsp; fclose(fp);
&nbsp; &nbsp;&nbsp;return&nbsp;0; &nbsp;// 正常
}

在真机上开 Frida,这段代码会读到含 frida 的行 → 返回错误标记;不开 Frida,读不到 → 返回 0 正常。

在 Unidbg 里呢?

  • Unidbg 默认找不到 /proc/self/mapsfopen 返回 NULL
  • 走第一个分支,直接返回 0x1337DEAD
  • 这个值进入后续签名算法,让最终结果偏离真机

整个过程没有任何报错。Unidbg 认认真真跑完了每一行 SO 代码,你也认认真真看着它跑完 —— 但你们都不知道,有一个检测分支在你眼皮底下被触发了

沉默的失败:同一段 SO 代码在三种环境下的三种结局

这就是文件系统层补环境的核心挑战:失败是沉默的,沉默是致命的


为什么文件访问容易被补漏

三个原因叠加,让这个通道成为盲区:

原因 1:Android 本身就很混乱

Android 的碎片化让文件访问本来就是个”测不准问题”。同一个 /sys/class/power_supply/battery/voltage_now,在不同机型、不同 Android 版本、不同 SELinux 状态下,可能存在、可能不存在、可能能读、可能 permission denied。SO 开发者也知道这一点,他们会写大量的 if (fp == NULL) return default_value 来处理不确定性。

原因 2:SO 往往会”兜底”

因为原因 1 的存在,SO 代码几乎总会给文件读取一个降级分支。Unidbg 里文件打不开时,SO 不会崩溃,它会安静地走降级。新手看到”没报错”就以为通了。

原因 3:开发者下意识觉得文件访问”无所谓”

打开文件失败,在真机上也经常发生 —— 这种直觉让你不会下意识地去盯着 fopenaccessfread。你会觉得 “noisy logging,忽略就行”。但反检测代码恰恰利用了这种轻视

三个原因叠加的结果:你没注意到一个失败,SO 走了错分支,最终结果错了


文件访问的两大用途:分清动机

要有意识地补对这个通道,第一步是分清 SO 读文件的两种动机。这两种动机的处理方式完全不同。

文件访问的两大用途

用途一:信息收集

SO 想知道一个客观事实:电池电压、CPU 温度、屏幕密度、Kernel 版本 ……这些信息在 Android API 里不一定有现成的获取方式,或者获取成本较高,直接读文件更方便。

典型路径

/sys/class/power_supply/battery/* &nbsp; &nbsp;设备电池信息
/sys/class/thermal/* &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; CPU 温度传感器
/proc/cpuinfo &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;CPU 型号 / 核心数
/proc/meminfo &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;内存信息
/sys/block/*/size &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;存储容量
/data/local/tmp/install_id &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; App 自己写的文件

处理原则:尽量伪造看起来合理且稳定的值。不用和真机 100% 一致(因为真机每次读出来也不完全一样),但必须在合理范围内。

用途二:环境检测

SO 想判断自己是不是跑在可疑环境里:有没有 Root?有没有 Frida?有没有 Xposed?是不是在模拟器里?这时候它就会去读特定的路径,看这些路径是否存在、内容是什么。

典型路径

/system/bin/su &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 判断 Root (经典)
/system/xbin/su &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;判断 Root (备用位置)
/proc/self/maps &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;内存映射, 检测 Frida / Xposed 注入
/proc/self/status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;TracerPid 反调试
/data/data/de.robv.android.xposed/ &nbsp; Xposed 安装路径
/system/lib/libxposed_art.so &nbsp; &nbsp; &nbsp; &nbsp; Xposed native 库
/proc/net/tcp / tcp6 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 检测 Frida 的 27042 端口
/sys/devices/system/cpu/kernel_max &nbsp; 某些厂商检测用
/sbin/magisk &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 判断 Magisk

处理原则:必须让 SO 觉得”这是一个干净的真实设备”。关键是:

  • Root 相关路径必须报不存在
  • /proc/self/maps 必须返回一份不含 frida / xposed / riru 的内容
  • /proc/self/status 里 TracerPid 必须是 0

核心区别一句话

信息收集 = 伪造一个合理值;环境检测 = 伪造一份干净的设备状态

把这两种用途分清楚,你就知道自己在”补什么”了。下面进入具体的实现机制。


IOResolver:Unidbg 的文件系统钩子

Unidbg 给文件系统设计的钩子叫 IOResolver。它的定位和 AbstractJni 非常对称:

| 通道 | 注册入口 | 核心接口 | 方法语义 | | — | — | — | — | | JNI | vm.setJni(jni) | Jni | 报错驱动,override 对应 callXxx | | 文件 | emulator.getSyscallHandler().addIOResolver(resolver) | IOResolver | 每次 open/access 时被回调 |

当 SO 调 open("/proc/self/maps", O_RDONLY) 时,Unidbg 的 SyscallHandler 不会直接去读文件,而是先遍历所有注册的 IOResolver,挨个问:”你能处理这个路径吗?” 第一个给出有效回答的 resolver 赢得这次调用。

这个设计非常经典:责任链模式。你可以注册多个 IOResolver,每个负责不同的路径:

// 注册顺序决定优先级, 先注册的先被问
emulator.getSyscallHandler().addIOResolver(new&nbsp;ProcSelfResolver()); &nbsp; &nbsp; &nbsp;&nbsp;// 专门处理 /proc/self/*
emulator.getSyscallHandler().addIOResolver(new&nbsp;AntiDetectResolver()); &nbsp; &nbsp;&nbsp;// 专门处理反检测路径
emulator.getSyscallHandler().addIOResolver(new&nbsp;DeviceInfoResolver()); &nbsp; &nbsp;&nbsp;// 专门处理设备信息

这种分层让代码可维护,也让你可以把”反检测”和”信息伪造”分成不同模块。

resolve 方法的三种返回语义

IOResolver 只有一个核心方法 resolve,但它的返回值有三种语义,这是整个文件系统层补环境的精髓:

IOResolver.resolve 的三种返回语义

public&nbsp;interface&nbsp;IOResolver<T&nbsp;extends&nbsp;NewFileIO>&nbsp;{
&nbsp; &nbsp;&nbsp;FileResult<T>&nbsp;resolve(Emulator<T> emulator, String pathname,&nbsp;int&nbsp;oflags);
}

返回值 1:一个有效的 FileResult

表示”这个路径我来处理,这是伪造的内容”。

@Override
public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(Emulator<AndroidFileIO> emulator,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;("/proc/self/cmdline".equals(pathname)) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 伪造进程名: 返回 App 真实的包名
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;byte[] cmdline =&nbsp;"com.example.app\0".getBytes();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname, cmdline));
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;null; &nbsp;// 其它路径交给下一个 resolver 或 Unidbg 默认
}

返回值 2:null

表示”我不管这个路径,交给下一个 resolver 或 Unidbg 默认去处理”。这是最常用的返回值,也是责任链模式能工作的关键。

public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(...)&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(!pathname.startsWith("/proc/self/")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;null; &nbsp;// 不是我负责的范围
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// ... 处理 /proc/self/* 路径
}

返回值 3:FileResult.failed()

最容易被忽略但最重要的一种返回。含义是:”这个路径确定不存在,不要再交给后续任何 resolver,也不要让 Unidbg 去查真实文件系统。直接告诉 SO 这里没有。”

public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(...)&nbsp;{
&nbsp; &nbsp;&nbsp;// 反检测: Root 相关路径必须报不存在
&nbsp; &nbsp;&nbsp;if&nbsp;(pathname.equals("/system/bin/su")
&nbsp; &nbsp; &nbsp; &nbsp; || pathname.equals("/system/xbin/su")
&nbsp; &nbsp; &nbsp; &nbsp; || pathname.equals("/sbin/magisk")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// FileResult.failed 告诉 SO "这里没有这个文件"
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 返回 null 会让 Unidbg 去查真实磁盘, 你可能不小心把主机的 /sbin 暴露出去
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.failed(-1);
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;null;
}

为什么不能用 null 代替 FileResult.failed?

因为 null 的语义是”我不管,你去问别人”。如果后面没有其他 resolver,Unidbg 会尝试去读真实磁盘。万一你的开发机恰好有 /sbin/magisk(别笑,Mac 上还真有一些 /sbin 下的东西),或者 Unidbg 内置的某些兜底把这个路径当存在,你的”反检测”就失效了。

记住这个三元关系

| 返回值 | 语义 | 什么时候用 | | — | — | — | | FileResult.success(fileIO) | “有,内容是这个” | 需要伪造文件内容 | | null | “不知道,你问别人” | 这条路径不在我的职责范围 | | FileResult.failed(-1) | “确定没有” | 反检测路径,必须断然否认 |

补环境的一个顶级原则:**反检测路径必须用 failed,不能用 null**。这是新手最常犯的错,也是检测代码最容易利用的缝隙。

SimpleFileIO vs ByteArrayFileIO:选哪个?

IOResolver 返回的 FileResult 需要包装一个 NewFileIO 的实现。Unidbg 给了两个常用选择:

SimpleFileIO —— 从磁盘真实文件读

// 从项目资源目录下加载一个事先准备好的 cpuinfo 文件
File cpuinfoFile =&nbsp;new&nbsp;File("target/resources/device/cpuinfo");
return&nbsp;FileResult.success(
&nbsp; &nbsp;&nbsp;new&nbsp;SimpleFileIO(oflags, cpuinfoFile, pathname));

适用场景

  • 文件内容体积大(几 KB 到几 MB),不想塞在代码里
  • 你已经从真机 adb pull 了一份真实文件,放在项目资源下
  • 内容不会变(比如 /proc/cpuinfo

ByteArrayFileIO —— 从内存字节数组读

// 直接构造字节内容, 无磁盘 IO
byte[] cmdline =&nbsp;"com.example.app\0".getBytes();
return&nbsp;FileResult.success(
&nbsp; &nbsp;&nbsp;new&nbsp;ByteArrayFileIO(oflags, pathname, cmdline));

适用场景

  • 内容体积小(几十到几百字节)
  • 内容需要动态生成(比如每次根据当前 PID 计算)
  • 你只想写一行代码不想建文件

选择原则

内容 > 1KB 且固定 → SimpleFileIO (文件准备一次, 永久复用)
内容 < 1KB 或需要动态生成 → ByteArrayFileIO (代码中直接构造)

大多数反检测场景用 ByteArrayFileIO,因为反检测相关的文件通常很小;大多数设备信息伪造用 SimpleFileIO,因为 /proc/cpuinfo/proc/meminfo 之类的文件内容较长。


proc 伪文件系统深入:最关键的一片战场

/proc 值得单独拉一章出来讲,因为它不是普通文件系统。

它的特殊之处:内核动态生成

/proc 是 Linux 内核提供的一个伪文件系统。里面的文件不在磁盘上,它们是内核根据当前进程状态动态生成的。每次你 cat /proc/self/status,内核都重新跑一段代码,把当前进程的信息格式化成文本丢给你。

这意味着两件事:

  1. 不能一字不改地把真机 adb pull /proc/self/status 的内容直接塞给 Unidbg —— 因为 self 指向真机当时的某个进程,里面的 Pid / Tgid 和你 Unidbg 里的不一样,直接复用会穿帮。正确做法是把它当模板,保留大部分原始字段,只按 Unidbg 当前运行时动态替换 Pid / Tgid / TracerPid 等几个关键行(见下文 /proc/self/status 的”进阶技巧”)
  2. Unidbg 必须手动模拟每个关键 proc 文件的输出

下面是反检测代码最关心的四个 proc 路径。逐一拆。

/proc 四大反检测战场:cmdline / status / maps / net/tcp 的全景导航

/proc/self/cmdline — 进程名

这是反检测里最基础的一道关。很多 SO 在初始化时会读这个文件,确认自己跑在”预期的包名”下。如果读出来的进程名不对,SO 会怀疑是被注入到其他进程。

if&nbsp;(pathname.equals("/proc/self/cmdline")) {
&nbsp; &nbsp;&nbsp;// 注意: cmdline 的格式是 argv 各段用 \0 分隔, 末尾也要有一个 \0
&nbsp; &nbsp;&nbsp;// 对大多数 App 来说只有一段: 包名\0
&nbsp; &nbsp; String packageName =&nbsp;"com.example.app";
&nbsp; &nbsp;&nbsp;byte[] cmdline = (packageName +&nbsp;"\0").getBytes(StandardCharsets.UTF_8);
&nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname, cmdline));
}

新手陷阱:很多人写成 packageName.getBytes(),忘记末尾的 \0。大部分 SO 用 read 读到 EOF 就停,可能没事;但用 strlen 找结束符的 SO 会读到后续的随机内存,行为未定义,下次跑不同的结果。末尾的 \0 不是可选的。

/proc/self/status — TracerPid 反调试

/proc/self/status 里有一行关键字段:TracerPid: 0。这个值不为 0 就表示有调试器(比如 gdbptrace 附加)正在跟踪这个进程。反调试代码会读这一行,看到非 0 就终止。

真机上的 status 文件内容大约 50 行,包含名称、状态、PID、内存占用等信息。Unidbg 里你必须构造一份包含正确 TracerPid: 0 的内容:

if&nbsp;(pathname.equals("/proc/self/status")) {
&nbsp; &nbsp;&nbsp;// 构造一份完整的 status, 关键是 TracerPid 必须是 0
&nbsp; &nbsp; StringBuilder sb =&nbsp;new&nbsp;StringBuilder();
&nbsp; &nbsp; sb.append("Name:\tcom.example.app\n");
&nbsp; &nbsp; sb.append("State:\tS (sleeping)\n");
&nbsp; &nbsp; sb.append("Tgid:\t12345\n");
&nbsp; &nbsp; sb.append("Pid:\t12345\n");
&nbsp; &nbsp; sb.append("PPid:\t1\n");
&nbsp; &nbsp; sb.append("TracerPid:\t0\n"); &nbsp; &nbsp;// !!! 这一行是重点 !!!
&nbsp; &nbsp; sb.append("Uid:\t10101\t10101\t10101\t10101\n");
&nbsp; &nbsp; sb.append("Gid:\t10101\t10101\t10101\t10101\n");
&nbsp; &nbsp;&nbsp;// ... 其它字段, 可以从真机 pull 一份作为模板
&nbsp; &nbsp;&nbsp;byte[] content = sb.toString().getBytes(StandardCharsets.UTF_8);
&nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname, content));
}

进阶技巧:正确姿势不是从零手写,而是用真机做模板、按运行时打补丁

  1. 在真机上 adb pull /proc/self/status 拿到一份真实样本
  2. 在 Unidbg 里用 emulator.getPid() 拿到当前运行时 PID
  3. 只替换 Pid / Tgid 两行为当前 PID,TracerPid 强制写 0,其他字段原样保留
  4. 这样既保持了”和真机完全一致的字段集”,又避免了 PID 不一致被穿帮

这是”模板 + 补丁“的思路:整体抄真机(保真),关键字段动态生成(避免穿帮)。下面 /proc/self/maps 用的也是同一个套路。

/proc/self/maps — 内存映射(最重要)

这是反检测代码最爱读的一个文件。原因是 /proc/self/maps 列出了进程加载的所有共享库和内存段,如果有人注入了 Frida / Xposed / 某个 rootkit,一定会在这里留下痕迹。

# 真机上一个干净进程的 maps 片段
70a1234000-70a1500000 r-xp 00000000 103:03 12345 &nbsp; &nbsp;/data/app/.../libapp.so
70a1500000-70a1600000 r--p 002cc000 103:03 12345 &nbsp; &nbsp;/data/app/.../libapp.so
...

# 被 Frida 注入后的 maps
7f12340000-7f12500000 r-xp 00000000 00:00 0 &nbsp; &nbsp; &nbsp; &nbsp; /data/local/tmp/re.frida.server/frida-agent-64.so
7f12500000-7f12600000 r--p 001cc000 00:00 0 &nbsp; &nbsp; &nbsp; &nbsp; /data/local/tmp/re.frida.server/frida-agent-64.so
...

反检测代码只需要 grep -i "frida\|gum\|xposed\|riru",就能识破所有常见的注入。

在 Unidbg 里伪造 maps 的关键

  1. 必须看起来像真实设备(有 /system/lib64/libc.solinker64 等基础库)
  2. 必须包含你自己的 libapp.so(SO 会在里面找自己)
  3. 必须不含 frida / xposed / substrate / riru 等敏感字眼
  4. 地址范围要合理(ARM64 用户空间大致在 0x12c00000 到 0x8000000000 范围)

实战中一般从真机 adb pull /proc/self/maps(针对一个目标 App 进程),然后:

  • 过滤掉 frida / xposed 相关行
  • 替换 libapp.so 的路径为 Unidbg 下的路径(或保留原路径,反正 SO 只看基址)
  • 保存为资源文件,用 SimpleFileIO 返回
if&nbsp;(pathname.equals("/proc/self/maps")) {
&nbsp; &nbsp;&nbsp;// 预先从真机 pull 并清洗过的 maps 文件
&nbsp; &nbsp; File mapsFile =&nbsp;new&nbsp;File("src/main/resources/device/clean_maps.txt");
&nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;SimpleFileIO(oflags, mapsFile, pathname));
}

警告:直接用 Unidbg 默认返回的 maps 往往不够干净。Unidbg 会把自己加载的 ELF 段列在里面,但不会主动加入 /system/lib64/libc.so 等基础库,这种”缺胳膊少腿”的 maps 本身就是特征。真实的检测代码可能不是检测”有没有 frida”,而是检测”有没有 libc.so” —— 没有就说明不是正常 Android 进程。

/proc/net/tcp 和 tcp6 — 端口检测

Frida 默认监听 27042 端口(如果你用 frida-server)或者某个随机端口(如果用 gadget)。反检测代码会读 /proc/net/tcp 看有没有进程监听这些可疑端口。

# /proc/net/tcp 的格式
sl local_address rem_address &nbsp; st tx_queue rx_queue tr tm->when retrnsmt ...
&nbsp;0: 0100007F:69A2 00000000:0000 0A ...
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^^^^
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 0x69A2 = 27042, 这就是 Frida 的默认端口

反检测策略:伪造一份只包含正常端口的 tcp 内容,或者直接返回文件不存在(有些系统这个文件本来就难读)。

if&nbsp;(pathname.equals("/proc/net/tcp") || pathname.equals("/proc/net/tcp6")) {
&nbsp; &nbsp;&nbsp;// 只返回一个空表头, 表示没有任何监听的 socket
&nbsp; &nbsp; String empty =&nbsp;" &nbsp;sl &nbsp;local_address rem_address &nbsp; st tx_queue rx_queue tr"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;+&nbsp;" tm->when retrnsmt &nbsp; uid &nbsp;timeout inode\n";
&nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;empty.getBytes()));
}

PID 一致性问题:一个隐蔽的坑

接着上面 /proc/self/status 的话题讲一个特别容易踩的坑。

Unidbg 的 PID 来自哪里

Unidbg 没有”进程”的概念。它是一个 Java 库,跑在宿主 JVM 里。但 SO 代码可能会调 getpid() 或 getppid()。Unidbg 怎么办?

它返回宿主 JVM 的 PID

// Unidbg 内部: UnixSyscallHandler.getpid
@Override
public&nbsp;int&nbsp;getpid(Emulator<?> emulator)&nbsp;{
&nbsp; &nbsp;&nbsp;// 直接返回 JVM 进程 PID, 每次运行都不一样
&nbsp; &nbsp;&nbsp;return&nbsp;android.os.Process.myPid(); &nbsp;// 实际是 ProcessHandle.current().pid()
}

这导致两个直接后果:

后果 1:每次运行 PID 都不同

你今天跑 Unidbg 拿到 PID=12345,明天拿到 PID=67890。所以任何涉及 PID 的逻辑都不能硬编码

后果 2:不能从真机复制 /proc/12345/ 下的任何内容

新手常干的事:从真机 adb pull /proc/self/ 一堆文件,然后在 IOResolver 里做路径匹配:

// 错误写法!
if&nbsp;(pathname.equals("/proc/12345/status")) { &nbsp;// 真机当时的 PID
&nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(...);
}

这段代码永远不会被触发,因为 Unidbg 里 /proc/self/ 被解析成 /proc/{当前 JVM PID}/,而你写死的是真机 PID。

正确的做法:动态匹配

@Override
public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(Emulator<AndroidFileIO> emulator,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp;&nbsp;// 拿到当前 Unidbg 里 SO 看到的 PID
&nbsp; &nbsp;&nbsp;int&nbsp;pid = emulator.getPid();
&nbsp; &nbsp; String selfPrefix =&nbsp;"/proc/"&nbsp;+ pid +&nbsp;"/";

&nbsp; &nbsp;&nbsp;// 三种形式都要拦: /proc/self/xxx, /proc/{pid}/xxx, 以及简写
&nbsp; &nbsp;&nbsp;if&nbsp;(pathname.startsWith("/proc/self/") || pathname.startsWith(selfPrefix)) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 提取最后一段文件名, 比如 "status" / "maps" / "cmdline"
&nbsp; &nbsp; &nbsp; &nbsp; String filename = pathname.substring(pathname.lastIndexOf('/') +&nbsp;1);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;handleProcSelf(filename, pathname, oflags);
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;null;
}

private&nbsp;FileResult<AndroidFileIO>&nbsp;handleProcSelf(String filename,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp;&nbsp;switch&nbsp;(filename) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"status": &nbsp;return&nbsp;buildStatus(pathname, oflags);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"maps": &nbsp; &nbsp;return&nbsp;buildMaps(pathname, oflags);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"cmdline":&nbsp;return&nbsp;buildCmdline(pathname, oflags);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ...
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;null;
}

关键点

  • 用 emulator.getPid() 拿到运行时 PID
  • 同时拦截 /proc/self/ 和 /proc/{pid}/ 两种前缀(SO 两种写法都可能用)
  • 永远不硬编码 PID

反检测实战:一份最小 IOResolver 模板

把前面所有东西串起来,这是一个覆盖大多数反检测场景的最小 IOResolver 模板。你可以直接拿去用:

public&nbsp;class&nbsp;AntiDetectIOResolver&nbsp;implements&nbsp;IOResolver<AndroidFileIO>&nbsp;{

&nbsp; &nbsp;&nbsp;// Root 相关路径, 必须报不存在
&nbsp; &nbsp;&nbsp;private&nbsp;static&nbsp;final&nbsp;Set<String> ROOT_PATHS =&nbsp;new&nbsp;HashSet<>(Arrays.asList(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/bin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/xbin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/sbin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/sd/xbin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/bin/failsafe/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/data/local/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/data/local/xbin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/data/local/bin/su",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/sbin/magisk",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/sbin/.magisk",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/cache/.disable_magisk",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/dev/.magisk.unblock"
&nbsp; &nbsp; ));

&nbsp; &nbsp;&nbsp;// Xposed 相关路径, 必须报不存在
&nbsp; &nbsp;&nbsp;private&nbsp;static&nbsp;final&nbsp;Set<String> XPOSED_PATHS =&nbsp;new&nbsp;HashSet<>(Arrays.asList(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/lib/libxposed_art.so",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/lib64/libxposed_art.so",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/system/framework/XposedBridge.jar",
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"/data/data/de.robv.android.xposed.installer/"
&nbsp; &nbsp; ));

&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(Emulator<AndroidFileIO> emulator,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 1. 反检测: 确定性报"不存在"
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(ROOT_PATHS.contains(pathname) || XPOSED_PATHS.contains(pathname)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// !!! 用 failed 而不是 null, 防止 Unidbg 默认去查真实磁盘 !!!
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.failed(-1);
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 2. /proc/self/* 系列, 动态构造
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;pid = emulator.getPid();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(pathname.startsWith("/proc/self/")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; || pathname.startsWith("/proc/"&nbsp;+ pid +&nbsp;"/")) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;handleProcSelf(pathname, oflags);
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 3. /proc/net/tcp 系列, 空表返回
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(pathname.equals("/proc/net/tcp") || pathname.equals("/proc/net/tcp6")) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; String empty =&nbsp;" &nbsp;sl &nbsp;local_address rem_address &nbsp; st tx_queue\n";
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;ByteArrayFileIO(oflags, pathname, empty.getBytes()));
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 4. 其它路径交给下一个 resolver 或 Unidbg 默认
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;null;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;private&nbsp;FileResult<AndroidFileIO>&nbsp;handleProcSelf(String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; String filename = pathname.substring(pathname.lastIndexOf('/') +&nbsp;1);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;switch&nbsp;(filename) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"cmdline":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;byte[] cmdline =&nbsp;"com.example.app\0".getBytes();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;ByteArrayFileIO(oflags, pathname, cmdline));

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"status":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 实际工程中这里应该加载一份预先清洗过的真机 status
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;SimpleFileIO(oflags,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;File("src/main/resources/device/status.txt"),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pathname));

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;"maps":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 清洗过的 maps, 已去除 frida/xposed
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;SimpleFileIO(oflags,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;new&nbsp;File("src/main/resources/device/maps.txt"),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pathname));

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;default:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;null;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
}

使用方式

// 在创建 emulator 之后, 加载 SO 之前注册
emulator.getSyscallHandler().addIOResolver(new&nbsp;AntiDetectIOResolver());

验证闭环:怎么知道自己补对了

和前两篇一样,”跑通”不等于”跑对”。文件系统层的验证方式有自己的特点:

方法 1:Frida trace fopen / open / access

在真机上用 Frida hook 这三个函数,记录 SO 实际访问过的所有路径。对照 Unidbg 里的日志,看路径覆盖是否一致。

// Frida 脚本: 抓 SO 调用的所有文件访问
var&nbsp;openPtr = Module.findExportByName('libc.so',&nbsp;'open');
Interceptor.attach(openPtr, {
&nbsp; &nbsp;&nbsp;onEnter:&nbsp;function(args)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;this.path = args[0].readCString();
&nbsp; &nbsp; },
&nbsp; &nbsp;&nbsp;onLeave:&nbsp;function(retval)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;console.log('[open] '&nbsp;+&nbsp;this.path +&nbsp;' => fd='&nbsp;+ retval);
&nbsp; &nbsp; }
});
// 类似地 hook fopen 和 access

方法 2:在 IOResolver 里加 log

@Override
public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(...)&nbsp;{
&nbsp; &nbsp; System.out.println("[IOResolver] "&nbsp;+ pathname +&nbsp;" flags="&nbsp;+ oflags);
&nbsp; &nbsp;&nbsp;// ...
}

方法 3:开 SyscallHandler 的 verbose 日志

// 让 Unidbg 打印所有 open 系统调用, 包括默认处理的
emulator.getSyscallHandler().setVerbose(true);

真正的检验:对照 Frida 抓到的路径列表和 Unidbg 实际处理的路径列表,两者必须完全一致。如果 Frida 里 SO 读了 /proc/self/maps 而 Unidbg 日志里没有,说明 Unidbg 把它兜底处理了,你没看到;或者 SO 没读到你的 resolver,说明它走了另一条路径。


总结:文件系统层的五条铁律

  1. 分清动机:信息收集 vs 环境检测,处理方式不同
  2. resolve 三返回:success / null / failed —— 反检测必须用 failed
  3. ByteArray 放小的,SimpleFileIO 放大的:选错了要么性能差要么难维护
  4. PID 永远不硬编码:用 emulator.getPid(),同时拦截 /proc/self/ 和 /proc/{pid}/
  5. 沉默是魔鬼:文件访问失败不报错,必须主动 log + 对照 Frida 验证

文件系统层补不好,SO 会在你看不见的地方走错分支。一旦你建立起”每条文件访问都要明确处理”的本能,大部分反检测就不再神秘。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:泡泡以安 泡泡以安 泡泡以安《Unidbg学习笔记(八):文件系统层补环境》

评论:0   参与:  0