猿人学 App 第二十三题「rustndk」离机复现:从 Rust NDK 里抠出标准 XTEA

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

[猿人学 App 逆向系列]第二十三题「九重春色醉仙桃【rustndk】」。签名逻辑放在 Rust 写的 NDK 库(libtwentythree.so)里,JNI 方法 data(int page) 直接返回 sign 串。
Rust 二进制的反汇编乍看比 C 更”劝退”(泛型、迭代器、panic 分支满天飞),但这题的核心其实是一段标准 XTEA。本文记录:靠调试符号缩小范围 → 反汇编认出 XTEA → Frida 逐位对拍确定输入编码 → 纯 Python 复现。

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

本文目录

  • 请求模型:sign 来自 native data(page)
  • 先读”招牌”:Rust 的 crate 都写在二进制里
  • 反汇编:0x9E3779B9 + 32 轮 = XTEA
  • 认清是标准 XTEA 而非 TEA/XXTEA
  • Frida 对拍:定位输入编码
  • 纯 Python 复现

一、请求模型:sign 来自 native data(page)

ChallengeTwentyThreeFragment 很干净:

1
2
3
4
5
static { System.loadLibrary("twentythree"); }
private native String data(int i);

// 翻页:
api.OooOOOO(page, data(page)); // POST /api/app23 @Field page / @Field sign

data(page) 是 Rust native 返回的字符串,就是 sign。请求只有 page + sign,没有单独的时间戳字段——说明时间被编进 sign 里了。

二、先读”招牌”:Rust 的 crate 都写在二进制里

Rust release 库虽然函数符号常被 strip,但 **panic 信息 / file!() 宏会把 .cargo/registry/.../<crate>-<版本>/src/xxx.rs 的路径原样打进 .rodata**。strings 一扫就知道用了什么:

1
2
3
jni-0.17.0        # JNI 绑定
cesu8-1.1.0 # JNI 的改良 UTF-8
miniz_oxide-0.7 # zlib/deflate

没有任何 crypto 哈希 crate(没有 sha2/md5/hmac)。这基本排除了”标准哈希”,提示签名要么是自研位运算,要么是经典轻量分组算法。带着这个先验去反汇编,方向就清楚多了。

三、反汇编:0x9E3779B9 + 32 轮 = XTEA

Java_..._data 是标准 JNI 导出,好定位。反汇编它,很快撞见两个信号:

1
2
3
4
mov  w9,  #0x79b9 ; movk w9, #0x9e37, lsl #16   ; = 0x9E3779B9  (TEA 系列 delta!)
mov w10, #0x20 ; 32 轮
; 内联的 128 位 key(两个 64 位常量):
; x10 = 0xf578c3b2dd899602 , x11 = 0x94ada1ab7d8a2e7f

0x9E3779B9(黄金分割常量)+ 32 轮几乎是 TEA 家族的身份证。再看循环体:

1
2
3
4
5
6
7
8
9
10
11
12
loop:
and w12, w8, #3 ; sum & 3
ldr w12, [key + (sum&3)*4] ; key[sum & 3]
lsl w11, v1, #4 ; eor w11, w11, v1, lsr #5 ; add w11, w11, v1 ; ((v1<<4)^(v1>>5)) + v1
add w12, sum, w12 ; sum + key[sum&3]
add sum, sum, delta ; sum += delta ← 在两半之间累加
eor w11, w12, w11
add v0, w11, v0 ; v0 += ...
ubfx w12, sum, #0xb, #2 ; (sum>>11) & 3
ldr w11, [key + ((sum>>11)&3)*4]
... add v1, ..., v1 ; v1 += (((v0<<4)^(v0>>5))+v0) ^ (sum + key[(sum>>11)&3])
subs w10, w10, #1 ; b.ne loop

四、认清是标准 XTEA 而非 TEA/XXTEA

TEA 家族有好几个变种,区别全在细节:

  • TEA:key 固定 k0/k1、k2/k3,sum 两半共用;
  • XXTEA:整块循环、上下文相关;
  • XTEA:sum 在两半之间累加,且用 key[sum & 3]key[(sum>>11) & 3] 动态取密钥字。

这段汇编里 and w8,#3key[sum&3]ubfx …#0xb,#2key[(sum>>11)&3]sum += delta 夹在两半中间——这正是教科书 XTEA。认出这个”取密钥字的方式”,就能把它和 TEA/XXTEA 区分开(第 22 题那道就是个非标准的 TEA 变种,别混)。

key 从内联的两个 64 位常量按小端拆成 4 个字:

1
key = [0x7d8a2e7f, 0x94ada1ab, 0xdd899602, 0xf578c3b2]

五、Frida 对拍:定位输入编码

XTEA 的输入是两个 32 位字 v0, v1v0 早就从 w2(JNI 的 page 参数)来,那 v1 是什么时间编码?光看汇编里那一堆时间转换(clock_gettime + 除 1000 的魔数)容易数错精度。直接 Frida 对拍最稳:

1
2
3
4
5
6
7
8
9
10
11
// 注意 Frida 17:用 Process.findModuleByName,不是 Module.findBaseAddress
var addr = Process.findModuleByName("libtwentythree.so")
.findExportByName("Java_com_yuanrenxue_challenge_fragment_challenge_ChallengeTwentyThreeFragment_data");
Interceptor.attach(addr, {
onEnter(a){ this.env=a[0]; this.page=a[2].toInt32(); },
onLeave(ret){ // 用 JNI 函数表 GetStringUTFChars(idx 169) 读返回串
var g=new NativeFunction(this.env.readPointer().add(169*8).readPointer(),
'pointer',['pointer','pointer','pointer']);
send("page="+this.page+" sign="+g(this.env, ret, ptr(0)).readCString());
}
});

抓到一条:page=1,并同时 hook clock_gettime 拿到 native 用的精确时间 CLOCK_REALTIME = 1788336369.613…,输出 sign = 9e129c4e1718c4f7(16 hex = 两个 32 位密文字大端拼接)。

拿这条去暴力/反解输入编码,一发命中:

1
2
3
v0 = page = 1
v1 = (epoch 毫秒) & 0xFFFFFFFF = 1788336369613 & 0xffffffff = 1629974477
XTEA(v0, v1, key) -> (0x9e129c4e, 0x1718c4f7) -> "9e129c4e1718c4f7" ✓ 逐位一致

关键坑:v1 不是”秒”也不是完整”毫秒”,而是毫秒截断到 32 位——native 把毫秒塞进 32 位寄存器时溢出丢高位。这种细节不对拍很难猜准。

六、纯 Python 复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time, requests
M=0xffffffff; DELTA=0x9E3779B9
KEY=[0x7d8a2e7f, 0x94ada1ab, 0xdd899602, 0xf578c3b2]

def xtea(v0, v1):
s=0
for _ in range(32):
v0=(v0 + (((((v1<<4)&M)^(v1>>5))+v1 & M) ^ ((s+KEY[s&3])&M)))&M
s=(s+DELTA)&M
v1=(v1 + (((((v0<<4)&M)^(v0>>5))+v0 & M) ^ ((s+KEY[(s>>11)&3])&M)))&M
return v0, v1

def sign(page):
v0,v1 = xtea(page, int(time.time()*1000)&M) # v1 = epoch 毫秒的低 32 位
return f"{v0:08x}{v1:08x}"

sess=requests.Session(); total=0
for page in range(1,101):
r=sess.post("https://www.python-spider.com/api/app23",
data={"page":page,"sign":sign(page)},
headers={"User-Agent":"okhttp/4.9.2"}, timeout=15).json()
total += sum(int(x["value"]) for x in r["data"]) # 每页 10 个数
print("ANSWER =", total)

自检(Frida 那条)+ 服务器 200 双双通过,逐页求和即通关。服务器端用同 key XTEA 把 sign 解回 (page, ms32),校验 page、并用 ms32 做新鲜度。

小结

第二十三题的”难”是心理上的——一看 “rustndk” + 3MB 二进制就容易怯。但拆开看:

  1. Rust 的 crate 清单白送(panic 路径里),先排除标准哈希、锁定”轻量分组算法”;
  2. 0x9E3779B9 + 32 轮 = TEA 家族;key[sum&3] / key[(sum>>11)&3] 的取字方式 = 标准 XTEA;
  3. Frida 逐位对拍确定输入编码(v0=page,v1=毫秒&0xFFFFFFFF,那个 32 位截断是精髓)。

一句话:Rust native 不神秘,认出经典算法的”指纹”(magic 常量 + 取密钥方式)比逐行读汇编重要得多。


猿人学 App 第二十三题「rustndk」离机复现:从 Rust NDK 里抠出标准 XTEA
https://kingjem.github.io/2026/09/02/猿人学App第二十三题离机复现-从RustNDK里抠出标准XTEA/
作者
Ruhai
发布于
2026年9月2日
许可协议