Unidbg学习笔记(十三):固定随机干扰项

admin 2026-04-29 04:55:11 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细阐述了在Unidbg模拟环境中固定随机干扰项的必要性和完整解决方案。核心观点指出固定随机源是确保补环境正确性、实现确定性系统和启用diff工作流的前提条件。文章系统分类了JNI层、库函数层、系统调用层和硬件特性四类随机源,并提供了具体的Hook拦截代码示例。关键操作建议包括先在Unidbg中固定所有随机源并验证输出稳定性,再在Frida真机进行同步固定,最后通过对比验证环境补全的正确性。 综合评分: 87 文章分类: 逆向分析,安全工具,移动安全,安全开发,二进制安全


cover_image

Unidbg学习笔记(十三):固定随机干扰项

原创

泡泡以安 泡泡以安

泡泡以安

2026年4月28日 09:09 日本

在小说阅读器读本章

去阅读

如果入参相同但每次结果不同,你就永远无法验证补环境是否正确。固定所有随机源是通往“可验证的正确模拟”的必经之路。这一篇是一道闸门 —— 不解决随机问题,你的整个工作流都没有标准答案。


上一篇把你留在了哪里

第十二篇我们打开了 Trace 的“内视镜”。但你可能已经隐隐感觉到一个矛盾:

“Trace 是确定性的 —— 同样的输入,每次产生同样的 Trace。但如果**我的 SO 调了 time() 或 rand()**,那它每次的执行路径都会变啊…”

对。这就是这一篇要解决的问题。

更精确地说,Trace、补环境验证、算法还原 —— 这三件事都依赖一个隐含前提:

同样的入参,产生同样的输出。

这个前提一旦被破坏,你就掉进了“调试地狱”:

  • 改了一个 IO 拦截,跑出来不对 —— 是补错了,还是随机数变了?
  • 在 Frida 上对照,Frida 也不对 —— 还是随机数?
  • diff 两次 Trace 找数据流,结果整个 Trace 全是 diff —— 因为时间戳变了

你陷在一个“任何变化都可能是随机源”的混沌里,根本分不清原因。

这一篇的目标,就是把这个混沌彻底关掉。


为什么必须固定随机项

理由一:可验证的正确性

补环境是个“很难证明对错”的活。你怎么知道你补的 getMethodID 正确?

唯一可靠的方法是:

  1. 在 Unidbg 里固定所有随机源
  2. 在 Frida 真机上也固定同样的随机源
  3. 用同样的入参跑两次
  4. 看输出是否完全一致

如果两边结果一致 —— 你的环境补对了。如果不一致 —— 哪里还有差异。

这就是“补环境正确性的标准答案机制”。没有它,你只能靠感觉。

理由二:把黑盒变成确定性系统

算法还原本质上是从“输入”推“输出变换函数”。如果输入到输出的关系不是函数(同样输入产生不同输出),那它根本不能被还原。

固定所有随机源,就是把样本从一个随机系统降维成一个确定性系统 —— 至少在数学意义上,它现在是可还原的了。

很多算法分析师把“固定随机”当成第一步,比补环境还要早

理由三:diff 工作流的前置条件

第十二篇我们讲过 diff 两次 Trace 找数据依赖点。这个手法的前提是:

除了输入不同,其他所有可能的差异源都被消除了。

时间戳是差异源。/dev/urandom 是差异源。uptime 是差异源。

每存在一个未固定的差异源,diff 出来的“差异”就多一份噪音。


完整工作流 固定随机项的验证工作流

下面是我的工作流,从开始到结束。

Step 1:在 Unidbg 中固定随机源

为什么先在 Unidbg 做:Unidbg 里所有交互都是经过 Unidbg 的(JNI / 文件 / syscall / libc),你能精确知道有哪些地方在读时间和随机数,也能精确控制。

具体怎么固定见下面“四类随机源”。

Step 2:用 Unidbg 跑一次,记下结果

跑两次,确认输出完全一致。如果还不一致 —— 说明还有随机源没固定,继续找。

直到能稳定复现为止。这是一个“标准答案 V0”。

推荐写一个极简的 harness,把“跑 N 次看是否一致”沉淀成一键脚本:

public static void verifyDeterministic(int runs) {
&nbsp; &nbsp; Set<String> outputs =&nbsp;new&nbsp;LinkedHashSet<>();
&nbsp; &nbsp;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < runs; i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// runOnce() 每次都重新 createDalvikVM + loadLibrary + call encrypt
&nbsp; &nbsp; &nbsp; &nbsp; String out = runOnce(newbyte[]{1,&nbsp;2,&nbsp;3});
&nbsp; &nbsp; &nbsp; &nbsp; outputs.add(out);
&nbsp; &nbsp; &nbsp; &nbsp; System.out.printf("run #%d: %s%n", i, out);
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(outputs.size() ==&nbsp;1) {
&nbsp; &nbsp; &nbsp; &nbsp; System.out.println("✓ deterministic across "&nbsp;+ runs +&nbsp;" runs");
&nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; System.out.println("✗ FOUND "&nbsp;+ outputs.size() +&nbsp;" distinct outputs:");
&nbsp; &nbsp; &nbsp; &nbsp; outputs.forEach(System.out::println);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;thrownew&nbsp;IllegalStateException("not deterministic yet");
&nbsp; &nbsp; }
}

public&nbsp;static&nbsp;void&nbsp;main(String[] args)&nbsp;{
&nbsp; &nbsp; verifyDeterministic(5);
}

跑出来看到 ✓ deterministic across 5 runs 之前,不要进入下一步。这一步看似琐碎,但它是后面整个 diff 工作流的前置条件——跳过这一步,后面所有对比都是白费力气。

Step 3:在 Frida 真机上做同等的固定

把 Step 1 里固定的项目,在 Frida 上用同样的方式固定:

Java.perform(function()&nbsp;{
&nbsp; &nbsp;&nbsp;// 固定 currentTimeMillis
&nbsp; &nbsp; Java.use("java.lang.System").currentTimeMillis.implementation =&nbsp;function()&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1700000000000;
&nbsp; &nbsp; };
&nbsp; &nbsp;&nbsp;// 固定 nanoTime
&nbsp; &nbsp; Java.use("java.lang.System").nanoTime.implementation =&nbsp;function()&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1700000000000000;
&nbsp; &nbsp; };
&nbsp; &nbsp;&nbsp;// ...
});

注意:Frida 上要固定的项目和 Unidbg 上要完全一致。漏一个都会导致结果不同。

Step 4:对比

两边用同样的入参,跑出来:

  • 完全一致 → 环境补对了,可以放心继续
  • 不一致 → 还有随机源没固定,或者环境有差异

如果发现不一致,先排查随机源(80% 概率是这个),实在排查不出再去看环境。


四类随机源及其固定方法 四类随机源及其固定位置

第一类:JNI 层

典型来源:

  • System.currentTimeMillis()
  • System.nanoTime()
  • Random.nextInt() / nextLong()
  • UUID.randomUUID()
  • Date.getTime()
  • SecureRandom.nextBytes() — 签名算法里高频出现,经常漏掉

固定方法:在 AbstractJni 的 callObjectMethod / callLongMethod 里返回固定值。

@Override
public&nbsp;long&nbsp;callLongMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg)&nbsp;{
&nbsp; &nbsp; String sig = dvmMethod.getSignature();
&nbsp; &nbsp;&nbsp;// 固定时间戳
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals("java/lang/System->currentTimeMillis()J")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return1700000000000L;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals("java/lang/System->nanoTime()J")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return1700000000000000L;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;returnsuper.callLongMethod(vm, dvmObject, dvmMethod, varArg);
}

@Override
public&nbsp;int&nbsp;callIntMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg)&nbsp;{
&nbsp; &nbsp; String sig = dvmMethod.getSignature();
&nbsp; &nbsp;&nbsp;// 固定 Random
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals("java/util/Random->nextInt()I")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return42;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals("java/util/Random->nextInt(I)I")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return0; &nbsp;&nbsp;// 固定返回最小可能值
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;returnsuper.callIntMethod(vm, dvmObject, dvmMethod, varArg);
}

⚠️ callXxxMethod 和 callXxxMethodV 必须两个都拦。AbstractJni 上每个 callLongMethod / callIntMethod / callObjectMethod 都有一个 callLongMethodV / callIntMethodV / callObjectMethodV 的孪生方法(V 版本接 VaList,非 V 版本接 VarArg)。SO 在 native 用 (*env)->CallLongMethod(...) 还是 CallLongMethodV(...) 决定走哪条路径,单独拦一边会漏。最常见的”明明拦了 currentTimeMillis 但每次结果还是不同”,60% 是这个原因。

单独说一下 SecureRandom.nextBytes

SecureRandom 是签名算法里最容易漏的一个。它的常见用法是:

byte[] salt =&nbsp;new&nbsp;byte[16];
new&nbsp;SecureRandom().nextBytes(salt); &nbsp;&nbsp;// 每次不同的 16 字节

在 Android 上,SecureRandom 的底层实现会走 /dev/urandom(见后文第四类),但它在 Java 层是一次独立的 JNI 调用,如果你只在 AbstractJni 里拦了 Random、没拦 SecureRandom,结果就不稳:

@Override
public&nbsp;void&nbsp;callVoidMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg)&nbsp;{
&nbsp; &nbsp; String sig = dvmMethod.getSignature();
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals("java/security/SecureRandom->nextBytes([B)V")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 参数 0 是 byte[] 输出缓冲
&nbsp; &nbsp; &nbsp; &nbsp; ByteArray buf = varArg.getObjectArg(0);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;byte[] data = buf.getValue();
&nbsp; &nbsp; &nbsp; &nbsp; Arrays.fill(data, (byte)&nbsp;0x42); &nbsp;// 整块填 0x42
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;super.callVoidMethod(vm, dvmObject, dvmMethod, varArg);
}

双保险做法:AbstractJni 拦一次 + IOResolver 把 /dev/urandom 固定一遍。两边都做,才能覆盖不同版本 Android 的实现差异。Frida 侧同样要 Java.use("java.security.SecureRandom").nextBytes.implementation = ... 同步一把。

第二类:库函数层

典型来源:

  • time(NULL)
  • clock()
  • rand() / srand()
  • arc4random()

固定方法:用 HookZz 在 libc 入口处直接 replace。

IHookZz hookZz = HookZz.getInstance(emulator);
Module libc = emulator.getMemory().findModule("libc.so");

// 固定 time()
hookZz.replace(libc.findSymbolByName("time"),&nbsp;new&nbsp;ReplaceCallback() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;HookStatus&nbsp;onCall(Emulator<?> emulator,&nbsp;long&nbsp;originFunction)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// time(NULL) 返回 Unix 时间戳
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;HookStatus.LR(emulator,&nbsp;1700000000L);
&nbsp; &nbsp; }
});

// 固定 rand()
hookZz.replace(libc.findSymbolByName("rand"),&nbsp;new&nbsp;ReplaceCallback() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;HookStatus&nbsp;onCall(Emulator<?> emulator,&nbsp;long&nbsp;originFunction)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;HookStatus.LR(emulator,&nbsp;42);
&nbsp; &nbsp; }
});

srand / rand 不要无脑拦 srand / rand 决策树 + PRNG 状态机

这是一个真实会翻车的细节。rand 的行为是“上一次调用状态 + 确定算法 → 下一次输出”,也就是说:如果 SO 自己调了 srand(某个种子),后续的 rand 序列本来就是确定性的。你再去 hook rand 让它恒返回 42,反而破坏了 SO 的预期——有些算法会用多次 rand 生成相关的值(比如 a = rand()b = rand(),然后要求 b > a),固定成 42 会让它自检失败。

实操判断规则:

  • SO 调了 srand(time(NULL)) / srand(getpid())(种子本身是随机) → 拦 srand 让种子固定(比如恒为 42),**不要拦 rand**,让原生 libc 算
  • SO 调了 srand(常量),比如 srand(1) → 谁都别拦,它已经是确定性的
  • SO 只调 rand 不调 srand(相当于用默认种子 1) → 可以不拦,也是确定性的
  • SO 调 arc4random / arc4random_buf → 必须拦,它内部从 /dev/urandom 取种子,不受 srand 控制

简言之:先看 SO 怎么用 srand,再决定拦什么。单纯“拦 rand 返回 42”是懒人做法,在有自检的样本上会炸。

第三类:系统调用层

典型来源:

  • clock_gettime (CLOCK_REALTIME / CLOCK_MONOTONIC)
  • gettimeofday
  • getrandom
  • getuid / getpid 严格说不是随机,但每次跑可能不一样

固定方法:在 SyscallHandler 里覆盖。

public&nbsp;class&nbsp;FixedSyscallHandler&nbsp;extends&nbsp;ARM64SyscallHandler&nbsp;{

&nbsp; &nbsp;&nbsp;privatelong&nbsp;monoCounter =&nbsp;1000L; &nbsp;// CLOCK_MONOTONIC 的"虚拟秒"

&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;void&nbsp;hook(Backend backend,&nbsp;int&nbsp;intno, Object user)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(intno !=&nbsp;2) {&nbsp;super.hook(backend, intno, user);&nbsp;return; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// clock_gettime — 按 clk_id 分发, 不同时钟有不同语义
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(NR ==&nbsp;113) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;clk_id = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Pointer tp = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;long&nbsp;sec, nsec;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;switch&nbsp;(clk_id) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case0: &nbsp;// CLOCK_REALTIME — 墙钟时间, 固定 Unix 时间戳
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec =&nbsp;1700000000L; nsec =&nbsp;0L;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case1: &nbsp;// CLOCK_MONOTONIC — 必须"每次递增", 否则触发超时检测
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec = monoCounter++; nsec =&nbsp;0L;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case7: &nbsp;// CLOCK_BOOTTIME — 模拟已开机 6 小时
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec =&nbsp;1700000000L&nbsp;+&nbsp;6&nbsp;*&nbsp;3600; nsec =&nbsp;0L;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;default:&nbsp;// 其他 clock_id 统一给 realtime
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec =&nbsp;1700000000L; nsec =&nbsp;0L;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tp.setLong(0, sec); &nbsp; &nbsp;// tv_sec
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tp.setLong(8, nsec); &nbsp;&nbsp;// tv_nsec
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; backend.reg_write(Arm64Const.UC_ARM64_REG_X0,&nbsp;0);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// getrandom
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(NR ==&nbsp;278) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Pointer buf = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X0);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;len = backend.reg_read(Arm64Const.UC_ARM64_REG_X1).intValue();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;byte[] fixed =&nbsp;newbyte[len];
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 全 0x42, 完全可预测
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < len; i++) fixed[i] =&nbsp;0x42;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; buf.write(0, fixed,&nbsp;0, len);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; backend.reg_write(Arm64Const.UC_ARM64_REG_X0, len);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super.hook(backend, intno, user);
&nbsp; &nbsp; }
}

关于 clock_gettime 的三种 clock_id——这是最容易出问题的细节:

| clk_id | 名字 | 语义 | 固定策略 | | — | — | — | — | | 0 | CLOCK_REALTIME | 墙钟时间,可能被手动修改 | 固定成某个 Unix 时间戳 | | 1 | CLOCK_MONOTONIC | 从某个起点单调递增,永不回退 | 必须递增 ,不能恒等 | | 7 | CLOCK_BOOTTIME | 含休眠时间的开机总时长 | 固定为“已开机 N 小时” |

如果你把三者都固定成同一个值,会立刻触发 SO 里的超时检测——很多反调试用 CLOCK_MONOTONIC 做前后两次调用的时间差,发现差值恒为 0 就报错。这其实就是后面“坑 2”的完整技术细节,别忽视 clk_id 分发。

第四类:文件系统层

典型来源:

  • /dev/urandom (读出来的字节)
  • /dev/random
  • /proc/uptime (运行时间)
  • /proc/stat (CPU 时间)
  • /proc/self/stat (进程时间相关字段)

固定方法:在 IOResolver 里返回固定内容的虚拟文件。

@Override
public&nbsp;FileResult&nbsp;resolve(Emulator<?> emulator, String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(pathname.equals("/dev/urandom") || pathname.equals("/dev/random")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 返回一个永远只吐 0x42 的虚拟文件
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname, fillBytes(4096, (byte)0x42)));
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(pathname.equals("/proc/uptime")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;FileResult.success(new&nbsp;ByteArrayFileIO(oflags, pathname,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"12345.67 9876.54\n".getBytes()));
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;null;
}

扩展:环境指纹也要一起冻结

上面四类讲的是“每次调用都可能不一样”的随机源。但在真实项目里,你很快会碰到另一类值——它每次调用不变,但换一台设备就会变:

  • Settings.Secure.ANDROID_ID
  • Build.SERIAL / Build.FINGERPRINT / Build.MODEL
  • 屏幕分辨率 DisplayMetrics
  • 首装 / 更新时间 PackageInfo.firstInstallTime
  • 电池状态 BatteryManager.getIntProperty
  • 进程 UID / GID(实测机上是设备分配的,跨设备不同)

严格说这不是“随机”,但从“标准答案机制”的角度看,它们和随机一样需要冻结——原因是:

你在 Unidbg 里跑出来的结果,要和 Frida 真机上跑出来的结果完全一致。 如果真机是 A 设备、Unidbg 里是默认值,那两边 android_id 不同,SO 算出来的东西就不同,整个对比失效

所以完整的冻结清单是“四类随机源 + 一类环境指纹”:

| 类别 | 变化速度 | 固定方法 | | — | — | — | | 四类随机源 | 每次调用变化 | 本篇上面的方法 | | 环境指纹 | 按设备 / 按安装次 | AbstractJni override getString / getIntProperty 等 |

标准做法:挑一台固定真机作为“基准机”,Frida 把这批值全部 dump 出来,Unidbg 侧把 dump 出来的值原样硬编码。这样做完,Unidbg 和那台真机就是“同一套环境”,结果才有可比性。

网易云音乐 Music163W238.java 就是一个典型——它用 switch-case 把 android_id、SERIAL、屏幕分辨率、首装时间、电池四段属性、UID 一共 7 类值全部固定下来。完整拆解放在案例补遗里:

[《Unidbg学习笔记(十三)案例补遗:Music163W238 的环境冻结清单》](Unidbg学习笔记(十三)案例补遗:Music163W238 的环境冻结清单.md)

里面有一个特别有意思的点:电池的四个属性值必须“物理上自洽”(状态=充电中 → 电流为正 → counter 单调增 → 电量合理),随便填 0 会被 SO 抓到“充电中但电流为 0”这种物理上不可能的组合,直接判定模拟器。这已经不是“冻结”,而是“扮演”——你在给 SO 一套完整的、内部一致的虚假环境。


系统化排查未知随机源

上面四类是常见的,但实际样本会有意想不到的姿势。下面是一套系统化排查方法。

方法 1:开启 JNI 日志,扫“time/random/date”

BaseVM.setVerbose(true) 会打印所有 JNI 调用:

VM vm = emulator.createDalvikVM(apkFile); &nbsp;// createDalvikVM 返回的是 VM 接口
vm.setVerbose(true); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 打印所有 JNI 调用

跑一遍,把 stdout 重定向到文件,grep:

grep -iE&nbsp;"time|random|date|nano|clock|currenttimemillis"&nbsp;jni.log

任何匹配上的地方,都是潜在的随机源。

方法 2:在 SyscallHandler 上加日志

SyscallHandler 没有现成的 setVerbose 开关 —— 你得自己继承 ARM64SyscallHandler 并重写 hook(...),在分发到 super.hook(...) 前打一行日志:

public&nbsp;class&nbsp;LoggingSyscallHandler&nbsp;extends&nbsp;ARM64SyscallHandler&nbsp;{
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;void&nbsp;hook(Backend backend,&nbsp;int&nbsp;intno, Object user)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(intno == ARMEmulator.EXCP_SWI) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; System.out.println("syscall NR="&nbsp;+ NR);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super.hook(backend, intno, user);
&nbsp; &nbsp; }
}

然后 emulator.getSyscallHandler() 实际上要在 emulator builder 里通过 setSyscallHandler 之类的姿势替换 —— 各版本 unidbg 接入点略有差异,最稳妥的做法是直接 fork 一份 ARM64SyscallHandler 改。grep:

grep -iE&nbsp;"NR=113|NR=169|NR=278"&nbsp;syscall.log &nbsp;&nbsp;# clock_gettime / gettimeofday / getrandom

方法 3:扫文件访问日志

unidbg 没有直接的文件访问开关,统一在 IOResolver 里加日志

emulator.getSyscallHandler().addIOResolver(new&nbsp;IOResolver<AndroidFileIO>() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;FileResult<AndroidFileIO>&nbsp;resolve(Emulator<AndroidFileIO> emu, String pathname,&nbsp;int&nbsp;oflags)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; System.out.println("OPEN "&nbsp;+ pathname); &nbsp;&nbsp;// 记录所有访问路径
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;null; &nbsp;&nbsp;// 不拦截, 让默认逻辑继续
&nbsp; &nbsp; }
});

跑完 grep urandom / random / proc/stat / proc/uptime

方法 4:Diff 两次输出

终极武器,也是最准的:

  1. 跑两次,把每次的输出 (中间状态、Trace) 都保存
  2. diff run_1.txt run_2.txt
  3. 任何差异 → 必然来自一个未固定的随机源

特别是配合指令级 Trace:

PrintStream out1 =&nbsp;new&nbsp;PrintStream(new&nbsp;FileOutputStream("trace_1.txt"));
emulator.traceCode(funcStart, funcEnd).setRedirect(out1);
// 跑一次
// 然后改输出文件名跑第二次

diff trace_1.txt trace_2.txt 出来的位置,就是有随机源参与的指令位置。从那个地址往回追溯,就能定位到随机源。


实战:固定一个 currentTimeMillis 引发的连锁反应

讲个真实的小故事来收尾。

某次我在分析一个 SDK 的请求签名算法。固定了 time() / rand() / urandom,结果还是每次不一样。

排查方法 4 上场,diff 出来一行可疑的:

< [0x5678] mov w0,&nbsp;#0x18b91234
---
> [0x5678] mov w0,&nbsp;#0x18b91567

这是一个 mov 立即数 —— 立即数怎么会变?

回去看代码,发现这个立即数是从某个全局变量读出来的,而那个全局变量是在另一个函数里写入的:

g_session_id = (int)(currentTimeMillis() &&nbsp;0x7fffffff);

但是这个 currentTimeMillis不是来自 Java,而是来自 SO 内部用 gettimeofday 自己拼的:

struct&nbsp;timeval&nbsp;tv;
gettimeofday(&tv,&nbsp;NULL);
long&nbsp;ms = tv.tv_sec *&nbsp;1000&nbsp;+ tv.tv_usec /&nbsp;1000;

我固定了 clock_gettime 但**漏了 gettimeofday**(NR 不一样,在 ARM64 上 gettimeofday 已经是 vDSO 调用,根本走不到 SyscallHandler)。

gettimeofday 的双路径:为什么 SyscallHandler 拦不住

最终用 HookZz 在 libc 的 gettimeofday 入口处 replace,问题解决。完整代码:

// gettimeofday(struct timeval *tv, struct timezone *tz)
// 在 ARM64 上走 vDSO, SyscallHandler 拦不到, 必须在 libc 入口拦
hookZz.replace(libc.findSymbolByName("gettimeofday"),&nbsp;new&nbsp;ReplaceCallback() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;HookStatus&nbsp;onCall(Emulator<?> emulator,&nbsp;long&nbsp;originFunction)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; Pointer tv = emulator.getContext().getPointerArg(0);
&nbsp; &nbsp; &nbsp; &nbsp; Pointer tz = emulator.getContext().getPointerArg(1);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(tv !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tv.setLong(0,&nbsp;1700000000L); &nbsp;// tv_sec
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tv.setLong(8,&nbsp;0L); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// tv_usec
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// tz 已经是 deprecated, 实测几乎不会传, 但以防万一清零
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(tz !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tz.setInt(0,&nbsp;0);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tz.setInt(4,&nbsp;0);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;HookStatus.LR(emulator,&nbsp;0); &nbsp;&nbsp;// gettimeofday 成功返回 0
&nbsp; &nbsp; }
});

教训:syscall 层固定不一定能拦住所有时间调用。库函数层的固定要 独立做一遍,因为有 vDSO 这种“绕开 syscall”的存在。**clock_gettime / gettimeofday 这两个函数要在 SyscallHandler + libc 两层都固定一次**——这是防御性补全,不是冗余。


几个常见的坑 固定随机项时的常见陷阱

最后留几个我踩过的、想留给以后翻这一篇的你。

坑 1:固定值不能用 0

很多人喜欢固定为 0,但 0 是个代数上的吸收元,在很多算法里会造成灾难:

  • XOR 累加器:state ^= rand() 如果 rand 恒为 0,state 永远不变 → 最终输出退化
  • 乘法链:result *= nonce,nonce 为 0 会让整个链条变成 0
  • 分支标志:if (timestamp == 0) return FAILED; —— SO 把 0 当“未初始化”,直接走错分支
  • 模运算:index = rand() % N,固定为 0 会让 SO 每次都访问数组第一个元素,被当作异常模式检测出来

建议:用一个看起来“普通”的固定值,1700000000 (2023 年的 Unix 时间戳),42,0x42424242。共同特征是:非零、非特殊魔数(如 0xDEADBEEF)、看起来像人类用的数字

坑 2:固定时间会让超时检测失效

如果 SO 内部有“两次时间戳之差大于 X 秒就报错”的逻辑,你固定时间会直接触发这个检查 —— 因为差值永远是 0。

应对:固定时,让两次调用之间有一个小幅递增:

private&nbsp;long&nbsp;counter =&nbsp;1700000000000L;

@Override
public&nbsp;long&nbsp;callLongMethod(...)&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(sig.equals(".../currentTimeMillis()J")) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;counter++; &nbsp;&nbsp;// 每次 +1ms
&nbsp; &nbsp; }
}

坑 3:Frida 和 Unidbg 固定方式不一致

Frida 在 Java 层 hook currentTimeMillis,Unidbg 在 AbstractJni 里固定 —— 看起来是同一个方法,但Frida 的 hook 在 Java 层执行,Unidbg 的 hook 在 native callback 执行

如果 SO 用 native 直接调 ART 内部的 gettimeofday,Frida 的 Java hook 拦不到。

对策:Frida 也要 hook libc 层的 gettimeofday,而不是只 hook Java 层。

坑 4:不要忘记 vDSO

Linux 的 clock_gettime / gettimeofday 在现代 ARM64 上是 vDSO 调用,不走 syscall。SyscallHandler 上的固定完全无效,必须在库函数层(libc 入口)固定。


总结

| 随机源类型 | 典型函数 | 固定位置 | | — | — | — | | JNI 层 | currentTimeMillis / Random | AbstractJni | | 库函数层 | time / rand / gettimeofday | HookZz / xHook | | 系统调用层 | clock_gettime / getrandom | SyscallHandler | | 文件层 | /dev/urandom / /proc/uptime | IOResolver |

最重要的两条规则:

  1. 固定随机是补环境之前的事,不是之后的事。先固定,再补,再验证。
  2. Frida 和 Unidbg 必须固定同样的项,否则对比无意义。

免责声明:

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

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

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

本文转载自:泡泡以安 泡泡以安 泡泡以安《Unidbg学习笔记(十三):固定随机干扰项》

工具|wappalyzergo 网络安全文章

工具|wappalyzergo

文章总结: wappalyzergo是一款基于Golang实现的Wappalyzer指纹识别工具,主要用于网站技术栈检测。该项目开源在GitHub,提供基础的指
评论:0   参与:  0