猿人学 App 第二十题「昨夜星辰昨夜风」离机复现:Lua 里的大端 MD5

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

[猿人学 App 逆向系列]第二十题「昨夜星辰昨夜风」。签名逻辑既不在 dex、也不在 so,而是在一段 Lua 脚本里(LuaJava 解释执行)。
好在把 code.lua 读出来后会发现它就是标准 MD5——只是输出字节序做了点手脚。本文记录定位 Lua、读脚本、还原大端 MD5 的过程。

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

本文目录

  • 签名去哪了:LuaState 出场
  • 抠 code.lua,读 sign
  • 认出标准 MD5 + 大端输出
  • 纯 Python 还原

一、签名去哪了:LuaState 出场

ChallengeTwentyFragment 里翻页的签名调用长这样:

1
2
3
4
long ms = System.currentTimeMillis();
String content = String.format(getString(R.string.challenge_two_data_format), page, ms); // "page:ms"
String sign = OooO0oo(content); // <-- 关键
api.OooOo(page, ms, sign); // POST /api/app20 form: page / t / sign

OooO0oo 把活儿交给了 Lua:

1
2
3
4
5
6
7
8
public final String OooO0oo(String str) {
this.luaState.getGlobal("sign"); // 取 Lua 全局函数 sign
this.luaState.pushString(str.getBytes());
this.luaState.pcall(1, 1, 0); // 调用 sign(str)
String r = this.luaState.toString(-1);
this.luaState.pop(1);
return r;
}

onCreate 里初始化了 LuaState 并加载脚本:

1
2
3
this.luaState = LuaStateFactory.newLuaState();
this.luaState.openLibs();
this.luaState.LdoString(OooO(getContext(), "code.lua")); // 读 assets/code.lua 执行

所以签名算法就在 assets/code.luasign 函数里。

二、抠 code.lua,读 sign

1
unzip -j base.apk assets/code.lua

打开一看,FF/GG/HH/II 四个轮函数 + transform + sign,一眼 MD 家族:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local function FF(a,b,c,d,x,s,ac)
a = (a + ((b & c) | ((~b) & d)) + x + ac) & 0xffffffff
a = ((a << s) | (a >> (32 - s))) & 0xffffffff
return (a + b) & 0xffffffff
end
-- GG: (b & d) | (c & ~d); HH: b ~ c ~ d; II: c ~ (b | ~d)

local function transform(state, input, i, t)
...
a = FF(a,b,c,d,t[1], 7, 0xd76aa478) -- 标准 MD5 常量 & 移位
d = FF(d,a,b,c,t[2],12, 0xe8c7b756)
... 共 64 步 FF/GG/HH/II ...
end

function sign(input)
local state = {0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476} -- 标准 MD5 IV
...填充: '\x80' + 0 + spack("<I8", inputbits) ... -- 标准 MD5 padding
return string.format("%08x%08x%08x%08x", state[1],state[2],state[3],state[4])
end

三、认出标准 MD5 + 大端输出

IV(0x67452301...)、四轮布尔函数、常量表(0xd76aa478...)、移位、<I8 小端长度填充——全是教科书 MD5

唯一的花样在输出:标准 MD5 的 hex 是把 4 个状态字按小端字节序拼出来(所以 md5("") = d41d8cd9...)。这里却是 string.format("%08x%08x%08x%08x", state1..4)——把每个 32 位状态字当整数直接大端打印。这跟第一题 MD4 是同一个套路:算法标准,只是输出字节序反了

四、纯 Python 还原

不用手写 MD5,直接用 hashlib,再把它输出的小端摘要按状态字重排成大端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import hashlib, struct, time, requests

def sign(content: str) -> str:
d = hashlib.md5(content.encode()).digest() # 标准 MD5(状态字以小端字节存储)
words = struct.unpack('<4I', d) # 还原 4 个 32 位状态字
return ''.join('%08x' % w for w in words) # 大端逐字打印(= code.lua 的 %08x)

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

page 1 就 200 返回数据,逐页求和即通关。

小结

第二十题的”新”只在载体——把签名塞进了 Lua 脚本。破法是顺着 LuaState.getGlobal("sign") 找到 assets/code.lua,读出来发现是标准 MD5,只是输出字节序改成了大端逐字。算法层面毫无难度,认出”标准算法 + 非标准输出”这个常见套路就行

顺带一提,code.lua 是明文的——这也提醒:逻辑一旦落到脚本/资源文件,往往比 native 好读得多,先翻 assets 常有惊喜。


猿人学 App 第二十题「昨夜星辰昨夜风」离机复现:Lua 里的大端 MD5
https://kingjem.github.io/2026/09/02/猿人学App第二十题离机复现-Lua里的大端MD5/
作者
Ruhai
发布于
2026年9月2日
许可协议