某里v2反混淆 codec 化路上踩到的两个隐蔽坑:被清零的 salt 与 opaque loop bound

这次复盘的是某高强度混淆 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 跑对了。


仅供学习交流,关键信息已脱敏。如有侵权等行为,私信联系作者删除。

相关推荐
跟着珅聪学java8 小时前
ECharts subtext(副标题)边距开发教程
前端·javascript·echarts
Hello world.Joey8 小时前
吴恩达深度学习基础
人工智能·深度学习·神经网络·opencv·算法·机器学习·计算机视觉
水木流年追梦8 小时前
大模型入门-大模型优化方法1
人工智能·学习·算法·机器学习·正则表达式
姓蔡小朋友9 小时前
TypeScript数据类型
javascript·ubuntu·typescript
zithern_juejin9 小时前
Symbol.hasInstance详解
javascript
ZengLiangYi9 小时前
插件式架构设计:SourceAdapter 接口抽象
前端·javascript·后端
cc.ChenLy9 小时前
大文件断点续传原理总结和Demo示例详解
javascript·vue.js·文件上传·大文件断点续传
lynnlovemin9 小时前
【信息学竞赛专题】滑动窗口(尺取法)超全详解|C++模板+经典例题+避坑指南
开发语言·c++·算法·滑动窗口·信息学竞赛
程序员祥云9 小时前
VUE2_TO_VITE_VUE3
javascript·vue.js·ecmascript