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

下面是我的工作流,从开始到结束。
Step 1:在 Unidbg 中固定随机源
为什么先在 Unidbg 做 :Unidbg 里所有交互都是经过 Unidbg 的(JNI / 文件 / syscall / libc),你能精确知道有哪些地方在读时间和随机数,也能精确控制。
具体怎么固定见下面"四类随机源"。
Step 2:用 Unidbg 跑一次,记下结果
跑两次,确认输出完全一致。如果还不一致 ------ 说明还有随机源没固定,继续找。
直到能稳定复现为止。这是一个"标准答案 V0"。
推荐写一个极简的 harness,把"跑 N 次看是否一致"沉淀成一键脚本:
java
public static void verifyDeterministic(int runs) {
Set<String> outputs = new LinkedHashSet<>();
for (int i = 0; i < runs; i++) {
// runOnce() 每次都重新 createDalvikVM + loadLibrary + call encrypt
String out = runOnce(new byte[]{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);
throw new IllegalStateException("not deterministic yet");
}
}
public static void main(String[] args) {
verifyDeterministic(5);
}
跑出来看到 ✓ deterministic across 5 runs 之前,不要进入下一步。这一步看似琐碎,但它是后面整个 diff 工作流的前置条件------跳过这一步,后面所有对比都是白费力气。
Step 3:在 Frida 真机上做同等的固定
把 Step 1 里固定的项目,在 Frida 上用同样的方式固定:
javascript
Java.perform(function() {
// 固定 currentTimeMillis
Java.use("java.lang.System").currentTimeMillis.implementation = function() {
return 1700000000000;
};
// 固定 nanoTime
Java.use("java.lang.System").nanoTime.implementation = function() {
return 1700000000000000;
};
// ...
});
注意:Frida 上要固定的项目和 Unidbg 上要完全一致。漏一个都会导致结果不同。
Step 4:对比
两边用同样的入参,跑出来:
- 完全一致 → 环境补对了,可以放心继续
- 不一致 → 还有随机源没固定,或者环境有差异
如果发现不一致,先排查随机源(80% 概率是这个),实在排查不出再去看环境。
四类随机源及其固定方法

第一类:JNI 层
典型来源:
System.currentTimeMillis()System.nanoTime()Random.nextInt()/nextLong()UUID.randomUUID()Date.getTime()SecureRandom.nextBytes()--- 签名算法里高频出现,经常漏掉
固定方法 :在 AbstractJni 的 callObjectMethod / callLongMethod 里返回固定值。
java
@Override
public long callLongMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg) {
String sig = dvmMethod.getSignature();
// 固定时间戳
if (sig.equals("java/lang/System->currentTimeMillis()J")) {
return 1700000000000L;
}
if (sig.equals("java/lang/System->nanoTime()J")) {
return 1700000000000000L;
}
return super.callLongMethod(vm, dvmObject, dvmMethod, varArg);
}
@Override
public int callIntMethod(BaseVM vm, DvmObject<?> dvmObject, DvmMethod dvmMethod, VarArg varArg) {
String sig = dvmMethod.getSignature();
// 固定 Random
if (sig.equals("java/util/Random->nextInt()I")) {
return 42;
}
if (sig.equals("java/util/Random->nextInt(I)I")) {
return 0; // 固定返回最小可能值
}
return super.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 是签名算法里最容易漏的一个。它的常见用法是:
java
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt); // 每次不同的 16 字节
在 Android 上,SecureRandom 的底层实现会走 /dev/urandom(见后文第四类),但它在 Java 层是一次独立的 JNI 调用 ,如果你只在 AbstractJni 里拦了 Random、没拦 SecureRandom,结果就不稳:
java
@Override
public void callVoidMethod(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); // 整块填 0x42
return;
}
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。
java
IHookZz hookZz = HookZz.getInstance(emulator);
Module libc = emulator.getMemory().findModule("libc.so");
// 固定 time()
hookZz.replace(libc.findSymbolByName("time"), new ReplaceCallback() {
@Override
public HookStatus onCall(Emulator<?> emulator, long originFunction) {
// time(NULL) 返回 Unix 时间戳
return HookStatus.LR(emulator, 1700000000L);
}
});
// 固定 rand()
hookZz.replace(libc.findSymbolByName("rand"), new ReplaceCallback() {
@Override
public 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 会让它自检失败。
实操判断规则:
- 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)gettimeofdaygetrandomgetuid/getpid严格说不是随机,但每次跑可能不一样
固定方法:在 SyscallHandler 里覆盖。
java
public class FixedSyscallHandler extends ARM64SyscallHandler {
private long monoCounter = 1000L; // CLOCK_MONOTONIC 的"虚拟秒"
@Override
public void hook(Backend backend, int intno, int swi, Object user) {
if (intno != ARMEmulator.EXCP_SWI) {
super.hook(backend, intno, swi, 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) {
case 0: // CLOCK_REALTIME --- 墙钟时间, 固定 Unix 时间戳
sec = 1700000000L; nsec = 0L; break;
case 1: case 4: case 6: // CLOCK_MONOTONIC / _RAW / _COARSE 三个单调时钟必须递增
sec = monoCounter++; nsec = 0L; break;
case 7: // 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;
}
// getrandom
if (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 = new byte[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, swi, user);
}
}
关于 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 里返回固定内容的虚拟文件。
java
@Override
public 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()));
}
return null;
}
扩展:环境指纹也要一起冻结
上面四类讲的是"每次调用都可能不一样 "的随机源。但在真实项目里,你很快会碰到另一类值------它每次调用不变,但换一台设备就会变:
Settings.Secure.ANDROID_IDBuild.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 把 7 类设备指纹全部固定下来。其中最值得讲的是电池------它揭示了"环境冻结"和"扮演"的本质区别:
java
// 电池四段属性 (必须"物理自洽", 否则随便填 0 会被识破)
@Override
public int callIntMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {
switch (signature) {
case "android/os/BatteryManager->getIntProperty(I)I": {
int arg = vaList.getIntArg(0);
if (arg == 1) return 2440883; // CHARGE_COUNTER (μAh)
if (arg == 2) return 459286; // CURRENT_NOW (μA, 正值=充电)
if (arg == 4) return 63; // CAPACITY (%)
if (arg == 6) return 2; // STATUS (2=charging)
return 0;
}
}
return super.callIntMethodV(vm, dvmObject, signature, vaList);
}
电池四属性必须"物理自洽" :状态 STATUS=2(充电中)→ CURRENT_NOW=+459286(电流为正,符合充电)→ CHARGE_COUNTER=2440883(counter 单调增)→ CAPACITY=63%(电量合理)。如果你随便填 STATUS=2, CURRENT_NOW=0(充电中但电流为零)这种物理上不可能的组合,SO 一次校验就把你揪出来。
这已经不是"冻结",而是"扮演"------你在给 SO 一套完整的、内部一致的虚假环境。冻结清单不是几个独立的值,而是几十个互相关联字段组成的"快照"。可复现性的关键不是"每个字段都对",而是"所有字段之间的关系都合理"。
其他 6 类的固定姿势按相同套路(不同 override 入口 + switch case 返回硬编码值),列表如下:
| 类别 | 接入点 | override 方法 |
|---|---|---|
| ANDROID_ID(16 hex) | Settings$Secure->getString 的 key="android_id" |
callStaticObjectMethodV |
| 设备序列号 | Build->SERIAL:Ljava/lang/String; 静态字段 |
getStaticObjectField |
| 首装/更新时间 | PackageInfo->firstInstallTime:J / lastUpdateTime:J |
getLongField |
| 屏幕分辨率 | App 自己的 SDK 函数(如 mobsec/poly/a->b) |
callStaticObjectMethodV |
| 网卡接口 + MAC | NetworkInterface->getName() + getHardwareAddress() |
callObjectMethodV |
| 进程 UID | android/os/Process->myUid()I |
callStaticIntMethodV |
实战姿势 :找一台真机作为基准机 ,用 Frida 把上面 7 类值全部 dump 出来,用 dump 出来的真值原样硬编码到 Unidbg (具体值这里不贴------直接把别人的设备指纹复制过去会被服务端按"重复指纹"检测出来,必须用你自己抓的)。基准机一旦定下来,永远不要换------换一台真机意味着重新 dump 全部字段,且 Frida 抓取顺序和 dump 时机的微小差异会让"自洽性"破裂。
系统化排查未知随机源
上面四类是常见的,但实际样本会有意想不到的姿势。下面是一套系统化排查方法。
方法 1:开启 JNI 日志,扫"time/random/date"
BaseVM.setVerbose(true) 会打印所有 JNI 调用:
java
VM vm = emulator.createDalvikVM(apkFile); // createDalvikVM 返回的是 VM 接口
vm.setVerbose(true); // 打印所有 JNI 调用
跑一遍,把 stdout 重定向到文件,grep:
bash
grep -iE "time|random|date|nano|clock|currenttimemillis" jni.log
任何匹配上的地方,都是潜在的随机源。
方法 2:在 SyscallHandler 上加日志
SyscallHandler 没有现成的 setVerbose 开关 ------ 你得自己继承 ARM64SyscallHandler 并重写 hook(...),在分发到 super.hook(...) 前打一行日志:
java
public class LoggingSyscallHandler extends ARM64SyscallHandler {
@Override
public void hook(Backend backend, int intno, int swi, 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, swi, user);
}
}
接入姿势见第九章"注册方式"一节 ------继承 AndroidARM64Emulator 并 override createSyscallHandler(SvcMemory),是 unidbg 官方扩展点(AbstractEmulator.java:164)。grep 日志:
bash
grep -iE "NR=113|NR=169|NR=278" syscall.log # clock_gettime / gettimeofday / getrandom
方法 3:扫文件访问日志
unidbg 没有直接的文件访问开关,统一在 IOResolver 里加日志:
java
emulator.getSyscallHandler().addIOResolver(new IOResolver<AndroidFileIO>() {
@Override
public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emu, String pathname, int oflags) {
System.out.println("OPEN " + pathname); // 记录所有访问路径
return null; // 不拦截, 让默认逻辑继续
}
});
跑完 grep urandom / random / proc/stat / proc/uptime。
方法 4:Diff 两次输出
终极武器,也是最准的:
- 跑两次,把每次的输出 (中间状态、Trace) 都保存
diff run_1.txt run_2.txt- 任何差异 → 必然来自一个未固定的随机源
特别是配合指令级 Trace:
java
PrintStream out1 = new PrintStream(new FileOutputStream("trace_1.txt"));
emulator.traceCode(funcStart, funcEnd).setRedirect(out1);
// 跑一次
// 然后改输出文件名跑第二次
diff trace_1.txt trace_2.txt 出来的位置,就是有随机源参与的指令位置。从那个地址往回追溯,就能定位到随机源。
实战:固定一个 currentTimeMillis 引发的连锁反应
讲个真实的小故事来收尾。
某次我在分析一个 SDK 的请求签名算法。固定了 time() / rand() / urandom,结果还是每次不一样。
排查方法 4 上场,diff 出来一行可疑的:
yaml
< [0x5678] mov w0, #0x18b91234
---
> [0x5678] mov w0, #0x18b91567
这是一个 mov 立即数 ------ 立即数怎么会变?
回去看代码,发现这个立即数是从某个全局变量读出来的,而那个全局变量是在另一个函数里写入的:
c
g_session_id = (int)(currentTimeMillis() & 0x7fffffff);
但是这个 currentTimeMillis 不是来自 Java ,而是来自 SO 内部用 gettimeofday 自己拼的:
c
struct timeval tv;
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,问题解决。完整代码:
java
// gettimeofday(struct timeval *tv, struct timezone *tz)
// 在 ARM64 上走 vDSO, SyscallHandler 拦不到, 必须在 libc 入口拦
hookZz.replace(libc.findSymbolByName("gettimeofday"), new ReplaceCallback() {
@Override
public 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 两层都固定一次------这是防御性补全,不是冗余。
几个常见的坑

最后留几个我踩过的、想留给以后翻这一篇的你。
坑 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。
应对 :固定时,让两次调用之间有一个小幅递增:
java
private long counter = 1700000000000L;
@Override
public long callLongMethod(...) {
if (sig.equals(".../currentTimeMillis()J")) {
return counter++; // 每次 +1ms
}
}
坑 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 |
最重要的两条规则:
- 固定随机是补环境之前的事,不是之后的事。先固定,再补,再验证。
- Frida 和 Unidbg 必须固定同样的项,否则对比无意义。