文章总结: 本文深度解析CISCN2025逆向题wasm-login,通过SourceMap还原WebAssembly源码,发现自定义Base64字符表与魔改HMAC-SHA256算法陷阱。文章详细阐述了复现算法逻辑及利用文件元数据缩小时间戳爆破范围的思路,最终成功构造特定时间戳生成符合MD5前缀要求的认证数据。建议重视SourceMap泄露风险并掌握密码学算法复现能力。 综合评分: 100 文章分类: CTF,二进制安全,WEB安全
CISCN 2025 Reverse – wasm-login 深度技术解析
原创
破镜安全
破镜安全
2026年1月5日 08:00 四川
CISCN 2025 Reverse – wasm-login 深度技术解析
前言
本文针对 CISCN 2025 初赛中的一道 WebAssembly 逆向题目 wasm-login 进行深度技术剖析。这道题目巧妙地融合了 WASM 逆向、密码学算法分析和时间戳爆破等多个技术点,对参赛者的综合能力提出了较高要求。通过本文的详细分析,读者将了解如何从零开始分析一个完整的 WebAssembly 逆向题目。
一、题目初步分析
1.1 题目文件结构
解压题目压缩包后,得到如下文件结构:
.
├── index.html # 前端登录页面
├── crypto-js.js # CryptoJS 库(提供 MD5 功能)
└── build/
├── release.js # WASM JavaScript 胶水代码
├── release.wasm # 编译后的 WebAssembly 二进制文件
└── release.wasm.map # Source Map 文件
从文件结构可以看出,这是一个典型的 WebAssembly 应用。其中 release.wasm.map 文件的存在引起了我们的注意,这可能是一个突破口。
1.2 分析前端页面
打开 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 哈希值满足前缀要求。
1.3 追溯数据来源
在 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 代码。
二、WebAssembly 逆向分析
2.1 利用 Source Map 获取源码
直接逆向 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 字节)
2.2 分析 authenticate 函数
在 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 值。
2.3 第一个陷阱:自定义 Base64 字符表
在 assembly/base64.ts 第 12 行发现了异常:
const ALPHA = "NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO";
标准 Base64 的字符表应该是:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
但题目使用了一个完全不同的字符映射表。Base64 编码的本质是将二进制数据映射到 64 个可打印字符,编码算法本身不变,只是字符映射表发生了改变。
这意味着:
- 标准 Base64(“admin”) =
YWRtaW4= - 自定义 Base64(“admin”) =
L0In602=
如果使用标准 Base64 库进行复现,将无法得到正确的结果。这是第一个需要注意的”魔改”点。
2.4 第二个陷阱:魔改的 HMAC-SHA256
在 assembly/index.ts 的 hmacSHA256 函数中发现了更隐蔽的修改。HMAC(Hash-based Message Authentication Code)是一种基于哈希函数的消息认证码算法,其标准实现在 RFC 2104 中定义。
陷阱 2.1:修改的 ipad/opad 常量
标准 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 完全不同。
陷阱 2.2:颠倒的外部哈希输入顺序
标准 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 的输出也毫无关联。
三、问题本质与解题思路
3.1 成功条件的完整依赖链
现在可以梳理出完整的依赖关系:
登录成功
↓
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 值不同
3.2 问题转化
因此,题目本质上是一个时间戳搜索问题:
找到一个特定的时间戳值,使得以 admin/admin 登录时,生成的 JSON 数据的 MD5 值恰好以 ccaf33e3512e31f3 开头。
MD5 是一个 128 位哈希函数,题目要求的前缀长度为 64 位(16 个十六进制字符)。理论上,随机字符串匹配这个前缀的概率约为 1/(2^64),这是一个天文数字。
但我们不需要随机搜索整个空间。时间戳是有范围的,而且出题人必定选择了一个特定的时间点作为正确答案。
3.3 缩小搜索范围
查看题目文件的元数据,发现 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 计算只需要几秒钟,完全可行。
四、算法复现与实现
4.1 复现自定义 Base64
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=
4.2 复现魔改的 HMAC-SHA256
严格按照源码中的逻辑实现:
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
4.3 复现 authenticate 函数
整合前面的函数,完整复现 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 密钥(不是整数)
4.4 完整的爆破脚本
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}")
五、执行结果与验证
5.1 爆破结果
运行脚本后,在约 10.7 秒内找到匹配的时间戳:
找到匹配的时间戳!
时间戳: 1766334550699
时间: 2025-12-21 16:29:10.699 UTC
已测试: 10700 个时间戳
JSON: {"username":"admin","password":"L0In602=","signature":"LxZiwA05Y9h7wX1CI0gUitOE2LBy9y8McoBqWgKIdDo="}
MD5: ccaf33e3512e31f36228f0b97ccbc8f1
时间戳 1766334550699 对应的时间正好在文件修改时间的同一分钟内(16:29),验证了我们的推测。
5.2 完整数据流验证
逐步验证每个环节的输出:
步骤 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
验证通过
5.3 对比验证魔改的影响
为了验证魔改确实起了作用,可以对比标准 HMAC 和魔改 HMAC 的输出:
使用相同的密钥 “1766334550699” 和消息 {"username":"admin","password":"L0In602="}:
- 标准 HMAC-SHA256:
74c93df8728f5a779c46c98fe6b2e6566f7a54e55775613ce145c5aebbbcb138 - 魔改 HMAC-SHA256:
62354c5105884ee07d51dd0e4567c4322fdce18abebbe9e5d60a89d9f7915d28
两者完全不同,证明魔改确实生效。
六、浏览器直接验证方法
除了离线爆破,还可以直接在浏览器中进行验证。
6.1 劫持 Date.now 函数
由于 WASM 通过 build/release.js 中的导入函数获取时间:
"Date.now"() { return Date.now(); }
可以在浏览器控制台中直接劫持这个函数:
Date.now = () => 1766334550699;
然后在登录页面输入:
- 用户名:
admin - 密码:
admin
点击登录后,将弹出”登录成功”提示。
6.2 使用 DevTools Snippets
如果浏览器控制台不允许粘贴代码(出于安全考虑),可以使用 Chrome DevTools 的 Snippets 功能:
- 按 F12 打开 DevTools
- 切换到 Sources 标签
- 在左侧面板选择 Snippets
- 点击 + New snippet
- 输入代码:
Date.now = () => 1766334550699; - 右键选择 Run 或按 Ctrl+Enter 执行
- 回到页面,输入 admin/admin 并登录
这个方法证明了我们的分析和计算是正确的。
七、关键技术点总结
7.1 WebAssembly 逆向技术
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()
}
})
这种机制为我们提供了劫持的机会,也是本题可以通过浏览器验证的原因。
7.2 密码学相关知识
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,使问题变得可解。
7.3 时间戳爆破技巧
利用文件元数据
文件修改时间往往是重要线索。在 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 格式为:
flag{ccaf33e3512e31f36228f0b97ccbc8f1}
完整的 MD5 哈希值即为 Flag 内容。
十、安全启示与防御建议
从这道题目可以学到重要的安全经验:
10.1 不要保留调试信息
问题:题目保留了完整的 Source Map 文件,包括源代码。
教训:在生产环境中,应该:
- 完全删除 Source Map 文件
- 或者仅保留映射信息,移除
sourcesContent字段 - 使用构建工具的 production 模式
配置示例(Webpack):
module.exports = {
mode: 'production',
devtool: false // 不生成 source map
}
10.2 不要实现自定义密码学算法
问题:题目修改了标准 HMAC-SHA256 算法。
教训:密码学算法的设计需要深厚的数学基础和大量的同行评审。即使是看似简单的修改,也可能引入严重的安全漏洞。
正确做法:
- 始终使用经过验证的标准实现
- 如 Python 的
hmac模块、Node.js 的crypto模块 - 不要尝试”改进”算法
10.3 时间戳不应作为唯一的安全依赖
问题:签名依赖于可预测的时间戳。
教训:时间戳是可以被猜测和爆破的。安全的做法应该是:
- 使用真正的随机数(如
crypto.getRandomValues()) - 结合多个熵源(时间+随机数+用户信息等)
- 使用足够长的随机数(至少 128 位)
10.4 客户端验证不可信
问题:题目的”服务器校验”实际在客户端执行。
教训:任何在客户端执行的代码都可以被修改和绕过。真实系统中:
- 所有安全检查必须在服务器端执行
- 客户端验证仅用于提升用户体验
- 不要在客户端暴露敏感的判断逻辑
10.5 文件元数据可能泄露信息
问题:文件修改时间泄露了时间戳范围。
教训:在发布敏感文件前,应该:
- 清理或统一化所有文件的时间戳
- 使用
touch命令或构建工具设置统一时间 - 注意 ZIP 文件会保留原始文件时间
清理示例:
find . -type f -exec touch -t 202001010000 {} \;
结语
本题巧妙地将 WebAssembly 逆向、密码学知识和爆破技巧结合在一起,是一道设计精良的综合性逆向题目。通过完整的分析过程,我们不仅解决了这道题,更重要的是学习了:
- 如何系统性地分析一个 WebAssembly 应用
- 如何利用 Source Map 快速获取源代码
- 如何识别并复现被修改的密码学算法
- 如何通过元数据缩小搜索空间
- 重要的安全开发实践
希望本文的详细分析能够帮助读者深入理解 WebAssembly 逆向技术和密码学相关知识。在实际的安全工作中,这些技术和思路同样适用。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:破镜安全 破镜安全《CISCN 2025 Reverse – wasm-login 深度技术解析》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论