使用视觉停留效应作为人机验证:Phantom 验证码安全分析
Phantom 验证码安全分析
本文使用DeepSeek辅助编写,好久没写博客了,兴趣来了写一下
一、项目概览
Phantom 是一种利用视觉停留效应区分人机的验证码。它的设计思路挺有意思——不用扭曲文字、不用选红绿灯,而是让一个方块藏进满屏雪花噪点里,只有噪点动起来的时候,人眼才能"看"到它。
两道防线:
- 视觉防线 —— 目标方块隐藏在满屏动态噪点中,只有粒子运动时人眼才能识别
- 行为防线 —— 用户按住按钮跟随移动方块,系统分析轨迹的"人性"特征
说到底,验证码的通用逻辑都一样:出难题 → 记录解题行为 → 评分 → 判定。难题长什么样不重要,重要的是行为特征能不能模拟。
实测体验:只要大致跟上方块、在曲线拐弯处别跑偏、结束时踩在目标上,基本就能过。真正的难点不在"看清方块",而在"让轨迹看起来像人"。
所以攻击思路也很自然,就是想办法制造一条"看起来像人画出来的轨迹"。三个方向:
| 方法 | 思路 | 类比 |
|---|---|---|
| 分析法 | 读源码拿到评分算法,抄贝塞尔曲线公式,靶向构造轨迹 | 开卷考试,对着答案写过程 |
| 模拟法 | 逆向前端 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 | 同上 |

运行方式:
cd phantom/复盘/分析法
uv run python crack.py -n 5
四、模拟法
分析法的思路其实很"赖皮"——直接翻源码,拿到评分公式和所有阈值,然后靶向构造一条每个参数都落在满分区间的轨迹。那如果不看源码呢?能不能只靠逆向前端 JS,搞清楚"前端到底发了什么、怎么发的",然后自己模拟一套出来?
这就是模拟法的思路:不看答案,看过程。
4.1 下载前端代码
协议层没变,所有核心参数还是从 /challenge 的加密响应里拿。现在要做的就是把前端 JS 弄下来,搞清楚渲染和加密的具体实现。
打开浏览器开发者工具——哦,差点忘了这玩意儿有反调试。看看它怎么做的:
- 窗口尺寸差探测 — 每 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直接全局搜索,发现在这里被调用
- 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)
}
说实话,这反调试有点可爱——检测手段非常"经典",绕过也很简单:
Ctrl+Shift+D把 DevTools 切成浮动窗口(绕开尺寸检测)Ctrl+F8禁用所有断点(绕开 debugger 计时检测)F5刷新,把所有 JS 源文件保存下来

或者更直接:在 JS 下载到本地后,全局搜索 debugger 和 setInterval,手动注释掉也行。
四个主要的 JS 文件:
content_main.jsndex-CS7YKcMF.jsbeacon.min.jsindex-CS7YKcMF.js
另外,被断点卡住不代表不能手动恢复执行。我们直接开始验证流程,然后在页面卡住时看看停在哪个 JS 文件里:

命中位置是 index-CS7YKcMF.js:

具体来说,核心逻辑在 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 中直接对应:
- 贝塞尔曲线 —
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];
}
- 标准三次贝塞尔公式,对应 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)
- 粒子簇生成 —
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/v 在 makeCluster 时固定,跨帧复用。每帧通过 renderFrame 把同一批粒子平移到当前 Nn 算出的 center 位置。
- 轨迹采集 —
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;
}
- 最终
/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。

4.5 结论:模拟法本质上和分析法殊途同归
追踪完完整数据流之后发现:模拟法的代码路径和分析法完全一致。两者都是:
- ECDH + AES-GCM 解密
encryptedParams→ 拿到controlPoints - 沿
Nn(controlPoints, t)采样生成轨迹 → 叠加生理噪声 - 加密提交
唯一的区别在于"参考了哪份文档":分析法直接翻 config.py 看评分阈值,模拟法通过逆向前端理解渲染逻辑。最终产出的轨迹结构一模一样。模拟法的代码甚至可以直接复用分析法的 crypto_utils.py 和 trajectory_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() 返回格式 |
用人话翻译一下这张表:
- 总共 300 帧(5 秒 × 60fps),需要在规定时间内处理完——从最后一帧渲染完到提交的时间差不能太大
- 背景噪点每帧随机刷新,目标方块内的粒子是持久化的——同一批粒子,每帧整体平移。单帧看方块和背景没区别(因为粒子灰度也是随机的),但多帧连续看就能发现"有一块区域的内容在同步移动"
- 我们知道目标方块是 44×44 正方形,不转不缩放——这个先验很重要
- 最终输出 300 个
[x, y]坐标
5.2 第一次尝试
最开始的想法很直觉:拿初始帧 + 下一帧,预测下一帧的目标位置,然后一步一步迭代出完整轨迹。结果呢?两个致命问题:
- 只有第一帧的中心是已知的。后续帧的预测值会越偏越远,误差像滚雪球一样累积,最终轨迹完全偏离贝塞尔曲线
- 单帧里根本看不到目标。方块和背景在统计上没区别(gain=0),必须靠帧间运动差异才能检测——但第一步的误差已经让"帧间差异"这个信号被淹没了
后来又想:能不能直接让网络输出整条轨迹?也不行:
- 连续帧输入 → 整条轨迹输出,中间还要后处理,误差累积同样严重
- 每帧只有 16.67ms(= 1/60s)的处理窗口,前向推理稍微慢一点就掉帧,轨迹直接废掉
最终确定的方案:
- 逆向渲染逻辑批量生成训练数据——图片 + GT 坐标
- 不做全图推理,只给网络一个 128×128 的局部窗口(中心点 + 目标大小 + 5px 余量),大幅削减背景噪点
- 10 帧堆叠输入 → 64×64 热力图输出,~1.9M 参数
- MPS 训练,Apple Silicon 友好
- 逐帧迭代推理
这个方案训练效果不错——测试集上准确率很高。但问题来了:实际攻击时,只有第一帧的 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 帧),每次:
- 用上一帧的预测裁剪新 ROI
- 堆叠最近的 10 帧(滑动窗口)
- 模型输出热力图
- 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

5.3.7 总结
神经网络法的关键突破可以归结为三个字:锚 + 窗 + 噪。
- 锚(预热帧):用亮度差检测初始位置,解决了"没有 GT 就无法启动"的死循环
- 窗(ROI + 10 帧上下文):大幅削减输入噪声 + 提供足够的时域信号,让模型能分辨"移动的簇"和"闪烁的背景"
- 噪(生理噪声叠加):模型只负责 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\) 和 \(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)\) 的三个痛点:
- 空间信息丢失:全连接层把 128×128×10 的时空信息暴力压缩成两个标量,中间没有任何空间结构保留——这就像把一部电影压缩成一个"好看/不好看"的评分
- 梯度稀疏:只有两个输出神经元接收梯度,训练信号极弱
- 无法表达不确定性:网络没法说"目标可能在两个位置之间"
热力图回归的数学形式是:
每个像素 \((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 请求的人,都可以自己生成密钥对、自己推导会话密钥、自己解密 encryptedParams。controlPoints 到手,整个验证体系就透明了。
三种攻击方法的对比很能说明问题:
| 攻击难度 | 需要什么 | 通过率 |
|---|---|---|
| 分析法 | 读一遍 config.py 和 engine.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
因为是练手的,就没有整理太多了

浙公网安备 33010602011771号