猿人学 App 第一题离机复现:dex 层标准 MD4(附非标准输出的坑)

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

[猿人学 App 逆向系列]的第一篇。第一题(HTTP / dex,难度”简单”)没有 native、没有加壳,签名逻辑完全写在 dex 里——是最适合开篇、把”静态读请求模型 → 认出标准算法 → 纯 Python 还原”这条主线走一遍的题。
本文记录怎么从 Fragment 顺到签名类,认出它是标准 MD4,并留意一个非标准的输出字节序的小坑。

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

本文目录

  • 请求模型(静态反编译)
  • 签名类:一眼标准 MD4
  • 唯一的坑:非标准的输出字节序
  • 纯 Python 还原

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

dex2jar 转 jar 后反射 Retrofit 接口,端点很直接:

1
2
@FormUrlEncoded @POST("/api/app1")
OooO0oo(@Field("page") Integer, @Field("sign") String, @Field("t") Long)

ChallengeOneFragment 里翻页时怎么拼参数:

1
2
3
4
5
6
sb.append("page=");
sb.append(page); // 1
long t = System.currentTimeMillis();
sb.append(t); // 1788241201381
String sign = new o00oOOoO.o000OOo().OooO(sb.toString().getBytes(UTF_8));
service.OooO0oo(page, sign, t);

content = "page=" + page + 毫秒(两者直接拼接,无分隔符,如 page=11788241201381),请求带 page / sign / t。核心就是签名类 o00oOOoO.o000OOoOooO()

二、签名类:一眼标准 MD4

o000OOo 是纯 Java(没有 native、没有加密 dex),jadx 直接还原。看几个特征就能定性:

  • 辅助函数 F=(~x&z)|(y&x)G=(x&y)|(x&z)|(y&z)H=x^y^z
  • 常量 1518500249 = 0x5A8279991859775393 = 0x6ED9EBA1
  • IV 0x67452301 / 0xEFCDAB89 / 0x98BADCFE / 0x10325476
  • 三轮移位 3,7,11,19 / 3,5,9,13 / 3,9,11,15,第三轮消息字顺序 0,8,4,12 · 2,10,6,14 · …

这就是教科书 MD4——IV、移位、常量、轮函数全是标准值,没有第五题那种魔改。消息分块 iArr[i6] = get(i7+3)<<24 | get(i7) | get(i7+1)<<8 | get(i7+2)<<16 是标准小端装字。

小注:get() 返回的是 Integer.valueOf(signedByte),字节 ≥0x80 会变负数、和 <<24 一起 OR 可能出问题;但本题 content 永远是 "page=" + 纯数字(全是 ASCII,< 0x80),不会触发,等价标准 MD4。

三、唯一的坑:非标准的输出字节序

MD4 主体是标准的,唯一要留意的是输出

1
return String.format("%02x%02x%02x%02x", i, i2, i3, i4);

标准 MD4 的 hex 摘要,是把 4 个 32 位状态字各自小端成 4 字节再拼(如字 0x6745230101234567)。而这里是 %02x 直接格式化整型,得到的是大端 hex(0x6745230167452301)。%02x 只保证最小 2 位宽(某个字很小时补前导 0),字满时就是 8 位。

所以复现时,别用现成 MD4 库的 hexdigest(那是小端字节序),要么自己实现、直接把状态字按大端 %02x 输出,要么把标准摘要每 4 字节反转一下。

四、纯 Python 还原

自己实现 MD4、按大端 %02x 输出,一把梭:

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
28
29
30
31
32
33
34
35
36
import struct, time, requests

def _F(x,y,z): return (x&y)|((~x)&z)
def _G(x,y,z): return (x&y)|(x&z)|(y&z)
def _H(x,y,z): return x^y^z
def _rol(x,s): x&=0xffffffff; return ((x<<s)|(x>>(32-s)))&0xffffffff

def _md4_words(msg):
ml=len(msg)*8; b=bytearray(msg); b.append(0x80)
while (len(b)*8+64)%512: b.append(0)
b+=struct.pack('<Q', ml & 0xffffffffffffffff)
a,bb,c,d=0x67452301,0xefcdab89,0x98badcfe,0x10325476
for off in range(0,len(b),64):
X=list(struct.unpack('<16I', bytes(b[off:off+64]))); A,B,C,D=a,bb,c,d
for i in (0,4,8,12):
A=_rol(A+_F(B,C,D)+X[i],3); D=_rol(D+_F(A,B,C)+X[i+1],7)
C=_rol(C+_F(D,A,B)+X[i+2],11);B=_rol(B+_F(C,D,A)+X[i+3],19)
for i in (0,1,2,3):
A=_rol(A+_G(B,C,D)+X[i]+0x5a827999,3); D=_rol(D+_G(A,B,C)+X[i+4]+0x5a827999,5)
C=_rol(C+_G(D,A,B)+X[i+8]+0x5a827999,9); B=_rol(B+_G(C,D,A)+X[i+12]+0x5a827999,13)
for i in (0,2,1,3):
A=_rol(A+_H(B,C,D)+X[i]+0x6ed9eba1,3); D=_rol(D+_H(A,B,C)+X[i+8]+0x6ed9eba1,9)
C=_rol(C+_H(D,A,B)+X[i+4]+0x6ed9eba1,11);B=_rol(B+_H(C,D,A)+X[i+12]+0x6ed9eba1,15)
a=(a+A)&0xffffffff; bb=(bb+B)&0xffffffff; c=(c+C)&0xffffffff; d=(d+D)&0xffffffff
return a,bb,c,d

def sign(content): return ''.join(f"{x:02x}" for x in _md4_words(content.encode())) # 大端逐字

def fetch(page, s):
ms=int(time.time()*1000)
j=s.post("https://www.python-spider.com/api/app1",
data={"page":page,"sign":sign(f"page={page}{ms}"),"t":ms},
headers={"User-Agent":"okhttp/4.9.2"}, timeout=10).json()
return [int(x["value"].strip()) for x in j["data"]]

print(fetch(1, requests.Session())) # sign 与服务器一致 -> 直连取回该页数据

到这一步就完全离机、零依赖:不碰手机、纯 Python 复刻签名即可逐页取数。tsign 只要自洽(sign == MD4("page={page}{t}"))即可,服务器不额外卡时间戳新鲜度。

小结

第一题是整个系列的”热身”:静态读请求模型 → 认出标准算法 → 纯 Python 还原,全程不碰手机。唯一容易踩的是输出字节序——主体是标准 MD4,但它把状态字按大端 %02x 直出,用现成库的 hexdigest 会对不上。往后的题会逐步加码:dex 层魔改(第五题)、native goron 混淆(第四题)、加壳(第十一题)、native 反爬(第十三题)。


猿人学 App 第一题离机复现:dex 层标准 MD4(附非标准输出的坑)
https://kingjem.github.io/2026/09/01/猿人学App第一题离机复现-dex层标准MD4/
作者
Ruhai
发布于
2026年9月1日
许可协议