猿人学 App 第三题「so 动态注册」离机复现:Looper 反模拟与一场"字节全对却被拒"的追凶
本文最后更新于:2026年9月1日 下午
[猿人学 App 逆向系列]第三篇。第三题「众里寻他千百度【so动态注册】」的 sign 由 native 生成。
这题是目前最曲折的一道:sign 逐字节逆出来了、模拟器和真机对拍一致,服务器却拒绝一切请求。本文完整记录这场追凶——从”设备时钟快 78 秒”,到最终揪出 native 里的Looper.myLooper()反模拟,再到在 Unicorn 里绕过它、做成真离线客户端。⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。
本文目录
- 请求模型 + so 动态注册(真身与诱饵)
- Unicorn 模拟 sign:与真机对拍一致
- 追凶(一):字节全对,服务器却拒
- 追凶(二):设备时钟快了 78 秒
- 追凶(三):app 的 sign 和我的不一样 —— Looper 反模拟
- 绕过 Looper,做成真离线
一、请求模型 + so 动态注册
ChallengeThreeFragment 翻页:
1 | |
ChallengeThreeNativeLib:static { System.loadLibrary("three"); } public native byte[] sign(int);。
侦察 libthree.so:不加壳、不 goron;sign 有一个静态导出 Java_..._sign。但题名叫「so动态注册」——真身其实是 JNI_OnLoad 里 RegisterNatives 注册的那个。用 Unicorn 模拟 JNI_OnLoad、在 RegisterNatives 处截获方法表,果然:
1 | |
静态导出是诱饵,动态注册的 0x2104c 才是真身(RegisterNatives 会覆盖同名静态导出)。
二、Unicorn 模拟 sign:与真机对拍一致
用 Unicorn 从 0x2104c 跑起来,打桩 JNIEnv/libc,捕获它 NewByteArray + SetByteArrayRegion 写出的 20 字节。函数里能看到 MD4 的 IV/常量,输入是 sprintf("%d", page) 与 time() 秒。
对拍:Frida 在真机上直接调 0x2104c 取 20 字节,喂模拟器相同的秒——逐字节一致。看起来收工了。
三、追凶(一):字节全对,服务器却拒
把模拟器现算的 sign(转大写 hex)打 /api/app3:
1 | |
逐字节正确、当前秒现算现提交、±300s 全扫、紧循环 25 次……全拒。而 app1/app2 从同一主机同一 IP 都正常,排除 IP 被封。诡异。
四、追凶(二):设备时钟快了 78 秒
先确认 app 自己能不能加载第三题——截图一看:界面一直转圈、加载不出。再一比时钟:
1 | |
sign 内嵌的是 native time()(设备时钟)。设备快 78s → sign 带的是”未来时间” → 服务器判定不新鲜 → 连 app 自己都过不了。root 下把设备时钟校准:
1 | |
再进第三题——app 出数据了(”请计算1-100页所有数字之和” + 一列数字)。机制确认:sign(page, 正确的秒) 有效。
五、追凶(三):app 的 sign 和我的不一样 —— Looper 反模拟
可我主机的模拟器 sign 还是被拒。于是做了最关键的一步对比:hook app 主线程真实调用的 sign,和我模拟器/Frida(都在非主线程)产出的 sign 放一起——
1 | |
不一样! 反汇编 sign 核心,真相浮出水面:
1 | |
字符串常量坐实:android/os/Looper + myLooper + ()Landroid/os/Looper;。
这是专门的反模拟/反 Frida:sign 回调 Looper.myLooper(),只有在有 Looper 的线程(app 主线程)才生成有效 sign;在模拟器、或 Frida 主动调用所在的工作线程(都没有 Looper)上,sign 被故意搞坏。难怪逐字节”对拍一致”的是两个同样无效的版本。
六、绕过 Looper,做成真离线
对症下药:让模拟器的 JNIEnv 在这次回调上返回非空即可走有效分支。先打日志看 sign() 实际调了哪些 JNIEnv 索引:
1 | |
于是把 索引 115(CallStaticObjectMethodV)桩成非空(伪造一个 Looper 指针):
1 | |
再对拍——emu-match-offset = 0,模拟器产出的 sign 与 app 主线程完全一致!
最后补上”时钟”这一环:服务器严格按自己的时钟校验 sign 内嵌的秒,而开发机时钟可能有偏差(我这台走代理,偏了 ~1.4s)。客户端做自校准——围绕本机当前秒试 now, now-1, now-2 …,命中一次就记住这个偏移:
1 | |
跑通——纯离线(Unicorn 模拟 + 自校准时钟,不碰手机)逐页取数。
小结
第三题把”反离线”写进了 native 本身:
- so动态注册:真身藏在 RegisterNatives,静态导出是诱饵;
- Looper 反模拟:
myLooper()==null(模拟器/Frida 工作线程)时故意产出无效 sign——这也解释了为什么”模拟器和 Frida 对拍一致”具有迷惑性,因为它俩都是无效版本; - 时钟强校验:sign 内嵌
time()秒,设备时钟一偏,连 app 自己都过不了。
破法对应三步:Unicorn 跑 JNI_OnLoad 揪真身 → 把 CallStaticObjectMethodV 桩成非空绕过 Looper → 自校准时钟。这场”字节全对却被拒”的追凶,最大的教训是:对拍一致 ≠ 正确,当两边都跑在”被反制的环境”里时,它们会一致地错。