验证码逆向工程实战:从旋转与点选验证码到自动化识别方案
1. 项目概述:从“识别”到“逆向”的攻防博弈
最近在分析一些涉及数据采集的自动化项目时,不可避免地要面对一个老对手:验证码。特别是像“某税局”这类涉及敏感业务和数据的平台,其验证码机制往往集成了当前主流的、对抗性较强的技术,比如旋转验证码和文字点选验证码。这不仅仅是简单的图片识别问题,而是一场涉及前端逆向、图像处理和逻辑模拟的综合性攻防。所谓“还原”,指的是将前端经过混淆、加密或动态生成的验证码逻辑,通过逆向工程的手段,理清其完整的生成、校验流程,并最终用代码复现这一过程,实现自动化识别。这背后牵扯到对JavaScript的深度调试、对网络请求的抓包分析,以及对图像算法的灵活应用。如果你正在尝试构建一个稳定、高效的自动化工具,却又被这类验证码卡住了脖子,那么深入理解其逆向与识别原理,就是你必须跨过的一道坎。
2. 验证码机制深度解析:旋转与点选的对抗逻辑
在开始动手之前,我们必须先理解对手。验证码设计的核心目标是区分人类和机器,因此其机制往往围绕着“增加机器识别难度”而展开。我们遇到的这两种验证码,正是这一思想的典型体现。
2.1 旋转验证码的“动态”陷阱
旋转验证码,通常要求用户将一张被随机旋转了角度的图片(比如一个箭头、一个滑块缺口)旋转回正确的位置。它的对抗性主要体现在两个方面:
前端动态生成与状态绑定:正确的旋转角度值(
targetAngle)通常不会明文出现在前端代码或网络响应里。它可能通过以下方式隐藏:- 前端计算:服务器下发一个种子(
seed)或偏移量(offset),前端JavaScript根据当前时间戳、用户会话ID等因子,通过一个特定的算法(可能是AES、DES或自定义的混淆算法)实时计算出目标角度。这意味着每次刷新,计算逻辑虽然相同,但输入因子变化,导致结果不同。 - 加密传输:服务器下发的角度值本身就是加密的,需要前端用特定的密钥或算法解密后使用。这个解密函数被高度混淆,增加了逆向难度。
- 状态校验:用户旋转操作产生的最终角度值,在提交时并不会直接发送这个角度数字,而是发送一个由该角度、会话令牌、时间戳等共同生成的“签名”(
sign或token)。服务器端通过验证这个签名的有效性来判断旋转是否正确。这防止了直接模拟提交一个固定角度值。
- 前端计算:服务器下发一个种子(
图像干扰与反识别:图片本身可能带有复杂的背景噪声、色彩扭曲、随机噪点,或者目标物体边缘模糊,旨在干扰传统图像模板匹配或特征点检测算法的准确性。
2.2 文字点选验证码的“语义”挑战
文字点选验证码则更进一步,它要求用户按照提示(如“请依次点击:天、地、人”),在一张包含多个文字的图片中,按顺序点击正确的文字位置。其核心难点在于:
- 文字位置动态化:文字在图片上的坐标 (
x, y) 不是固定的。每次请求,服务器生成图片时,文字的排列顺序、位置都是随机的。这些坐标信息同样不会直接给前端,而是通过加密或前端计算的方式隐藏。 - 坐标加密与提交:用户点击后,前端会捕获点击位置的坐标。但提交给服务器的,往往不是原始的
(x, y)坐标对,而是经过加密、编码或与点击顺序索引绑定后生成的一串密文。服务器端通过解密和校验这串密文,来判断用户是否点击了正确的位置和顺序。 - 语义理解与OCR干扰:图片中的文字可能是手写体、艺术字,或者带有粘连、扭曲、背景干扰,对通用OCR引擎的识别准确率构成挑战。此外,提示语本身也可能有变化,如近义词、成语填空等,增加了语义理解的难度。
注意:逆向工程的目标,就是穿透这些“动态”、“加密”和“混淆”的迷雾,找到其背后确定性的生成与校验规则,并用代码复现。这完全是一个技术研究过程,旨在理解系统工作原理,任何实际应用都必须严格遵循相关平台的服务条款与法律法规,绝对禁止用于恶意攻击、爬取敏感数据或干扰系统正常运行。
3. 逆向工程核心路径:从抓包到逻辑还原
面对一个全新的验证码,我的逆向分析通常遵循一套标准化的“侦查”流程。这套流程的目标是绘制出验证码从加载到验证完成的完整数据流和逻辑图。
3.1 网络请求抓包:定位关键端点
一切始于抓包。我会使用 Fiddler、Charles 或浏览器开发者工具的 Network 面板。
- 首次加载:打开验证码页面。通常会发现一个初始化请求,可能返回一个包含
sessionId、token、challenge或一张背景图bgImage的接口。这个接口的响应体是重点分析对象,里面可能藏着后续计算所需的“种子”。 - 触发验证码:点击刷新或触发验证码显示。观察是否有新的请求获取旋转的图片 (
sliceImage) 或包含文字的图片 (wordImage)。同时,注意请求参数是否携带了上一步获取的sessionId或token。 - 提交验证:手动完成一次正确的验证(旋转到大致位置或点击正确文字)。在提交瞬间,捕获发送到服务器的请求。这个请求的URL、参数(特别是那些长串的、像乱码的参数)、请求头(如自定义的
X-Sign)是核心中的核心。通常,提交的参数名可能是data、signature、validate等。
实操心得:在抓包时,务必开启Preserve log并禁用缓存。对于提交请求,要仔细对比多次正确操作和错误操作的请求差异,这有助于快速定位出那个真正用于校验的核心参数。
3.2 JavaScript 逆向:解构前端逻辑
抓包找到了数据出入口,接下来就要深入前端逻辑的腹地。这是最考验耐心和技巧的环节。
- 定位关键文件:在 Network 面板的
JS文件列表中,搜索与验证码相关的关键词,如captcha、verify、rotate、click、validate。文件名称可能也被混淆,如index.abc123.js。 - 格式化与搜索:找到疑似文件后,点击进入
Sources面板,如果代码被压缩成一行,点击左下角的{}(Pretty-print) 进行格式化。然后,使用以下关键词进行全局搜索 (Ctrl+Shift+F):- 提交请求的 URL 片段。
- 提交参数名,如
signature。 - 可能的关键函数名,如
getEncryptData、calculateSign、generateToken。 - 网络请求库的方法,如
axios.post、fetch、XMLHttpRequest.send。
- 下断点动态调试:
- 在疑似生成提交参数的函数入口处下断点。
- 重新触发验证码并执行正确操作,代码会在断点处暂停。
- 利用
Call Stack查看调用栈,理解函数调用关系。 - 在
Scope和Console中观察局部变量、函数参数的值。特别是观察目标角度值或点击坐标是如何被传入,并经过哪些函数处理,最终变成了提交的那个加密字符串。
- 处理代码混淆:如果代码被严重混淆(变量名变成
a, b, c,逻辑被分割成无数小函数),就需要一些策略:- Hook 关键函数:使用浏览器控制台注入代码,劫持或监听特定函数。例如,可以 Hook
JSON.stringify或Array.prototype.push来观察哪些数据被序列化或收集。
// 示例:Hook console.log 来输出特定函数的输入输出 var oldLog = console.log; console.log = function(...args) { if (args[0] && args[0].includes('calculate')) { // 假设函数名含calculate oldLog('【HOOK】函数被调用,参数:', args); // 调用原函数并打印结果 var result = originalFunction.apply(this, args.slice(1)); oldLog('【HOOK】函数结果:', result); return result; } oldLog.apply(console, args); };- AST 还原:对于极度复杂的混淆,可以借助本地 Node.js 环境,使用
Babel等工具进行抽象语法树解析和反混淆,但这需要较高的 JavaScript 语言知识。
- Hook 关键函数:使用浏览器控制台注入代码,劫持或监听特定函数。例如,可以 Hook
避坑指南:很多验证码逻辑会检测调试环境。如果下断点后页面自动刷新或验证码失效,可以尝试使用setTimeout包裹断点代码,或使用debugger;语句配合条件断点,绕过简单的反调试。
3.3 图像识别方案选型与适配
逆向搞定了参数生成逻辑,我们还需要让程序能“看懂”图片,得出那个关键的“目标值”(旋转角度或文字坐标)。
旋转验证码角度识别:
- 传统图像处理:如果目标物体轮廓清晰,可以使用 OpenCV。步骤通常是:灰度化 -> 二值化 -> 边缘检测(Canny)-> 轮廓查找 -> 提取最大轮廓 -> 拟合最小外接矩形 -> 计算矩形角度。这个方法速度快,但对图片质量要求高,抗干扰能力弱。
- 深度学习:更鲁棒的方法是使用深度学习模型。可以采集一批验证码图片,手动标注其正确角度,训练一个回归模型(如基于 MobileNetV2 的模型)直接预测角度。对于缺口旋转型,可以训练一个分类模型,判断当前旋转状态是否为“对齐”。深度学习的优势是抗干扰强,但需要数据准备和训练成本。
文字点选验证码识别:
- OCR + 位置映射:这是最直接的思路。使用 PaddleOCR、Tesseract 或 Ddddocr 等OCR引擎识别图片中的所有文字及其包围框坐标。然后,根据提示语(也需要从页面HTML或接口响应中提取),在识别结果中匹配对应的文字,并取其包围框的中心点作为点击坐标。
- 面临的挑战与解决:
- 文字干扰:如果通用OCR识别率低,可以考虑针对该验证码字体训练专用的OCR模型。
- 坐标修正:OCR返回的坐标是基于识别图片的,而前端点击事件的坐标体系可能与图片的
CSS position、transform有关,需要进行坐标转换。这需要分析前端图片的DOM样式。 - 顺序处理:按提示语顺序,组织好要提交的坐标数组。
工具选型参考表:
| 任务 | 推荐工具/库 | 说明 |
|---|---|---|
| 网络抓包 | Fiddler/Charles, 浏览器 DevTools | 基础必备 |
| JS 调试 | Chrome/Firefox DevTools | 核心工具 |
| 本地JS执行 | Node.js | 用于还原算法后本地测试 |
| 图像处理 | OpenCV-Python (cv2) | 传统方法,轻量快速 |
| OCR 识别 | PaddleOCR, Ddddocr | 中文识别效果好,易集成 |
| 深度学习框架 | PyTorch, TensorFlow | 用于训练定制化识别模型 |
| 自动化测试 | Playwright, Selenium | 用于集成整个识别流程,模拟点击提交 |
4. 实战演练:拆解一个模拟的旋转验证码
为了把上述理论说清楚,我们构造一个简化的模拟案例,演示从分析到还原的全过程。假设我们有一个旋转箭头验证码。
4.1 第一步:抓包分析数据流
- 访问页面:抓包发现
GET /api/captcha/init请求,返回如下JSON:{ "code": 200, "data": { "sessionId": "sess_abc123xyz", "challenge": "7f3a1c8b", "imageUrl": "/api/captcha/image?c=7f3a1c8b" } } - 获取图片:浏览器根据
imageUrl自动请求图片。图片是一个随机旋转了角度的箭头。 - 提交验证:手动旋转箭头至大致垂直向上,点击提交。抓到一个
POST /api/captcha/verify请求,载荷为:
这里,sessionId=sess_abc123xyz&challenge=7f3a1c8b&rotation=315&sign=4f8e2a9d0b1c7...(很长一串)rotation=315是我旋转后前端计算的角度(0-359度),但关键的校验参数是那个sign。
4.2 第二步:逆向签名生成算法
在格式化后的JS文件中搜索sign或verify,定位到一段疑似代码:
function generateSign(sessionId, challenge, rotation) { var key = CryptoJS.MD5(sessionId + 'SALT_STRING').toString(); var dataToSign = challenge + '|' + rotation + '|' + Date.now(); var sign = CryptoJS.HmacSHA256(dataToSign, key).toString(CryptoJS.enc.Base64); return sign.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }逻辑还原:
- 用
sessionId拼接一个盐值 (SALT_STRING),计算其MD5值作为HMAC的密钥 (key)。 - 将
challenge、rotation和当前时间戳用|连接,组成待签名字符串 (dataToSign)。 - 使用
HmacSHA256算法,用key对dataToSign进行签名。 - 将签名结果进行Base64编码,并替换掉URL不安全的字符 (
+/=替换为-_),生成最终的sign。
注意:这里的
SALT_STRING是逆向分析时从代码常量中发现的,Date.now()是时间戳。在实际逆向中,盐值可能隐藏更深,时间戳也可能被取整或与服务器时间同步。
4.3 第三步:实现角度识别与签名生成
现在,我们可以用Python复现这个流程。
import hashlib import hmac import base64 import time import cv2 import numpy as np from PIL import Image import requests def recognize_rotation_angle(image_path): """识别图片中箭头的旋转角度(0-359度)""" # 这里使用简化的OpenCV方法示例,实际场景可能需要更复杂的处理或深度学习模型 img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 假设进行阈值化和轮廓查找,找到箭头主体... # ... (此处省略具体的图像处理代码) ... # 假设最终计算出的角度为 angle angle = 315 # 示例值 return angle def generate_sign(session_id, challenge, rotation): """复现JS端的签名生成算法""" # 1. 生成 key salt = 'SALT_STRING' key_material = session_id + salt key = hashlib.md5(key_material.encode()).hexdigest() # 注意:JS的MD5结果是Hex字符串,这里保持一致 # 在Python的hmac中,key需要是bytes key_bytes = bytes.fromhex(key) # 2. 构造待签名字符串 timestamp = int(time.time() * 1000) # 模拟JS的Date.now(),毫秒时间戳 data_to_sign = f"{challenge}|{rotation}|{timestamp}" # 3. HmacSHA256 签名 sign_bytes = hmac.new(key_bytes, data_to_sign.encode(), hashlib.sha256).digest() # 4. Base64编码并替换字符 sign_b64 = base64.urlsafe_b64encode(sign_bytes).decode() # 替换回JS中做的特殊替换(虽然urlsafe_b64encode已经用-_,但JS可能做了额外处理,这里按JS逻辑还原) final_sign = sign_b64.replace('+', '-').replace('/', '_').rstrip('=') return final_sign, timestamp # 模拟流程 session_id = "sess_abc123xyz" challenge = "7f3a1c8b" # 假设通过图像识别得到角度 recognized_angle = recognize_rotation_angle("captcha_image.png") # 生成签名 signature, ts = generate_sign(session_id, challenge, recognized_angle) # 构造提交数据 payload = { "sessionId": session_id, "challenge": challenge, "rotation": recognized_angle, "timestamp": ts, # 注意:原JS代码里时间戳是混在dataToSign里的,并未单独提交。这里需确认前端实际提交的参数。 "sign": signature } # 根据实际抓包情况,可能只需要提交 sessionId, challenge, sign # 因为 rotation 可能被编码在 sign 里了,服务器通过解密 sign 来校验 rotation。 # 这是一个关键点,需要根据逆向结果确认。 print("构造的提交参数:", payload)关键点解析:在这个模拟案例中,我们发现rotation参数是明文提交的,但校验核心在于sign。服务器收到后,会用同样的算法(使用存储的sessionId和盐值)重新计算一次sign,并与客户端提交的比对。同时,服务器可能还会检查sign中的时间戳是否在合理窗口期内,以防止重放攻击。
5. 文字点选验证码的逆向与识别实现
文字点选验证码的逆向思路类似,但识别环节更侧重于OCR和坐标处理。
5.1 逆向坐标提交逻辑
假设抓包发现,提交验证的请求参数是一个名为point的字符串,格式如"x1,y1|x2,y2|x3,y3",但每个坐标点都被处理过。通过JS逆向,发现其生成逻辑如下:
function encryptPoint(x, y, index) { var key = window.__secret_key__; // 一个动态生成的密钥 var plain = index + ',' + x + ',' + y; // 格式:序号,x坐标,y坐标 var encrypted = CryptoJS.AES.encrypt(plain, key, { mode: CryptoJS.mode.ECB }).toString(); return encrypted; } function submitPoints(pointArray) { // pointArray 如 [[100,200], [150,300], [80,120]] var encryptedPoints = []; for(var i=0; i<pointArray.length; i++) { var enc = encryptPoint(pointArray[i][0], pointArray[i][1], i); encryptedPoints.push(enc); } var finalData = encryptedPoints.join('|'); // 发送 finalData 作为 `point` 参数 }逻辑还原:每个点击坐标连同其点击顺序索引,被单独用AES-ECB模式加密,然后所有密文用|连接。密钥__secret_key__可能在页面加载时由另一个接口返回,或隐藏在某个JS变量中。
5.2 OCR识别与坐标提取实现
Python端需要做以下工作:
- 获取并识别图片:
import paddleocr from PIL import Image import io # 初始化PaddleOCR,启用方向分类(对于可能旋转的文字) ocr = paddleocr.PaddleOCR(use_angle_cls=True, lang='ch') # 假设从网络获取图片字节流 image_bytes = requests.get(image_url).content image = Image.open(io.BytesIO(image_bytes)) image.save('temp_captcha.png') # 进行OCR识别 result = ocr.ocr('temp_captcha.png', cls=True) # result结构:[[[[x1,y1],[x2,y2],[x3,y3],[x4,y4]], (文字, 置信度)], ...] - 解析提示语与坐标匹配:
# 假设从页面解析出的提示文字列表 prompt_words = ['天', '地', '人'] click_positions = [] for word in prompt_words: for line in result: text, confidence = line[1] if text == word and confidence > 0.7: # 设置置信度阈值 # 计算文字包围框的中心点坐标 box = line[0] x_center = int((box[0][0] + box[2][0]) / 2) y_center = int((box[0][1] + box[2][1]) / 2) click_positions.append([x_center, y_center]) break # 找到一个即跳出,假设文字不重复 - 复现加密逻辑生成提交参数:
from Crypto.Cipher import AES import base64 def encrypt_point_aes(x, y, index, key): """模拟前端的AES-ECB加密""" plaintext = f"{index},{x},{y}" # 确保明文是16字节的倍数(ECB模式,PKCS7填充) cipher = AES.new(key, AES.MODE_ECB) # 前端CryptoJS默认可能是PKCS7填充,Python需要手动处理或使用库 from pkcs7 import PKCS7Encoder encoder = PKCS7Encoder() padded_text = encoder.encode(plaintext) ciphertext = cipher.encrypt(padded_text.encode()) # CryptoJS默认输出是Base64格式的OpenSSL字符串 encrypted_b64 = base64.b64encode(ciphertext).decode() return encrypted_b64 # 假设通过逆向获取了密钥 key (bytes类型,长度16/24/32) key = b'16bytekey12345678' encrypted_points = [] for idx, pos in enumerate(click_positions): enc = encrypt_point_aes(pos[0], pos[1], idx, key) encrypted_points.append(enc) final_point_param = '|'.join(encrypted_points) print("最终提交的 point 参数:", final_point_param)
6. 常见问题排查与稳定性优化
在实际操作中,不可能一帆风顺。以下是我踩过的一些坑和对应的解决思路。
6.1 逆向与识别环节的典型问题
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 抓不到提交验证的请求 | 1. 请求被重定向或取消。 2. 使用了 fetchAPI且模式为no-cors。3. 请求是WebSocket或SSE。 | 1. 勾选Preserve log。2. 在 Fetch/XHR和All类型中仔细查找。3. 检查 WebSocket或EventSource面板。 |
| JS代码极度混淆,无法阅读 | 使用了obfuscator等高级混淆工具。 | 1. 尝试使用浏览器插件(如Deobfuscator)或在线工具进行初步反混淆。2. 重点动态调试,关注函数调用栈和参数流转,而非静态阅读。 3. Hook 浏览器原生API(如 atob,JSON.parse,Function.prototype.call)来定位关键数据。 |
| 生成的签名/参数总是被服务器拒绝 | 1. 算法还原有误(如盐值、密钥错误)。 2. 参数顺序或格式不对。 3. 缺少了某个隐藏参数(如 csrfToken)。4. 时间戳不同步或窗口期太短。 | 1.对比验证:用Python生成签名后,在浏览器执行相同操作的瞬间,将中间变量(如key,dataToSign)打印出来,与Python端逐字符对比。2.参数排查:仔细对比成功请求和你的模拟请求的所有Headers、Cookies、Query Parameters、Form Data。 3.时间戳:检查服务器时间与本地时间差,尝试使用服务器时间(可从某个接口响应头获取)。 |
| OCR识别文字位置不准或漏识别 | 1. 图片预处理不足(噪声、扭曲)。 2. 文字字体特殊或背景复杂。 3. OCR引擎对于该场景效果不佳。 | 1.图像预处理:尝试灰度化、二值化、降噪、锐化、透视校正等OpenCV操作。 2.更换OCR引擎:对比 PaddleOCR、Ddddocr、CnOCR 等在特定图片上的效果。 3.训练微调:如果验证码样式固定,收集数据训练一个专用的检测或识别模型是终极方案。 |
| 程序运行时验证码突然失效,返回“请求异常” | 触发了风控策略: 1. 请求频率过高。 2. 行为模式异常(如鼠标移动轨迹太规则)。 3. IP被标记。 | 1.降低频率:在关键步骤间增加随机延迟(time.sleep(random.uniform(1, 3)))。2.模拟人类行为:使用 Playwright/Selenium 时,模拟更自然的鼠标移动轨迹(如贝塞尔曲线),并加入随机停顿。 3.代理IP池:使用高质量的代理IP服务,并合理轮换。 |
6.2 提升稳定性的工程化建议
- 模块化与可配置:将逆向得到的加密算法、识别逻辑封装成独立的类或函数。将密钥、盐值、接口URL等配置项外置,便于维护和修改。
- 健全的日志系统:记录每个关键步骤的输入输出,特别是网络请求和响应、识别结果、生成的参数。当校验失败时,日志是排查问题的第一手资料。
- 熔断与重试机制:识别到连续多次失败(如IP被封、验证码失效)时,自动触发熔断,暂停任务并报警。对于网络波动等临时错误,实现带指数退避的重试机制。
- 定期更新与验证:验证码系统可能会升级。建立一个定期(如每天)运行一次完整验证流程的监控任务,一旦失败,立即触发告警,提示需要重新进行逆向分析。
- 尊重
robots.txt与服务条款:这是最重要的原则。在实施任何自动化操作前,务必检查目标网站的robots.txt文件和服务条款,明确是否允许自动化访问。对于明确禁止或涉及敏感数据的网站(如税局),应仅限于技术研究学习,切勿用于实际生产性爬取,以免引发法律风险。
逆向和识别验证码是一个不断与防御系统博弈的过程。它没有一成不变的银弹,核心在于耐心地分析、严谨地复现和持续地适配。掌握这套方法论,不仅能解决验证码问题,更能深刻理解现代Web应用的安全交互逻辑,对于从事安全研究、自动化测试或高级数据工程都大有裨益。记住,技术是把双刃剑,始终用在正当的、被允许的范围内,才是长久之道。