做授权测试这些年,验证码一直是登录爆破路上最大的拦路虎。尤其是那种4位数字、扭曲比较严重的验证码,手动输入一轮还能忍,但每请求一次刷新一张、还要配合字典跑上千个密码时,人就麻了。后来我把ddddocr和yakit的MITM热加载结合在一起,在授权测试中彻底打通了“验证码识别+web登录爆破”这条链路——请求自动带出验证码,yakit自动完成识别、回填、爆破,全程不用手动介入。这篇文章就把整套方案的架构、代码和踩坑记录完整晒出来,适合正在做Web渗透测试、或者准备在CTF里打登录爆破题的同学参考。
我用的方案不是简单的“跑个识别脚本”,而是把ddddocr封装成本地OCR服务,再通过yakit插件在流量层自动接管验证码。整个过程会被拆成几个模块,每一步为什么这么做、坑在哪里、怎么改,文章里都会写清楚。
1. 为什么要把验证码识别做进yakit插件
1.1 传统验证码爆破的三重痛点
先说最常见的做法:遇到验证码时,手动把图片保存下来,然后跑一段ddddocr脚本识别,再把识别结果填回爆破工具。听着不算复杂,但实际操作起来问题很多。
第一是效率低。4位数验证码还好,识别一次大概几百毫秒,但如果验证码是6位混合字符甚至带干扰线,识别成功率就算只有八成,每次失败都要重新刷新、重新下载、重新识别。一个字典跑下来几千次请求,可能一半时间都花在“搬运验证码图片”上。
第二是联动差。Burp、yakit这类工具的爆破模块本身不认识验证码,它们只负责把payload填进字段。验证码需要额外写脚本处理,数据怎么在脚本和爆破工具之间传递,往往要手动用剪贴板或临时文件来中转。这样不仅慢,还容易串数据——上一轮识别的验证码还没用上,还没来得及刷新,服务器已经把它作废了。
第三是状态维护难。很多登录接口的验证码是跟着Session或Cookie走的。爆破工具如果每个请求都重新建Session,验证码和手机号/用户名就对应不上,必然导致大量误报。真正的问题是:验证码不只是“一张图”,它是一个绑定会话状态的资源。要爆破成功,必须在同一个会话里完成“获取验证码->识别验证码->提交登录”这个闭环。
1.2 MITM热加载:把OCR服务串进流量管道
yakit的MITM热加载功能,简单说就是在中间人代理处理每一个HTTP/HTTPS请求和响应时,插入一段自定义逻辑。这就像一个中转站,流量从客户端到达服务器之前、以及响应返回客户端之前,都会经过你写的脚本,由脚本决定是原样放行还是修改数据包。
把这个机制用在验证码爆破上,思路就变成:
- 当浏览器或攻击脚本访问登录页时,yakit拦到验证码图片响应(通常是一个GET请求返回的图片流)。
- 热加载脚本把图片二进制提取出来,POST给本地跑着的ddddocr服务。
- OCR服务返回值发给热加载脚本,脚本再把识别结果绑到当前会话,等真正的登录请求发出时,自动替换掉验证码参数。
这样一来,人工只需要做一件事:启动爆破任务。剩下的验证码获取、识别、回填全部由管道自动完成。
1.3 整套链路的架构分层
整体架构分三层:
| 层级 | 组件 | 职责 |
|---|---|---|
| 识别层 | Python + ddddocr | 接收图片,返回识别文本 |
| 桥接层 | yakit MITM热加载脚本 | 拦截流量、提取图片、调用OCR、回填验证码字段 |
| 执行层 | yakit Web Fuzzer / 手工发包 | 按字典发送登录请求,完成爆破 |
三层之间通过本地HTTP接口通信,不依赖外网服务,所以即使目标站在内网环境,只要本机能跑yakit和Python服务,就能成立。这个分层还有一个额外好处:OCR服务和代理工具解耦,后续如果要换其他OCR引擎,比如加个深度学习模型,只需要改识别层,不用动yakit脚本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与选型:ddddocr、Python版本、模型加载
2.1 为什么用ddddocr而不是其它OCR方案
市面上能识别验证码的方案其实不少——Tesseract是一个老牌选择,但从实践效果看,除非你花大量时间调图像二值化、字符分割,否则Tesseract对扭曲、粘连字符的表现非常一般,4位数字还行,混入字母基本就废。商用OCR服务(如各类云识别API)识别率高一些,但需要联网,而且在授权测试中把目标站的验证码图片发到第三方服务,本身就是个隐患。
ddddocr的优势在三个地方:
- 它基于深度学习模型,对常见的数字、字母混淆、扭曲变形处理很强,而且专门针对验证码场景做了优化。
- 模型文件不大,不需要GPU,CPU就能跑,离线可用。这意味着它可以在测试机的隔离网络里部署。
- 它不只是识别纯数字,还支持滑块、点选等部分验证码(不同版本支持程度不一样),通用性不错。
我实测4位纯数字验证码,在无干扰线、无复杂背景的情况下,几乎100%识别;6位字母+数字混合大概在80%到90%之间,具体看字符有没有粘连。如果目标站验证码比较复杂,可以配合图像预处理提高识别率,后面会讲。
2.2 环境准备细节
Python侧,建议直接用3.8到3.10之间的版本。我踩过Python 3.11的坑,某些onnxruntime版本在3.11下会出现兼容问题,运行时报DLL加载失败。安装命令很简单:
code复制pip install ddddocr
安装时它会自动拉取onnxruntime、Pillow、numpy这些依赖。如果环境里已经有旧版本onnxruntime,建议先升级到最新版,不然后面加载模型时容易报Failed to load library。
验证ddddocr是否装好,可以写个最小测试:
python复制import ddddocr
ocr = ddddocr.DdddOcr()
with open("captcha.png", "rb") as f:
image = f.read()
print(ocr.classification(image))
能输出验证码文本就说明环境没问题。
2.3 模型加载:别在每次请求时重新初始化
这是最容易忽略的性能问题。ddddocr初始化时会加载一个几十MB的模型文件,Windows下大约耗时几百毫秒到一秒。如果脚本每次接收到一张验证码都重新DdddOcr(),在高并发爆破场景中会变成灾难——还没识别完,流程就崩了。
正确做法是:在服务启动时初始化一次OCR实例,之后所有请求复用同一个实例。但要注意,ddddocr实例是否线程安全在不同版本不太一样。我在本地HTTP服务里用的是单线程处理队列的方式,保证同一时刻只有一个识别请求在处理,避免模型并发调用导致崩溃。
另外一个细节:ddddocr提供了show_ad参数,默认情况下它可能会输出一些提示信息,正式跑的时候建议显式关闭:
python复制ocr = ddddocr.DdddOcr(show_ad=False)
这样日志会干净很多,排查问题也容易。
3. 核心实现:从OCR服务到yakit热加载脚本
3.1 本地OCR HTTP服务完整代码
先上一份可以直接跑的Python服务代码。它监听8765端口,接收POST上来的图片二进制,返回识别结果。这样yakit侧只需要用HTTP请求就能把图片变成文本,不需要去管Python环境。
python复制# -*- coding: utf-8 -*-
import json
import ddddocr
from flask import Flask, request, jsonify
app = Flask(__name__)
ocr = ddddocr.DdddOcr(show_ad=False)
@app.route("/ocr", methods=["POST"])
def ocr_image():
data = request.get_data()
if not data:
return jsonify({"code": 400, "msg": "empty image"}), 400
try:
text = ocr.classification(data)
return jsonify({"code": 0, "result": text})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@app.route("/healthcheck", methods=["GET"])
def healthcheck():
return jsonify({"code": 0, "msg": "alive"})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8765, threaded=False)
这里用Flask只是为了起服务方便,threaded=False强制单线程处理,避免并发调用ddddocr。实际爆破时单线程完全够用,因为爆破的瓶颈通常不在OCR识别,而在网络请求和服务器响应速度。如果以后需要提高OCR吞吐,再用线程池配互斥锁来做。
跑起来后,可以先用curl做个冒烟测试:
bash复制curl -X POST --data-binary @captcha.png http://127.0.0.1:8765/ocr
正常情况下几毫秒内就返回JSON结果。
3.2 yakit热加载脚本:流量拦截与验证码回填
yakit的MITM热加载脚本本质上是一段在代理数据流中执行的逻辑代码,不同版本API会有差异,但核心思路一致:通过hook beforeRequest或类似回调拿到URL和请求体,判断是否命中验证码路径,然后把验证码替换成OCR结果。
下面是我在项目中使用的脚本骨架,逻辑完整,涵盖“响应阶段识别验证码”和“请求阶段替换参数”两个入口:
javascript复制/*
* yakit MITM热加载脚本
* 功能:自动识别验证码并回填登录爆破请求
*/
// 本地OCR服务地址
const OCR_URL = "http://127.0.0.1:8765/ocr";
// 登录请求的验证码字段名,按实际目标调整
const CAPTCHA_PARAM = "captcha";
// 验证码图片URL的关键路径,按实际目标调整
const CAPTCHA_PATH = ["/captcha", "/getCaptcha", "/verifycode"];
function isCaptchaRequest(url) {
for (let i = 0; i < CAPTCHA_PATH.length; i++) {
if (url.indexOf(CAPTCHA_PATH[i]) >= 0) {
return true;
}
}
return false;
}
// 响应阶段:拦截验证码图片,识别后缓存
function mirrorResponse(url, response) {
if (!isCaptchaRequest(url)) {
return;
}
let body = response.Body;
if (!body || body.length === 0) {
return;
}
let resp = http.POST(OCR_URL, body);
let text = "";
if (resp.StatusCode === 200) {
let jsonResp = json.Loads(resp.Body);
text = jsonResp.result;
}
if (text) {
// 将识别结果存到当前会话,供后续请求使用
setLastCaptcha(url, text);
println("[+] 验证码识别成功: " + text);
} else {
println("[!] 验证码识别失败");
}
}
// 请求阶段:匹配登录请求,自动替换验证码参数
function beforeRequest(req) {
let body = req.Body;
if (!body || body.length === 0) {
return;
}
let captcha = getLastCaptcha("");
if (!captcha) {
return;
}
let newBody = body.replace(CAPTCHA_PARAM + "=([^&]*)", CAPTCHA_PARAM + "=" + captcha);
if (newBody !== body) {
req.Body = newBody;
println("[+] 已替换验证码为: " + captcha);
}
}
用几个注意点解释这段脚本的意图:
isCaptchaRequest是为了缩小拦截范围。如果不做路径过滤,代理会尝试识别所有图片资源,包括logo、背景图,浪费时间还容易误伤。setLastCaptcha和getLastCaptcha建议用一个带过期时间的内存缓存放验证码。简单场景下用全局变量也可以,但最好保证验证码和会话关联,不然多线程并发时验证码会错乱。- 替换请求体时用的是字符串正则替换,前提是验证码字段参数名没有歧义。如果目标站是JSON格式的登录接口,需要把
body.replace换成JSON解析再改参数。
3.3 爆破任务配置与联动
代码就位后,剩下的是在yakit里配置爆破任务。推荐直接用yakit的Web Fuzzer:
- 先手动登录一次目标系统,抓到登录请求包。
- 把请求发送到Web Fuzzer,标记要爆破的用户名字段、密码字段,验证码字段维持原样。
- 开启MITM代理,并确保热加载脚本已启用。
- 启动一个不断刷新验证码的上下文(比如让浏览器保持登录页开启),保证MITM会持续接收到验证码请求。
- 开始爆破。
这里有个关键点:爆破的线程数不要开太大。因为热加载脚本是同步等待OCR服务返回的,线程数一旦超过OCR服务的处理能力,就会出现验证码被新请求覆盖、或识别结果和会话错位的情况。我用的是4到8个线程,稳定性和速度都比较居中。
4. 实战中的踩坑与识别率优化
4.1 识别失败的五个高频原因
第一个高频原因是图片格式不对。ddddocr直接接收图片二进制,但有些目标站的验证码是WebP格式,旧版本ddddocr可能识别不了。解决办法是在Python服务里加一段格式转换,Pillow打开图片后转成PNG或JPEG再识别。
第二个高频原因是图片被拉伸或加了噪声背景。这种验证码肉眼看着都费劲,模型识别率自然低。可以对图片做灰度化、二值化、降噪处理后再送OCR。实际操作中我把预处理做成可配置开关,在确认目标验证码风格后再决定要不要开启。
第三个高频原因是验证码包含大写字母、小写字母、数字混合,而且服务端区分大小写。ddddocr输出的结果不保证大小写正确率,如果登录接口对大小写敏感,爆破时识别错一个字母就全错。这类问题必须在测试环境里先跑几轮,统计识别正确率,低于60%的建议换思路,或者用模糊匹配规则把识别结果转成几个候选值分别尝试。
第四个高频原因是验证码状态与登录Session不一致。很多时候验证码是绑定Cookie的,但爆破工具为了并发效率,没有维持同一个Cookie容器。结果就是请求A获取验证码、识别出结果,请求B却用另一个Session去提交,服务器核对时发现验证码对不上——不是OCR错了,是状态错了。解决办法是用yakit的Cookie池或会话机制,保证“取验证码”和“提交登录”在同一个会话里。
第五个高频原因是验证码有过期时间。有些目标站验证码60秒就作废,识别和提交的时间间隔稍长就失败。这种情况要在热加载脚本里记录验证码的生成时间,超过有效期就丢弃,等下一次验证码加载时再识别,避免把过期结果硬塞进请求。
4.2 服务端触发的频率限制
爆破遇到验证码绕过方案,很多人都容易忽略另一个防线:服务端的登录失败次数限制。即使验证码被完美绕过,同一个IP或账号连续失败几十次,照样触发锁定或封禁。
应对策略有几种:
- 爆破线程不要开太高,用“慢速字典”控制请求频率,模拟真人操作的时间间隔。
- 字典里可以插入大量无效密码,先从弱密码开始尝试,降低触发锁定的概率。这种策略比较适合只验证弱口令是否存在的场景。
- 目标如果有图形验证码以外的风控,比如滑块、短信二次验证,那验证码绕过只是第一步,还需要配合其他手段。
我在测试时有一次没控制频率,连续失败20次后账号直接被锁了15分钟,整个爆破计划全被打乱。后来我把每次爆破请求的间隔设置为2到3秒,配合随机延时,反而更稳。
4.3 请求指纹和浏览器特征
绕过验证码不意味着目标站没有其他检测手段。有些Web应用会在请求头里校验User-Agent、Referer,甚至检查浏览器的完整指纹。爆破工具如果只带了简单UA,容易被WAF识别拦截。
在yakit里做两件事可以缓解:
- 把浏览器访问登录页时的完整请求头复制到爆破模板里,尤其是
Referer、Origin、X-Requested-With这类业务字段。服务器有时候就靠这些字段判断“是不是正经浏览器发起的请求”。 - 给爆破请求加上
Accept-Language、Sec-Fetch-Site、Sec-Fetch-Mode这些现代浏览器标准请求头。虽然不能完全模拟浏览器指纹,但能避免被基础规则直接拦掉。
4.4 踩坑对照表
把上面这些坑整理成一张速查表,方便大家排错:
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 识别结果为空 | 图片格式不受支持 | 打印图片前几个字节,确认格式 | 统一转PNG再识别 |
| 识别率骤降 | 图片背景复杂/干扰线多 | 肉眼对比原始验证码和预处理图 | 开启灰度化、二值化 |
| 爆破全部失败 | 验证码与Session不绑定 | 检查爆破线程数和Cookie池 | 用yakit Cookie池维持会话 |
| 爆破中卡死 | OCR服务被并发打满 | 检查Python服务日志 | 调低线程数,服务改为单线程 |
| 频繁触发封禁 | 请求频率过高 | 查看目标返回信息 | 降低线程数,增加延时 |
| 验证码过期 | 识别耗时过长 | 打印时间戳日志 | 缓存中记录过期时间,及时丢弃 |
5. 爆破之外的思考:边界、授权与触发条件
5.1 授权测试是底线,不是选项
写作本文的所有前提是你拥有目标系统的明确授权,或者这是你自己的靶场、CTF题目、测试环境。在真实的授权渗透测试项目中,把ddddocr和yakit结合做登录爆破,确实能省下大量时间,但这里有一个容易被忽略的合规点:爆破行为本身虽然是测试动作,但“识别验证码并自动提交”的服务端日志会留下明显痕迹,如果授权范围没有覆盖到登录接口的自动化测试,需要先和技术负责人确认,而不是拿到授权就开始猛跑。
5.2 登录爆破的触发条件评估
不是所有登录框都需要爆破,也不是所有验证码都需要自动绕过。动手之前先做三个判断:
- 目标登录接口有没有账号锁定策略。如果有,爆破的价值会大打折扣,因为字典还没跑完,账号就被锁了。
- 目标有没有多因素认证。如果登录后还要求短信验证码或动态口令,单纯爆破登录密码意义不大。
- 验证码能不能通过其他方式处理。比如目标验证码本身生成逻辑有缺陷,固定验证码或验证码不校验,那根本不需要OCR,直接删掉验证码参数或者复用固定值即可。
很多时候,聪明地跳过爆破比盲目地爆破更高效。我见过一个内部系统,登录验证码存在code参数里且服务端根本没验,直接用旧密码重放就能测试出弱口令,连验证码识别都用不上。
5.3 测试完成后的清理动作
自动化爆破跑完,千万别直接关掉工具走人。按照我的习惯,至少要做三件收尾工作:
- 停掉OCR服务和yakit热加载脚本,避免后续流量被意外篡改。
- 清理yakit的MITM缓存和爆破历史记录,防止敏感数据留在本地,尤其是包含明文密码的请求包。
- 如果目标有日志审计机制,确认本次爆破的流量是否能被相关方查阅——授权测试中,测试痕迹最好保留给项目复盘用,而不是随手删光。这个度要跟项目负责人确认好。
6. 一点经验之谈
ddddocr + yakit这套组合拳,我前后用了大半年,最大的体会是“把工具链打通比学会某个函数更重要”。网上关于验证码识别的教程一抓一大把,但真正决定爆破能不能跑起来的,是OCR服务和代理工具之间的连接方式、会话状态的一致性、以及频率控制的节奏。这三个点,每一个都足够让自动化思路翻车。
如果你刚开始尝试,建议不要一上来就想着全家桶自动化,先手动走通一遍完整链路:验证码图片保存 -> Python脚本识别 -> 手动填入登录包 -> 登录成功。链路通了,再逐步把每个环节交给脚本接管,出问题时也知道该去哪一层排查。
最后再分享一个实战中的小技巧:不要把验证码识别结果当成百分百准确的“标准答案”,而是当成一个普通请求参数来看待。爆破时验证码填一次失败了,没关系,重新刷新、重新识别、再提交。只要OCR准确率在80%以上,配合自动重试,整体成功率可以做到95%以上。这个思路,比追求单次识别100%准确要实际得多。
