本帖最后由 过往233 于 2026-2-22 08:04 编辑
大家好我是个逆向小白,一直想搞h5st,但是每次都不知道该怎么搞,总是插桩到一半由于手头有各种事情耽搁了,上个月看到了一个大佬的文章,ai逆向,寻思试一试,没想到补环境1个小时不到就自己补好了,纯算从头来耽搁了两天走了不少弯路
先来说说心路历程,我是直接用补完的环境直接丢给ai让ai去操作,让他把补环境版本改成纯算版本,想法挺好现实给了一记重锤,ai直接去静态分析,然后上下文吃满,直接忘掉了最一开始的要求
算了,别的不多说了直接放总结吧,总之ai确实不错,而且还有待提升的空间,比如利用mcp添加更完善的提示词
哦对了xsh的补环境也搞出来了,直接把两个js一丢给ai他自己就可以搞好
以下是纯算和补环境的总结
h5st 签名本地化 — 完整对话记录
第一轮:补环境,让 JS 能在 Node.js 里跑
我:
底下那段是 ParamsSign 调用接口,我需要你帮我补环境让我可以本地运行 JS。
AI:
分析了 1.js(约 10500 行混淆代码),发现它重度依赖浏览器全局对象:window、document、navigator、localStorage、XMLHttpRequest、screen 等。底部还有 TypeScript 语法(const time: any)在 Node 里跑不了。
创建了两个文件:
env.js — 补浏览器环境(window、document、navigator、localStorage、screen、crypto 等)
run.js — 入口文件,加载环境后调用 ParamsSign 进行签名
原 1.js 底部的 TypeScript 调用代码已移除。
运行方式:cd h5stai醇酸 && node run.js
第二轮:用生成的 h5st 发请求,遇到 403
我:
(贴了一段 curl 命令,包含完整的 headers、cookies 和 h5st)
带着生成的 h5st 请求这个试试。
AI:
签名生成成功了,h5st 正常产出。但请求返回了 403。初步判断可能是 cookie 过期。
我:
算了我不要发请求测试了,给我直接生成下 h5st:
{
"appid": "search-pc-java",
"functionId": "pc_search_adv_Search",
"client": "pc",
"clientVersion": "1.0.0",
"t": 1771501263005,
"body": "881e4ddbb37b54759c65772e65dda6cb04433e3b0dd553eb89e698d51c67daf1"
}
AI:
生成成功,run.js 已精简成纯签名模式,改底部参数就能生成不同的 h5st。
第三轮:写 Python 脚本自动调签名
我:
(贴了一段 Python 代码,包含 headers、cookies、body、h5st)
来替换这个的 h5st 试试。
AI:
改造了流程:run.js 接受命令行 JSON 参数输出 h5st,1.py 调用它自动替换。
测试结果还是 403。
我:
现在是没问题的。(把 h5st 注释掉了,不带 h5st 能正常请求)
AI:
看到了,不带 h5st 能通。需要自动生成的时候把 get_h5st 函数加回来就行。
我:
我需要你把 h5st 加过来。
AI:
把自动生成 h5st 的逻辑加到了 1.py 里。
第四轮:排查 403 的真正原因
我:
现在报错 403,body 不应该换。
AI:
开始排查。发现 Python 端用 hashlib.sha256 算的 body hash 和 JS 内部算的不一致。改成直接把原始 body 传给 JS,让 JS 内部统一处理。
还是 403。
我:
2.py 是正常能请求的,也就是说你的 h5st 生成的不对。
AI:
对比发现 2.py 用的是原始抓包的固定 h5st,能正常返回 200。确认 cookie 没问题,问题在 h5st 签名本身。
第五轮:发现 signAppId 和 appid 是两个不同的值
AI:
发现关键区别:
ParamsSign({ appId: '01a47' }) — 构造函数用 01a47(h5st 第三段)
sign({ appid: 'pc-rate-qa', ... }) — sign 参数里用 pc-rate-qa(POST data 里的 appid)
之前把这两个混在一起了。分开后还是 403。
第六轮:发现 token 问题(tk05 vs tk03)
AI:
通过 hook 内部方法,发现签名流程:
_$cps — 排序参数
_$rds — 获取 token
_$clt — 环境检测(收集浏览器指纹)
_$ms — 主签名逻辑
_$gdk — 用 token 派生密钥
_$gs / _$gsd — 计算签名和摘要
_$gsp — 拼接最终 h5st
关键发现:我们生成的 h5st 里 token 是 tk05(本地生成),但原始抓包的是 tk03(远程获取)。
原因:_onRequestTokenRemotely 是空函数,没有真正去服务端请求 token。token 请求发到 https://cactus.jd.com/request_algo。
修复:在 env.js 里实现了真正能发 HTTP 请求的 XMLHttpRequest(基于 Node.js 的 https 模块),让 token 请求能正常工作。
修复后 token 变成了 tk03,但还是 403。
第七轮:找到根本原因 — Node.js 内置 navigator
AI:
用 Proxy 监控 _$clt(环境检测方法)执行时访问的属性:
document.cookie
navigator.languages
navigator.plugins
navigator.userAgent
document.head
document.body
document.all
navigator.hardwareConcurrency
检查实际值发现:
| 属性 |
期望值 |
实际值 |
navigator.userAgent |
Chrome/145 macOS |
Node.js/25 |
navigator.languages |
["zh-CN", "zh", "en"] |
["en-US"] |
navigator.plugins |
5个 PDF 插件 |
undefined |
document.cookie |
完整 cookies |
空 |
document.all |
HTMLAllCollection |
undefined |
根本原因:Node.js 18+ 内置了 navigator 对象,且该属性是不可写的(non-writable)。
env.js 中的 global.navigator = navigator 赋值被静默忽略了!
node -e "console.log(navigator.userAgent)"
# 输出: Node.js/25
_$clt 收集到这些 Node.js 环境信息后编码到 expandParams(h5st 第8段),服务端检测到非浏览器环境直接返回 403。
修复
1. env.js — 用 Object.defineProperty 覆盖内置对象
// 之前(无效,被 Node.js 内置 navigator 覆盖)
global.navigator = navigator;
// 之后(有效)
const defineGlobal = (name, value) => {
Object.defineProperty(global, name, {
value, writable: true, configurable: true
});
Object.defineProperty(globalThis, name, {
value, writable: true, configurable: true
});
};
defineGlobal('navigator', navigator);
2. 更新 navigator 属性匹配真实浏览器
- UA 改为 Chrome/145 macOS
- languages 改为
['zh-CN', 'zh', 'en']
- plugins 补充 5 个 PDF 插件
- 补充 cookieEnabled、vendor 等属性
3. 修复 document.all
// 之前
all: undefined,
// 之后
all: { length: 0 },
4. run.js / 1.py — 传递 cookies 到 document.cookie
Python 端把 cookies dict 传给 Node.js,设置到 document.cookie,让环境指纹包含正确的 cookie 信息。
修复后测试:返回 200,问题解决。
用 AI 逆向 h5st 5.2 纯算签名 — 当 CryptoJS 不再是标准 CryptoJS
背景
h5st 是某电商平台的前端签名协议,核心逻辑藏在一个多态 VM 混淆的 JS 文件里。目标是把它从"补环境 + 跑 VM"变成纯算实现 — 不依赖任何混淆代码,只用标准 Node.js crypto 模块。
SHA256、HMAC、Base64 编码、fingerprint 生成都已经逆向完成,但最后一步 — tk05 token 的 checksum — 始终和原始 VM 输出对不上,导致 API 返回 403。
定位过程
第一步:对比输出,锁定 checksum
让 AI 做的第一件事不是猜,而是对比。
把原始 VM 的 _$ik(fp) 函数导出,和纯算 generateTk05(fp) 用同一个 fp 调用,逐字段比对输出。结果:
- token 结构一致 ✓
- 自定义 Base64 编码一致 ✓
- 加密密文一致 ✓
- MD5 checksum ✗ — 每次都不一样
问题锁定在 checksum 计算。纯算代码用的是 crypto.createHash('md5').update(body).digest('hex'),看起来没毛病。
这一步做得很干净。与其猜哪里错了,不如让两个实现并排跑,让差异自己浮出来。
第二步:Hook _doFinalize,发现预处理
AI 的下一步是 hook VM 内部的 CryptoJS,在 _doFinalize 阶段截获实际传入 MD5 的数据。发现:
输入: "tk05w41lMngxWlhiMmQ1f..." (82 bytes)
实际哈希数据: 93 bytes — 多了 11 个字节
多出来的 11 字节 = 5 字节动态后缀 + 6 字节常量 &d74&y。
这就是 _seData — 一个挂在 Hasher.prototype 上的预处理钩子。它不改变 CryptoJS 的 API 接口(CJ.MD5(str) 调用方式完全一样),但在内部悄悄修改了输入数据。
这是整个调试过程中最关键的一步。不去读混淆代码,而是在运行时拦截数据流,直接看"进了什么、出了什么"。这种黑盒 + 灰盒结合的方式,在面对多态 VM 时比白盒逆向高效得多。
_seData 算法
1. 对输入的每个字节,通过 BYTE_MAP[byte] 映射到 MAP1 字母表中的一个字符
2. 按位置分成 5 组(chunkSize = floor(len / 5))
3. 每组内用 (idx + idx2 + 51) % 64 累加折叠为一个 MAP1 索引
4. 追加 5 个字符作为后缀
5. 再追加常量 "&d74&y"
有趣的是,pure_crypto.js 里 SHA256 用的 seData 是另一种写法 — (charCode * 19) % 64 — 但数学上完全等价。AI 通过对比验证确认了这一点。
踩坑记录
踩坑 1:试图从混淆代码中逆向 BYTE_MAP
最初尝试阅读 VM 生成的混淆代码来理解字节映射逻辑。多态 VM 的变量名全是 _$xx 格式,控制流被打散,几乎不可读。浪费了不少时间。
转换思路后,直接写脚本对 0x00-0xFF 所有字节值调用 VM 的 _seData,从运行时 dump 出完整的 256 字节查找表。几秒钟搞定。
反思:面对重度混淆代码,"运行时提取"几乎总是比"静态阅读"更高效。AI 特别擅长写这种一次性的 dump 脚本 — 告诉它你要什么数据,让它去 hook。
踩坑 2:分组累加公式的常量偏移
_seData 将映射后的字节分成 5 组,每组内累加折叠。最初以为是简单的 (a + b) % 64,对比发现不对。通过构造不同长度的测试字符串,逐步确认实际公式是 (idx + idx2 + 51) % 64 — 多了一个常量 51。
这个 51 藏得很深。如果只测试短字符串(每组只有 1 个字符,不触发累加),根本发现不了。是在测试较长字符串时才暴露的。
反思:测试用例的多样性很重要。短字符串、长字符串、边界长度(刚好整除 5、不整除 5)都要覆盖。AI 可以批量生成这些测试,但需要人来意识到"我的测试覆盖不够"。
踩坑 3:两种 seData 实现的困惑
pure_crypto.js 里 SHA256 用的 seData 是 (charCode * 19) % 64 + 字母表 UTSRQPONM...。我从 VM dump 出来的是 BYTE_MAP 查找表 + (idx + idx2 + 51) % 64 + MAP1 字母表 hgfedcba...。两种写法看起来完全不同。
一度怀疑 MD5 和 SHA256 用的是不同的 _seData(毕竟 VM 可以给不同 hasher 挂不同钩子)。花了时间去验证,最终证明两种实现数学上完全等价 — 只是同一个映射的不同表达方式。
反思:这是一个典型的"看起来不同但本质相同"的陷阱。在逆向中很常见 — 同一个算法可以有多种等价实现。AI 在这里的价值是快速写出对比测试来证明等价性,而不是靠人肉推导数学关系。
踩坑 4:SHA256 的"多余"验证
担心 pure_crypto.js 的 SHA256 也有问题,花时间 hook SHA256 的 _doFinalize 做对比。结果发现 SHA256 一直是正确的 — 问题只出在 tk05 的 MD5 上。
反思:严格来说这步是"白做"的,但在调试中排除变量是有价值的。当你不确定问题边界时,花一点时间确认"这个没问题"比假设它没问题更安全。不过如果一开始就做了更精确的 diff(只对比 checksum 而不是整个签名链),可以更早缩小范围。
踩坑 5:Windows shell 引号问题
端到端测试时,用 execSync 传 JSON 参数给子进程。在 Windows CMD 下单引号不被识别为字符串定界符,导致 JSON.parse 失败。最终改为直接 require 模块调用函数,完全绕过 shell。
反思:这是一个低级但浪费时间的坑。AI 生成的测试脚本默认用 Unix 风格的 shell 语法,在 Windows 上跑不通。如果一开始就用模块调用而不是子进程,可以避免这个问题。跨平台兼容性是 AI 生成代码时容易忽略的盲区。
修复
// 修复前 — 标准 MD5
const checksum = md5Hex(body).substring(0, 8);
// 修复后 — 带 _seData 预处理的 MD5
const checksum = md5HexStr(body).substring(0, 8);
一行之差,checksum 从 0% 匹配变成 100% 匹配。
验证
- tk05 checksum: pure vs VM 输出完全一致
requestAlgo 请求: 服务器正常返回 token(HTTP 200)
- 实际 API 调用: pure 实现和原始 VM 行为完全一致
更大的思考
关于 AI 在逆向工程中的角色
这次对话展示了 AI 在逆向工程中最有价值的模式:不是让 AI 直接"读懂"混淆代码(它做不到,至少对多态 VM 级别的混淆做不到),而是让 AI 充当一个高速的"实验助手" — 写 hook 脚本、dump 运行时数据、生成对比测试、批量验证假设。
人的角色是提出假设和判断方向:
- "checksum 不对,可能是 MD5 输入被修改了" — 这是方向判断
- "hook _doFinalize 看实际输入" — 这是实验设计
- "dump 所有 256 个字节的映射" — 这是数据获取策略
AI 的角色是快速执行这些实验:
- 写 hook 代码、跑测试、格式化输出、对比结果
- 这些都是机械性但容易出错的工作,AI 做得又快又准
关于"标准库不标准"的陷阱
这次的核心教训是:在混淆 JS 中,CryptoJS.MD5(str) 不一定是标准 MD5。多态 VM 的一个高明手法是在 Hasher.prototype 上挂预处理钩子 — _seData、_eData 这类函数。它们不改变 API 接口(调用方式完全一样),但悄悄修改了输入数据。
这种手法之所以有效,恰恰是因为逆向者会假设"CryptoJS.MD5 就是 MD5"。当你看到 CJ.MD5(body).toString() 时,本能反应是用 crypto.createHash('md5').update(body).digest('hex') 替代。99% 的情况下这是对的,但在多态 VM 的场景下,这个假设会害死你。
关于调试效率
回顾整个过程,最高效的步骤是:
- 并排对比(2 分钟定位到 checksum)
- Hook _doFinalize(5 分钟发现预处理)
- 运行时 dump BYTE_MAP(几秒钟)
最低效的步骤是:
- 试图阅读混淆代码(浪费时间)
- 验证 SHA256 没问题(有价值但非必要)
- Windows shell 引号问题(纯粹的环境坑)
如果重来一次,整个过程可以压缩到 30 分钟以内。关键是一开始就建立"对比 → hook → dump"的工作流,而不是试图"读懂"混淆代码。
AI 协作的几个关键点
-
让 AI 做对比,不要让它猜。把 VM 函数导出,和纯算函数并排跑,逐字段 diff。AI 擅长写这种一次性的对比脚本。
-
Hook 内部状态,不要只看输入输出。让 AI patch _doFinalize 截获实际哈希数据,比盯着混淆代码猜逻辑高效得多。
-
提取查找表,不要手动推导。BYTE_MAP 有 256 个条目,让 AI 写脚本从 VM 运行时 dump 出来,比逆向混淆代码里的生成逻辑快一个数量级。
-
交叉验证等价性。两种不同写法的 seData(查找表 vs 乘法取模)看起来完全不同,但 AI 可以快速写测试证明它们数学等价。
|