使用视觉停留效应作为人机验证:Phantom 验证码安全分析

Phantom 验证码安全分析

本文使用DeepSeek辅助编写,好久没写博客了,兴趣来了写一下

一、项目概览

Phantom 是一种利用视觉停留效应区分人机的验证码。它的设计思路挺有意思——不用扭曲文字、不用选红绿灯,而是让一个方块藏进满屏雪花噪点里,只有噪点动起来的时候,人眼才能"看"到它。

原项目: https://github.com/techccy/phantom

两道防线:

  1. 视觉防线 —— 目标方块隐藏在满屏动态噪点中,只有粒子运动时人眼才能识别
  2. 行为防线 —— 用户按住按钮跟随移动方块,系统分析轨迹的"人性"特征

说到底,验证码的通用逻辑都一样:出难题 → 记录解题行为 → 评分 → 判定。难题长什么样不重要,重要的是行为特征能不能模拟。

实测体验:只要大致跟上方块、在曲线拐弯处别跑偏、结束时踩在目标上,基本就能过。真正的难点不在"看清方块",而在"让轨迹看起来像人"。

所以攻击思路也很自然,就是想办法制造一条"看起来像人画出来的轨迹"。三个方向:

方法 思路 类比
分析法 读源码拿到评分算法,抄贝塞尔曲线公式,靶向构造轨迹 开卷考试,对着答案写过程
模拟法 逆向前端 JS,提取渲染逻辑,自己模拟人类行为参数 把考卷偷出来,自己在家做一遍
神经网络法 不读评分源码,用 CV 模型追踪粒子的视觉运动,让网络自己学会跟踪 不看卷子,直接盯着屏幕看方块在哪

二、通信协议分析

抓包得到三个 API 的交互流程:

POST /challenge       →  发送 ECDH 公钥,接收加密参数
POST /verify          →  发送加密轨迹,接收评分和 token
POST /consume-token   →  发送 token,核销验证

2.1 /challenge

请求只需一个 ECDH P-256 公钥,服务端不验证调用方身份:

// Request
{"clientPublicJwk": {"crv": "P-256", ...}, "device": "pc"}

// Response
{"challengeId": "...", "serverPublicJwk": {...}, "salt": "...", "encryptedParams": {...}}

流程:前端发 ECDH 公钥 → 后端生成临时密钥对并派生会话密钥 → 返回后端公钥 + salt + 加密参数。

ECDH 的对称性:双方各自用对方公钥 × 自己私钥,推导出完全相同的共享密钥。我们的公钥是自己生成的,服务端的公钥会明文返回,所以我们可以自己推导出会话密钥——根本不需要"破解"什么。

2.2 /verify

// Request (加密后)
{"challengeId": "...", "iv": "...", "ciphertext": "..."}

// Response(生产环境不返回 detail,这里是 DEBUG 模式)
{
  "passed": true, "score": 0.8773, "token": "...",
  "detail": {
    "composite": 0.8773, "s_dtw": 0.7955, "s_bio": 1.0,
    "residual_energy": 1.19, "accel_zerocrossings": 59,
    "tremor_amplitude_px": 0.615, "psd_8_12_ratio": 0.370
  }
}

2.3 /consume-token

发送 token,返回 {"valid": true/false},一票一用(Redis GETDEL)。

三、分析法

3.1 核心发现:无客户端认证

main.py/challenge 路由只校验了 JWK 格式(kty/crv/x/y 字段存在 + 曲线是 P-256),不做身份验证。任何人可以自己生成 ECDH 密钥对,走完密钥交换流程,自己推导会话密钥。

crypto.py 确认:标准 ECDH → SHA256 → HKDF(salt, info="phantom-v1") → AES-256-GCM,无额外认证层。

结论:攻击者可以主动获取会话密钥,解密 encryptedParams,拿到贝塞尔路径、画布尺寸、时长等全部题目参数。服务端实际上把"试卷"加密发到了客户端,但加密密钥是临时协商的——而协商的另一方就是我们自己。

3.2 评分引擎

评分在 scoring/engine.py,核心公式:

Composite = 0.6 × S_DTW + 0.4 × S_Bio   (阈值 0.8)
S_Bio     = 0.4 × energy + 0.3 × zc + 0.3 × tremor

DSP 管道(dsp.py):

原始轨迹 → 线性插值 → 100Hz 均匀网格 → 低通滤波(7Hz, 4阶双向巴特沃斯)
  ├── 低通信号("自愿运动") → DTW 方向拟合
  └── 残差(=原始-低通) → 残差能量 + 加速度零交叉 + 8-12Hz 带通 PSD

各子分的判定区间(config.py):

指标 满分区间 否决条件
残差能量 0.15 ~ 8.0 px < 0.08 → 平滑否决(composite ≤ 0.20)
零交叉率 1.67 ~ 15.0 次/秒 < 1.0 或 > 40.0 → 0 分
震颤振幅 0.3 ~ 8.0 px (8-12Hz)
震颤 PSD 占比 2% ~ 40%

评分系统是确定性的分段线性函数,没有随机性,没有任何机器学习模型。知道阈值就能精确绕过——它不是"猜",是"算"。

一个重要的设计特性:7Hz 以上的信号全部进入残差区。这意味着在轨迹上叠加 10Hz 正弦波,会 100% 贡献给 tremor_score,而对 DTW 分数毫无影响。DTW 和 Bio 两个维度可以独立优化——这是我们后面所有攻击方法的核心前提。

DTW 算法(dtw.py)把双方轨迹归一化到 [0,1]²,算动态时间规整距离,然后指数饱和映射到 [0,1]。沿贝塞尔路径密集采样,DTW 就能接近满分。

3.3 攻击构造

3.3.1 代码复用

分析阶段已经摸清了后端评分系统的全部细节,攻击代码只需要复用三个部分:

复用什么 来源 作用
ECDH 密钥交换 + AES-GCM 加解密 原项目 app/crypto.py 与服务端通信需要相同的密钥协商和加密协议,直接提取为 crypto_utils.py
贝塞尔曲线公式 原项目 app/challenge.py controlPoints 四个控制点算出标准路径,确保生成的轨迹在形状上完全吻合
评分阈值 原项目 app/config.py 所有判定区间的具体数值,指导噪声参数的选定

这三个模块提取出来,可以直接使用。

3.3.2 轨迹生成的逆向推导

核心问题一句话:已知评分函数 score(points, controlPoints, canvas) 的每一行源码,求一条 points 使得 score ≥ 0.8

本质上是个带约束的优化问题。但评分函数不是黑盒——我们已经通读了它的每一行,因此不需要梯度下降、不需要暴力搜索,直接"靶向投喂",让三个生理指标各自落在满分区间就行。

评分函数内部做了 DSP 预处理,流程为:

输入轨迹 points
  → 线性插值到 100Hz 均匀时间网格
  → 4 阶双向巴特沃斯低通(截止频率 7Hz)
       ├── 低通信号 → 与贝塞尔路径算 DTW → s_dtw
       └── 残差(原始 - 低通)
            ├── 残差 RMS 能量 → energy_score
            ├── 加速度二阶差分 → 零交叉计数 → zc_score
            └── 8-12Hz 带通 → Welch PSD → tremor_score

关键结论:DTW 和 Bio 两个维度可以独立优化。

  • DTW 只使用低通信号(≤7Hz),与高频残差无关。只要 base 轨迹足够贴近贝塞尔路径,DTW 自然高分。
  • Bio 只在残差(= 原始 - 低通)上计算。低通截止为 7Hz,所以高于 7Hz 的信号全部归入残差,不会影响 DTW。

3.3.3 基础轨迹:沿贝塞尔路径密集采样

# 直接抄原项目 challenge.py 的贝塞尔公式
def bezier_point(cp, t):
    u = 1 - t
    x = u**3*cp[0][0] + 3*u**2*t*cp[1][0] + 3*u*t**2*cp[2][0] + t**3*cp[3][0]
    y = u**3*cp[0][1] + 3*u**2*t*cp[1][1] + 3*u*t**2*cp[2][1] + t**3*cp[3][1]
    return (x, y)

# 在 [0,1] 上等间距取 300 个点(= 5秒 × 60fps)
base_path = [bezier_point(control_points, t) for t in np.linspace(0, 1, 300)]

这一步产出一条"完美机器轨迹"——DTW 约 0.993,几乎是满分。但残差能量仅 0.03px,平滑得像尺子画的,必然触发平滑否决(composite 被强制拉低到 ≤ 0.20)。

所以光有骨架不够,还得给它"注魂"——也就是叠加上去那些看起来像人手抖出来的噪声。

3.3.4 噪声叠加:靶向命中三个生理区间

在基础轨迹上叠加噪声,目标是让评分引擎从残差信号中"看到"三项人类特征。每一项对应一个子分:

第一层:10Hz 正弦震颤 → tremor_score

tremor_x = 0.5 * sin(2π × 10.0 × t + phase)
tremor_y = 0.5 × 1.1 * sin(2π × 10.0 × t + phase + 0.3)
  • 频率选 10Hz:正好在 8-12Hz 生理震颤频段正中,带通滤波后全部保留
  • 振幅选 0.5px:落在 TREMOR_AMP_LOW(0.3) ~ TREMOR_AMP_HIGH(8.0) 区间,振幅子分接近满分
  • 10Hz > 7Hz 低通截止 → 完全进入残差通道,对 DTW 无任何影响
  • x/y 相位偏移 0.3 弧度,避免两轴完全同步(真实人类的手指震颤在两轴上有微小相位差)

第二层:双频反馈纠偏 → zc_score(零交叉率)

corr_x = 0.8 * sin(2π × 2.5 × t) + 0.4 * sin(2π × 4.5 × t)
corr_y = 0.72 * sin(2π × 2.5 × t + 0.7) + 0.4 * sin(2π × 4.5 × t + 0.5)
  • 两个低频正弦波叠加,模拟人类"滞后→跟上一偏离→修正"的反馈循环
  • 2.5Hz + 4.5Hz 均低于 7Hz 低通截止 → 部分信号进入低通通道。但因为振幅小(共 1.2px),对 DTW 的影响可忽略
  • 两个频率叠加产生不规则的零交叉模式,比单一正弦更接近真实人类轨迹
  • 零交叉率约 10-15 次/秒,正好命中 ZC_RISE(1.67) ~ ZC_PLATEAU(15.0) 满分区间

第三层:Ornstein-Uhlenbeck 有色噪声 → 残差能量

# OU 过程:均值回归的随机游走,相邻采样值相关
dx = -theta * x * dt + sigma * sqrt(dt) * dW
  • 选 OU 而非白噪是关键决策。白噪各采样点独立,在加速度域(二阶差分)会产生大量符号翻转,导致零交叉率飙升到 40+ 次/秒(被判定为白噪机器)
  • OU 过程有均值回归特性(theta=8),相邻值高度相关,加速度域的零交叉数大幅下降
  • sigma=2 使残差 RMS 约 0.5px,命中 ENERGY_LOW(0.15) ~ ENERGY_HIGH(8.0) 满分区间
  • 同时叠加 0.15px 标准差的白噪微抖动,模拟量化误差

3.3.5 合成与校验

xs = base_x + tremor_x + corr_x + ou_x + jitter_x
ys = base_y + tremor_y + corr_y + ou_y + jitter_y
xs = np.clip(xs, 0, canvas_w - 1)   # 保证坐标不出画布
ys = np.clip(ys, 0, canvas_h - 1)

最终产物是一组 [[x1, y1, t1], [x2, y2, t2], ...] 格式的点序列,与前端 tracker.ts 采集的输出格式完全一致。

时效校验通过 lastPointT_ms 设为提交时的当前时间戳来满足。

3.3.6 结果

环境 通过率 平均分 文件
本地 20/20 0.926 复盘/分析法/crack.py
远程生产 3/3 0.920 同上

PixPin_2026-07-25_19-30-47

运行方式:

cd phantom/复盘/分析法
uv run python crack.py -n 5

四、模拟法

分析法的思路其实很"赖皮"——直接翻源码,拿到评分公式和所有阈值,然后靶向构造一条每个参数都落在满分区间的轨迹。那如果不看源码呢?能不能只靠逆向前端 JS,搞清楚"前端到底发了什么、怎么发的",然后自己模拟一套出来?

这就是模拟法的思路:不看答案,看过程。

4.1 下载前端代码

协议层没变,所有核心参数还是从 /challenge 的加密响应里拿。现在要做的就是把前端 JS 弄下来,搞清楚渲染和加密的具体实现。

打开浏览器开发者工具——哦,差点忘了这玩意儿有反调试。看看它怎么做的:

  1. 窗口尺寸差探测 — 每 1.5 秒检查 outerWidth - innerWidth,差值 > 160px(说明 DevTools 停靠在侧边/底部),触发反制
  (function() {
        const a = t
          , g = {
            VsAbO: x.FZAHB,
            AzTEG: function(d, l) {
                return x.ALCnP(d, l)
            },
            Oknrs: x[a(430)],
            AWfAv: x[a(416)],
            BCtRv: function(d, l) {
                return x[a(358)](d, l)
            },
            TdRHO: function(d) {
                return d()
            }
        };
        x[a(445)](c, this, function() {
            const d = a
              , l = new RegExp(g[d(356)])
              , w = new RegExp(d(455),"i")
              , B = Sn("init");
            !l.test(g[d(373)](B, g[d(361)])) || !w[d(448)](g.AzTEG(B, g[d(374)])) ? g.BCtRv(B, "0") : g[d(453)](Sn)
        })()
    }
    )();

还有一个debugger直接全局搜索,发现在这里被调用

  1. debugger 计时探测 — 每 2 秒执行 debugger 语句,如果执行耗时 > 100ms(说明 DevTools 开着且命中了断点),触发反制
function Lr(e) {
    const n = {
        Tbnuo: function(i, r) {
            return i > r
        },
        SxiUp: function(i, r) {
            return i - r
        },
        TTOkm: function(i, r, c) {
            return i(r, c)
        }
    };
    if (!e)
        return;
    setInterval( () => {
        const i = F
          , r = performance[i(326)]();
        debugger ;n[i(334)](n[i(335)](performance[i(326)](), r), 100) && k0()
    }
    , 2e3);
    const x = () => {
        const i = F
          , r = Math[i(306)](window.outerWidth - window[i(276)])
          , c = Math[i(306)](window[i(300)] - window[i(291)]);
        (r > 160 || c > 160) && k0()
    }
    ;
    n.TTOkm(setInterval, x, 1500)
}

说实话,这反调试有点可爱——检测手段非常"经典",绕过也很简单:

  1. Ctrl+Shift+D 把 DevTools 切成浮动窗口(绕开尺寸检测)
  2. Ctrl+F8 禁用所有断点(绕开 debugger 计时检测)
  3. F5 刷新,把所有 JS 源文件保存下来
    PixPin_2026-07-25_19-50-00

或者更直接:在 JS 下载到本地后,全局搜索 debuggersetInterval,手动注释掉也行。

四个主要的 JS 文件:

  • content_main.js
  • ndex-CS7YKcMF.js
  • beacon.min.js
  • index-CS7YKcMF.js

另外,被断点卡住不代表不能手动恢复执行。我们直接开始验证流程,然后在页面卡住时看看停在哪个 JS 文件里:

PixPin_2026-07-25_19-57-33

命中位置是 index-CS7YKcMF.js

PixPin_2026-07-25_19-58-28

具体来说,核心逻辑在 function Sn(e) 里。

4.2 去混淆

de4js 反混淆,得到可读性很高的 JS——之后的分析就容易多了。

4.3 追踪数据流:从服务器参数到贝塞尔曲线

不需要啃完整个 JS。目标很明确:顺着数据流找到"服务端下发的参数 → 屏幕上的曲线"这条路。

/challenge 字符串入手,定位到请求函数:

function xx(e, n, t) {
    const x = P,
        i = {
            adVlI: function (r, c, s, o) {
                return r(c, s, o)
            },
            dbwOa: "/challenge"
        };
    return i.adVlI(a0, e, i[x(363)], t ? {
        clientPublicJwk: n,
        device: t
    } : {
        clientPublicJwk: n
    })
}

这个函数和我们之前抓包看到的请求体完全对应。顺着它的返回值一路追下去就能理清整条数据流。

(当然,你也可以直接把反混淆后的 JS 丢给 AI,让它帮你分析——毕竟我们做逆向的,只要能出结果,工具无所谓。)

反混淆后的数据流如下:

/challenge 响应
  │  {serverPublicJwk, salt, encryptedParams}
  ▼
hr() 生成客户端 ECDH 密钥对
  │
gr(serverPublicJwk) 导入服务端公钥
  │
br(clientPriv, serverPub, salt) 派生会话密钥
  │
Br(sessionKey, iv, ciphertext) AES-GCM 解密
  │
  ▼
params = {
  canvas:      {w: 480, h: 480},
  duration:    5.0,
  fps:         60,
  controlPoints: [[x0,y0], [x1,y1], [x2,y2], [x3,y3]],  ← 这就是贝塞尔路径
  targetHalf:  22,
  noiseSeed:   "..."
}
  │
  ▼
new Hr(canvas, params)   // Hr = PhantomRenderer 的混淆后类名
  │  this.targetParticleCount = (2*targetHalf)² × particleDensity
  │  this.particles = Dr(count, targetHalf)  // Dr = makeCluster
  │
  ▼
Hr.start(onTick)
  │  requestAnimationFrame 循环
  │  每帧: t = elapsed / duration  (0 → 1)
  │
  ▼
Hr.renderFrame(t)
  │  center = Nn(controlPoints, t)  ← 核心!贝塞尔曲线计算
  │  paintFullNoise(data)           // 铺满随机噪点
  │  _r(buf, particles, center, gain, dropRate)  // 粒子簇平移到 center

三个关键函数,从反混淆 JS 中直接对应:

  1. 贝塞尔曲线 — Nn(e, n)(约 4220 行)
function Nn(e, n) {  // e=controlPoints[4], n=t∈[0,1]
    var r = 1 - n;
    var c = r*r*r * e[0][0] + 3*r*r*n * e[1][0] + 3*r*n*n * e[2][0] + n*n*n * e[3][0];
    var s = r*r*r * e[0][1] + 3*r*r*n * e[1][1] + 3*r*n*n * e[2][1] + n*n*n * e[3][1];
    return [c, s];
}
  1. 标准三次贝塞尔公式,对应 Python:
def bezier_at(cp, t):
    u = 1 - t
    x = u**3*cp[0][0] + 3*u**2*t*cp[1][0] + 3*u*t**2*cp[2][0] + t**3*cp[3][0]
    y = u**3*cp[0][1] + 3*u**2*t*cp[1][1] + 3*u*t**2*cp[2][1] + t**3*cp[3][1]
    return (x, y)
  1. 粒子簇生成 — Dr(e, n)(约 3900 行)
function Dr(e, n) {  // e=数量, n=targetHalf
    var r = new Array(e);
    for (var c = 0; c < e; c++)
        r[c] = {
            rx: Math.random()*2*n - n,   // 相对偏移 [-half, +half)
            ry: Math.random()*2*n - n,
            v:  Math.random()*256 | 0     // 固定灰度 0-255
        };
    return r;
}

粒子是持久化的——rx/ry/vmakeCluster 时固定,跨帧复用。每帧通过 renderFrame 把同一批粒子平移到当前 Nn 算出的 center 位置。

  1. 轨迹采集 — class Nr(Tracker,约 4690 行)

没有源码的情况下,从反混淆 JS 中提取轨迹采集逻辑。Nr 类的核心方法是 stop(),它的返回值直接决定 /verify 提交的轨迹格式。

// class Nr - 构造函数
constructor(n) {
    this.samples = [];        // 采样点数组
    this.active = false;      // 是否正在采集
    this.rect = null;         // canvas 的 getBoundingClientRect()
    this.touchActive = false; // iOS 触屏去重标志
    this.canvas = n;
}

// 坐标映射:clientX/Y → canvas 像素坐标,clamp 到 [0, w-1]/[0, h-1]
mapToCanvas(n, t) {
    if (!this.rect) return [n, t];
    var c = this.canvas.width / this.rect.width,
        s = this.canvas.height / this.rect.height,
        o = (n - this.rect.left) * c,
        f = (t - this.rect.top) * s,
        u = Math.max(0, Math.min(this.canvas.width - 1, Math.round(o))),
        a = Math.max(0, Math.min(this.canvas.height - 1, Math.round(f)));
    return [u, a];
}

// 采集过程:pointermove / touchmove → push [x, y, performance.now()]
onMove(e) {
    if (!this.active || !this.rect || this.touchActive) return;
    var n = this.mapToCanvas(e.clientX, e.clientY);
    this.samples.push([n[0], n[1], performance.now()]);
}

// 停止采集,过滤非法坐标后返回
stop() {
    this.active = false;
    // 移除所有事件监听器...
    var W = this.canvas.width, H = this.canvas.height;
    return this.samples.filter(function(n) {
        return Number.isFinite(n[0]) && Number.isFinite(n[1]) && Number.isFinite(n[2])
            && n[0] >= 0 && n[1] >= 0 && n[0] < W && n[1] < H;
    });
}

// 最后一个采样点的时间戳
get lastPointT() {
    return this.samples.length ? this.samples[this.samples.length - 1][2] : 0;
}
  1. 最终 /verify 提交的 payload 组装

追踪到 rx 函数(约 555 行),它调用底层的 a0 发送 POST:

function rx(e, n, t, x) {    // e=apiBase, n=challengeId, t=iv, x=ciphertext
    var r = {};
    r.challengeId = n;
    r.iv = t;
    r.ciphertext = x;
    return a0(e, "/verify", r);
}

a0 就是通用的 fetch 封装:

async function a0(e, n, t) {  // e=apiBase, n=路径, t=body对象
    var c = e.replace(/\/+$/, "");
    var s = await fetch(c + n, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(t)
    });
    if (!s.ok) throw new Error(s.status + " " + s.statusText);
    return s.json();
}

所以从前端视角,/verify 的请求体就是:

// 调用链:crack_once 中组装
var payload = { points: samples, lastPointT_ms: lastPointT };
var plainBytes = JSON.stringify(payload);
var enc = encrypt_payload(sessionKey, plainBytes);  // yr函数
rx(apiBase, challengeId, enc.iv, enc.ciphertext);

我们不需要知道服务器怎么评分——只要知道前端发什么格式、加密方式是什么,就能自己构造同样的请求。

4.4 模拟实现

不依赖 Python / 服务端源码,直接从反混淆 JS 提取关键片段,用 Node.js 组装攻击脚本:

文件 提取来源 内容
crypto.js hr() / gr() / br() / yr() / Br() / Ut() / It() ECDH P-256 + AES-256-GCM + base64url
bezier.js Nn(e, n)(约 4220 行) 三次贝塞尔曲线计算
trajectory.js 新写(噪声参数参考分析法的调参结果) 贝塞尔采样 + 三层生理噪声叠加
crack.js xx()a0()(约 530-560 行)的发包模式 HTTP 请求组装 + 攻击主逻辑

文件结构:

复盘/模拟法/
├── crypto.js       # 密码学(ECDH + AES-GCM)
├── bezier.js       # 贝塞尔曲线(Nn 提取)
├── trajectory.js   # 轨迹生成器
└── crack.js        # 攻击入口

运行方式:

cd phantom/复盘/模拟法
node crack.js           # 本地测试
node crack.js --remote  # 远程生产测试

结果:本地 2/2,远程 2/2,平均分 0.926。

PixPin_2026-07-25_20-23-59

4.5 结论:模拟法本质上和分析法殊途同归

追踪完完整数据流之后发现:模拟法的代码路径和分析法完全一致。两者都是:

  1. ECDH + AES-GCM 解密 encryptedParams → 拿到 controlPoints
  2. 沿 Nn(controlPoints, t) 采样生成轨迹 → 叠加生理噪声
  3. 加密提交

唯一的区别在于"参考了哪份文档":分析法直接翻 config.py 看评分阈值,模拟法通过逆向前端理解渲染逻辑。最终产出的轨迹结构一模一样。模拟法的代码甚至可以直接复用分析法的 crypto_utils.pytrajectory_generator.py

这其实说明了一个更根本的问题:只要 encryptedParams 能被客户端解密,controlPoints 就必然落入攻击者手中。不管攻击者是从源码看、还是从 JS 逆向,最终拿到的都是同一组贝塞尔控制点。 整个安全模型建立在一个无法成立的假设上——"前端的加密参数只有浏览器能解开"。

五、神经网络法

终于到了最"叛逆"的部分——作者在文档里写了"不建议使用神经网络攻击",那我偏要试试。

前两种方法本质上都是在走捷径:分析法直接看了答案,模拟法逆向了出题逻辑。但假如——假如前端渲染被挪到了服务端,我们拿不到 controlPoints 了,那怎么办?唯一的线索就只剩下屏幕上的像素了。

5.1 问题简化

抛开协议、加密、HTTP 通信,神经网络法要解决的核心问题是:

给定连续的视频帧(480×480 灰度图,60fps,持续 5 秒),输出一个坐标序列 [[x1,y1], [x2,y2], ..., [x300,y300]],使得服务端评分 ≥ 0.8。

已知条件:

条件 来源
画布尺寸 480×480 px(PC) params.canvas.w/h 解密后可见
图像格式 灰度图(RGB 三通道值相等),每像素 uint8 [0,255] paintFullNoise 每像素随机灰度
帧率 60fps params.fps
时长 5 秒(默认值) params.duration
总帧数 5 × 60 = 300 帧 可推算
目标方块大小 44×44 px(targetHalf=22 → 边长=44) params.targetHalf 解密后可见
目标方块形状 正方形,边长固定,不旋转、不缩放 渲染逻辑确认
每帧噪点 每像素独立随机 [0,255],帧间完全不相关(背景) paintFullNoise 逐像素 Math.random
目标粒子 方块内约 1162 个持久化粒子,帧间整体平移 Dr(count, targetHalf) + _r(...)
运动轨迹 三次贝塞尔曲线,平滑无跳跃 Nn(controlPoints, t)
目标可见性 单帧与背景无统计差异(gain=0),只能靠帧间运动差异检测 视觉防线设计意图
输出格式 [[x, y], [x, y], ...],300 个坐标点 Tracker.stop() 返回格式

用人话翻译一下这张表:

  1. 总共 300 帧(5 秒 × 60fps),需要在规定时间内处理完——从最后一帧渲染完到提交的时间差不能太大
  2. 背景噪点每帧随机刷新,目标方块内的粒子是持久化的——同一批粒子,每帧整体平移。单帧看方块和背景没区别(因为粒子灰度也是随机的),但多帧连续看就能发现"有一块区域的内容在同步移动"
  3. 我们知道目标方块是 44×44 正方形,不转不缩放——这个先验很重要
  4. 最终输出 300 个 [x, y] 坐标

5.2 第一次尝试

最开始的想法很直觉:拿初始帧 + 下一帧,预测下一帧的目标位置,然后一步一步迭代出完整轨迹。结果呢?两个致命问题:

  1. 只有第一帧的中心是已知的。后续帧的预测值会越偏越远,误差像滚雪球一样累积,最终轨迹完全偏离贝塞尔曲线
  2. 单帧里根本看不到目标。方块和背景在统计上没区别(gain=0),必须靠帧间运动差异才能检测——但第一步的误差已经让"帧间差异"这个信号被淹没了

后来又想:能不能直接让网络输出整条轨迹?也不行:

  1. 连续帧输入 → 整条轨迹输出,中间还要后处理,误差累积同样严重
  2. 每帧只有 16.67ms(= 1/60s)的处理窗口,前向推理稍微慢一点就掉帧,轨迹直接废掉

最终确定的方案:

  1. 逆向渲染逻辑批量生成训练数据——图片 + GT 坐标
  2. 不做全图推理,只给网络一个 128×128 的局部窗口(中心点 + 目标大小 + 5px 余量),大幅削减背景噪点
  3. 10 帧堆叠输入 → 64×64 热力图输出,~1.9M 参数
  4. MPS 训练,Apple Silicon 友好
  5. 逐帧迭代推理

这个方案训练效果不错——测试集上准确率很高。但问题来了:实际攻击时,只有第一帧的 GT 坐标是已知的。 后续帧你拿什么作为 ROI 的中心?拿上一个预测值?那和"盲走"有什么区别?

所以第一次尝试在一个关键环节卡住了:如何在没有任何 GT 的情况下,自己找到第一帧的目标位置?

5.3 第二次尝试

第一次尝试失败的根本原因很简单:自举循环缺少一个可靠的起点。 如果能不依赖任何 GT,自己从第一帧图像里找出目标位置,整个追踪就能跑起来。

5.3.1 自举追踪(Bootstrap Tracking)

关键灵感来自前端的 startPreview() 阶段。在正式播放前,前端会先显示一帧"预览图"——方块内粒子以统一高亮度(~217,85% 亮度)渲染在随机噪点背景上。这一帧里目标方块是"肉眼可见"的。

攻击时我们也生成一张这样的预热帧(preview.png),然后用一个朴素的滑动窗口检测:

preview.png(方块高亮 ~217,背景随机噪点均值 ~127.5)
  → 44×44 滑动窗口,找像素均值最高的区域
  → 目标窗口均值 ~217,背景窗口均值 ~127.5,差值 ~90
  → 检测到初始位置 (x₀, y₀)
  → 以此作为第 0 帧中心,启动迭代追踪

这不涉及任何机器学习——就是一个纯信号处理的滑动窗口均值检测。因为亮度差足够大(~90 vs 背景标准差 ~1.67),检测几乎不会失败。

有了初始锚点,后续帧就可以用模型迭代追踪了:每帧用上一帧的预测位置裁剪 128×128 ROI → 模型推理 → 热力图 → argmax → 新位置。

5.3.2 模型架构

SimpleConvTracker (~1.94M 参数)

输入: (B, 10, 128, 128)   ← 连续 10 帧灰度 ROI
  │
  ├─ Encoder: 3 层 ConvBlock + MaxPool (128→64→32)
  ├─ Bottleneck: ConvBlock (16×16)
  ├─ Decoder: 3 层 ConvTranspose2d + skip connection
  │
输出: (B, 64, 64)          ← 高斯热力图,峰值 = 预测位置

10 帧输入对应 ~167ms 的时间窗口,相比最初失败的 5 帧方案(83ms),多了整整一倍的运动上下文。这个差异是决定性的——5 帧时模型输出相当于随机猜测(误差 ~314px),10 帧时误差收敛到 2-3px。为什么?因为 5 帧里目标只移动了 2-3 像素,和背景噪点的帧间波动处于同一数量级;10 帧里目标移动了 5-7 像素,信号终于压过了噪声。

5.3.3 训练数据生成

从逆向的前端渲染逻辑出发,用 Python 复刻了完整的帧生成管线(generate.py):

  • 随机生成贝塞尔路径 → 300 帧 480×480 灰度图 + 每帧 GT 中心坐标
  • 每份数据额外渲染一张 preview.png(高亮方块),用于训练初始位置检测
  • 多线程批量生成(batch_generate.py),1000 份数据集约 3 分钟

训练配置很简单:

参数
损失函数 MSE(热力图)
优化器 AdamW,lr=1e-3
批次大小 8
Epochs 50
训练/验证分割 9:1
硬件 Apple MPS

5.3.4 推理管线:一帧一帧追踪的完整过程

下面从预热帧到 300 帧轨迹输出,逐步追溯整个推理过程中数据的变化。为了直观,假设某次真实攻击的参数是:

  • 画布 480×480,时长 6.0s → 360 帧
  • 控制点:\(P_0(61,415) \to P_1(\ldots) \to P_2(\ldots) \to P_3(421,96)\)
  • 目标方块 44×44,内含 ~1162 个持久化粒子

第 0 步:预热帧检测初始位置

preview.png: 480×480 灰度图
┌──────────────────────────────────────┐
│  全屏随机噪点 [0,255]                  │
│     ┌──────────┐                     │
│     │ 方块区域  │ ← 亮度统一 ~217      │
│     │ 44×44   │    (85% 亮度)        │
│     └──────────┘                     │
│  背景噪点均值 ≈ 127.5                 │
└──────────────────────────────────────┘

滑动 44×44 窗口 → 计算每窗口像素均值
  背景窗口均值: ~127.5
  目标窗口均值: ~217   ← 最高!
  
→ detect_initial_position() → (62, 414)
  (检测到的初始方块中心坐标)

这不是模型做的——是一个纯信号处理的滑动窗口检测。因为预热帧中目标方块内是统一高亮度,与背景随机噪点有 ~90 的均值差,检测几乎不会失败。

第 1–9 帧:用初始位置"垫"起步

贝塞尔曲线在起点处速度极慢(一阶导数为零),前 9 帧(~150ms)目标实际位移不到 1 像素。因此直接用初始位置 (62, 414) 作为这 9 帧的预测值,误差可忽略。

帧 0–8: 预测坐标 = (62, 414) × 9 帧

这 9 帧的作用不是"给出正确答案",而是为第 10 帧提供足够的时间上下文窗口——模型需要看 10 帧才能工作。

第 10 帧起:迭代追踪循环

以第 10 帧(target_idx=9)为例,追踪流程如下:

Step A — 确定 ROI 中心
  用上一帧(第 9 帧)的预测位置作为当前帧的 ROI 中心:
    ref_center = (62, 414)

Step B — 裁剪 ROI
  从全图 480×480 中,以 (62, 414) 为中心裁剪 128×128 窗口:
  
  全图 480×480                     ROI 128×128
  ┌──────────────┐                ┌──────────────┐
  │              │                │ ░░▒▒▓▓▒▒░░   │
  │   ┌──────┐   │    roi_crop    │ ▒▒▓▓██▓▓▒▒   │
  │   │128×128│  │  ─────────→   │ ▓▓██◉██▓▓   │ ← ◉ = (62,414)
  │   └──────┘   │                │ ▒▒▓▓██▓▓▒▒   │
  │              │                │ ░░▒▒▓▓▒▒░░   │
  └──────────────┘                └──────────────┘
  
  目标方块 44×44 在 ROI 中占比约 38%,背景噪点被大幅削减。

Step C — 堆叠 10 帧
  取第 0~9 帧(同一个 ROI 窗口位置),堆叠成 (10, 128, 128) 的张量:
  
  frame_stack.shape = (10, 128, 128)
  
  这 10 帧中,背景噪点在每帧之间完全随机刷新(i.i.d.),
  但目标方块内的粒子保持相同的 rx/ry/v,整簇刚体平移。
  模型通过时空卷积捕捉"哪部分像素是固定的、哪部分是闪烁的"。

Step D — 模型推理
  model(frame_stack) → heatmap (64, 64)
  
  输入 (1, 10, 128, 128)
    → Encoder 下采样 (128→64→32→16)
    → Bottleneck
    → Decoder 上采样 (16→32→64→128) + skip connections
    → Head (Conv + Conv1×1)
    → Upsample to (64, 64)
  输出 (1, 64, 64) 热力图

  热力图是一个 64×64 的二维高斯分布,峰值位置 = 模型预测的目标中心:

  heatmap (64×64), 值域 [0, 1]
  ┌────────────────────────────┐
  │                            │
  │         ░░                 │
  │        ▒███▒               │
  │       ▓█████▓              │  ← 峰值 (~1.0) 在 (hx, hy)
  │       ███████              │
  │       ▓█████▓              │
  │        ▒███▒               │
  │         ░░                 │
  └────────────────────────────┘

Step E — argmax 取峰值坐标
  hy, hx = argmax(heatmap)  →  (hx=31, hy=33)
  
  这是热力图坐标系 (64×64) 中的坐标。

Step F — 坐标反算
  热力图坐标 → ROI 局部坐标 → 全局画布坐标:
  
  scale = 128 / 64 = 2.0
  cx_local = hx × 2.0 = 62.0
  cy_local = hy × 2.0 = 66.0
  cx_global = cx_local + roi_offset_x = 62.0 + (-2) = 60.0
  cy_global = cy_local + roi_offset_y = 66.0 + 350 = 416.0
  
  → 第 10 帧预测: (60.0, 416.0)

Step G — clamp & 记录
  cx = clamp(cx_global, 0, 479)  # 确保不出画布
  cy = clamp(cy_global, 0, 479)
  predicted.append([cx, cy])
  
  → 这个坐标将成为第 11 帧的 ref_center,回到 Step A。

循环推进 360 帧

以上 A→G 循环重复 351 次(第 10 帧到第 360 帧),每次:

  1. 用上一帧的预测裁剪新 ROI
  2. 堆叠最近的 10 帧(滑动窗口)
  3. 模型输出热力图
  4. argmax + 坐标反算

每帧推理约 9-10ms(MPS),360 帧总计约 3-4 秒。

时间轴 →
帧0  帧9  帧10          帧20          帧100                    帧360
│ ... │    │             │             │                        │
(62,  (62,  (60,          (58,          ...                      (421,
 414)  414)  416)          418)                                 96)
            ↑             ↑                                     ↑
            模型第一次介入  误差 ~2-3px                            终点预测接近 GT

收尾:叠加生理噪声

模型输出的轨迹是一条"干净的"机械路径——DTW 接近满分,但残差能量极低(<0.08px),会触发平滑否决。

在 360 个坐标点上叠加三层噪声(见分析法 §3.3.4):

  • 10Hz 正弦震颤(0.5px 振幅)→ tremor_score
  • 双频反馈纠偏(2.5Hz+4.5Hz)→ zc_score
  • 高斯微抖动(σ=0.3px)→ residual_energy

输出最终 360 个 [x, y, timestamp] 三元组,加密后提交 /verify

全流程数据流总览

preview.png (480×480)
  │  滑动窗口均值检测
  ▼
初始位置 (x₀, y₀)
  │
  ├─ 帧 0~8: 直接用 (x₀,y₀) 填充
  │
  └─ 帧 9~359: 逐帧循环 ──────────────────────────┐
       │                                           │
       ├─ 上一帧预测 → ref_center                  │
       ├─ roi_crop(全图, ref_center) → 128×128     │
       ├─ 堆叠最近10帧 → (10, 128, 128)            │
       ├─ model() → heatmap (64, 64)               │
       ├─ argmax → (hx, hy)                        │
       ├─ 坐标反算 → (cx_global, cy_global)        │
       ├─ clamp → 当前帧预测                        │
       └──────────────────────────────────────────┘
  │
  ▼
360 个预测坐标 [(x₁,y₁), ..., (x₃₆₀,y₃₆₀)]
  │  叠加生理噪声
  ▼
最终轨迹 → AES-GCM加密 → POST /verify

5.3.5 结果

本地测试e2e_test.py,自举模式,50 轮):

指标 数值
通过率 49/50 (98.0%)
Score 均值 0.9522
Score 范围 [0.9312, 0.9674]
s_dtw 均值 0.9730
s_bio 均值 0.9211
预测误差 Mean 7.73 px
平均耗时 4.26s/轮

唯一失败的那一帧,是初始位置检测偏差了几像素,导致后续追踪整体偏移——自举模式的偶发边界情况。

真实服务端攻击attack.py,完整 /challenge → 解密 → 渲染 → NN 追踪 → /verify/consume-token):

指标 数值
通过率 10/10 (100%)
Score 均值 0.9517
Score 范围 [0.9401, 0.9623]
s_dtw 均值 0.9752
s_bio 均值 0.9168
平均耗时 5.1s/轮
Token 核销 10/10 ✅

10 轮 100% 通过,没有任何失败案例。考虑到这完全是"NN 看噪点 → 猜位置 → 加噪声 → 提交"的管道,没有直接使用 controlPoints 来构造轨迹,这个结果相当令人满意。

5.3.6 文件结构

最终落地的代码结构——从数据生成到真实攻击的完整工具链:

cd phantom/复盘/神经网络法
# 生成数据
uv run python batch_generate.py --num 100 --out dataset/
# 训练模型
uv run python train.py --data-root dataset --epochs 30 --batch-size 16
# 端到端本地测试
uv run python infer.py --checkpoint checkpoints/best.pt --test-dir dataset/video000000
# 真实服务端攻击
uv run python e2e_test.py --checkpoint checkpoints/best.pt --num 50
# 真实服务端攻击(多轮)
uv run python attack.py -n 20 --api-base https://127.0.0.1:8080

PixPin_2026-07-25_22-55-23

5.3.7 总结

神经网络法的关键突破可以归结为三个字:锚 + 窗 + 噪

  1. 锚(预热帧):用亮度差检测初始位置,解决了"没有 GT 就无法启动"的死循环
  2. 窗(ROI + 10 帧上下文):大幅削减输入噪声 + 提供足够的时域信号,让模型能分辨"移动的簇"和"闪烁的背景"
  3. 噪(生理噪声叠加):模型只负责 DTW 维度的追踪精度,bio 维度直接复用分析法的噪声参数——毕竟没必要重新发明轮子

三种方法的横向对比:

方法 核心思路 是否需要评分阈值 是否需要前端逆向 通过率
分析法 直接读 config.py 阈值,靶向构造 100%
模拟法 逆向前端 JS,提取贝塞尔+噪声逻辑 100%
神经网络法 纯 CV 追踪粒子簇运动,叠加生理噪声 是(渲染逻辑) 98-100%

三种方法从不同角度证明了 Phantom 的安全边界:只要攻击者能解密 encryptedParams 拿到 controlPoints,整个验证体系就完全可控。

5.3.8 理论分析:为什么神经网络法居然能成功?

回过头来看这个问题会觉得有点不可思议:满屏雪花噪点里追踪一个"肉眼看不见"的方块——直觉上这不是应该失败吗?但神经网络做到了,而且准确率 98%+。拆解一下背后的原理。

一、信号与噪声的时空可分离性

这是整个方法成立的数学前提。考虑画布上任意一个像素 \((x,y)\) 在第 \(t\) 帧的灰度值:

  • 背景像素\(I_{\text{bg}}(x,y,t) \sim \text{Uniform}(0,255)\),每一帧独立重采样。在时间维度上,背景信号是 i.i.d. 白噪声——任意两帧之间没有任何相关性。

  • 目标粒子\(I_{\text{target}}(x,y,t) = v_p\)(粒子 \(p\) 的固定灰度值),其中粒子位置 \((x_p(t), y_p(t)) = (x_p(0) + \Delta_x(t),\, y_p(0) + \Delta_y(t))\),整簇粒子在帧间做刚体平移。在时间维度上,目标信号具有完美的帧间相关性

这就构成了一个经典的二分类信号检测问题:

\[H_0: \text{所有像素都是 i.i.d. 背景噪声} \\ H_1: \text{存在一个 44×44 区域,其内部像素具有帧间相关性} \]

单帧无法区分 \(H_0\)\(H_1\)(因为目标粒子灰度也是 Uniform(0,255)),但多帧可以——因为 \(H_1\) 区域的像素值在帧间保持相对稳定(同一粒子保持同一灰度),而 \(H_0\) 区域每帧完全刷新。

二、时间窗口的信噪比增益

设单帧的检测信噪比极低(趋近于 0),那么堆叠 \(N\) 帧能带来什么?

考虑最简单的情况:对某个像素,计算 \(N\) 帧的均值:

  • 背景像素:\(\text{Var}[\bar{I}_{\text{bg}}] = \frac{255^2}{12N}\)\(N\) 个独立均匀随机变量的均值方差)
  • 目标粒子:如果粒子始终在该像素位置,\(\text{Var}[\bar{I}_{\text{target}}] = 0\)(同一固定值)

实际上粒子会移动,所以在连续的 \(N\) 帧中,一个 128×128 的 ROI 窗口内,目标簇始终保持其内部结构(粒子之间的相对位置不变),而背景在每帧之间完全重新随机化。卷积网络通过 3D 时空卷积(在 \(N\) 帧的通道维度上)天然能捕获这种"一部分像素在动、其余像素在闪"的差异模式。

一个有趣的数据点:最初尝试 5 帧(83ms),模型预测误差 ~314px——等于闭眼瞎猜。因为 5 帧内目标位移仅 2-3 像素,和背景噪声的帧间波动处于同一数量级。换到 10 帧(167ms),目标位移积累到 5-7 像素,信号终于压过了背景噪声的时空方差——模型开始收敛了。这个临界点(约 5-7 帧的有效运动上下文)也许是这个特定问题的相变边界。

三、热力图回归优于直接坐标回归

一个问题:为什么不直接让网络输出两个浮点数 \((x, y)\),而要输出一张 64×64 的热力图?

直接回归 \((x,y)\) 的三个痛点:

  1. 空间信息丢失:全连接层把 128×128×10 的时空信息暴力压缩成两个标量,中间没有任何空间结构保留——这就像把一部电影压缩成一个"好看/不好看"的评分
  2. 梯度稀疏:只有两个输出神经元接收梯度,训练信号极弱
  3. 无法表达不确定性:网络没法说"目标可能在两个位置之间"

热力图回归的数学形式是:

\[\hat{H}(u,v) = \exp\left(-\frac{(u - \hat{c}_x)^2 + (v - \hat{c}_y)^2}{2\sigma^2}\right) \]

每个像素 \((u,v)\) 都在"投票"目标中心的位置。这等价于让网络学习一个从时空特征到空间概率分布的映射。优势:

  • 密集监督:64×64 = 4096 个像素每个都贡献梯度,训练信号丰富
  • 空间平滑性:相邻像素的预测应该接近——卷积架构天然满足这个归纳偏置
  • 鲁棒性:即使某些区域的信号被噪声淹没,其他区域仍能提供正确的梯度方向

本质上,热力图回归把一个困难的回归问题(从噪声中回归两个精确坐标)转化为了一个相对容易的语义分割问题(在 4096 个格点中,哪个最像目标中心?)。

四、ROI 裁剪作为硬注意力机制

全图 480×480 = 230,400 像素,目标只占 44×44 = 1,936 像素——不到 0.84%。如果让网络看全图,99% 以上的计算都在处理无关背景。

ROI 裁剪将输入从 480×480 缩小到 128×128(约 7% 的像素量),同时将目标占比从 0.84% 提升到约 38%。这等价于一个硬注意力机制:我们告诉网络"目标大概在上一次的位置附近",网络只需要在局部窗口内精确定位。

这也是为什么自举追踪不会发散——即使某一帧的预测偏了几像素,只要偏差不超过 ROI 窗口半径(64px),下一帧的 ROI 仍然完整包含真实目标(44×44),网络可以独立地重新定位。实际测试中预测误差均值约 3-7px,远小于 64px 的安全边界。说得夸张一点:只要你还待在同一个房间里,就可以重新找到门在哪里。

五、预热帧的信息论解释

预热帧(preview.png)本质上是在向系统注入 1 bit 的先验信息:"目标方块在这里"。这个 1 bit 的信息通过以下方式编码:

  • 方块内像素亮度 \(\approx 217\)(固定值),背景 \(\sim \text{Uniform}(0,255)\)
  • 44×44 窗口均值:目标区域 \(\approx 217\),纯背景区域 \(\approx 127.5\)
  • 信噪比足够高(\(217 - 127.5 = 89.5\),远超背景标准差 \(\frac{255}{\sqrt{12 \times 1936}} \approx 1.67\)),检测几乎不会失败

一旦拿到这个初始锚点,整个自举循环就有了可靠的起点。

六、为什么不会"漂移发散"?

这是一个合理担忧:每一步都在用预测值作为下一步的输入,误差累积不应该指数爆炸吗?

答案在于模型输入的是 10 帧原始图像,而不是 10 个预测坐标。每一帧的预测独立地基于该帧前后的视觉证据,而非仅仅依赖上一帧的预测值。预测值只用于确定 ROI 的裁剪位置——即使这个位置有 5px 的偏差,ROI(128×128)仍然完整包含真实目标(44×44),网络看到的是完整的视觉信号,可以独立地重新定位目标。

形象地说:这更像是"每一帧独立做目标检测,搜索范围缩小到上一帧附近",而不是"蒙着眼睛沿着上一步的方向继续走"。视觉信号每一帧都在刷新,提供了持续的外部校正——这才是自举追踪稳定的根本原因。

七、总结

神经网络法的成功建立在几个关键的理论基础上:

理论基础 解决的问题 具体实现
时空可分离性 单帧不可见的目标如何检测? 10 帧堆叠,背景 i.i.d. vs 目标相关
热力图回归 如何从噪声中回归精确坐标? 密集监督 + 空间平滑先验
硬注意力(ROI) 如何降低计算量并提高精度? 128×128 局部窗口
预热帧信息注入 如何获取初始锚点? 亮度差检测
视觉信号持续校正 如何防止漂移发散? 每帧独立重定位而非盲推

六、总结

其实 Phantom 的项目方向本身没问题——用视觉停留效应做验证码这个想法很新颖。真正的问题出在架构上:把题目参数加密后直接发给前端,指望 ECDH 加密能挡住攻击者。

但 ECDH 的密钥协商是对称的——服务端用客户端的公钥推导会话密钥,客户端也能用服务端的公钥推导出完全相同的密钥。这就意味着任何能发 HTTP 请求的人,都可以自己生成密钥对、自己推导会话密钥、自己解密 encryptedParamscontrolPoints 到手,整个验证体系就透明了。

三种攻击方法的对比很能说明问题:

攻击难度 需要什么 通过率
分析法 读一遍 config.pyengine.py 100%
模拟法 逆向前端 JS + de4js 反混淆 100%
神经网络法 逆向渲染逻辑 + 训练一个 CNN 98-100%

攻击难度递增,但通过率基本不变。这说明:防御的薄弱点不在评分算法本身,而在参数分发阶段。 一旦 controlPoints 泄露,后面再多 DSP 分析和阈值调优都挡不住攻击者靶向构造轨迹。

如果要改进,方向很明确:把帧渲染放在服务端,只把像素流传到前端。 前端只负责采集鼠标轨迹,后端直接对比轨迹和贝塞尔路径。这样攻击者拿不到 controlPoints,只能对着像素流做纯 CV 追踪——而我们第五章的神经网络法已经证明了,就算是纯 CV,只要有足够的数据也能做到 98% 的通过率。所以更根本的防御可能需要引入更复杂的视觉变换(旋转、缩放、遮挡),让 CV 模型也难以追踪。

但无论如何,在当前架构下,Phantom 的三道防线(视觉 + 行为 + 加密)已经被全部绕过。


最后说一个很有意思的细节。Phantom 的设计里藏着一个内在矛盾:

为了不让机器通过单帧分析定位目标,方块内外的噪点都必须是完全随机的——空间维度上,目标和背景在统计上完全平等。但为了让人类借助视觉停留效应识别目标,方块内的粒子又必须在时间维度上保持连续(不能跳变),而背景噪点可以每帧完全刷新。

这个“空间上公平、时间上不公平”的设计,恰恰就是 Phantom 的阿喀琉斯之踵。神经网络捕捉的正是这种时空不对称性——它不是在看“哪个区域更亮”,而是在看“哪个区域昨天和今天长得一样”。


感兴趣的话,附件:https://1818161038.share.123pan.cn/123pan/dlcdjv-t5dG?pwd=AUpG#
密码:AUpG
因为是练手的,就没有整理太多了

posted @ 2026-07-26 00:28  归海言诺  阅读(7)  评论(0)    收藏  举报