结论先说

登录表单里的一次性凭证 —— 隐藏的防伪 token、图形验证码 —— 是「取一次、用一次」的。
在同一个会话里多取一次,上一次的就作废了。

浏览器里这个坑极难自己发现,因为:

  • 报错话术指向验证码(「验证码错误」),实际可能是 token 的问题,反之亦然
  • 图片/表单看起来正常,受害者看不出任何异常
  • 换成无痕、换浏览器、换网络 —— 全都无效,因为它不在本地

用脚本时反而容易避开:一个会话里取一次,立刻用掉,中间不插任何请求。

症状

出现下面任意一条,先怀疑这个:

  • 图形验证码肉眼看对了,提交却一直报「验证码错误」
  • 换浏览器 / 无痕 / 换网络 / 清 cookie,全部无效
  • 同样的操作,手写脚本能过,浏览器不能
  • 一次操作里,前端对同一个验证码接口发了多次请求

最后一条最关键。看一次抓包就知道。

排查过程(黑盒,不需要密码)

第 1 步:同一个会话反复取,凭证稳不稳

SID=$(curl -sS -c - -o /dev/null 'https://<host>/login' | awk '/PHPSESSID/{print $7}')
for i in 1 2 3 4 5; do
  curl -sS -H "Cookie: PHPSESSID=$SID" 'https://<host>/login' \
    | grep -oE 'name="token" value="[a-f0-9]+"'
done

每次都不一样 → 这个 token 是每次渲染重新生成的。

第 2 步:抓一次真实失败

抓包要注意别只记请求,要记「哪个请求带了哪个会话」和「响应体」。
关键对比:取凭证那次请求带的 PHPSESSID,和提交那次带的,是不是同一个。

第 3 步:数一数前端发了几次凭证请求

一次操作里发 3 次,就说明有 2 次是白发的 —— 每一次都把前一次的作废。

排查时踩过的坑(四个被我误判又推翻的)

这几条都是看着很像、实测不成立的,记下来省得下次再绕:

猜测怎么推翻的
CDN 缓存了验证码图图每次都是新的(字节指纹都不同)。真缓存了图就不会变
页面缓存发旧 token响应头明写 Cache-Control: no-store, no-cache, must-revalidate
浏览器走代理、脚本直连,落到不同后端两条路出口 IP 一模一样(直连和走代理实测同 IP)
两个请求用了不同的 PHPSESSID抓包里全程同一个 PHPSESSID
前端有 indexOf('400') 误判二进制图片实测 60 张图,0 张命中,排除

教训:每一条都要用数据打死,别停在"很像"。 上面五条里前四条我都一度当成结论说过。

根因

服务端把凭证实现成「生成新的就覆盖旧的、只保留最新一个」,而前端会在一次操作里发多次请求。

页面加载        -> 取到凭证 A(服务端存 A)
用户点图刷新    -> 取到 B(服务端存 B,A 作废)
再点一次        -> 取到 C(服务端存 C,B 作废)
用户看着屏幕上的 C,却...

只要屏上显示的那一次和服务端最后收到的那一次不是同一个,输入就永远对不上。
响应乱序到达时,这两者会分叉。

解法

同会话内「取一次 → 立刻用」,中间不插任何请求。

GET  /login     -> 拿 token 和 cookie(同一次请求,天然配套)
GET  /verify    -> 拿验证码图
POST /login     -> 立刻提交,用同一个 token

给普通用户的绕过办法

没有脚本的时候,也能靠操作习惯绕开:

  • 绝对不要点那张验证码图刷新它。 它多半绑了 onClick 刷新,点一次服务端就换一个答案
  • 打开登录页,看第一张图,直接敲,立刻提交
  • 打字期间不刷新、不切标签、不点任何东西
  • 图看不清就整页重载,而不是点图

案例记录

cloud.crzhong.cn(铬中智擎,IDCsmart / 智简魔方面板),2026-09-21:

  • 用户没有面板密码,只能走「手机号 + 短信验证码」登录
  • 浏览器里图形验证码永远报「图形验证码有误」,换无痕、换网络都无效
  • 一次操作抓包:成功提交前,前端对 /verify 发了 3 次请求(页面加载 1 次 + 用户点图 2 次)
  • 自建脚本用「单会话取一次立刻提交」,一次就过
  • 用户向服务商报障后已修复。修复在服务端 —— 前端 JS 与响应头前后完全一致(public.js 三个时间点 MD5 相同)

相关概念


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Web