猿人学 App 第五题离机复现:OLLVM 包裹的变形 MD4 纯 Python 还原

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

猿人学 App 第五题(HTTP / dex,难度”简单”)的纯离机复现记录:不驱动 App,只靠逆向把签名算法搞清楚。
端点靠反射 Retrofit 运行期注解读出;签名是 assets/encrypted.dex 里一个被 OLLVM 混淆的变形 MD4——本文记录怎么在反编译失败的情况下,用 ASM 插桩 dump 执行轨迹反推出这个变形,并还原成零依赖纯 Python

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

本文目录

  • 手上的静态素材
  • 请求模型(静态反编译)
  • 签名:OLLVM 包裹的变形 MD4
  • 纯 Python 还原 + JVM 预言机对拍
  • 取数与反爬

一、手上的静态素材

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

  • base.apkclasses*.dex(dex2jar 转 app.jar)、assets/encrypted.dex
  • 工具:androguard(纯 Python 反编译)、dex2jar + 本机 Temurin JDK(把提取出的类当离机预言机跑)、jadx/CFR(反编译)、ASM(字节码插桩)

二、请求模型(静态反编译)

ChallengeFiveFragment 的翻页逻辑很直白:

1
2
3
String content = page + ":" + System.currentTimeMillis();   // 例:"1:1787308899669"
String sign = DexUtils.OooO0O0(ctx, this, content); // 32 位十六进制
service.OooOOOo(content, sign);

端点不用解字符串加密——Retrofit 的 @POST/@FieldRUNTIME 注解dex2jar 转 jar 后直接反射接口就能读出来:

1
2
@FormUrlEncoded @POST("/api/app5")
OooOOOo(@Field("content") content, @Field("sign") sign)

POST /api/app5,表单 content=页号:毫秒sign=签名;响应 JSON 每页 10 个数字,且按页号确定性返回

三、签名:OLLVM 包裹的变形 MD4

DexUtils.OooO0O0assets/encrypted.dex逐字节 XOR 0xA6)内存加载,调用 com.yuanrenxue.challenge.five.Sign.sign()。这个 sign()21938 条指令、控制流扁平化 + 字符串加密,jadx 和 CFR 都无法反编译主体——但辅助函数 f/g/h 和常量 0x5A827999/0x6ED9EBA1 一眼就是 MD4

麻烦的是 md4(content) ≠ 实测签名。于是换思路:用 ASM 给 ff/gg/hh 插桩,跑一次 dump 出执行轨迹(每步的 a,b,c,d, 消息字, 移位),再把每步的消息字反查回下标,还原出完整调度。结论——这是一个四处被改动的 MD4

改动点 标准 MD4 本题
IV 的 C 字 0x98BADCFE 0xEFCDAB98
第 1 轮移位 3,7,11,19 3,9,11,19
第 2 轮移位 3,5,9,13 3,5,7,13
第 3 轮移位 3,9,11,15 一致
预处理 正式 48 步前对字 0 多做一次 a=rotl(a+F(b,c,d)+X0,5)只改 a、不轮换
输出 定长 hex 4 个结果字各自 Integer.toHexString逐字丢前导 0)拼接

四、纯 Python 还原 + JVM 预言机对拍

按上面反推的调度纯 Python 复现;再用 dex2jar 把 Sign 类跑在本机 JVM 上当预言机,喂随机输入对拍——360 组 0 差异才算真复现。核心实现:

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
26
27
import struct
def _F(x,y,z): return (x&y)|((~x&0xffffffff)&z)
def _G(x,y,z): return (x&y)|(x&z)|(y&z)
def _H(x,y,z): return x^y^z
def lrot(x,n): x&=0xffffffff; return ((x<<n)|(x>>(32-n)))&0xffffffff
# 49 步调度:第 0 步为预处理(只更新 a,不轮换)
_SCHED=[('ff',0,5),
('ff',0,3),('ff',1,9),('ff',2,11),('ff',3,19),('ff',4,3),('ff',5,9),('ff',6,11),('ff',7,19),
('ff',8,3),('ff',9,9),('ff',10,11),('ff',11,19),('ff',12,3),('ff',13,9),('ff',14,11),('ff',15,19),
('gg',0,3),('gg',4,5),('gg',8,7),('gg',12,13),('gg',1,3),('gg',5,5),('gg',9,7),('gg',13,13),
('gg',2,3),('gg',6,5),('gg',10,7),('gg',14,13),('gg',3,3),('gg',7,5),('gg',11,7),('gg',15,13),
('hh',0,3),('hh',8,9),('hh',4,11),('hh',12,15),('hh',2,3),('hh',10,9),('hh',6,11),('hh',14,15),
('hh',1,3),('hh',9,9),('hh',5,11),('hh',13,15),('hh',3,3),('hh',11,9),('hh',7,11),('hh',15,15)]
def sign(content):
M=content.encode(); b=bytearray(M); b.append(0x80)
while len(b)%64!=56: b.append(0)
b+=struct.pack('<Q',len(M)*8)
A,B,C,D=0x67452301,0xefcdab89,0xEFCDAB98,0x10325476 # C 被篡改
X=list(struct.unpack('<16I',b[:64])); a,bb,c,d=A,B,C,D
for i,(t,idx,s) in enumerate(_SCHED):
fn=_F if t=='ff' else (_G if t=='gg' else _H)
K=0 if t=='ff' else (0x5a827999 if t=='gg' else 0x6ed9eba1)
na=lrot((a+fn(bb,c,d)+X[idx]+K)&0xffffffff,s)
if i: a,bb,c,d=d,na,bb,c # 标准 MD4 轮换
else: a=na # 预处理:不轮换
A=(A+a)&0xffffffff;B=(B+bb)&0xffffffff;C=(C+c)&0xffffffff;D=(D+d)&0xffffffff
return ''.join(format(w&0xffffffff,'x') for w in (A,B,C,D)) # 逐字 toHexString

五、取数与反爬

有了纯 Python 的 sign(),客户端就能自己拼 content=页号:毫秒 + 签名,直连公网逐页取数。服务器对同 IP 限频(约 60 次/窗口后 403 Access forbidden),对策是请求间隔 ~0.7s + 403 退避(重试时用新时间戳重新签名)。

到这一步就完全离机、零依赖了:不碰手机、不加载 dex,纯 Python 复刻签名即可翻页取数。

小结

这题的关键判断是**”是不是标准算法”——f/g/h + 那两个常量一露头,就该往 MD4 靠。真正花功夫的是确认它被四处魔改:靠 ASM 插桩 dump 执行轨迹、再用本机 JVM 当预言机对拍,才敢说”干净还原成纯 Python”。对可反编译失败但本质是标准算法变体的 dex 层混淆,插桩取轨迹 + 预言机对拍**是很省事的正解。


猿人学 App 第五题离机复现:OLLVM 包裹的变形 MD4 纯 Python 还原
https://kingjem.github.io/2026/08/26/猿人学App第五题离机复现-变形MD4纯Python还原/
作者
Ruhai
发布于
2026年8月26日
许可协议