本文针对 CISCN 2025 初赛中的一道 WebAssembly 逆向题目 wasm-login 进行深度技术剖析。这道题目巧妙地融合了 WASM 逆向、密码学算法分析和时间戳爆破等多个技术点,对参赛者的综合能力提出了较高要求。通过本文的详细分析,读者将了解如何从零开始分析一个完整的 WebAssembly 逆向题目。
解压题目压缩包后,得到如下文件结构:
.
├── index.html # 前端登录页面
├── crypto-js.js # CryptoJS 库(提供 MD5 功能)
└── build/
├── release.js # WASM JavaScript 胶水代码
├── release.wasm # 编译后的 WebAssembly 二进制文件
└── release.wasm.map # Source Map 文件
从文件结构可以看出,这是一个典型的 WebAssembly 应用。其中release.wasm.map文件的存在引起了我们的注意,这可能是一个突破口。
打开index.html文件,这是一个登录界面。在 HTML 底部发现了一个注释:
<!-- 测试账号 admin 测试密码 admin-->
这看起来是提供了一组测试账号。继续查看页面的 JavaScript 代码,在第 237-250 行找到关键的验证逻辑:
function simulateServerRequest(data) {
return new Promise(resolve => {
setTimeout(() => {
const check = CryptoJS.MD5(JSON.stringify(data)).toString(CryptoJS.enc.Hex);
if (check.startsWith("ccaf33e3512e31f3")){
resolve({ success: true });
}else{
resolve({ success: false });
}
}, 1000);
});
}
这段代码揭示了登录成功的真正条件:
将认证数据authData对象序列化为 JSON 字符串
计算该 JSON 字符串的 MD5 哈希值
MD5 值必须以ccaf33e3512e31f3开头才能登录成功
这个发现非常重要:题目的成功条件并非简单的用户名密码匹配,而是需要构造特定的数据使其 MD5 哈希值满足前缀要求。
在index.html第 186-187 行,找到authData的生成代码:
const authResult = authenticate(username, password);
const authData = JSON.parse(authResult);
其中authenticate函数是从 WASM 模块导入的(第 135 行):
import { authenticate } from "./build/release.js";
这意味着authData的内容完全由 WASM 模块决定。要理解authData的结构,必须深入分析 WASM 代码。
直接逆向 WASM 二进制文件是一项耗时的工作,但题目提供了release.wasm.map文件。Source Map 是一种映射文件,用于将编译后的代码映射回源代码,主要用于调试。
检查release.wasm.map文件(大小约 477 KB),发现其包含完整的sourcesContent字段。这意味着开发者在编译时启用了 Source Map 功能,并且没有在发布前删除源代码内容。
通过解析这个 JSON 格式的 map 文件,可以提取出完整的 AssemblyScript 源代码:
import json
with open('build/release.wasm.map', 'r') as f:
source_map = json.load(f)
sources = source_map['sources']
contents = source_map['sourcesContent']
for i, source_file in enumerate(sources):
if contents[i]:
print(f"文件: {source_file}")
print(f"大小: {len(contents[i])} 字节")
从中提取出三个关键的源文件:
assembly/index.ts- 主逻辑和认证函数(5,240 字节)
assembly/base64.ts- Base64 编码实现(2,135 字节)
assembly/sha256.ts- SHA256 哈希实现(8,709 字节)
在assembly/index.ts中找到authenticate函数的完整实现:
export function authenticate(username: string, password: string): string {
// 1. Base64编码密码
const encodedPassword = encode(stringToUint8Array(password));
// 2. 获取当前时间戳(毫秒)
const timestamp = Date.now().toString();
// 3. 构建原始JSON消息
const message = `{"username":"${username}","password":"${encodedPassword}"}`;
// 4. 使用HMAC-SHA256签名
const signature = signMessage(message, timestamp);
// 5. 构建最终的JSON消息
const finalMessage = `{"username":"${username}","password":"${encodedPassword}","signature":"${signature}"}`;
return finalMessage;
}
函数执行流程清晰明了:
使用自定义 Base64 对密码进行编码
获取当前时间戳(毫秒级)
构建包含用户名和编码密码的 JSON 消息
使用时间戳作为密钥,对消息进行 HMAC-SHA256 签名
返回包含 username、password、signature 三个字段的 JSON 字符串
这里有两个重要发现:
发现一:返回的 JSON 字符串包含三个字段,且顺序固定为username -> password -> signature。由于 JavaScript 的JSON.parse会保留字段顺序,后续的JSON.stringify也会保持这个顺序,这对 MD5 计算至关重要。
发现二:签名依赖于时间戳Date.now(),这意味着每次运行都会产生不同的签名,进而导致不同的 MD5 值。
在assembly/base64.ts第 12 行发现了异常:
const ALPHA = "NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO";
标准 Base64 的字符表应该是:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
但题目使用了一个完全不同的字符映射表。Base64 编码的本质是将二进制数据映射到 64 个可打印字符,编码算法本身不变,只是字符映射表发生了改变。
这意味着:
标准 Base64("admin") =YWRtaW4=
自定义 Base64("admin") =L0In602=
如果使用标准 Base64 库进行复现,将无法得到正确的结果。这是第一个需要注意的"魔改"点。
在assembly/index.ts的hmacSHA256函数中发现了更隐蔽的修改。HMAC(Hash-based Message Authentication Code)是一种基于哈希函数的消息认证码算法,其标准实现在 RFC 2104 中定义。
标准 HMAC 算法中,ipad 和 opad 的异或常量分别为:
ipad:0x36(二进制:00110110)
opad:0x5C(二进制:01011100)
但在源码第 118-119 行:
for (let i = 0; i < blockSize; i++) {
store<u8>(ipadPtr + i , load<u8>(paddedKeyPtr + i) ^ 0x76); // 应为 0x36
store<u8>(opadPtr + i , load<u8>(paddedKeyPtr + i) ^ 0x3C); // 应为 0x5C
}
实际使用的常量为:
ipad:0x76(二进制:01110110)
opad:0x3C(二进制:00111100)
这个修改看似微小,但会导致生成的内部密钥与标准 HMAC 完全不同。
标准 HMAC 算法的外部哈希输入应为:
outerInput = opad || innerHash
即先拼接 opad,再拼接 innerHash。
但在源码第 144-145 行:
memory.copy(outerInputPtr, innerHashPtr, innerHash.byteLength);
memory.copy(outerInputPtr + innerHash.byteLength, opadPtr, opad.byteLength);
实际实现为:
outerInput = innerHash || opad
顺序完全颠倒。
这两处修改使得该 HMAC-SHA256 实现与标准算法产生完全不同的结果。即使使用相同的密钥和消息,标准 HMAC 和魔改 HMAC 的输出也毫无关联。
现在可以梳理出完整的依赖关系:
登录成功
↓
MD5 前缀匹配 (以 ccaf33e3512e31f3 开头)
↓
JSON.stringify(authData)
↓
authData = { username, password, signature }
↓
signature = buggy_HMAC_SHA256(message, timestamp)
↓
password = custom_Base64(原密码)
↓
timestamp = Date.now() # 动态变化
关键点在于timestamp。由于时间戳是动态的,每次运行都会不同,导致:
signature 不同
authData 不同
JSON 字符串不同
MD5 值不同
因此,题目本质上是一个时间戳搜索问题:
找到一个特定的时间戳值,使得以 admin/admin 登录时,生成的 JSON 数据的 MD5 值恰好以ccaf33e3512e31f3开头。
MD5 是一个 128 位哈希函数,题目要求的前缀长度为 64 位(16 个十六进制字符)。理论上,随机字符串匹配这个前缀的概率约为 1/(2^64),这是一个天文数字。
但我们不需要随机搜索整个空间。时间戳是有范围的,而且出题人必定选择了一个特定的时间点作为正确答案。
查看题目文件的元数据,发现build/release.js的修改时间为:
2025-12-21 16:29:xx
这很可能就是出题人构造题目时使用的时间点。因此,可以合理推测目标时间戳就在这个时间附近。
将搜索范围设定为:
2025-12-21 16:29:00 至 16:30:00 (UTC)
这对应 60 秒,即 60,000 个毫秒值。对于现代计算机来说,遍历 60,000 次 HMAC+MD5 计算只需要几秒钟,完全可行。
Python 标准库的base64模块可以进行标准 Base64 编码,我们只需要在此基础上进行字符映射转换:
import base64
# 定义字符表映射
ALPHA = "NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO"
STD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
ENC_TRANS = str.maketrans(STD, ALPHA)
def b64_custom_encode(b: bytes) -> str:
"""自定义 Base64 编码"""
return base64.b64encode(b).decode().translate(ENC_TRANS)
验证:
result = b64_custom_encode(b"admin")
print(result) # 输出: L0In602=
严格按照源码中的逻辑实现:
import hashlib
def buggy_hmac_sha256(key: bytes, msg: bytes) -> bytes:
"""
魔改的 HMAC-SHA256 实现
- ipad/opad 常量:0x76 / 0x3C (标准为 0x36 / 0x5C)
- outerInput:innerHash || opad (标准为 opad || innerHash)
"""
block_size = 64
# 步骤 1: 填充密钥
if len(key) > block_size:
# 如果密钥长度超过块大小,先哈希
kh = hashlib.sha256(key).digest()
pk = kh + b"\x00" * (block_size - len(kh))
else:
# 否则直接填充到 64 字节
pk = key + b"\x00" * (block_size - len(key))
# 步骤 2: 使用错误的常量生成 ipad 和 opad
ipad = bytes([x ^ 0x76 for x in pk]) # 标准应为 0x36
opad = bytes([x ^ 0x3C for x in pk]) # 标准应为 0x5C
# 步骤 3: 计算内部哈希
inner = hashlib.sha256(ipad + msg).digest()
# 步骤 4: 使用错误的顺序计算外部哈希
outer = hashlib.sha256(inner + opad).digest() # 标准应为 opad + inner
return outer
整合前面的函数,完整复现 WASM 的authenticate逻辑:
def authenticate(username: str, password: str, ts_ms: int) -> str:
"""
复现 WASM 中的 authenticate 函数
"""
# 1. 自定义 Base64 编码密码
encoded_pw = b64_custom_encode(password.encode())
# 2. 构建消息(注意 JSON 格式,无空格)
message = f'{{"username":"{username}","password":"{encoded_pw}"}}'.encode()
# 3. 使用魔改的 HMAC-SHA256 签名
sig_bytes = buggy_hmac_sha256(str(ts_ms).encode(), message)
signature = b64_custom_encode(sig_bytes)
# 4. 构建最终 JSON(注意字段顺序必须与 WASM 一致)
return f'{{"username":"{username}","password":"{encoded_pw}","signature":"{signature}"}}'
注意几个关键点:
JSON 字符串中没有空格({"username":"admin"而非{ "username": "admin")
字段顺序必须是 username、password、signature
时间戳以字符串形式作为 HMAC 密钥(不是整数)
import hashlib
import datetime
PREFIX = "ccaf33e3512e31f3"
def md5_hex(s: str) -> str:
"""计算 MD5 十六进制字符串"""
return hashlib.md5(s.encode()).hexdigest()
# 定义搜索范围
base = datetime.datetime(2025, 12, 21, 16, 29, 0, tzinfo=datetime.timezone.utc)
start = int(base.timestamp() * 1000)
end = start + 60_000
print(f"搜索范围: {start} - {end} (共 {end - start} 个时间戳)")
print("开始爆破...\n")
# 执行爆破
for i, t in enumerate(range(start, end)):
s = authenticate("admin", "admin", t)
h = md5_hex(s)
if h.startswith(PREFIX):
ts_datetime = datetime.datetime.fromtimestamp(t / 1000, tz=datetime.timezone.utc)
print(f"找到匹配的时间戳!")
print(f"时间戳: {t}")
print(f"时间: {ts_datetime.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC")
print(f"已测试: {i + 1} 个时间戳")
print(f"JSON: {s}")
print(f"MD5: {h}")
break
# 每 10000 次显示进度
if (i + 1) % 10000 == 0:
print(f"进度: {i + 1}/{end - start}")
运行脚本后,在约 10.7 秒内找到匹配的时间戳:
找到匹配的时间戳!
时间戳: 1766334550699
时间: 2025-12-21 16:29:10.699 UTC
已测试: 10700 个时间戳
JSON: {"username":"admin","password":"L0In602=","signature":"LxZiwA05Y9h7wX1CI0gUitOE2LBy9y8McoBqWgKIdDo="}
MD5: ccaf33e3512e31f36228f0b97ccbc8f1
时间戳1766334550699对应的时间正好在文件修改时间的同一分钟内(16:29),验证了我们的推测。
逐步验证每个环节的输出:
步骤 1:Base64 编码密码
输入: "admin"
输出: "L0In602="
步骤 2:构建中间消息
{"username":"admin","password":"L0In602="}
步骤 3:HMAC-SHA256 签名
密钥: "1766334550699" (字符串)
消息: {"username":"admin","password":"L0In602="}
签名 (hex): 62354c5105884ee07d51dd0e4567c4322fdce18abebbe9e5d60a89d9f7915d28
签名 (base64): LxZiwA05Y9h7wX1CI0gUitOE2LBy9y8McoBqWgKIdDo=
步骤 4:构建最终 JSON
{"username":"admin","password":"L0In602=","signature":"LxZiwA05Y9h7wX1CI0gUitOE2LBy9y8McoBqWgKIdDo="}
步骤 5:计算 MD5
ccaf33e3512e31f36228f0b97ccbc8f1
步骤 6:验证前缀
目标前缀: ccaf33e3512e31f3
实际前缀: ccaf33e3512e31f3
验证通过
为了验证魔改确实起了作用,可以对比标准 HMAC 和魔改 HMAC 的输出:
使用相同的密钥 "1766334550699" 和消息{"username":"admin","password":"L0In602="}:
标准 HMAC-SHA256:74c93df8728f5a779c46c98fe6b2e6566f7a54e55775613ce145c5aebbbcb138
魔改 HMAC-SHA256:62354c5105884ee07d51dd0e4567c4322fdce18abebbe9e5d60a89d9f7915d28
两者完全不同,证明魔改确实生效。
除了离线爆破,还可以直接在浏览器中进行验证。
由于 WASM 通过build/release.js中的导入函数获取时间:
"Date.now"() { return Date.now(); }
可以在浏览器控制台中直接劫持这个函数:
Date.now = () => 1766334550699;
然后在登录页面输入:
用户名:admin
密码:admin
点击登录后,将弹出"登录成功"提示。
如果浏览器控制台不允许粘贴代码(出于安全考虑),可以使用 Chrome DevTools 的 Snippets 功能:
按 F12 打开 DevTools
切换到 Sources 标签
在左侧面板选择 Snippets
点击 + New snippet
输入代码:Date.now = () => 1766334550699;
右键选择 Run 或按 Ctrl+Enter 执行
回到页面,输入 admin/admin 并登录
这个方法证明了我们的分析和计算是正确的。
Source Map 的价值
Source Map 文件可能包含完整的源代码,这在逆向工程中是一个巨大的礼物。通过sourcesContent字段,我们可以直接获取 TypeScript 或 AssemblyScript 源码,比反编译 WASM 二进制文件高效得多。
在实际应用中,开发者应该:
生产环境不要发布 Source Map 文件
如果必须发布(如开源项目),应移除sourcesContent字段
使用构建工具的相应配置选项
WASM 与 JavaScript 的交互
WebAssembly 本身不能直接访问 JavaScript API(如 Date.now),必须通过 imports 对象传递:
WebAssembly.instantiate(wasmBytes, {
env: {
"Date.now": () => Date.now()
}
})
这种机制为我们提供了劫持的机会,也是本题可以通过浏览器验证的原因。
Base64 编码原理
Base64 的本质是字符映射表,而非特定的数学算法。它将 3 个字节(24 位)分成 4 个 6 位单元,每个单元对应 64 个字符中的一个。
修改字符表不改变编码逻辑,只改变输出字符。这就像用不同的字母表写同一句话,句子结构不变,但字符不同。
HMAC 算法的实现细节
HMAC 是一个精密的算法,标准定义在 RFC 2104 中。其中的每一个细节都经过精心设计:
ipad (0x36) 和 opad (0x5C) 的选择不是随意的,它们在二进制上有特定的模式
内外两次哈希的顺序是算法安全性的重要保证
即使微小的修改也会导致完全不同的输出
本题的两处修改看似简单,但足以使算法输出完全不可预测。这也提醒我们:永远不要尝试"改进"标准密码学算法,除非你是该领域的专家。
MD5 前缀碰撞
64 位前缀(16 个十六进制字符)在密码学中已经是相当长的前缀。理论碰撞概率约为 1/(2^64) ≈ 1/18,446,744,073,709,551,616。
但本题不是随机碰撞,而是有针对性的搜索。通过缩小时间戳范围,我们将搜索空间从理论上的无限大降低到实际的 60,000,使问题变得可解。
利用文件元数据
文件修改时间往往是重要线索。在 CTF 题目中,出题人通常会选择一个有意义的时间点(如题目构造时间、文件编译时间)作为正确答案。
通过ls -la或文件属性可以查看文件时间戳,这些信息可以大大缩小搜索范围。
爆破效率考虑
60 秒 = 60,000 个时间戳。现代计算机每次计算(包括 Base64、HMAC、MD5)耗时约 0.1-1 毫秒,总耗时约 6-60 秒,完全可行。
如果扩大到 10 分钟(600,000 个时间戳),耗时也只是 1-10 分钟。因此,即使没有精确的文件时间线索,在一个合理的时间范围内搜索也是可行的。
完整的解题流程如下:
分析index.html,发现 MD5 前缀校验条件
追溯authData来源,定位到 WASM 模块的authenticate函数
检查release.wasm.map文件,发现包含完整源代码
提取并分析assembly/index.ts,理解认证流程
发现第一个陷阱:assembly/base64.ts中的自定义字符表
发现第二个陷阱:hmacSHA256函数中的两处修改(ipad/opad 常量、拼接顺序)
理解问题本质:时间戳依赖导致需要搜索特定时间点
通过文件时间戳缩小搜索范围至 2025-12-21 16:29:00-16:30:00
编写 Python 脚本,精确复现所有算法(包括魔改部分)
执行爆破,在 10.7 秒内找到正确时间戳1766334550699
验证生成的 JSON 数据,MD5 值为ccaf33e3512e31f36228f0b97ccbc8f1
确认前缀匹配,提交 Flag
根据题目要求,最终的 Flag 格式为:
flag{ccaf33e3512e31f36228f0b97ccbc8f1}
完整的 MD5 哈希值即为 Flag 内容。
从这道题目可以学到重要的安全经验:
问题:题目保留了完整的 Source Map 文件,包括源代码。
教训:在生产环境中,应该:
完全删除 Source Map 文件
或者仅保留映射信息,移除sourcesContent字段
使用构建工具的 production 模式
配置示例(Webpack):
module.exports = {
mode: 'production',
devtool: false // 不生成 source map
}
问题:题目修改了标准 HMAC-SHA256 算法。
教训:密码学算法的设计需要深厚的数学基础和大量的同行评审。即使是看似简单的修改,也可能引入严重的安全漏洞。
正确做法:
始终使用经过验证的标准实现
如 Python 的hmac模块、Node.js 的crypto模块
不要尝试"改进"算法
问题:签名依赖于可预测的时间戳。
教训:时间戳是可以被猜测和爆破的。安全的做法应该是:
使用真正的随机数(如crypto.getRandomValues())
结合多个熵源(时间+随机数+用户信息等)
使用足够长的随机数(至少 128 位)
问题:题目的"服务器校验"实际在客户端执行。
教训:任何在客户端执行的代码都可以被修改和绕过。真实系统中:
所有安全检查必须在服务器端执行
客户端验证仅用于提升用户体验
不要在客户端暴露敏感的判断逻辑
问题:文件修改时间泄露了时间戳范围。
教训:在发布敏感文件前,应该:
清理或统一化所有文件的时间戳
使用touch命令或构建工具设置统一时间
注意 ZIP 文件会保留原始文件时间
清理示例:
find . -type f -exec touch -t 202001010000 {} \;
本题巧妙地将 WebAssembly 逆向、密码学知识和爆破技巧结合在一起,是一道设计精良的综合性逆向题目。通过完整的分析过程,我们不仅解决了这道题,更重要的是学习了:
如何系统性地分析一个 WebAssembly 应用
如何利用 Source Map 快速获取源代码
如何识别并复现被修改的密码学算法
如何通过元数据缩小搜索空间
重要的安全开发实践
希望本文的详细分析能够帮助读者深入理解 WebAssembly 逆向技术和密码学相关知识。在实际的安全工作中,这些技术和思路同样适用。