如果入参相同但每次结果不同,你就永远无法验证补环境是否正确。固定所有随机源是通往“可验证的正确模拟”的必经之路。这一篇是一道闸门 —— 不解决随机问题,你的整个工作流都没有标准答案。
第十二篇我们打开了 Trace 的“内视镜”。但你可能已经隐隐感觉到一个矛盾:
"Trace 是确定性的 —— 同样的输入,每次产生同样的 Trace。但如果**我的 SO 调了
time()或rand()**,那它每次的执行路径都会变啊..."
对。这就是这一篇要解决的问题。
更精确地说,Trace、补环境验证、算法还原 —— 这三件事都依赖一个隐含前提:
同样的入参,产生同样的输出。
这个前提一旦被破坏,你就掉进了“调试地狱”:
你陷在一个“任何变化都可能是随机源”的混沌里,根本分不清原因。
这一篇的目标,就是把这个混沌彻底关掉。
补环境是个“很难证明对错”的活。你怎么知道你补的 getMethodID 正确?
唯一可靠的方法是:
如果两边结果一致 —— 你的环境补对了。如果不一致 —— 哪里还有差异。
这就是“补环境正确性的标准答案机制”。没有它,你只能靠感觉。
算法还原本质上是从“输入”推“输出变换函数”。如果输入到输出的关系不是函数(同样输入产生不同输出),那它根本不能被还原。
固定所有随机源,就是把样本从一个随机系统降维成一个确定性系统 —— 至少在数学意义上,它现在是可还原的了。
很多算法分析师把“固定随机”当成第一步,比补环境还要早。
第十二篇我们讲过 diff 两次 Trace 找数据依赖点。这个手法的前提是:
除了输入不同,其他所有可能的差异源都被消除了。
时间戳是差异源。/dev/urandom 是差异源。uptime 是差异源。
每存在一个未固定的差异源,diff 出来的“差异”就多一份噪音。

下面是我的工作流,从开始到结束。
为什么先在 Unidbg 做:Unidbg 里所有交互都是经过 Unidbg 的(JNI / 文件 / syscall / libc),你能精确知道有哪些地方在读时间和随机数,也能精确控制。
具体怎么固定见下面“四类随机源”。
跑两次,确认输出完全一致。如果还不一致 —— 说明还有随机源没固定,继续找。
直到能稳定复现为止。这是一个“标准答案 V0”。
推荐写一个极简的 harness,把“跑 N 次看是否一致”沉淀成一键脚本:
publicstaticvoidverifyDeterministic(int runs){ Set<String> outputs = new LinkedHashSet<>();for (int i = 0; i < runs; i++) {// runOnce() 每次都重新 createDalvikVM + loadLibrary + call encrypt String out = runOnce(newbyte[]{1, 2, 3}); outputs.add(out); System.out.printf("run #%d: %s%n", i, out); }if (outputs.size() == 1) { System.out.println("✓ deterministic across " + runs + " runs"); } else { System.out.println("✗ FOUND " + outputs.size() + " distinct outputs:"); outputs.forEach(System.out::println);thrownew IllegalStateException("not deterministic yet"); }}publicstaticvoidmain(String[] args){ verifyDeterministic(5);}跑出来看到 ✓ deterministic across 5 runs 之前,不要进入下一步。这一步看似琐碎,但它是后面整个 diff 工作流的前置条件——跳过这一步,后面所有对比都是白费力气。
把 Step 1 里固定的项目,在 Frida 上用同样的方式固定:
Java.perform(function() {// 固定 currentTimeMillis Java.use("java.lang.System").currentTimeMillis.implementation = function() {return1700000000000; };// 固定 nanoTime Java.use("java.lang.System").nanoTime.implementation = function() {return1700000000000000; };// ...});注意:Frida 上要固定的项目和 Unidbg 上要完全一致。漏一个都会导致结果不同。
两边用同样的入参,跑出来:
如果发现不一致,先排查随机源(80% 概率是这个),实在排查不出再去看环境。

典型来源:
System.currentTimeMillis()System.nanoTime()Random.nextInt() / nextLong()UUID.randomUUID()Date.getTime()SecureRandom.nextBytes() — 签名算法里高频出现,经常漏掉固定方法:在 AbstractJni 的 callObjectMethod / callLongMethod 里返回固定值。
@OverridepubliclongcallLongMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg){ String sig = dvmMethod.getSignature();// 固定时间戳if (sig.equals("java/lang/System->currentTimeMillis()J")) {return1700000000000L; }if (sig.equals("java/lang/System->nanoTime()J")) {return1700000000000000L; }returnsuper.callLongMethod(vm, dvmObject, dvmMethod, varArg);}@OverridepublicintcallIntMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg){ String sig = dvmMethod.getSignature();// 固定 Randomif (sig.equals("java/util/Random->nextInt()I")) {return42; }if (sig.equals("java/util/Random->nextInt(I)I")) {return0; // 固定返回最小可能值 }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.nextBytesSecureRandom 是签名算法里最容易漏的一个。它的常见用法是:
byte[] salt = newbyte[16];new SecureRandom().nextBytes(salt); // 每次不同的 16 字节在 Android 上,SecureRandom 的底层实现会走 /dev/urandom(见后文第四类),但它在 Java 层是一次独立的 JNI 调用,如果你只在 AbstractJni 里拦了 Random、没拦 SecureRandom,结果就不稳:
@OverridepublicvoidcallVoidMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg){ String sig = dvmMethod.getSignature();if (sig.equals("java/security/SecureRandom->nextBytes([B)V")) {// 参数 0 是 byte[] 输出缓冲 ByteArray buf = varArg.getObjectArg(0);byte[] data = buf.getValue(); Arrays.fill(data, (byte) 0x42); // 整块填 0x42return; }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"), new ReplaceCallback() {@Overridepublic HookStatus onCall(Emulator<?> emulator, long originFunction){// time(NULL) 返回 Unix 时间戳return HookStatus.LR(emulator, 1700000000L); }});// 固定 rand()hookZz.replace(libc.findSymbolByName("rand"), new ReplaceCallback() {@Overridepublic HookStatus onCall(Emulator<?> emulator, long originFunction){return HookStatus.LR(emulator, 42); }});srand / rand 不要无脑拦
这是一个真实会翻车的细节。rand 的行为是“上一次调用状态 + 确定算法 → 下一次输出”,也就是说:如果 SO 自己调了 srand(某个种子),后续的 rand 序列本来就是确定性的。你再去 hook rand 让它恒返回 42,反而破坏了 SO 的预期——有些算法会用多次 rand 生成相关的值(比如 a = rand(), b = rand(),然后要求 b > a),固定成 42 会让它自检失败。
实操判断规则:
srand(time(NULL)) / srand(getpid())(种子本身是随机) → 拦 srand 让种子固定(比如恒为 42),**不要拦 rand**,让原生 libc 算srand(常量),比如 srand(1) → 谁都别拦,它已经是确定性的rand 不调 srand(相当于用默认种子 1) → 可以不拦,也是确定性的arc4random / arc4random_buf → 必须拦,它内部从 /dev/urandom 取种子,不受 srand 控制简言之:先看 SO 怎么用 srand,再决定拦什么。单纯“拦 rand 返回 42”是懒人做法,在有自检的样本上会炸。
典型来源:
clock_gettime (CLOCK_REALTIME / CLOCK_MONOTONIC)gettimeofdaygetrandomgetuid / getpid 严格说不是随机,但每次跑可能不一样固定方法:在 SyscallHandler 里覆盖。
publicclassFixedSyscallHandlerextendsARM64SyscallHandler{privatelong monoCounter = 1000L; // CLOCK_MONOTONIC 的"虚拟秒"@Overridepublicvoidhook(Backend backend, int intno, Object user){if (intno != 2) { super.hook(backend, intno, user); return; }int NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();// clock_gettime — 按 clk_id 分发, 不同时钟有不同语义if (NR == 113) {int clk_id = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue(); Pointer tp = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);long sec, nsec;switch (clk_id) {case0: // CLOCK_REALTIME — 墙钟时间, 固定 Unix 时间戳 sec = 1700000000L; nsec = 0L; break;case1: // CLOCK_MONOTONIC — 必须"每次递增", 否则触发超时检测 sec = monoCounter++; nsec = 0L; break;case7: // CLOCK_BOOTTIME — 模拟已开机 6 小时 sec = 1700000000L + 6 * 3600; nsec = 0L; break;default: // 其他 clock_id 统一给 realtime sec = 1700000000L; nsec = 0L; break; } tp.setLong(0, sec); // tv_sec tp.setLong(8, nsec); // tv_nsec backend.reg_write(Arm64Const.UC_ARM64_REG_X0, 0);return; }// getrandomif (NR == 278) { Pointer buf = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X0);int len = backend.reg_read(Arm64Const.UC_ARM64_REG_X1).intValue();byte[] fixed = newbyte[len];// 全 0x42, 完全可预测for (int i = 0; i < len; i++) fixed[i] = 0x42; buf.write(0, fixed, 0, len); backend.reg_write(Arm64Const.UC_ARM64_REG_X0, len);return; }super.hook(backend, intno, user); }}关于 clock_gettime 的三种 clock_id——这是最容易出问题的细节:
CLOCK_REALTIME | |||
CLOCK_MONOTONIC | 必须递增 | ||
CLOCK_BOOTTIME |
如果你把三者都固定成同一个值,会立刻触发 SO 里的超时检测——很多反调试用 CLOCK_MONOTONIC 做前后两次调用的时间差,发现差值恒为 0 就报错。这其实就是后面“坑 2”的完整技术细节,别忽视 clk_id 分发。
典型来源:
/dev/urandom (读出来的字节)/dev/random/proc/uptime (运行时间)/proc/stat (CPU 时间)/proc/self/stat (进程时间相关字段)固定方法:在 IOResolver 里返回固定内容的虚拟文件。
@Overridepublic FileResult resolve(Emulator<?> emulator, String pathname, int oflags){if (pathname.equals("/dev/urandom") || pathname.equals("/dev/random")) {// 返回一个永远只吐 0x42 的虚拟文件return FileResult.success(new ByteArrayFileIO(oflags, pathname, fillBytes(4096, (byte)0x42))); }if (pathname.equals("/proc/uptime")) {return FileResult.success(new ByteArrayFileIO(oflags, pathname,"12345.67 9876.54\n".getBytes())); }returnnull;}上面四类讲的是“每次调用都可能不一样”的随机源。但在真实项目里,你很快会碰到另一类值——它每次调用不变,但换一台设备就会变:
Settings.Secure.ANDROID_IDBuild.SERIAL / Build.FINGERPRINT / Build.MODELDisplayMetricsPackageInfo.firstInstallTimeBatteryManager.getIntProperty严格说这不是“随机”,但从“标准答案机制”的角度看,它们和随机一样需要冻结——原因是:
你在 Unidbg 里跑出来的结果,要和 Frida 真机上跑出来的结果完全一致。 如果真机是 A 设备、Unidbg 里是默认值,那两边
android_id不同,SO 算出来的东西就不同,整个对比失效。
所以完整的冻结清单是“四类随机源 + 一类环境指纹”:
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 一套完整的、内部一致的虚假环境。
上面四类是常见的,但实际样本会有意想不到的姿势。下面是一套系统化排查方法。
BaseVM.setVerbose(true) 会打印所有 JNI 调用:
VM vm = emulator.createDalvikVM(apkFile); // createDalvikVM 返回的是 VM 接口vm.setVerbose(true); // 打印所有 JNI 调用跑一遍,把 stdout 重定向到文件,grep:
grep -iE "time|random|date|nano|clock|currenttimemillis" jni.log任何匹配上的地方,都是潜在的随机源。
SyscallHandler 没有现成的 setVerbose 开关 —— 你得自己继承 ARM64SyscallHandler 并重写 hook(...),在分发到 super.hook(...) 前打一行日志:
publicclassLoggingSyscallHandlerextendsARM64SyscallHandler{@Overridepublicvoidhook(Backend backend, int intno, Object user){if (intno == ARMEmulator.EXCP_SWI) {int NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue(); System.out.println("syscall NR=" + NR); }super.hook(backend, intno, user); }}然后 emulator.getSyscallHandler() 实际上要在 emulator builder 里通过 setSyscallHandler 之类的姿势替换 —— 各版本 unidbg 接入点略有差异,最稳妥的做法是直接 fork 一份 ARM64SyscallHandler 改。grep:
grep -iE "NR=113|NR=169|NR=278" syscall.log # clock_gettime / gettimeofday / getrandomunidbg 没有直接的文件访问开关,统一在 IOResolver 里加日志:
emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {@Overridepublic FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emu, String pathname, int oflags){ System.out.println("OPEN " + pathname); // 记录所有访问路径returnnull; // 不拦截, 让默认逻辑继续 }});跑完 grep urandom / random / proc/stat / proc/uptime。
终极武器,也是最准的:
diff run_1.txt run_2.txt特别是配合指令级 Trace:
PrintStream out1 = new PrintStream(new FileOutputStream("trace_1.txt"));emulator.traceCode(funcStart, funcEnd).setRedirect(out1);// 跑一次// 然后改输出文件名跑第二次diff trace_1.txt trace_2.txt 出来的位置,就是有随机源参与的指令位置。从那个地址往回追溯,就能定位到随机源。
讲个真实的小故事来收尾。
某次我在分析一个 SDK 的请求签名算法。固定了 time() / rand() / urandom,结果还是每次不一样。
排查方法 4 上场,diff 出来一行可疑的:
< [0x5678] mov w0, #0x18b91234---> [0x5678] mov w0, #0x18b91567这是一个 mov 立即数 —— 立即数怎么会变?
回去看代码,发现这个立即数是从某个全局变量读出来的,而那个全局变量是在另一个函数里写入的:
g_session_id = (int)(currentTimeMillis() & 0x7fffffff);但是这个 currentTimeMillis不是来自 Java,而是来自 SO 内部用 gettimeofday 自己拼的:
structtimevaltv;gettimeofday(&tv, NULL);long ms = tv.tv_sec * 1000 + tv.tv_usec / 1000;我固定了 clock_gettime 但**漏了 gettimeofday**(NR 不一样,在 ARM64 上 gettimeofday 已经是 vDSO 调用,根本走不到 SyscallHandler)。

最终用 HookZz 在 libc 的 gettimeofday 入口处 replace,问题解决。完整代码:
// gettimeofday(struct timeval *tv, struct timezone *tz)// 在 ARM64 上走 vDSO, SyscallHandler 拦不到, 必须在 libc 入口拦hookZz.replace(libc.findSymbolByName("gettimeofday"), new ReplaceCallback() {@Overridepublic HookStatus onCall(Emulator<?> emulator, long originFunction){ Pointer tv = emulator.getContext().getPointerArg(0); Pointer tz = emulator.getContext().getPointerArg(1);if (tv != null) { tv.setLong(0, 1700000000L); // tv_sec tv.setLong(8, 0L); // tv_usec }// tz 已经是 deprecated, 实测几乎不会传, 但以防万一清零if (tz != null) { tz.setInt(0, 0); tz.setInt(4, 0); }return HookStatus.LR(emulator, 0); // gettimeofday 成功返回 0 }});教训:syscall 层固定不一定能拦住所有时间调用。库函数层的固定要 独立做一遍,因为有 vDSO 这种“绕开 syscall”的存在。**
clock_gettime/gettimeofday这两个函数要在 SyscallHandler + libc 两层都固定一次**——这是防御性补全,不是冗余。

最后留几个我踩过的、想留给以后翻这一篇的你。
很多人喜欢固定为 0,但 0 是个代数上的吸收元,在很多算法里会造成灾难:
state ^= rand() 如果 rand 恒为 0,state 永远不变 → 最终输出退化result *= nonce,nonce 为 0 会让整个链条变成 0if (timestamp == 0) return FAILED; —— SO 把 0 当“未初始化”,直接走错分支index = rand() % N,固定为 0 会让 SO 每次都访问数组第一个元素,被当作异常模式检测出来建议:用一个看起来“普通”的固定值,1700000000 (2023 年的 Unix 时间戳),42,0x42424242。共同特征是:非零、非特殊魔数(如 0xDEADBEEF)、看起来像人类用的数字。
如果 SO 内部有“两次时间戳之差大于 X 秒就报错”的逻辑,你固定时间会直接触发这个检查 —— 因为差值永远是 0。
应对:固定时,让两次调用之间有一个小幅递增:
privatelong counter = 1700000000000L;@OverridepubliclongcallLongMethod(...){if (sig.equals(".../currentTimeMillis()J")) {return counter++; // 每次 +1ms }}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 层。
Linux 的 clock_gettime / gettimeofday 在现代 ARM64 上是 vDSO 调用,不走 syscall。SyscallHandler 上的固定完全无效,必须在库函数层(libc 入口)固定。
最重要的两条规则: