猿人学 App 第二十二题「书中自有黄金屋」离机复现:STOMP + native 魔改 TEA

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

[猿人学 App 逆向系列]第二十二题「书中自有黄金屋【stomp协议】」。这题把两样东西叠在一起:传输走 STOMP over WebSocket,payload 由 native 加密
完整链路:反汇编 libtwentytwo.so 还原一个 TEA 变种 → Frida 逐位对拍验证 → 抓 App 的 WebSocket 帧(解 RFC6455 掩码)补齐 payload 的 base64 包装 → 手写 STOMP 客户端离机取数。

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

本文目录

  • 请求模型:STOMP 订阅 + 发送
  • native encrypt:反汇编还原 TEA 变种
  • 输入块是什么:page + 一个”奇怪的时间戳”
  • Frida 逐位对拍
  • 最后一公里:抓 WS 帧解掩码,发现 base64
  • 纯 Python STOMP 客户端

一、请求模型:STOMP 订阅 + 发送

ChallengeTwentyTwoFragment 用的是 STOMP over WebSocket(naiksoftware/StompProtocolAndroid):

1
2
3
stomp = Stomp.over(OKHTTP, <ws_url>);         // 连接
stomp.topic(<sub>).subscribe(...); // 订阅:收数据
stomp.send(<dest>, nativeLib.encrypt(page)); // 发送:payload = native encrypt(page)

混淆字符串用 JVM 预言机解出来:

1
2
3
ws_url = ws://47.95.8.136:8888/api-endpoint/websocket
sub = /topic/api22-receive (广播 topic,数据从这里下发)
dest = /topic/api22 (往这里发,触发服务端处理)

NativeLib:

1
2
static { System.loadLibrary("twentytwo"); }
public native String encrypt(int page);

所以核心是搞清 libtwentytwo.soencrypt(int)

二、native encrypt:反汇编还原 TEA 变种

libtwentytwo.so 的 JNI 是 RegisterNatives 动态注册(不是 Java_... 导出名)。读 JNI_OnLoad 里的 JNINativeMethod 表,encrypt/(I)Ljava/lang/String; 绑定到内部符号 f1

反汇编 f1,核心是个循环,一眼 TEA/XTEA——因为出现了魔数 0x9E3779B9(TEA delta) 和 32 轮:

1
2
3
4
5
6
7
8
mov  w14, #0x79b9 ; movk w14, #0x9e37, lsl #16   ; delta = 0x9E3779B9
mov w8, #0x20 ; 32 轮
; key 内联常量:
; k0=0x01234567 k1=0x89abcdef k2=0xfedcba98 k3=0x76543210
loop:
; v0 += (k0+(v1<<4)) ^ (v1+sum) ^ (k1+(v1>>5))
; v1 += (k2+(v0<<4)) ^ (sum+v0) ^ (k3+(v0>>5))
; sum += delta

但它不是标准 XTEA:标准 XTEA 每半轮之间会更新 sum、且用 sum 的不同位去索引 key;这里两个半轮共用同一个 sum(轮末才 +delta)、key 固定 k0/k1、k2/k3,还把移位项直接 k + (v<<4) 相加再异或。总之是一套自定义的 TEA 变种,照着汇编逐行转写即可。

三、输入块是什么:page + 一个”奇怪的时间戳”

f1 里两个输入字:

  • v0 = page(JNI 传入的 int);
  • v1 = get_timestamp()

反汇编 get_timestamp:它拿一个时间值,乘以除 1000 的魔数(0x20C49BA5E353F7CF + 右移 7)做除以 1000,结果存进 **32 位寄存器 w0**。

坑就在这:Frida hook 出来 v1 = 1624913135,可设备当前时间是 2026 年(1788331385),差了 5 年——一开始以为是”秒”,怎么都对不上。用模运算反推才明白:底层时间取的是微秒,/1000 得到毫秒,再塞进 32 位寄存器发生截断,所以

1
v1 = (当前 epoch 毫秒) & 0xFFFFFFFF

1788331308271 ms & 0xFFFFFFFF = 1624913135 ——对上了。**”截断到 32 位”这一步,不反汇编 get_timestamp 是猜不出来的。**

四、Frida 逐位对拍

光靠读汇编容易在细节上翻车,拿 Frida 做真机对拍最稳。注意本机 Frida 17 移除了 Module.findBaseAddress,要用 Process.findModuleByName:

1
2
3
4
var base = Process.findModuleByName("libtwentytwo.so").base;
// 加密前读输入,加密后读输出两个字
Interceptor.attach(base.add(0x42054), function(){ send("IN "+this.context.x22.and(0xffffffff)+" "+this.context.x23.and(0xffffffff)); });
Interceptor.attach(base.add(0x42094), function(){ send("OUT "+this.context.x22.and(0xffffffff)+" "+this.context.x23.and(0xffffffff)); });

真机跑一次翻页:IN page=1 v1=1624913135OUT 1581750949 3716016438。拿同样的输入喂给 Python 实现,输出逐位一致——算法确认无误。

五、最后一公里:抓 WS 帧解掩码,发现 base64

算法对了,可手写 STOMP 客户端 SEND 过去却服务端毫无反应(而”只订阅不发”能收到 App 触发的广播,说明订阅没问题)。差别一定在 SEND 帧本身。

明文 ws:// 不走 mitm,而且 WebSocket 客户端帧按 RFC6455 是掩码的,socket 层直接读是乱码。hook libc sendto 拿原始字节,自己解掩码:

1
2
3
# 81 bc <mask4> <masked...>   81=text帧, bc=掩码+60字节
b = bytes.fromhex(frame); ln=b[1]&0x7f; mask=b[2:6]; payload=b[6:6+ln]
print(bytes(payload[i]^mask[i%4] for i in range(ln)).decode())

解出来 App 真正发的是:

1
2
3
4
SEND
destination:/topic/api22

MzM1ODUzNzA4OjIyNDYwNTQ1Mw== # <- base64!解码 = "335853708:2246054553"

两个之前漏掉的点:① SEND body 是 base64("v0:v1"),不是裸的 “v0:v1”;② 帧里没有 content-length。补上这两点,服务端立刻回数据。

六、纯 Python STOMP 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import time, json, base64, websocket
M=0xffffffff; DELTA=0x9E3779B9; KEY=(0x01234567,0x89abcdef,0xfedcba98,0x76543210)

def encrypt(page):
v0, v1 = page & M, int(time.time()*1000) & M # v1 = epoch 毫秒 & 0xFFFFFFFF
k0,k1,k2,k3 = KEY; s = DELTA
for _ in range(32):
v0 = (v0 + (((k0+((v1<<4)&M))&M) ^ ((v1+s)&M) ^ ((k1+(v1>>5))&M))) & M
v1 = (v1 + (((k2+((v0<<4)&M))&M) ^ ((s+v0)&M) ^ ((k3+(v0>>5))&M))) & M
s = (s + DELTA) & M
return base64.b64encode(f"{v0}:{v1}".encode()).decode()

def frame(cmd,h,b=""): return cmd+"\n"+"".join(f"{k}:{v}\n" for k,v in h.items())+"\n"+b+"\x00"

ws = websocket.create_connection("ws://47.95.8.136:8888/api-endpoint/websocket", timeout=20)
ws.send(frame("CONNECT",{"accept-version":"1.1,1.0","heart-beat":"0,0"})); ws.recv()
ws.send(frame("SUBSCRIBE",{"id":"sub-0","destination":"/topic/api22-receive"})); time.sleep(0.5)
total=0
for page in range(1,101):
ws.send(frame("SEND",{"destination":"/topic/api22"}, encrypt(page))) # 只带 destination
for raw in ws.recv().split("\x00"):
if raw.lstrip("\n").startswith("MESSAGE"):
data = json.loads(raw.split("\n\n",1)[-1])["data"]
total += sum(int(x["value"]) for x in data) # 每页 20 个数
print("ANSWER =", total)

100 页取齐,求和即通关。

小结

第二十二题是这一批里最”厚”的一题,三层皮都得揭:

  1. 传输层:STOMP over WebSocket——订阅 /topic/api22-receive 收广播,SEND/topic/api22 触发;
  2. 算法层:native encrypt魔改 TEA(delta 标准、key 内联、但轮结构非标准),v1 还是被截断到 32 位的 epoch 毫秒——这个只有反汇编 get_timestamp 才看得出;
  3. 封装层:payload 要 base64SEND不带 content-length——这两点靠抓 WS 帧 + 解掩码才补齐。

方法论上最值得记的是:读汇编 + Frida 对拍确认算法,hook sendto 解 RFC6455 掩码看清应用层真正发的字节。当”算法明明对了却收不到响应”时,别怀疑算法,去看封装的最后一层


猿人学 App 第二十二题「书中自有黄金屋」离机复现:STOMP + native 魔改 TEA
https://kingjem.github.io/2026/09/02/猿人学App第二十二题离机复现-STOMP与native魔改TEA/
作者
Ruhai
发布于
2026年9月2日
许可协议