第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
- 1Python 爬虫入门实战:服务端渲染页面怎么取数(CH-001)
- 2Python 爬虫接口实战:翻页取完再累加(CH-002)
- 3Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
- 4Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
- 5Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
- 6Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
- 7Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
- 8Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
- 9Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
- 10第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
- 11第 11 关:无限 debugger + VM 混淆 + 签名请求头
- 12第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
- 13第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控本文
- 14第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
- 15第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
本文是 LearnSpider 第 13 关的解密教程。这一关的靶场是一张点选验证码:把图上所有文字点掉。看着像「识图题」,真正的坎却在后面 —— 提交的不是「点了哪几个坐标」,而是一整段鼠标轨迹(时间、坐标、速度),用
AES+RSA加密成w;AES 的密钥一次一密、IV 由 challenge 派生,抓包重放没用。就算坐标全对,只要轨迹像机器(匀速直线、等间隔撒点、瞬移),也会被服务端的行为风控拦下。
靶场:/captcha?challenge=CH-013(登录后打开) 参考解法:参考代码/s15.py
现象
- 打开靶场,页面上就是一个普通的登录表单:用户名、密码、一个「登录」按钮,下面用虚线框标出演示账号
demo / demo123(写死在页面上,账号密码也已经填好了,直接点「登录」就行)。 - 点「登录」—— 弹出一个验证码窗口,标题写着「请点击图中的 N 个文字(不分先后)」。
- 图上有 3~5 个手写体汉字,散在噪点背景上。每点掉一个,图上会出现一个带序号的红色小圆点;点满 N 下自动提交,窗口里的数字就是这一轮的行为轨迹。
- 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"}w长这样(两段,用@拼起来):
w = "SBx2K9...(base64,RSA 加密的 AES 密钥)" + "@" + "9kQm...(base64,AES 加密的轨迹 JSON)"- 每次都换一张图,也每次都换一个 challenge:
/get返回的challenge和/init那个不一样,而且用一次就废。 - 手动点得慢一点、准一点,能过;但如果用脚本「算出中心点 → 瞬间点掉」,坐标全对也会失败 —— 而且服务端只会回一句:
{"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}- 如果你是用 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 ddddocrdetector = 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=True | headless=False |
|---|---|---|
navigator.webdriver | True | True ← 一条就够 |
| UA | HeadlessChrome/149 | 正常 Chrome(不带 headless 字样) |
navigator.plugins.length | 0 | 5 |
Object.keys(window.chrome).length | window.chrome 都没有 | 3(loadTimes/csi/app) |
Notification.permission | denied | denied |
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 // falseObject.keys(window.chrome) // ["app","csi","loadTimes","runtime", ...] ← 不是空数组navigator.plugins.length // 5Notification.permission // "default"navigator.languages.length // 2window.outerWidth, outerHeight // 都是正数⚠️ 关键认知:这些字段全是客户端自报的,服务端没法验证真伪。所以这一层只能挡住「不洗指纹就上」的自动化;愿意花力气伪装的人照样能过 —— 这正是反爬的常态:每一层都不是铜墙铁壁,它的价值是把门槛抬高、把成本转嫁给对手。参考解法里的 clean_env() 干的就这件事。
顺带一提,本地开发要拿自动化浏览器测这一关(比如自己写 Playwright 脚本调试),可以在服务端开个后门:
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,打开后跳过环境检查,行为风控照旧。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












