猿人学 App 第四题离机复现:Unicorn 忠实模拟 goron so 造 UDP 包

本文最后更新于:2026年9月1日 下午

猿人学 App 第四题(UDP / native,难度”困难”)的纯离机复现记录:不驱动 App,逆向 libfour.so 的请求逻辑。
libfour.sogoron/OLLVM 混淆,请求包由双 PRNG 造出。本文记录先看穿响应结构(头部即”位置表”)省掉逆 PRNG,再用 Unicorn 忠实模拟 echo_cli 造包、本机直连公网取值的全过程。

⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。

本文目录

  • 手上的静态素材
  • 协议与响应(最关键的发现)
  • goron 造包:用 Unicorn 忠实模拟
  • 取值流程
  • 为什么第四题没做成纯 Python

一、手上的静态素材

从设备 pullbase.apk 后就再没碰过手机:

  • base.apklib/arm64-v8a/libfour.so
  • 工具:unicorn + lief + capstone(模拟 / 反汇编 native),都是 pip 可装的纯 Python 库

二、协议与响应(最关键的发现)

ChallengeFourActivity.onCreate 是 native,逻辑全在 libfour.so(arm64,goron/OLLVM:控制流扁平化 + 运行期填充的间接跳转表)。硬编码服务器 47.95.8.136:12000(UDP),关键符号:echo_cli(造包+发送)、get_timestampxorshift64RC4::*minstd_rand(LCG 48271)+ uniform_int_distribution<uint8>

先看响应(1024 字节)。最关键的发现是它的头部就是”位置表”——key 和 value 都散布在响应里,取值根本不用逆请求侧的 PRNG

1
2
3
4
def decode(resp):
key = bytes(resp[resp[1+i]] for i in range(16)) # resp[1..16] = 16 个 key 字节的位置
vb = bytes(resp[resp[17+i]] for i in range(4)) # resp[17..20] = 4 个 value 字节的位置
return struct.unpack("<I", rc4(key, vb))[0] # 数值 = uint32_LE(RC4(key, vbytes))

而且本机公网能直连 12000 端口,连一个全零包都能收到 1024 字节响应。所以只剩一个问题:如何构造一个携带指定时间戳的请求包

三、goron 造包:用 Unicorn 忠实模拟

echo_cliget_timestamp() 的毫秒时间戳派生 PRNG、组 255 字节包、sendto 发送。这段被 goron 的间接跳转(realaddr = *(table+off) + delta 大常量算术)包裹,纯静态啃代价高。于是**用 Unicorn 模拟 echo_cli**:

  1. 按 program header 映射各 LOAD 段;
  2. 应用重定位(踩坑点):
    • R_AARCH64_RELATIVE*addr = addend(填 goron 跳转表,addend 常是 0xffffffff… 负值靠 64 位回绕落回模块内);
    • R_AARCH64_ABS64(导入符号):***addr = stub + addend** —— goron 会在调用点 +(magic - state) 把 addend 抵消回 stub漏加 addend 会直接跳飞,这是最大的坑;
  3. 打桩:导入函数(malloc/memcpy/inet_addr/socket/clock_gettime/…)用 Python 实现或返回 0;get_timestamp hook 返回目标时间戳sendto hook 截获 255B 请求包并停机;
  4. JNIEnvenv* → 函数表 → 统一 stub,所有 JNI 调用返回 0(造包不依赖 JNI 返回值);
  5. x0=0, x1=env, x2=obj,从 echo_cli 跑到 sendto,拿到确定性请求包(同一时间戳稳定复现,不同时间戳不同)。

关键片段(重定位):

1
2
3
4
5
6
7
8
for r in b.relocations:
t, a, add = str(r.type), r.address, r.addend
nm = r.symbol.name if r.has_symbol else ""
if "RELATIVE" in t: w(a, add) # base=0
elif "ABS64" in t or "GLOB_DAT" in t or "JUMP_SLOT" in t:
if nm in sym_va: w(a, sym_va[nm]+add)
elif nm: w(a, stub_for(nm)+add) # goron: stub+addend
else: w(a, add)

get_timestamp / sendto 的 hook:

1
2
3
if addr == sym['_Z13get_timestampv']:  ret(self.TS); return          # 喂目标时间戳
...
if nm == "sendto": self.sendbuf = read(x1, x2); ret(x2); stop() # 截获请求包

四、取值流程

题目要”获取某个指定时刻的当前数值”,只要把目标时间戳喂给被模拟的 get_timestamp,就得到对应的请求包:

1
2
3
build_packet(1640391171671)255B 请求包
→ 本机 UDP 直发 47.95.8.136:12000 → 收 1024B 响应
decode(响应) → 该时刻数值

服务器接受任意时间戳并返回 f(时间戳),所以把题目给的固定时刻喂进去即得确定结果——全程离机,只依赖 unicorn 造包 + 本机 UDP 直连。

五、为什么第四题没做成纯 Python?

顺手也把造包往纯 Python 方向挖了挖:它不用 RC4;用两个 PRNG——xorshift64 链(种子=完整毫秒 ts)+ minstd_rand(种子≈秒级 ts//1000,同秒同流)+ libc++ uniform_int_distribution<uint8>;包体是交错 XOR 块(每 16 字节 = 明文^掩码明文 字节交错),且调用次数随数据变长(不同 ts 下 xorshift 286 vs 297 次)。

结论:这段造包是一段定制、混淆、双 PRNG + 变长循环的逻辑,不是标准算法。对这类 OLLVM native,用 CPU 模拟器执行真实机器码才是稳健、忠实、同样离机的正解——依赖只有 unicorn/lief,都是 pip 可装的纯 Python 可调库。

小结

这题的方法论对照上一篇第五题很有意思:先判断”是不是标准算法”。第五题的签名是标准 MD4 的变体,能干净还原成纯 Python;而第四题的造包是定制的双 PRNG 逻辑,就别硬啃混淆——用 Unicorn 把真实机器码离机跑起来。另外一个省时的洞察是:很多”看起来随机”的响应包,其实是位置表 + 对称解密,取值根本无需逆请求侧的 PRNG。


猿人学 App 第四题离机复现:Unicorn 忠实模拟 goron so 造 UDP 包
https://kingjem.github.io/2026/08/26/猿人学App第四题离机复现-Unicorn模拟goron造UDP包/
作者
Ruhai
发布于
2026年8月26日
许可协议