猿人学 App 第十题「React Native」离机复现:魔改 MD4 藏在 JS Bundle 里

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

[猿人学 App 逆向系列]第十题。这题不是 Fragment,而是一个 RNActivity——React Native 应用,取数逻辑在打包好的 index.android.bundle 里。
好消息是这份 bundle 是明文压缩 JS(不是 Hermes 字节码),签名算法能直接读。坏消息是它把签名做成了一个魔改 MD4,长得像标准 MD4、却对不上——差点被”想当然”骗过去。
本文记录:定位 RN 的签名 → 想当然用标准 MD4 吃 403 → 用 node 直跑 bundle 拿 ground truth → 发现是魔改 MD4 → 逐字转写成纯 Python。

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

本文目录

  • 定位:RNActivity + index.android.bundle
  • 读 bundle:sign_hex(page:ms)
  • 想当然的坑:标准 MD4 → 403
  • node 直跑拿 ground truth:它不是标准 MD4
  • 逐字转写魔改 MD4 → 纯 Python

一、定位:RNActivity + index.android.bundle

第十题在 Fragment 列表里根本没有 ChallengeTenFragment——它是个独立 Activity:

1
2
3
// RNActivity (@Page 名 "题目十")
this.reactRootView = new ReactRootView(this);
this.reactRootView.startReactApplication(mgr, "rn_module", null);

ReactRootView + rn_module = React Native。RN 的业务代码全在 assets/index.android.bundle。看一眼文件头:

1
2
$ head -c 20 assets/index.android.bundle
var __BUNDLE_START_TIME=...

var __BUNDLE_START_TIME 说明是 Metro 打的明文 JS bundle(不是 Hermes .hbc 字节码),761KB 压缩 JS——算法可读,省去了反编译 Hermes 的麻烦。

二、读 bundle:sign_hex(page:ms)

直接在 bundle 里搜端点,顺出 loadData:

1
2
3
4
n = "https://www.python-spider.com/api/app10",
o = (new Date).getTime(), // 毫秒时间戳
l = (0, r(d[10]).sign_hex)(this.page + ":" + o), // sign = sign_hex("page:ms")
(new f.default).fetchData(n, { page: this.page, t: o, sign: l })

fetchDatafetch(url, {method:"post", headers:{"Content-Type":"application/json"}, body:JSON.stringify({page,t,sign})})

所以请求模型是:POST /api/app10,JSON body {page, t:ms, sign},而 sign = sign_hex("page:ms")

再找 sign_hex 的定义:

1
2
3
4
5
e.sign_hex = function(u){ return h(t(i(u), u.length*n)) }; var n = 8;
function t(n,t){
n[t>>5]|=128<<t%32, n[14+(t+64>>>9<<4)]=t; // 经典 MD 填充
for(var u=1732584193,i=-271733879,h=-1732584194,v=271733878, d=0; d<n.length; d+=16){ ... }
}

初值 1732584193/-271733879/-1732584194/271733878 = 0x67452301/0xefcdab89/0x98badcfe/0x10325476——MD4/MD5 的标准 IV;三轮里出现常量 0x5A8279990x6ED9EBA1,移位 3,7,11,19——这是 MD4

三、想当然的坑:标准 MD4 → 403

看到这里很容易下结论:”就是标准 MD4 嘛”。于是直接拿现成的纯 Python MD4 上:

1
2
3
sign = hex_md4(f"{page}:{ms}")     # 标准 MD4
requests.post(URL, json={"page":page,"t":ms,"sign":sign})
# -> 403 {"status":"0","message":"Access forbidden;please check request"}

403。做个判别:对的 sign、错的 sign、干脆不带 sign,三者返回一模一样的 403——看起来像是 sign 校验之前就被拦(WAF/请求头层面)。抓一条真机请求看头部:头很普通(okhttp/4.9.2 + json),唯一区别是走 HTTP/2

但真正的破绽在 body 里——把真机的 sign 和我算的一比:

1
2
3
content = "1:1788326879974"
真机 sign = 0a2ed050f876c3d9d26527b495d693d5
我的 MD4 = 1b702147b101a235162b10f69b5ab351 ← 不一样!

根本不是我”想当然”的那个 MD4。 之前的 403,不是 WAF,就是 sign 算错了(错 sign 和无 sign 恰好走同一个拒绝分支)。

四、node 直跑拿 ground truth:它不是标准 MD4

bundle 是明文 JS,那最没有争议的办法就是sign_hex 抠出来直接用 node 跑,拿一份权威输出:

1
2
node -e '<sign_hex 及其依赖函数>; console.log(sign_hex("abc"))'
# -> 0eb1b355494de8a49cd2e1d030e38267

标准 MD4(“abc”) = a448017aaf21d8525fc10ae87aa6729d。两者不同 → 这是一个魔改 MD4

差在哪?细看它的轮函数:

1
2
3
4
function u(n,t,u,c,f,o){ return a((i=a(a(t,n),a(c,o)))<<(h=f)|i>>>32-h, u); var i,h }
function c(n,t,c,f,o,a){ return u(c&f|~c&t, n,0,o,a,0) } // F,但参数序被打乱
function f(n,t,c,f,o,a){ return u(t&c|t&f|c&f, n,0,o,a,1518500249)} // G
function o(n,t,c,f,o,a){ return u(t^c^f, n,0,o,a,1859775393) } // H

外层那串 u=c(u,i,h,v,...)v=c(v,u,i,h,...)… 的状态字轮换顺序是 u,v,h,i,而 F/G/H 里参与运算的三个字的位置也和标准 MD4 不同。IV、常量、移位全是标准的,唯独 F/G/H 的”喂参顺序”被换了——足以让输出彻底改变,却又骗过”一眼 MD4”的直觉。

五、逐字转写魔改 MD4 → 纯 Python

既然只是参数序/轮换不同,那就照着 JS 一比一转写,不做任何”标准化”想当然,再拿 node 和真机抓包双重对拍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
M = 0xffffffff
def rotl(i, s): i &= M; return ((i << s) | (i >> (32 - s))) & M
def C(n,t,c,f,o,a): return rotl((n + (((c&f)|((c^M)&t)) & M) + o) & M, a) # R1 K=0
def F(n,t,c,f,o,a): return rotl((n + (((t&c)|(t&f)|(c&f)) & M) + o + 0x5a827999) & M, a) # R2
def H(n,t,c,f,o,a): return rotl((n + ((t^c^f) & M) + o + 0x6ed9eba1) & M, a) # R3

def core(x, nbits):
x[nbits>>5] = x.get(nbits>>5,0) | (128 << (nbits%32)); x[14+((nbits+64>>9)<<4)] = nbits
u,i,h,v = 0x67452301,0xefcdab89,0x98badcfe,0x10325476
g = lambda k: x.get(k,0) & M
for d in range(0, ((max(x)+16)&~15), 16):
l,A,C_,b = u,i,h,v
for k in (0,4,8,12): # R1,移位 3/7/11/19,状态轮换 u,v,h,i
u=C(u,i,h,v,g(d+k),3); v=C(v,u,i,h,g(d+k+1),7)
h=C(h,v,u,i,g(d+k+2),11); i=C(i,h,v,u,g(d+k+3),19)
for k in (0,1,2,3): # R2,移位 3/5/9/13,X 序 0,4,8,12 ...
u=F(u,i,h,v,g(d+k),3); v=F(v,u,i,h,g(d+k+4),5)
h=F(h,v,u,i,g(d+k+8),9); i=F(i,h,v,u,g(d+k+12),13)
for k in (0,2,1,3): # R3,移位 3/9/11/15,X 序 0,8,4,12 ...
u=H(u,i,h,v,g(d+k),3); v=H(v,u,i,h,g(d+k+8),9)
h=H(h,v,u,i,g(d+k+4),11); i=H(i,h,v,u,g(d+k+12),15)
u=(u+l)&M; i=(i+A)&M; h=(h+C_)&M; v=(v+b)&M
return [u,i,h,v]

对拍:

1
2
assert sign_hex("1:1788326879974") == "0a2ed050f876c3d9d26527b495d693d5"  # == 真机抓包
assert sign_hex("abc") == "0eb1b355494de8a49cd2e1d030e38267" # == node 直跑

两条都过,换上正确 sign 后 HTTP/1.1 直接 200——说明当初 403 跟 HTTP/2 无关,纯粹是 sign 错了。之后自己发请求、翻 100 页求和即可(服务器偶发空页,重试补齐)。

小结

第十题的两层皮:

  1. 壳是 React Native——业务在 index.android.bundle;所幸是明文 JS(不是 Hermes),搜端点顺出 sign_hex("page:ms");
  2. 核是魔改 MD4——IV/常量/移位全标准,唯独 F/G/H 参数序与状态轮换被换掉,输出和标准 MD4 完全不同。

最大的教训是那句”一眼 MD4”的想当然:magic number 对上不代表算法一致。明文 JS 的最大优势,就是能直接 node 跑出 ground truth——用它一对拍,李逵李鬼立现,再逐字转写就稳了。


猿人学 App 第十题「React Native」离机复现:魔改 MD4 藏在 JS Bundle 里
https://kingjem.github.io/2026/09/02/猿人学App第十题ReactNative离机复现-魔改MD4/
作者
Ruhai
发布于
2026年9月2日
许可协议