顶象 AC 纯算法迁移实战:从补环境到纯算的完整拆解
一、这类问题的核心,不是"能不能补环境",而是"能不能把浏览器依赖收敛成确定公式"
很多验证码在浏览器里运行时,看起来依赖大量环境对象:
windowdocumentnavigatorscreenlocation- DOM 事件
Date.now()Math.random()- 函数
toString()
补环境方案的本质,是把这些对象伪造出来,让原始 JS 继续在"像浏览器的环境里"跑下去。
纯算法方案的本质,则完全不同:
不是继续模拟环境,而是把这些浏览器依赖逐一压缩成确定的输入、固定的常量、可复现的随机过程和明确的字节变换公式。
所以从补环境转纯算,真正要做的是下面四件事:
- 找出最终
ac的整体拼接结构。 - 找出每个段的明文来源。
- 找出每个段的加密公式。
- 把原本依赖浏览器对象的部分改写为纯数据生成。
这篇文章就只围绕这个转换过程展开,不讨论某次具体排错过程。
二、先把目标拆开:AC 不是一个整体字符串,而是一段段字节流拼出来的
当前版本的最终结果可以写成:
text
ac = "5618#" + customBase64(uaBytes)
其中 uaBytes 不是随便拼的,而是由多个 tag 段组成:
text
uaBytes = [1字节tag][2字节大端len][payload] + [下一段tag][len][payload] + ...
这里最关键的第一步,不是先看某个加密公式,而是先把整条包的段顺序和 tag 编号搞清楚。
当前版本的真实顺序是:
| 顺序 | tag | 名称 | 作用 |
|---|---|---|---|
| 1 | 5 | TM | 初始化时间戳 |
| 2 | 14 | BR | 浏览器 / 版本 |
| 3 | 17 | LO | location / referrer |
| 4 | 15 | CF | 内部源码片段采样 |
| 5 | 4 | DI | 窗口差异 / devtools 检测 |
| 6 | 9 | EM | 自动化环境检测位图 |
| 7 | 2 | JSV | 版本段 |
| 8 | 3 | TK | sid / token |
| 9 | 12 | SC | 屏幕信息 |
| 10 | 10 | SA | 轨迹段,可重复多次 |
| 11 | 13 | CA | 点击段,可重复多次 |
| 12 | 1 | TEMP | 临时 JSON 段 |
从补环境转纯算时,第一原则就是:
先把包结构固定下来,再逐段改写。
因为只要 tag 编号错了,后面的公式再对也没有意义。
三、补环境里哪些东西可以直接"压缩"为常量
补环境运行时,JS 会去读很多环境信息。但这些信息不全都需要继续模拟。
迁移成纯算时,最重要的是先分辨哪些环境值在目标场景里是稳定的。
1. 可直接固化的环境段
如果页面、UA、窗口尺寸、DOM mock 都是固定的,那么下面这些段通常可以直接固化密文:
BR:浏览器和版本LO:页面地址和 referrerDI:devtools / 窗口差异检测结果EM:自动化环境位图结果JSV:版本常量段SC:屏幕信息段
也就是说,原本补环境需要靠对象访问得到的值,可以在纯算里改成:
- 先离线分析一次真实输出
- 记录最终明文或密文
- 以后直接作为常量参与拼装
这种转换最能显著减少纯算复杂度。
2. 不能固化、必须实时计算的段
下面这些段通常不能直接写死:
TK:因为 sid / token 变化SA:因为轨迹变化CA:因为点击坐标变化TEMP:因为业务字段变化CF:因为内部随机采样变化
所以纯算迁移时可以把环境依赖分成两类:
- 固定环境段:尽量固化
- 业务动态段:必须公式化
这一步分层做对,后面会轻松很多。
四、时间段 TM 如何从补环境转换成纯算
补环境里,时间通常来自 Date.now()。但纯算里不能停留在"调用时间函数"这个层面,而要继续往下还原成字节级处理。
1. 明文结构
TM 段的明文可以抽象成:
text
bs8(now)
也就是 8 字节大端时间戳。
2. 纯算转换点
从补环境迁移时,需要把:
Date.now()8字节大端编码- 当前版本的每字节变换
拆成独立步骤。
当前版本这一段可以写成:
js
function u64be(num) {
const value = BigInt(num);
const hi = Number((value >> 32n) & 0xffffffffn);
const lo = Number(value & 0xffffffffn);
return [
(hi >>> 24) & 0xff,
(hi >>> 16) & 0xff,
(hi >>> 8) & 0xff,
hi & 0xff,
(lo >>> 24) & 0xff,
(lo >>> 16) & 0xff,
(lo >>> 8) & 0xff,
lo & 0xff,
];
}
function encryptTm(bytes) {
return bytes.map((value) => ((value - 2) & 0xff)).map((value) => ((value << 3) | (value >>> 5)) & 0xff);
}
这里的迁移思路非常典型:
- 补环境阶段:我只知道它读了时间。
- 纯算阶段:我要明确到"先编码成
bs8,再做逐字节减 2 和左旋 3 位"。
五、sid / token 段 TK 如何从补环境转换成纯算
补环境里,业务 sid 通常作为入参传进初始化逻辑。纯算里这部分不能简化成常量,因为 sid 每次都可能变化。
1. 明文结构
TK 段明文可以写成:
text
[bs2(token.length), tokenBytes]
即:
- 先写 token 长度的 2 字节大端
- 再拼 token 本身的 ASCII 字节
2. 转换成纯算法
当前版本这一段不是简单异或,而是一个索引相关的状态异或:
js
function encryptIndexedXor(bytes, startState, addState) {
const out = [];
let state = startState;
for (let index = 0; index < bytes.length; index += 1) {
out.push((bytes[index] ^ state) & 0xff);
state = state * index % 256 + addState;
}
return out;
}
TK 段对应使用:
js
encryptIndexedXor(tkPlain, 43521, 24351)
这里的关键转换点是:
- 补环境方案里,sid 只是 JS 运行时自然参与运算。
- 纯算方案里,要把"字符串输入"精确变成"长度头 + 字节数组 + 状态异或"。
六、轨迹段 SA 如何从事件录制转换成纯算
这是从补环境转纯算时最关键的一类转换,因为它涉及"浏览器事件"如何落成"可复现的轨迹字节"。
1. SA 明文不是事件对象,而是标准化后的三元数据
浏览器里 mousemove、touchmove 之类的事件对象字段很多,但最终进入加密的其实只剩:
text
[bs4(delta), bs2(x), bs2(y)]
也就是说,补环境里你看到的是事件,纯算里真正要做的是把它收敛成:
- 时间差
delta - 横坐标
x - 纵坐标
y
2. 两种不同的时间基线必须拆开处理
从补环境迁移时,最容易犯的错,就是认为所有轨迹函数都能共用一套"时间列"。实际上至少要分两类。
第一类:外层手动回退 ua.tm
像点击类、坐标类、滑块直线类,通常逻辑是:
js
firstTm = Date.now()
for (...) {
firstTm -= random(500, 900)
ua.tm = firstTm
recordSA({ pageX, pageY })
}
这类情况下,真正写进明文的 delta 是:
text
delta = now - ua.tm
而不是你看到的某个轨迹数组第三列。
第二类:轨迹本身直接携带 delta
另一类轨迹函数会这样写:
js
ua.tm = Date.now() - track[i][2]
recordSA(...)
这时明文里的 delta 就等于轨迹数组第三列。
3. 纯算要做的不是"模拟鼠标",而是生成可复现的点序列
因此纯算里最好的写法,不是继续伪造 DOM 事件,而是直接生成点:
js
const plain = [...u32be(delta), ...u16be(x), ...u16be(y)];
然后做加密。
4. 当前 SA 段的加密公式
当前版本是滚动密钥异或:
js
const SA_XOR_KEY = Array.from("KS6BkH8NsJ", (ch) => ch.charCodeAt(0));
function encryptSa(bytes) {
const out = [];
let index = 72;
for (const value of bytes) {
index = (index + 1) % SA_XOR_KEY.length;
out.push((value ^ SA_XOR_KEY[index]) & 0xff);
}
return out;
}
5. 纯算迁移总结
补环境里的"鼠标移动事件"在纯算里应当拆成四层:
- 轨迹生成器
- 时间基线生成器
- 点标准化器:
delta/x/y - 字节加密器
做到这一步,轨迹就不再依赖 DOM 事件。
七、点击段 CA 如何从浏览器点击事件转换成纯算
点击段的迁移思路和 SA 类似,但又有两个重要不同:
- 它使用点击坐标,而不是移动路径。
- 它复用 SA 结束时留下的时间基线。
1. 明文结构
CA 段明文同样是:
text
[bs4(delta), bs2(x), bs2(y)]
2. 时间基线的转换点
补环境里,点击事件往往写成:
js
recordCA({ offsetX, offsetY })
但纯算迁移时不能只看坐标,因为当前版本里点击段的 delta 通常是:
text
delta = now - SA结束后保留下来的tm
这意味着:
- 先要跑完整个 SA
- 记住最后的
tm - 再批量计算 CA
3. 当前 CA 段加密公式
当前版本是状态机式异或:
js
function encryptCa(bytes) {
const out = [];
let state = 367;
for (const value of bytes) {
state = (((state << 2) ^ state) & 240) + (state >> 5);
out.push((value ^ state) & 0xff);
}
return out;
}
4. 纯算转换原则
补环境里,"点击某个 DOM 元素"这个动作,在纯算里要改写成:
- 保留坐标
- 保留时间差
- 丢掉事件对象本身
- 直接构造字节明文
八、TEMP 段如何从 DOM 读取转换成纯算
TEMP 是最典型的"补环境里看起来像 DOM 访问,纯算里其实是 JSON 组包"的一段。
1. 补环境里 TEMP 的来源
补环境运行时,TEMP 通常来自三部分合并:
- 页面 meta 信息
- 业务临时字段
- 页面 HTML 片段
抽象后可以写成:
js
u = extend({}, getMetaInfo(), businessData)
u.fragment = encodeURIComponent(bodyHtml.substr(0, (tm & 127) + 50))
json = JSON.stringify(u)
2. 纯算迁移时,要把 DOM 依赖拆成固定页面数据
从补环境转纯算时,TEMP 的改造思路是:
第一步:把页面常量抽出来
例如:
- 页面
title keywordsdescriptionsbody.innerHTMLhead.innerHTML.length
这些在目标页面固定时,都可以变成纯数据常量。
第二步:把 getMetaInfo 改写成纯函数
例如:
js
function buildMetaInfo(rng) {
return {
title: encodeURIComponent(TITLE.substr(0, 25)),
keywords: encodeURIComponent(sampleFragment(KEYWORDS, 10, rng)),
descriptions: encodeURIComponent(sampleFragment(DESCRIPTIONS, 10, rng)),
bodyLength: BODY_HTML.length,
headLength: HEAD_LENGTH,
};
}
第三步:把 fragment 逻辑保留下来
这一点非常重要,不能偷懒写成固定字符串:
js
fragment = encodeURIComponent(BODY_HTML.substr(0, (tm & 127) + 50))
也就是说,TEMP 的某部分长度仍然依赖当前时间基线。
3. 当前 TEMP 加密公式
当前版本 TEMP 不是简单 repeat xor,而是链式前值异或:
js
function encryptPrevXor(bytes, startState) {
const out = [];
let state = startState;
for (const value of bytes) {
const cipher = (value ^ state) & 0xff;
out.push(cipher);
state = cipher;
}
return out;
}
TEMP 段使用:
js
encryptPrevXor(tempPlain, 38295)
4. 纯算转换后的本质
补环境里,TEMP 看起来依赖 document。
纯算里,TEMP 实际上应该变成:
- 固定页面常量
- 随机截取规则
- 业务字段拼接规则
- JSON 序列化规则
- 链式异或加密规则
这样就不再需要 DOM。
九、CF 段如何从运行时函数采样转换成纯算
CF 是纯算迁移里最特别的一段,因为它并不直接来自用户行为,也不直接来自页面 DOM,而是来自运行时函数源码。
1. 补环境里的行为
补环境时,JS 会拿到若干内部函数对象,然后对它们做:
js
"" + fn
也就是函数 toString()。
随后再:
- 随机选一个函数字符串
- 随机选起始位置
- 随机选长度
- 抽取子串
- 组合成一段数据
2. 纯算转换的关键
这里不能继续"模拟函数对象",更合理的方法是:
- 直接把当前版本真实参与采样的那几段
toString()结果提取出来 - 固化成纯数据字符串池
然后纯算时做:
js
function buildCfPayload(rng) {
const source = SOURCE_POOL[randomInt(0, SOURCE_POOL.length - 1, rng)];
const start = randomInt(0, source.length - 10, rng);
const take = randomInt(2, 10, rng);
const plain = [...u16be(start), ...u16be(take), ...asciiBytes(source.substr(start, take))];
return encryptIndexedXor(plain, 9532, 1276);
}
3. 为什么这一步必须做
因为 CF 段不是纯常量,也不是单纯随机数,它依赖的是"当前版本真实源码字符串"。
如果这里仍然拿旧版池子或者手工猜的片段,整条 AC 就会有一个段永远不一致。
所以从补环境转纯算时,CF 的核心转换点是:
把运行时函数对象,转换成当前版本的固定源码字符串池。
十、六类入口函数,在纯算里应该怎么拆
从补环境转纯算时,不要把所有入口混成一套模板,而是按行为类型拆开。
1. 点击排序类
特点:
- 有点击前轨迹
SA - 有点击点
CA - TEMP 带顺序数组
纯算结构:
text
init -> SA* -> CA* -> TEMP
2. 点击坐标类
特点:
- 同样有
SA + CA - TEMP 改成坐标字段或固定 xpath
纯算结构和点击排序类接近,只是 TEMP 业务字段不同。
3. 滑块直线类
特点:
- 只有
SA - 没有
CA - 轨迹来自线性插值
纯算应直接生成:
js
linearInterpolation(start, end, steps)
再批量转成 SA 字节。
4. 旋转 / 刮刮乐类
特点:
- 轨迹来自离散滑动轨迹生成器
- 时间差直接来自轨迹第三列
- TEMP 字段不同
所以纯算里应单独保留一套:
- 轨迹生成器
- 点转化器
- TEMP 业务数据构造器
不要和前面的点击类混用。
十一、随机数如何从补环境迁移成纯算
补环境里,随机数通常直接来自 Math.random()。但纯算为了可复现,最好改造成可注入的伪随机函数。
例如:
js
function createMulberry32(seed) {
let t = seed >>> 0;
return function () {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
为什么这一步重要?
因为从补环境转纯算之后,最需要的能力不是"随机",而是"可重放"。
可重放之后,你才能:
- 固定随机序列
- 固定时间戳
- 逐段比较纯算和真实 JS 输出
- 准确定位差异在哪个 tag
所以纯算迁移建议统一把所有随机逻辑改成:
js
randomInt(min, max, rng)
而不是直接调用全局 Math.random()。
十二、从补环境转纯算,最推荐的实现结构
如果要把这类逻辑写得后续容易维护,我建议按下面四层拆。
第一层:字节工具层
负责:
u16be / u32be / u64be- 自定义 Base64
- 各种 xor / rotate / 状态机加密
第二层:环境常量层
负责:
- 页面 title / meta / body / head 常量
- 固定密文段常量
- CF 源码池常量
第三层:行为生成层
负责:
trace生成slideTrack生成- 线性插值
- TEMP 对象构造
第四层:业务入口层
负责:
- 点击类入口
- 坐标类入口
- 滑块类入口
- 旋转类入口
- 刮刮乐类入口
这样做的好处是:
- 哪段升级了,只改哪层
- 验证时能直接定位到"是轨迹层错了,还是加密层错了"
- 以后换版本也更容易对比
十三、如何验证纯算已经和补环境等价
从补环境转纯算后,最可靠的验证方法,不是直接请求接口,而是做"固定随机数 + 固定时间"的字节级对比。
推荐验证方法
- 固定随机种子。
- 固定
Date.now()。 - 同样入参分别调用:
- 原始 JS 逻辑
- 纯算逻辑
- 比较最终 AC 是否完全一致。
- 如果不一致,再把 AC 解包成 tag 段逐个对比。
验证顺序建议是:
- 先比总长度。
- 再比每个 tag 的数量和顺序。
- 再比每段
len。 - 最后比每段
payloadHex。
这样排查效率最高。
为什么不建议一开始就直接打服务端
因为服务端只会告诉你成功或失败,它不会告诉你:
- 是 TM 错了
- 还是 TEMP 错了
- 是轨迹点错了
- 还是 CF 不一致
而字节级对比可以把问题精确缩到某一段。
所以"补环境转纯算"的关键能力,不只是写算法,而是搭建出一套能做逐段比对的验证方式。
十四、这类迁移最容易踩的坑
最后总结几个从补环境改纯算时最常见的坑。
1. 只改前缀,不改 tag 顺序
这类错误最常见,也最致命。
2. 只看事件对象,不看最终明文结构
纯算要看的是 delta/x/y,不是浏览器事件本身。
3. 把 TEMP 当作固定模板
TEMP 往往包含:
- 页面 meta 截取
- 动态 fragment
- 业务字段
- 时间相关长度
不能直接模板化到死。
4. 忽略 CF 这类"源码采样段"
这种段看起来不像业务段,但又不是固定常量,最容易被低估。
5. 随机数不可重放
如果纯算里所有地方都直接用 Math.random(),后续会非常难比对。
6. 轨迹生成器没有按入口类型拆开
点击类、滑块类、旋转类往往不是同一种时间基线,必须分开写。
十五、结语
从补环境迁移到纯算法,真正的难点从来不是"把 JS 改写成另一个语言",而是:
把浏览器运行时里那些看似分散的行为,收敛成确定的字节结构、确定的随机过程和确定的加密公式。
如果按这条思路去做,整个迁移过程其实会很清晰:
- 先抽包结构。
- 再抽明文来源。
- 再抽加密公式。
- 最后把 DOM / 环境依赖改写成常量和纯函数。
做完这四步,补环境和纯算法之间就不再是"两个完全不同的方案",而只是同一套逻辑的两种实现方式。
对于这类验证码迁移,最实用的一句话总结就是:
不要试图把浏览器搬进纯算里,而要把浏览器行为压缩成可复现的数据流。
