视频加载失败

第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控

4390 字
22 分钟
第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控

本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张点选验证码:把图上所有文字点掉。看着像「识图题」,真正的坎却在后面 —— 提交的不是「点了哪几个坐标」,而是一整段鼠标轨迹(时间、坐标、速度),用 AES+RSA 加密成 w;AES 的密钥一次一密、IV 由 challenge 派生,抓包重放没用。就算坐标全对,只要轨迹像机器(匀速直线、等间隔撒点、瞬移),也会被服务端的行为风控拦下。

靶场:/captcha?challenge=CH-013(登录后打开) 参考解法:参考代码/s15.py

现象#

  1. 打开靶场,页面上就是一个普通的登录表单:用户名、密码、一个「登录」按钮,下面用虚线框标出演示账号 demo / demo123(写死在页面上,账号密码也已经填好了,直接点「登录」就行)。
  2. 点「登录」—— 弹出一个验证码窗口,标题写着「请点击图中的 N 个文字(不分先后)」。
  3. 图上有 3~5 个手写体汉字,散在噪点背景上。每点掉一个,图上会出现一个带序号的红色小圆点;点满 N 下自动提交,窗口里的数字就是这一轮的行为轨迹。
  4. Network 里一共四条请求(都是点了「登录」之后才发出去的):
GET /api/challenges/CH-013/init -> {"gt":"3f9c...","challenge":"cd5c...","rounds":3,"demo":{...}}
POST /api/challenges/CH-013/get -> {"bg":"/api/challenges/CH-013/image?challenge=...","width":320,"height":160,...}
GET /api/challenges/CH-013/image?challenge=... -> 图片本体(image/png)
POST /api/challenges/CH-013/verify -> 请求体只有 {"gt","challenge","w"};成功回 {"validate":"...","rounds_left":2}
POST /api/challenges/CH-013/login -> {"username","password","validate"},验证通过后才回 {"passphrase":"LS13-XXXX-XXXX-XXXX"}
  1. w 长这样(两段,用 @ 拼起来):
w = "SBx2K9...(base64,RSA 加密的 AES 密钥)" + "@" + "9kQm...(base64,AES 加密的轨迹 JSON)"
  1. 每次都换一张图,也每次都换一个 challenge:/get 返回的 challenge 和 /init 那个不一样,而且用一次就废。
  2. 手动点得慢一点、准一点,能过;但如果用脚本「算出中心点 → 瞬间点掉」,坐标全对也会失败 —— 而且服务端只会回一句:
{"code":"1","result":"fail","message":"验证失败,请重新完成验证"}

弹窗同时换上一张新图。它不告诉你差在哪(真实站点就是这样,「差多少」不会下发给调用方)—— 详情只写服务端日志:

CH-013 验证不通过 user=1 reason=track_uniform_speed detail={"image_id":"00047","speed_stddev":0.0001,...}
CH-013 验证不通过 user=1 reason=automation detail={"ua":"...Electron...","chromeKeys":0,"notification":"denied",
"soft":["UA 是 Electron 等嵌入式浏览器","window.chrome 是空的", ...],"score":3}
  1. 如果你是用 Playwright / Puppeteer / Selenium 打开的页面,那连”点得准”这一步都到不了 —— 服务端会先看你上报的浏览器环境,判定是自动化就直接拒绝(reason=automation)。这一层就是接下来要拆的第四步。

分层:这一关要拆五层#

层手法破法
报文加密w = base64(RSA(AES 密钥)) + "@" + base64(AES-128-CBC(轨迹))找到公钥,自己按同样格式拼 w
一次一密AES 密钥每次现造;IV = sha256(challenge) 的前 16 个字符;challenge 一次性别想重放,每轮都按当前 challenge 重算
前端混淆采集/加密的代码在 static/captcha/security.js,真正的逻辑是一段 base64 源码,new Function 运行时才编译解两层 base64(注意混淆器把大小写翻转了);或 hook Function
反调试每 500ms 拼出 debugger 触发一次断点,量 Date.now() 差值;一旦发现被停住就把采到的轨迹清空Ctrl+F8 停用断点,或干脆不开 DevTools
行为风控坐标 + 轨迹(速度/间隔/转弯/瞬移)生成拟人轨迹(见第五步)
反自动化静态指纹:navigator.webdriver、自动化全局变量、UA、window.chrome、Notification 权限……洗指纹(s15.py 的 clean_env()),或者干脆不用浏览器

第一步:先把流程和 challenge 的用法搞清楚#

四步,和真实的点选验证码(极验那套)是一个套路:

init = session.get(INIT_PATH).json() # {"gt": ..., "challenge": ...}
got = session.post(GET_PATH, json={"gt": init["gt"], "challenge": init["challenge"]}).json()
# 换一张图 → 拿**新**的 challenge + bg + 图尺寸
image = session.get(BASE + got["bg"]).content # 图片本体
verify= session.post(VERIFY_PATH, json={"gt": ..., "challenge": got["challenge"], "w": w}).json()
login = session.post(LOGIN_PATH, json={"username": ..., "password": ..., "validate": verify["validate"]}).json()

三个坑:

  • challenge 是一次性的:/init 给一个(用来换图),/get 再给一个(用来提交)。提交时必须用后一个,用错或者用两次都是 challenge 无效或已过期;
  • 验证失败也作废:想再试一次,得重新 /init → /get 换张图,不能对着同一张图反复试坐标;
  • validate 也只能用一次:登录接口消费掉就没了。

第二步:认出图上的文字#

数据集只有标注框、没有字符内容(标注的类别就是 word / icon),服务端也不会告诉你答案。所以先得自己做检测:

import ddddocr
detector = ddddocr.DdddOcr(det=True, show_ad=False)
boxes = detector.detection(image_bytes) # [[x1,y1,x2,y2], ...]

更稳的做法:这个靶场的图本来就来自一个目标检测数据集(仓库里那份 点选验证码目标检测数据集(1)/,VOC 标注,6300 张)。拿它训一个小模型(YOLO 之类),检测效果比通用 OCR 的检测头稳得多 —— 数据集里 labels/ 目录已经是 YOLO 格式了。

⚠️ 识别结果必须”不漏不重”:服务端要求「框全部点到、点击次数等于框的数量」。多认出一个噪点、或者少认一个字,都会直接判失败(错误码 click_count)。识别没把握时,换一张图比硬着头皮提交划算。

第三步:把 w 拼出来(一次一密在这儿)#

w 的结构和 CH-010 那套「RSA 传密钥 + AES 加密报文」像,但多了两处:

key_text = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16))
# ① IV 不是固定值,而是 sha256(challenge) 的前 16 个字符 —— challenge 一换,密文全变
iv = hashlib.sha256(challenge.encode()).hexdigest()[:16].encode()
cipher = AES.new(key_text.encode(), AES.MODE_CBC, iv)
body = base64.b64encode(cipher.encrypt(pad(json.dumps(payload).encode(), 16))).decode()
# ② AES 密钥用 RSA 公钥包起来(PKCS#1 v1.5)
wrapped = base64.b64encode(PKCS1_v1_5.new(public_key).encrypt(key_text.encode())).decode()
w = f"{wrapped}@{body}"

明文的 payload:

{"gt": "...", "challenge": "...", "ts": 1760000000000, "size": [320, 160],
"track": [[dt, x, y], ...], "clicks": [[dt, x, y], ...],
"env": {"webdriver": false, "ua": "...", "globals": [], "chromeKeys": 4, "notif": "default", ...},
"ver": "1.0.2"}

track / clicks 都是增量时间编码:第一个元素的 dt 是「距 ts 多少毫秒」,后面每个是「距上一个点多少毫秒」。ts 是开始采集的时刻(图片刚画出来),size 是图片在页面上的显示尺寸 —— 服务端用它把像素坐标归一化,所以自己算的时候直接用图的原尺寸就行。env 是浏览器环境指纹(第四步要拆的那一层)。

公钥在哪儿? 不在页面里明文写着,而是被 base64 藏进了混淆产物 security.js。参考代码/s15.py 里的 grab_public_key() 干的就是这件事:把产物里所有 base64 片段「原样 / 大小写翻转」各解两层,谁解出 BEGIN PUBLIC KEY 就是它:

for token in re.findall(r"['\"]([A-Za-z0-9+/=]{40,})['\"]", source):
for candidate in (token, token.swapcase()): # 混淆器把大小写翻转了
outer = base64.b64decode(candidate + pad).decode() # 第一层:payload(base64)
inner = base64.b64decode(outer + pad).decode() # 第二层:VM 源码
# inner 里就有 PEM ——顺带还能看到整段采集/加密逻辑

顺带说一句:解出来的那份 VM 源码就是这一关前端的全部逻辑(怎么采轨迹、怎么派生 IV、反调试怎么判断),比动态调试省事得多。

第四步:先过”环境”这一关 —— 自动化浏览器一进门就被认出来了#

这一步很容易被忽略:很多人第一反应是用 Playwright / Puppeteer 打开页面点两下了事。结果会发现怎么点都过不去 —— 因为服务端除了看轨迹,还会把你上报的浏览器环境过一遍。

关于这一层,一个实测出来的事实值得先说清楚:在事件层面是查不出自动化的。

你可能以为的破绽实测结果
注入的事件 isTrusted === false❌ CDP 派发的事件到页面时 isTrusted 就是 true
event.movementX/movementY 恒为 0❌ 照常按坐标差算出来(实测 18px 的步长 → movementX = 18)
只有 mousemove、没有 pointermove❌ pointermove / pointerdown / mousedown / mouseup 一个不少
screenX 和 clientX 对不上❌ 差值恒定(窗口偏移),和真鼠标一样

所以这一层是靠静态指纹判的(backend/click_captcha_risk.py 的 check_environment())。最典型的那种写法 ——

browser = p.chromium.launch(headless=False) # 甚至不用无头

—— 实测下来是这样:

信号headless=Trueheadless=False
navigator.webdriverTrueTrue ← 一条就够
UAHeadlessChrome/149正常 Chrome(不带 headless 字样)
navigator.plugins.length05
Object.keys(window.chrome).lengthwindow.chrome 都没有3(loadTimes/csi/app)
Notification.permissiondenieddenied
cdc_* 之类的全局变量无无

也就是说:裸跑 Playwright,光 navigator.webdriver 这一条就露了,无头还是有头都一样。

硬信号(沾一个就拦)

  • navigator.webdriver === true —— Selenium / Puppeteer 的默认值,最经典的自动化标记;
  • window 上留着自动化框架的全局变量:cdc_*(ChromeDriver)、__selenium*、__driver_*、__playwright*、__puppeteer*、callPhantom、_phantom、__nightmare、domAutomation……
  • UA 里有 HeadlessChrome / PhantomJS / Selenium / Puppeteer / Playwright 字样。

软信号(攒够 2 分才拦 —— 单条会误伤真人)

  • UA 是 Electron 这类嵌入式浏览器;
  • Object.keys(window.chrome).length === 0(正规 Chrome 有一堆 chrome.* 对象);
  • Notification.permission === 'denied'(headless Chrome 的默认值);
  • navigator.plugins.length === 0;
  • window.outerWidth/outerHeight 为 0;
  • navigator.languages.length === 0。

脚本页面一加载就会把指纹 POST 给服务端(POST /api/challenges/CH-013/env)—— 也就是说门一开就被登记了,不用等你点验证码。服务端日志当场留一行,页面上也会弹一条提示:

CH-013 页面打开即识别 user=1 automation=True signals=["navigator.webdriver = true"] ua=...

怎么知道自己的浏览器报了什么? 页面上的安全脚本内置了采集函数,控制台里直接看:

window.LSClick.collectEnv()
// {webdriver: false, ua: "...Chrome/150...", globals: [], chromeKeys: 4,
// notif: "default", plugins: 5, languages: 2, outer: [2048, 1152], ...}

拿这个对照下面的「正规 Chrome 长什么样」:

navigator.webdriver // false
Object.keys(window.chrome) // ["app","csi","loadTimes","runtime", ...] ← 不是空数组
navigator.plugins.length // 5
Notification.permission // "default"
navigator.languages.length // 2
window.outerWidth, outerHeight // 都是正数

⚠️ 关键认知:这些字段全是客户端自报的,服务端没法验证真伪。所以这一层只能挡住「不洗指纹就上」的自动化;愿意花力气伪装的人照样能过 —— 这正是反爬的常态:每一层都不是铜墙铁壁,它的价值是把门槛抬高、把成本转嫁给对手。参考解法里的 clean_env() 干的就这件事。

顺带一提,本地开发要拿自动化浏览器测这一关(比如自己写 Playwright 脚本调试),可以在服务端开个后门:

Terminal window
LEARNSPIDER_CH013_ALLOW_AUTOMATION=1 python app.py # 默认关,线上别开

第五步:最难的一关是”像个人”#

坐标对了只是入场券。服务端拿到解密后的轨迹,会同时看结果和过程:

检查阈值为什么
点击次数 = 文字框数量相等漏点 / 多点都算错
每一下都落在某个框里归一化容差 3%(320×160 的图约 9.6×4.8 像素)允许手抖,但不许点空
每个框恰好被点一次—防止把一个字点两遍充数
第一下点击的延迟≥ 200ms图片刚出来就点 → 人眼来不及看
相邻点击间隔60ms ~ 8s太快是连点器,太慢是挑战已过期
点击间隔标准差≥ 2ms(间隔数 ≥ 3 时才判)固定 sleep 的脚本标准差是 0.0x 毫秒
轨迹采样点数量≥ 12两点一线直接连过去的不行
瞬时速度≤ 24 px/ms人手做不到,这就是瞬移
速度标准差≥ 0.02 px/ms匀速直线插值的轨迹,这里是 0
方向变化次数≥ 2(转角 > 10°)整条轨迹一条直线没有转折

所以「算出中心点 → 直接点过去」必然挂在速度标准差上。要过这一关,得生成一段像人的轨迹:

顺带一提:采样点的「时间间隔标准差」这一条没有。浏览器按帧投递 mousemove,真人的事件间隔同样被量化到 ~16.7ms 且方差极小 —— 拿它当机器特征会大面积误伤真人。这是这关的一个设计取舍:宁可漏判,不可误伤;也提醒你,风控阈值不是越多越好。

def human_move(rng, start, end):
"""慢-快-慢(ease)+ 手抖。"""
distance = math.hypot(end[0] - start[0], end[1] - start[1])
steps = max(5, int(max(140.0, distance / rng.uniform(0.30, 1.10)) / rng.uniform(9, 17)))
samples = []
for step in range(1, steps + 1):
ratio = step / steps
ease = 3 * ratio ** 2 - 2 * ratio ** 3 # 速度先快后慢,绝不会是常数
samples.append((
max(4.0, rng.gauss(14, 4.5)), # 事件间隔带抖动(±几毫秒)
start[0] + (end[0] - start[0]) * ease + rng.gauss(0, 1.1), # 手抖
start[1] + (end[1] - start[1]) * ease + rng.gauss(0, 1.1),
))
return samples

几个「别省」的细节:

  • 别用线性插值 + 等分时间:那正是 speed_stddev ≈ 0 的来源,一条匀速直线就露馅;
  • ts 要贴近当前时间:服务端允许 3 分钟误差,超了直接判失败;
  • 点之间要”停一下再按”(rng.uniform(90, 260) 毫秒),这也是人手特征;
  • 别用 time.sleep(0.3) 这种固定节律:点得多了(≥4 下、3 个间隔)会被看间隔标准差。

第六步:完整脚本#

参考代码/s15.py 把上面五步串成了一条流水线:抠公钥 → init/get → ddddocr 检测 → 洗指纹 → 生成拟人轨迹 → 拼 w → verify → 登录拿口令。核心就四个函数:detect_targets()、clean_env()、build_payload()、build_w()。

跑通后拿到的是通关口令(形如 LS13-XXXX-XXXX-XXXX,按账号派生、唯一),把它提交到挑战广场。

排查清单(⚠️ 先看这一条)#

服务端只说一句「验证失败」,不会告诉你差在哪 —— 真实站点就是这样,把 reason / metrics 下发到页面等于给脚本送调试助攻。所以你只能自己对着上面的阈值表复现:

  • 想知道到底挂在哪一条,就把 backend/click_captcha_risk.py 里的判定函数单独跑一遍(check_clicks() / check_timing() / check_track() 都是纯函数,喂进去就能出结论),或者看服务端日志 —— 每次失败都会留一行 CH-013 验证不通过 user=... reason=... detail={...},里面的 image_id 能对上是哪张图。

常见的几种失败与原因:

可能的原因怎么看出来
环境被判成自动化(最常见)用 Playwright / Puppeteer / Selenium 直接跑必挂。控制台跑 window.LSClick.collectEnv(),对照上面的「正规 Chrome 长什么样」比一遍;服务端日志里有 reason=automation 和命中的信号
坐标没点准容差只有图上 3%(320×160 的图约 9.6×4.8 像素):检测框的中心算错了、忘了按显示尺寸缩放、或者点到了框外
漏点 / 多点检测结果和真实框数量不一致(噪点被认成字、或者有字没认出来)→ 换一张图或换更稳的模型
首点太快ts 设得太早(比如设成了脚本启动时间),或者一个循环里”算完就点”,没留看图的时间
轨迹像机器匀速直线(速度标准差 ≈ 0)、点太少(< 12)、整条线没有转折、或者出现瞬移
间隔太整齐用了固定 sleep;点得越多越容易露
w 根本没解开IV 没用当次 challenge 派生;RSA 段不是 PKCS#1 v1.5;或者密文被动过
challenge 失效用了 /init 那个旧 challenge;一张图验两次;验失败后没重新取图
轨迹点数为 0多半是被反调试抓到了:挂断点会让采集结果被清空,Ctrl+F8 停用断点再来
validate 令牌无效…已经用过了validate 一次性,换新的再登

FAQ#

Q:为什么图不从 CDN 直接下发,非要服务端代理一层? A:题库种子(backend/data/click_captcha_seed.json)是随仓库走的,里面每条都带着 image_url 和标注框。要是把七牛的持久化链接直接发到浏览器,image_id 一露,查一眼种子就知道该点哪儿了。代理之后 URL 上只剩一次性的 challenge。

Q:反调试真的会拦我吗? A:它只做一件事:发现「被断点停住过」就把采到的轨迹清空。不开 DevTools、或者开了但按 Ctrl+F8 停用断点,它完全无害。想读逻辑不必动态跟 —— 把 security.js 里的 base64 解两层,源码就出来了。

Q:识别的准确率不够怎么办? A:两招。一是换一张图(服务端每次随机给图,识别没把握就重取);二是拿仓库里那个数据集训一个检测模型 —— 数据就是为这一关准备的。

Q:为什么必须”像人”? A:这就是行为式验证码的核心:它验证的不是「结果」而是「过程」。真实站点上,哪怕坐标分毫不差,一条匀速直线也会被判定为机器。反过来说,知道它看什么,也就知道该往哪儿使劲 —— 以及知道哪些站点的验证码不该硬闯。

Q:我用 Playwright 怎么点都过不去,为什么? A:两个可能:环境被判成自动化(reason=automation),或者轨迹太”机械”。先看环境:控制台跑 window.LSClick.collectEnv() 看自己报了什么,再跟上面「正规 Chrome 长什么样」对一遍。要过的话有两条路 —— 洗指纹(像 s15.py 的 clean_env() 那样,用真实浏览器的值伪造一份),或者不走浏览器(自己拼 w,那样连环境字段都是你自己说了算)。

Q:这些环境字段服务端能验证真伪吗? A:不能。它们全是客户端自报的,服务端只做「像不像正常浏览器」的启发式判断。所以这一层挡的是「不洗指纹就上」的自动化,挡不住愿意花力气伪装的人 —— 这不是缺陷,是这类防护的本来面目:每一层都把成本往上抬一点,最后决定胜负的是对手愿不愿意付出那份成本。

Q:我本地想用自动化浏览器测这一关怎么办? A:服务端留了开关(默认关,线上不要开):LEARNSPIDER_CH013_ALLOW_AUTOMATION=1 python app.py,打开后跳过环境检查,行为风控照旧。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
https://jsnote.top/posts/ls-ch13/
作者
xiajiao
发布于
2026-10-03
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
爬虫靶场本文是 LearnSpider 第 10 关的解密教程。这一关把「RSA + AES 混合加密」和「JavaScript 混淆」凑在一起:请求头 sm 是每次现算的,报文也不是明文,靶场还只放你书单里的前四页。
2
Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
爬虫靶场本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了:请求/响应都是 AES-ECB 加密的 hex,还要带一个前端写死的 Adam-AppKey,而且每翻一页都得先过一道算术验证码。目标是取 10 页…
3
第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
爬虫靶场本文是 LearnSpider 第 14 关的解密教程。这一关的数据分 5 页、每页 10 个数字,但接口只认一个签过名的 sign Cookie:这个 Cookie 是前端一段混淆过的 JS连着过七层算法算出来的(拼盐 → MD5 派生密…
4
第 11 关:无限 debugger + VM 混淆 + 签名请求头
爬虫靶场本文是 LearnSpider 第 11 关的解密教程。这一关的 JS 防守最"脏":无限 debugger(关键字还是拼出来的)、base64 源码交给 new Function 在运行时编译、请求头 m 要现算签名,票价则靠 base6…
5
第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
爬虫靶场本文是 LearnSpider 第 15 关的解密教程。这一关的靶场是最普通不过的一个 HTML 列表页:不加密、不验签、不弹验证码,5 页 × 10 条公告,谁都能抓。难的是你不知道自己抓到的是不是真的 —— 页面里埋了三类「只给爬虫准备…
随机文章随机推荐

评论区

Profile Image of the Author
xiajiao
写爬虫,也写防爬虫的靶场。
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
15
分类
1
标签
33
总字数
22,317
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录