顶象 AC 纯算法迁移实战:从补环境到纯算的完整拆解

顶象 AC 纯算法迁移实战:从补环境到纯算的完整拆解

一、这类问题的核心,不是"能不能补环境",而是"能不能把浏览器依赖收敛成确定公式"

很多验证码在浏览器里运行时,看起来依赖大量环境对象:

  • window
  • document
  • navigator
  • screen
  • location
  • DOM 事件
  • Date.now()
  • Math.random()
  • 函数 toString()

补环境方案的本质,是把这些对象伪造出来,让原始 JS 继续在"像浏览器的环境里"跑下去。

纯算法方案的本质,则完全不同:

不是继续模拟环境,而是把这些浏览器依赖逐一压缩成确定的输入、固定的常量、可复现的随机过程和明确的字节变换公式。

所以从补环境转纯算,真正要做的是下面四件事:

  1. 找出最终 ac 的整体拼接结构。
  2. 找出每个段的明文来源。
  3. 找出每个段的加密公式。
  4. 把原本依赖浏览器对象的部分改写为纯数据生成。

这篇文章就只围绕这个转换过程展开,不讨论某次具体排错过程。


二、先把目标拆开: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:页面地址和 referrer
  • DI: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 明文不是事件对象,而是标准化后的三元数据

浏览器里 mousemovetouchmove 之类的事件对象字段很多,但最终进入加密的其实只剩:

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. 纯算迁移总结

补环境里的"鼠标移动事件"在纯算里应当拆成四层:

  1. 轨迹生成器
  2. 时间基线生成器
  3. 点标准化器:delta/x/y
  4. 字节加密器

做到这一步,轨迹就不再依赖 DOM 事件。


七、点击段 CA 如何从浏览器点击事件转换成纯算

点击段的迁移思路和 SA 类似,但又有两个重要不同:

  1. 它使用点击坐标,而不是移动路径。
  2. 它复用 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 通常来自三部分合并:

  1. 页面 meta 信息
  2. 业务临时字段
  3. 页面 HTML 片段

抽象后可以写成:

js 复制代码
u = extend({}, getMetaInfo(), businessData)
u.fragment = encodeURIComponent(bodyHtml.substr(0, (tm & 127) + 50))
json = JSON.stringify(u)

2. 纯算迁移时,要把 DOM 依赖拆成固定页面数据

从补环境转纯算时,TEMP 的改造思路是:

第一步:把页面常量抽出来

例如:

  • 页面 title
  • keywords
  • descriptions
  • body.innerHTML
  • head.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()

随后再:

  1. 随机选一个函数字符串
  2. 随机选起始位置
  3. 随机选长度
  4. 抽取子串
  5. 组合成一段数据

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 对象构造

第四层:业务入口层

负责:

  • 点击类入口
  • 坐标类入口
  • 滑块类入口
  • 旋转类入口
  • 刮刮乐类入口

这样做的好处是:

  • 哪段升级了,只改哪层
  • 验证时能直接定位到"是轨迹层错了,还是加密层错了"
  • 以后换版本也更容易对比

十三、如何验证纯算已经和补环境等价

从补环境转纯算后,最可靠的验证方法,不是直接请求接口,而是做"固定随机数 + 固定时间"的字节级对比。

推荐验证方法

  1. 固定随机种子。
  2. 固定 Date.now()
  3. 同样入参分别调用:
    • 原始 JS 逻辑
    • 纯算逻辑
  4. 比较最终 AC 是否完全一致。
  5. 如果不一致,再把 AC 解包成 tag 段逐个对比。

验证顺序建议是:

  1. 先比总长度。
  2. 再比每个 tag 的数量和顺序。
  3. 再比每段 len
  4. 最后比每段 payloadHex

这样排查效率最高。

为什么不建议一开始就直接打服务端

因为服务端只会告诉你成功或失败,它不会告诉你:

  • 是 TM 错了
  • 还是 TEMP 错了
  • 是轨迹点错了
  • 还是 CF 不一致

而字节级对比可以把问题精确缩到某一段。

所以"补环境转纯算"的关键能力,不只是写算法,而是搭建出一套能做逐段比对的验证方式。


十四、这类迁移最容易踩的坑

最后总结几个从补环境改纯算时最常见的坑。

1. 只改前缀,不改 tag 顺序

这类错误最常见,也最致命。

2. 只看事件对象,不看最终明文结构

纯算要看的是 delta/x/y,不是浏览器事件本身。

3. 把 TEMP 当作固定模板

TEMP 往往包含:

  • 页面 meta 截取
  • 动态 fragment
  • 业务字段
  • 时间相关长度

不能直接模板化到死。

4. 忽略 CF 这类"源码采样段"

这种段看起来不像业务段,但又不是固定常量,最容易被低估。

5. 随机数不可重放

如果纯算里所有地方都直接用 Math.random(),后续会非常难比对。

6. 轨迹生成器没有按入口类型拆开

点击类、滑块类、旋转类往往不是同一种时间基线,必须分开写。


十五、结语

从补环境迁移到纯算法,真正的难点从来不是"把 JS 改写成另一个语言",而是:

把浏览器运行时里那些看似分散的行为,收敛成确定的字节结构、确定的随机过程和确定的加密公式。

如果按这条思路去做,整个迁移过程其实会很清晰:

  1. 先抽包结构。
  2. 再抽明文来源。
  3. 再抽加密公式。
  4. 最后把 DOM / 环境依赖改写成常量和纯函数。

做完这四步,补环境和纯算法之间就不再是"两个完全不同的方案",而只是同一套逻辑的两种实现方式。

对于这类验证码迁移,最实用的一句话总结就是:

不要试图把浏览器搬进纯算里,而要把浏览器行为压缩成可复现的数据流。

相关推荐
SccTsAxR3 小时前
算法基石:手撕离散化、递归与分治
c++·经验分享·笔记·算法
wuweijianlove4 小时前
算法测试中的数据规模与时间复杂度匹配的技术4
算法
小叶lr4 小时前
jenkins打包前端样式丢失/与本地不一致问题
运维·前端·jenkins
浩星4 小时前
electron系列1:Electron不是玩具,为什么桌面应用需要它?
前端·javascript·electron
Q741_1474 小时前
每日一题 力扣 3655. 区间乘法查询后的异或 II 模拟 分治 乘法差分法 快速幂 C++ 题解
c++·算法·leetcode·模拟·快速幂·分治·差分法
The_Ticker4 小时前
印度股票实时行情API(低成本方案)
python·websocket·算法·金融·区块链
夏乌_Wx4 小时前
剑指offer | 2.4数据结构相关题目
数据结构·c++·算法·剑指offer·c/c++
ZC跨境爬虫4 小时前
Scrapy工作空间搭建与目录结构解析:从初始化到基础配置全流程
前端·爬虫·python·scrapy·自动化
开心码农1号4 小时前
Java rabbitMQ如何发送、消费消息、全套可靠方案
java·rabbitmq·java-rabbitmq