这次复盘的是某高强度混淆 JS SDK 的 codec 化反混淆------ 把控制流平坦化里的每个 IIFE 抽出来,包装成可独立调用的
transform(input) → encoded_string纯函数,绕开运行时跑整套 SDK 那种思路。流程都走通了,10 个 codec 全部产出,输出非空、长度合理,看着像"成了"。 但服务端就是不接受。
最后追出来两个隐蔽得让人想骂街的坑,一个属于反混淆 pipeline 自己手滑,一个属于在 loop 边界塞的小心机。
关键信息已脱敏,仅供学习交流。
一、起因:返回值突然变了
某天某版本 SDK 更新后,端到端跑下来,校验接口的返回从成功变成失败。
按经验讲,这种情况最常见就是新版本 IIFE 改了,重新抽 codec 即可。反混淆 pipeline 跑了一遍,新版本的 10 个 iife_N_codec.js 都顺利产出,聚合器 slot_codecs.js 也对得上 algoOrder。
跑一下 codec_runner,把每个 slot 的输入喂进去看输出:
arduino
slot 70 → "ѤҬӌҌ" (非空)
slot 3 → "" (空输入,预期容忍)
slot 2 → "ÑÑ" (非空)
slot 89 → "V" (非空)
slot 7 → "ðäø..." (非空)
slot 24 → "©¬" (非空)
slot 87 → "vHHNH..." (非空)
slot 19 → "1\x026\x00\x01;21\x03\x007\x00\x03\x0706..." (非空)
slot 86 → "ᅪ" (非空)
slot 88 → "\x9cô\x94..." (非空)
每个 codec 都给出了字符串,长度跟输入相关,没抛错。
但最后拼出来的 50 字符 df6 跟服务端期望的对不上。
那这就有点意思了..
二、第一件事:拿 oracle,别盯着 codec 自己看
排查这类问题最容易掉进的坑:盯着 codec 输出本身 debug。
你看输出是字符串、没乱码、长度对,就觉得"好像没问题"。
错。
codec 跑得动 ≠ codec 跑对了。
正确姿势是拿一条真机能跑通的完整 fp_data 做 ground truth,把它的 df6 拆成 10 个 5 字符的 chunk,逐位跟你算的对比:
scss
work_df6 = "94d96***1309b4dedb5af8e1d3ce16921d79e77084b25862" # 真机抓到的 df6
work_chunks = [work_df6[i:i+5] for i in range(0, 50, 5)]
# ['94d96', '00000', '1309b', '4dedb', '5af8e',# '1d3ce', '16921', 'd79e7', '7084b', '25862']
algo_order = [70, 3, 2, 89, 7, 24, 87, 19, 86, 88] # 当前版本的 slot 顺序
跑一遍我们自己的 codec,按 algoOrder 把 chunk 排出来对照:
| pos | slot | working | ours | match |
|---|---|---|---|---|
| 0 | 70 | 94d96 | 94d96 | ✓ |
| 1 | 3 | 00000 | d41d8 | ✗ (空字符串,容忍) |
| 2 | 2 | 1309b | 1309b | ✓ |
| 3 | 89 | 4dedb | 52065 | ✗ |
| 4 | 7 | 5af8e | 5af8e | ✓ |
| 5 | 24 | 1d3ce | bf440 | ✗ |
| 6 | 87 | 16921 | 16921 | ✓ |
| 7 | 19 | d79e7 | 4446b | ✗ |
| 8 | 86 | 7084b | 7084b | ✓ |
| 9 | 88 | 25862 | 25862 | ✓ |
四个错(slot 3、89、24、19),六个对。
slot 3 那个 d41d8 = MD5("") 是空输入的产物,是状态机内部变量,服务端容忍,可以先不管。
剩下 slot 89 / 24 / 19 三个,每个都得追。
三、slot 89:看不见的 salt 字符串
先看 slot 89 的 codec 长什么样:
ini
// iife_0_codec.js (slot 89)
function transform(input) {
const __slotRoot = { algoPoints: [] };
const ru = __slotRoot;
(function (t) {
var r, e, n, i, a, u, c, s, f, l, h, v, d, p, b, w, g, m, y, k;
l = ""; // <-- 这里
f = 37;
m = "";
v = 0;
while ((v - t["length"]) * 21 + 44 < 44) {
b = t["charCodeAt"](v);
y = "charCode";
w = b + f;
p = l["length"]; // <-- l 用作 .length
c = v % p;
a = l[y + "At"](c); // <-- l 用作字符表
d = w + v;
g = ~a;
i = w + v;
b = d & g | ~i & l[y + "At"](c);
m += String["fromCharCode"](b);
v++;
}
ru["algoPoints"][89] = m;
})(input);
return __slotRoot.algoPoints[89];
}
输入 "1",输出 "V"。
但服务端期望的 chunk 是 4dedb。MD5("V") = 52065,不对。
那 codec 真实应该输出什么?反过来看算法:
ini
p = l["length"]; // p = 0 (l 是空串)
c = v % p; // v % 0 = NaN
a = l[y + "At"](c); // ""["charCodeAt"](NaN) → NaN
v % 0 = NaN,后面所有依赖 c 的位运算全是 NaN,最终经过 & | ~ 折腾出来"恰好"是一个非 0 字符码,但跟 SDK 真实行为完全无关。
l 不是死代码------它是 XOR salt,一个 7~11 字符的常量字符串,跟 input 做位混合用。
那 l = "" 是怎么来的?
去翻反混淆 pipeline 的中间产物 unflat(codec 化之前的版本):
ini
// iife_0_unflat.js
h = function (t, r) {
return uy.bind(0, r, t - -9)();
};
l = h.apply(4, [469, 11]); // <-- 原来 l 不是空串,是 decoder 调用
l 实际上是一次 string decoder 调用的返回值。pipeline 把 decoder 调用识别为"外部引用"后,直接简化成空串------"反正这是 decoder ref,先清掉"------但忽略了 l 在 loop 体内被当作字符串用。
这是个典型的反混淆 over-aggressive cleanup 错误:清理外部引用时只看赋值左边,没看右边在哪被用过。
四、跑 decoder 把 salt 解出来
修法很直接:拿 decoder 源码跑一次,把 salt 的真值 inline 进去。
每个版本的 slot_map.json 都存了 _decoderSource 和 _aliasSource,正是为此准备的:
lua
import json
d = json.load(open('slot_map.json'))
print(d['_decoderSource']) # decoder 完整函数体
print(d['_aliasSource']) # alias wrapper (var uy = function(t,r){...})
把这两段拼在一起,再追加调用,存成 dec.js:
javascript
function T(t, r) {
var e = function () {
return ["yScn/S7+O8", "aNdGa=UZaq", "FlQ6UmzYUd8", ...];
};
return (T = function (r, n) {
var i = e[r -= 3];
if (i) { /* XOR + base64-like 解码 */ }
})(t, r);
}
var uy = function (t, r) {
return T.apply(0, [r - 9, t]);
};
// 目标:算 h.apply(4, [469, 11]) 的返回值
// h(t,r) = uy.bind(0, r, t-(-9))() = uy(r, t+9)
// 所以 h.apply(4, [469, 11]) = h(469, 11) = uy(11, 478)
console.log('uy(11, 478) =', JSON.stringify(uy(11, 478)));
arduino
$ node dec.js
uy(11, 478) = "XK37ML9"
7 个字符的 salt。
把 codec 里的 l = "" 改回 l = "XK37ML9",重跑:
lua
slot 89: input="1" → output(1 char) → MD5[:5] = 4dedb ✓
对上了。
slot 24 是同一类问题的另一个版本:
ini
b = 119;
y = ""; // ← 又是空 salt
跑 decoder:
ruby
$ node -e "...; console.log(uy(19, 292));"
"XOR119"
改 y = "XOR119",slot 24 chunk 立刻对上。
骚点: .bak 文件是金矿
只要 pipeline 在 codec 化时把每个 codec 的原始版本存了 .bak,"被清零的 salt 应该是什么 decoder 调用"就有对照。
如果你的 pipeline 没存 .bak,养成存的习惯,比啥都强。后面任何"被反混淆错的位置"都能拿来回溯。
五、slot 19:opaque loop bound 的猫腻
slot 19 的 codec 看着跟 slot 89 不太像,没空 salt:
ini
// iife_9_codec.js (slot 19)
(function (t) {
var r, e, i, a, o, u, c, s, h, v, d, p, b, w, g, m, y, k, M;
g = 79;
y = 179;
h = "";
p = 0;
while (p - t.length + 11 + 85 < 85) { // <-- 注意这行
s = "charCode";
c = t[s + "At"](p);
d = c + g;
o = c + g;
k = ~o;
u = ~y;
v = d | y;
b = k | u;
c = v & b; // ← (c+79) XOR 179
h += String.fromCharCode(c);
p++;
}
ru['algoPoints'][19] = h;
})(input);
算法很干净:每个 char c → (c+79) XOR 179。
但输出对不上。仔细看 loop bound:
matlab
while (p - t.length + 11 + 85 < 85)
数学化简一下:
css
p - t.length + 11 + 85 < 85
⇔ p - t.length + 11 < 0
⇔ p < t.length - 11
少跑 11 个字符。
我们喂 32 字符的 md5(ip),codec 只处理前 21 个,输出 21 字符。MD5(21 字符) ≠ MD5(32 字符) ≠ 期望 chunk。
这种把简单条件改写成多个常量叠加的形式,专业术语叫 opaque predicate------表面上数学合法,看着像有用的边界判断,但化简后等价于一个平凡条件(甚至不是同一个条件)。
loop 边界上塞的就是这玩意。
横向对照同算法的其他版本:
arduino
// 其他版本同样 slot 同样算法
while (p < t.length) { ... } // 干净
// 当前版本
while (p - t.length + 11 + 85 < 85) { ... } // 多了个 +11
// 还见过这种
while ((v - t.length) * 21 + 44 < 44) { ... } // 这种 OK
最后一种 (v - t.length) * X + N < N 形式,因为乘了个正常数 X,化简后等价 v < t.length,无害;
但 p + N1 + N2 < N2(N1 ≠ 0)等价 p < t.length - N1,会少跑 N1 个 char。
肉眼很像,语义差一个量级。
修法:直接改回 while (p < t.length)。
arduino
// 改之前
while (p - t.length + 11 + 85 < 85) {
// 改之后 pipeline 没把 opaque predicate fold 干净,SDK 实际行为是处理整个 input
while (p < t.length) {
重跑:
css
slot 19: input=md5(ip) → output(32 chars) → MD5[:5] = d79e7 ✓
对上了。
六、Sanity check 模板
每改一个 codec,必跑一次这个:
python
import json, hashlib, subprocess
# 1. 真机 fp_data 拆出来的 chunks (oracle)
work_chunks = {
70: '94d96', 3: '00000', 2: '1309b',
89: '4dedb', 7: '5af8e', 24: '1d3ce',
87: '16921', 19: 'd79e7', 86: '7084b', 88: '25862',
}
# 2. 各 slot 的 IIFE 真实输入(从 fp_data 同名字段拷过来)
inputs = {
70: '-480',
3: '',
2: '11',
89: '<某派生值>',
7: '131.0.0.0',
24: '24',
87: '<sessionItem.timestamp>',
19: '<md5(ip)>',
86: '0',
88: '<canvas data 长 base64 串>',
}
# 3. 调 codec_runner
req = json.dumps({'verNum': '<version>', 'inputs': {str(k): v for k, v in inputs.items()}})
proc = subprocess.run(
['node', 'codec_runner.js'],
input=req, capture_output=True, text=True, encoding='utf-8', timeout=30,
)
results = json.loads(proc.stdout)
# 4. 逐位对比
for slot, expect in work_chunks.items():
enc = results.get(str(slot)) or ''
md5 = hashlib.md5(enc.encode('utf-8')).hexdigest()[:5]
ok = 'YES' if md5 == expect else 'NO '
print(f'slot {slot:>3}: {ok} md5={md5} expect={expect}')
10 个 slot 里 9 个能对上、剩下 1 个是 state-machine 内部变量(input 静态算不出来,服务端容忍 MD5("") = d41d8)就算健康。
每改一处必跑一次,不要相信"只改了一行不会影响别的"------AST 重写经常牵一发动全身。
七、骚操作复盘
整个流程回过头看,几个关键节点:
骚操作 1:先有 oracle,再 debug
没有"真机能跑通的 fp_data 做 ground truth",盯着 codec 输出看一辈子也看不出毛病。
判断标准从"非空 / 没崩"升级到"chunk MD5 跟服务端期望逐位匹配" 。这一步换个口径,后面所有 debug 才有方向。
骚操作 2:.bak 文件是 ground truth
只要 pipeline 留了原始备份,"反混淆后的 codec 应该长什么样"就有了对照。手工清理 codec 前先存 .bak,养成习惯。任何"被反混淆错的位置"都能拿来回溯。
骚操作 3:跑 decoder 拿真值 inline
_decoderSource + _aliasSource 两个字段在 slot_map.json 里早就存着,但很少有人主动用。
3 行 Node 就能解出来一个 salt 字符串。
javascript
// 把 decoder + alias 拼到一起
console.log(JSON.stringify(uy(arg1, arg2)));
比手工 trace 强 100 倍,比把代码丢 AI 让它"猜"强 1000 倍。
骚操作 4:opaque predicate 横向对照
单独看 p + 11 + 85 < 85,你判断不出它跟 p < t.length 是不是等价的------容易脑补"应该是等价吧,反正都是 t.length"。
跨版本对照同算法的其他 codec,看它们 loop bound 长什么样。3 个版本是 p < t.length,1 个是 p + N + M < M,那个 +N 大概率是 pipeline 没折干净。
* X + N < N 形式无害(等价 v < t.length), + N1 + N2 < N2(N1 ≠ 0)形式有害(少跑 N1 个 char)。
两种形式肉眼很像,记住这个口诀。
骚操作 5:sanity check 不嫌啰嗦
每改一个 codec,跑一遍 §6 那段 Python,10 个 chunk 全过一次。
反混淆是个一动牵全身的活,没有自动验证机制等于盲改。
八、AI 在这里干了什么
值得单独拎出来讲一下。这次整个 debug 流程,AI 主要做了三件事:
1. 横向对照同算法的其他版本 codec
我手里有 150 个版本的 codec 文件,让 AI 扫一遍同 slot 的 loop bound、salt 赋值,几秒钟就能告诉我"3 个版本是 p < t.length,1 个是 p + 11 + 85 < 85"。
肉眼看这种问题,要么漏掉,要么浪费 1 小时。
2. 把 decoder 调用结构化解出来
h.apply(4, [469, 11]) 这种调用要换算成 uy(11, 478),再去查 decoder------AI 一步把 alias 公式 h(t,r) = uy(r, t+9) 列出来,参数换算清清楚楚。
3. 协助快速产出诊断脚本
sanity check 那段 Python 不是一次写完的,是 AI 根据"我要逐位对比 chunk 而不是看输出形式"这个需求迭代出来的。
但有一点要警惕:AI 自己没有 oracle 意识。如果你让它"看看 codec 有什么问题",它会告诉你"输出非空、长度合理、看起来没问题",跟你最初的盲点一模一样。
你得先给它一个"判定标准",它才能基于这个标准帮你查。
这就是为什么 §2 那段最重要------不是"找 bug",是"先建立 bug 的定义" 。
九、一句话总结
反混淆 codec 化最容易栽的两类坑:
- Salt 字符串被 over-cleanup 成空串------症状:codec 跑得动但输出乱。修法:跑 decoder 解出真值 inline。
- Opaque loop bound 没被 fold 干净 ------症状:codec 跑得动但输出短。修法:跨版本横向对照,识别
+N1+N2<N2形式 (N1≠0) 这种伪等价。排查口径必须从"非空"升级到"跟 oracle 逐位匹配"。codec 跑得动 ≠ codec 跑对了。
仅供学习交流,关键信息已脱敏。如有侵权等行为,私信联系作者删除。