摘要:在金融App的自动化回归测试中,点选类验证码(文字/图标点选)一直是阻断CI/CD流水线的“拦路虎”。市面上多数教程停留在Selenium+打码平台的浅层应用,面对设备指纹检测、行为轨迹分析等金融级风控时成功率极低。本文基于Playwright底层协议,从环境伪装、轨迹仿真、图像识别三个维度,复盘一套在内部测试环境中达到99%通过率的点选验证自动化方案。
免责声明:本文所述技术仅限授权安全测试与自动化测试场景,请勿用于非法用途。
一、 为什么你的自动化脚本总被“秒封”?
在接手某银行信贷系统的自动化测试项目时,我们遇到了典型的点选验证码拦截。初期使用 pyppeteer + 第三方打码接口,通过率不足40%。经过两周的流量分析与逆向排查,发现失败原因并非“识别不准”,而是**“机器特征太明显”**。
金融级验证码的风控维度远超传统认知,其检测链路如下:
#mermaid-svg-ZUWEJ7Ym1n0yig8N{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .error-icon{fill:#552222;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .marker.cross{stroke:#333333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N p{margin:0;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster-label text{fill:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster-label span{color:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster-label span p{background-color:transparent;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .label text,#mermaid-svg-ZUWEJ7Ym1n0yig8N span{fill:#333;color:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .node rect,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node circle,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node ellipse,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node polygon,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .rough-node .label text,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node .label text,#mermaid-svg-ZUWEJ7Ym1n0yig8N .image-shape .label,#mermaid-svg-ZUWEJ7Ym1n0yig8N .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .rough-node .label,#mermaid-svg-ZUWEJ7Ym1n0yig8N .node .label,#mermaid-svg-ZUWEJ7Ym1n0yig8N .image-shape .label,#mermaid-svg-ZUWEJ7Ym1n0yig8N .icon-shape .label{text-align:center;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .node.clickable{cursor:pointer;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .arrowheadPath{fill:#333333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZUWEJ7Ym1n0yig8N .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZUWEJ7Ym1n0yig8N .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster text{fill:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .cluster span{color:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZUWEJ7Ym1n0yig8N rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .icon-shape,#mermaid-svg-ZUWEJ7Ym1n0yig8N .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .icon-shape p,#mermaid-svg-ZUWEJ7Ym1n0yig8N .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .icon-shape .label rect,#mermaid-svg-ZUWEJ7Ym1n0yig8N .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZUWEJ7Ym1n0yig8N .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZUWEJ7Ym1n0yig8N .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZUWEJ7Ym1n0yig8N :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
WebDriver标识
Canvas/WebGL指纹异常
正常
直线/匀速/无抖动
贝塞尔曲线/随机停顿
Token时效/IP风险
通过
客户端触发验证码
环境检测
直接拒绝
下发验证码图片
用户交互
行为轨迹分析
判定为机器
提交验证
服务端二次校验
验证失败
返回业务Token
核心痛点总结:
二、 整体技术方案架构
针对上述问题,我们重构了验证模块,采用 “本地识别+轨迹仿真+协议级伪装” 三位一体方案:
#mermaid-svg-fnwv7OVbN5CHy8wA{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fnwv7OVbN5CHy8wA .error-icon{fill:#552222;}#mermaid-svg-fnwv7OVbN5CHy8wA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fnwv7OVbN5CHy8wA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fnwv7OVbN5CHy8wA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fnwv7OVbN5CHy8wA .marker.cross{stroke:#333333;}#mermaid-svg-fnwv7OVbN5CHy8wA svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fnwv7OVbN5CHy8wA p{margin:0;}#mermaid-svg-fnwv7OVbN5CHy8wA .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster-label text{fill:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster-label span{color:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster-label span p{background-color:transparent;}#mermaid-svg-fnwv7OVbN5CHy8wA .label text,#mermaid-svg-fnwv7OVbN5CHy8wA span{fill:#333;color:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA .node rect,#mermaid-svg-fnwv7OVbN5CHy8wA .node circle,#mermaid-svg-fnwv7OVbN5CHy8wA .node ellipse,#mermaid-svg-fnwv7OVbN5CHy8wA .node polygon,#mermaid-svg-fnwv7OVbN5CHy8wA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fnwv7OVbN5CHy8wA .rough-node .label text,#mermaid-svg-fnwv7OVbN5CHy8wA .node .label text,#mermaid-svg-fnwv7OVbN5CHy8wA .image-shape .label,#mermaid-svg-fnwv7OVbN5CHy8wA .icon-shape .label{text-anchor:middle;}#mermaid-svg-fnwv7OVbN5CHy8wA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fnwv7OVbN5CHy8wA .rough-node .label,#mermaid-svg-fnwv7OVbN5CHy8wA .node .label,#mermaid-svg-fnwv7OVbN5CHy8wA .image-shape .label,#mermaid-svg-fnwv7OVbN5CHy8wA .icon-shape .label{text-align:center;}#mermaid-svg-fnwv7OVbN5CHy8wA .node.clickable{cursor:pointer;}#mermaid-svg-fnwv7OVbN5CHy8wA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fnwv7OVbN5CHy8wA .arrowheadPath{fill:#333333;}#mermaid-svg-fnwv7OVbN5CHy8wA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fnwv7OVbN5CHy8wA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fnwv7OVbN5CHy8wA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fnwv7OVbN5CHy8wA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fnwv7OVbN5CHy8wA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fnwv7OVbN5CHy8wA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster text{fill:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA .cluster span{color:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fnwv7OVbN5CHy8wA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fnwv7OVbN5CHy8wA rect.text{fill:none;stroke-width:0;}#mermaid-svg-fnwv7OVbN5CHy8wA .icon-shape,#mermaid-svg-fnwv7OVbN5CHy8wA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fnwv7OVbN5CHy8wA .icon-shape p,#mermaid-svg-fnwv7OVbN5CHy8wA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fnwv7OVbN5CHy8wA .icon-shape .label rect,#mermaid-svg-fnwv7OVbN5CHy8wA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fnwv7OVbN5CHy8wA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fnwv7OVbN5CHy8wA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fnwv7OVbN5CHy8wA :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
轨迹仿真引擎
视觉识别层
Playwright控制层
Stealth Plugin
Browser Context
Page Instance
验证码截图
OCR/目标检测模型
坐标序列+置信度
贝塞尔路径生成器
人类化鼠标事件注入
验证结果回调
三、 关键突破点详解
3.1 环境伪装:不是“隐藏”,而是“真实”
很多教程教你用JS覆盖 navigator.webdriver,这在金融级风控面前等于掩耳盗铃——因为检测是在V8引擎层面完成的,JS层的修改无法骗过CDP协议检测。
正确做法:使用Playwright原生Stealth能力 + 启动参数调优
# 核心启动配置(非完整代码)
browser = await playwright.chromium.launch(
headless=False, # 金融风控对headless模式极其敏感,测试环境务必用有头模式
args=[
"–disable-blink-features=AutomationControlled",
"–disable-infobars",
"–window-size=1920,1080",
# 关键:模拟真实GPU渲染,避免Canvas指纹为空
"–use-gl=desktop",
"–ignore-gpu-blacklist",
]
)
context = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 …",
locale="zh-CN",
timezone_id="Asia/Shanghai",
# 注入真实的屏幕信息,而非默认的800×600
screen={"width": 1920, "height": 1080, "orientation": {"type": "landscape-primary"}}
)
踩坑记录:我们曾尝试在测试服务器上用Xvfb虚拟显示器运行,但WebGL渲染结果与真实显卡差异巨大,导致指纹哈希值异常。最终方案是在测试机上插一张亮机卡,用真实GPU渲染,通过率从62%提升至91%。
3.2 轨迹仿真:让鼠标“像人一样动”
这是提升通过率最关键的环节。人类鼠标移动遵循Fitts定律,具有加速-减速-微调三阶段特征,且伴随微小的Y轴抖动。
我们实现了一个轻量级轨迹生成器,核心思路:
import numpy as np
def generate_human_track(start, end, duration_ms=800):
"""
生成符合人类工学的鼠标轨迹
:param start: (x, y) 起点
:param end: (x, y) 终点
:param duration_ms: 总耗时(毫秒),人类点选通常在600-1200ms
"""
# 1. 用三阶贝塞尔曲线生成基础路径(非直线)
control1 = (start[0] + (end[0]–start[0])*0.3, start[1] + np.random.uniform(–30, 30))
control2 = (start[0] + (end[0]–start[0])*0.7, end[1] + np.random.uniform(–20, 20))
t_values = np.linspace(0, 1, num=int(duration_ms / 16)) # ~60fps采样
points = []
for t in t_values:
# 贝塞尔公式
x = (1–t)**3*start[0] + 3*(1–t)**2*t*control1[0] + 3*(1–t)*t**2*control2[0] + t**3*end[0]
y = (1–t)**3*start[1] + 3*(1–t)**2*t*control1[1] + 3*(1–t)*t**2*control2[1] + t**3*end[1]
# 添加微米级随机抖动(模拟手部肌肉震颤)
x += np.random.normal(0, 0.8)
y += np.random.normal(0, 0.8)
points.append((int(x), int(y)))
# 2. 终点前减速:最后20%的点密集化
# 3. 添加click事件的down/up延迟(50-150ms)
return points
关键细节:不要用 page.mouse.move() 一步到位! 必须逐点触发 mousemove 事件,且每个事件之间加入 10-30ms 的随机间隔。Playwright的 mouse.move 支持 steps 参数,但步长均匀仍会被检测,建议手动循环注入。
3.3 图像识别:放弃打码平台,拥抱本地轻量化模型
金融验证码通常包含干扰线、形变、背景噪声,通用OCR(如Tesseract)效果极差。我们的方案:
| 第三方打码平台 | 2-5s | 85% | 按次计费 | 低频、简单验证码 |
| PaddleOCR PP-OCRv4 | 200ms | 92% | 免费 | 中文文字点选 |
| YOLOv8-nano自训练 | 80ms | 97% | GPU一次性投入 | 图标点选/定制样式 |
| GroundingDINO | 300ms | 96% | 免费 | 开放词汇图标匹配 |
对于文字点选,PP-OCRv4配合简单的图像预处理(灰度化→自适应二值化→去干扰线)即可达到生产可用水平。
对于图标点选,推荐使用GroundingDINO这类开放词汇检测模型,无需针对每种图标重新训练,直接用文本描述(如“红色消防车”)即可定位,泛化能力极强。
# 伪代码:识别+点击一体化流程
async def solve_click_captcha(page):
# 1. 等待验证码容器加载
captcha_el = page.locator("#captcha-container")
await captcha_el.wait_for(state="visible")
# 2. 截图并识别(本地模型,无网络请求)
img_bytes = await captcha_el.screenshot()
targets = local_model.predict(img_bytes) # 返回 [(x,y,label), …]
# 3. 获取验证码区域在页面中的绝对偏移
box = await captcha_el.bounding_box()
# 4. 依次点击(注意:点选顺序可能是要求排序的)
for target in sorted(targets, key=lambda t: t[2]): # 按序号排序
abs_x = box["x"] + target[0]
abs_y = box["y"] + target[1]
# 从当前位置生成人类轨迹并执行
current_pos = await page.evaluate("() => ({x: window._mouseX, y: window._mouseY})")
track = generate_human_track((current_pos["x"], current_pos["y"]), (abs_x, abs_y))
for px, py in track:
await page.mouse.move(px, py)
await asyncio.sleep(random.uniform(0.008, 0.025))
# 模拟真实点击:down → 短暂停留 → up
await page.mouse.down()
await asyncio.sleep(random.uniform(0.05, 0.15))
await page.mouse.up()
await asyncio.sleep(random.uniform(0.3, 0.6)) # 两次点击间的思考间隔
四、 99%成功率背后的“隐形工作”
达到99%不是靠单一技术点,而是大量工程细节的堆叠:
五、 给安全团队的防御建议
作为攻防一体研究者,我们也向甲方安全团队提出了改进建议:
- 增加语义理解题:当前点选验证多为“找相同”,可升级为“找出图中正在打电话的人”等需要常识推理的题目,大幅提升AI破解门槛;
- 动态混淆验证逻辑:不要固定“点击第N个字”,改为“按拼音首字母升序点击”等规则,且规则文本本身做CSS视觉欺骗;
- 多模态行为融合:结合触摸压力(移动端)、陀螺仪数据、页面滚动节奏等多维信号,单一鼠标轨迹已不足以区分人机;
- 验证码降级策略:对可信设备/老用户免验证或降级为短信验证,平衡安全与体验。
六、 总结
金融级验证码的自动化测试,本质是一场 “拟真度” 的较量。Playwright提供了优秀的底层控制能力,但真正的壁垒在于对人类行为的建模和对风控规则的敬畏。
99%的通过率是在受控测试环境下的结果,生产环境的风控策略更为复杂且持续迭代。希望本文能为从事安全测试、质量保障的同学提供可落地的参考,也提醒开发者:没有绝对安全的验证码,只有不断提高的攻击成本。
参考资料:
- Playwright Stealth Plugin 源码分析
- Fitts’ Law in Human-Computer Interaction
- PaddleOCR PP-OCRv4 技术报告
- 《Web自动化测试中的反检测实践》- 2025 QCon演讲

