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

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

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


上一篇把你留在了哪里

第十二篇我们打开了 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 次看是否一致"沉淀成一键脚本:

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);
}

⚠️ callXxxMethodcallXxxMethodV 必须两个都拦 。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)
  • gettimeofday
  • getrandom
  • getuid / 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_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 把 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 两次输出

终极武器,也是最准的:

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

特别是配合指令级 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

最重要的两条规则:

  1. 固定随机是补环境之前的事,不是之后的事。先固定,再补,再验证。
  2. Frida 和 Unidbg 必须固定同样的项,否则对比无意义。
相关推荐
泡泡以安1 小时前
Unidbg学习笔记(十六):Console Debugger
android·逆向
赏金术士1 小时前
Room + Flow 完整教程(现代 Android 官方方案)
android·kotlin·room·compose
泡泡以安1 小时前
Unidbg学习笔记(八):文件系统层补环境
android·逆向
泡泡以安1 小时前
Unidbg学习笔记(六):补环境的思维框架
android·逆向
通往曙光的路上2 小时前
mysql2
android·adb
木易 士心2 小时前
会见SDK文档
android
Co_Hui3 小时前
Android:多线程
android
赏金术士3 小时前
Kotlin 协程面试题大全(Android 高频版)
android·开发语言·kotlin
y小花3 小时前
DRM-Direct Rendering Manager
android·drm