逆向分析电商验证码:从JavaScript混淆到AES加密的攻防实战
1. 项目概述与背景
最近在安全研究和业务风控对抗的圈子里,验证码的攻防一直是个热门话题。尤其是电商平台,作为黑灰产攻击的重灾区,其验证码的复杂度和更新频率往往走在行业前列。这次我花了近两周时间,对一个头部电商平台(以下简称“某多多”)的登录与关键业务环节所使用的验证码进行了逆向分析。这个项目不是为了教你如何绕过验证码去做违规操作,而是从一个安全研究者和开发者的角度,去拆解其背后的技术实现、防护逻辑以及设计思路。理解这些,无论是为了提升自家产品的风控能力,还是进行合规的自动化测试,都极具价值。
某多多的验证码体系给我的第一印象是“动静结合”。它不像一些传统平台使用单一的静态图片验证,而是融合了点选、滑块、推理等多种形式,并且会根据用户环境、行为轨迹进行动态风险评估,从而触发不同难度的验证。本次分析聚焦于其最具代表性、也相对复杂的“点选验证码”——要求用户按照提示,依次点击图中出现的特定文字或物品。这种验证码对人来说直观,但对机器而言,要准确识别并模拟人类点击,就需要破解其前端加密、通信协议以及后台验证逻辑等多个环节。
2. 逆向分析的核心目标与思路拆解
逆向分析不是漫无目的地翻代码,而是带着明确目标去拆解一个黑盒系统。对于这个点选验证码,我的核心目标可以分解为以下几个问题:
- 验证码图片如何生成与加载? 图片是动态合成的还是预先生成的?图片中的干扰元素(如扭曲、粘连、背景噪声)是如何添加的?
- 点击坐标的采集与加密流程是怎样的? 用户点击后,前端是如何收集坐标信息?这些原始数据经过了怎样的加密、混淆处理才发送给服务器?
- 服务器端的验证逻辑是什么? 服务器如何判断一次点击是“人类行为”还是“机器模拟”?它校验的究竟是绝对坐标、相对位置,还是某种行为特征?
- 整个验证流程的会话(Session)是如何管理的? 从初始化验证码到最终提交结果,中间经历了哪些关键的网络请求?每个请求携带了哪些关键参数?
基于这些目标,我制定的逆向思路是“由外而内,动静结合”:
- 静态分析: 使用浏览器开发者工具,查看验证码页面的HTML结构、CSS样式以及最重要的——JavaScript源代码。通过代码格式化、搜索关键函数名(如
encrypt、submit、validate、captcha)和网络请求关键字,定位核心逻辑。 - 动态调试: 在验证码触发和提交的关键时刻,使用断点(Breakpoint)、监听(Monitor)和调用栈(Call Stack)跟踪,观察关键变量的值如何变化,理解数据流。
- 网络抓包: 使用抓包工具(如浏览器自带的Network面板或Fiddler/Charles)记录下从页面加载到验证完成的全链路HTTP/HTTPS请求,分析请求参数、响应数据以及它们之间的依赖关系。
3. 前端逆向:JavaScript核心逻辑剖析
前端的逆向是整个分析中最繁琐但也最核心的一步。某多多的前端代码经过了严重的混淆和压缩,变量名都是单字母,逻辑被分割成无数个小函数。但这难不倒有经验的逆向者,关键是要找到入口。
3.1 验证码初始化与图片获取
首先,我观察到当触发验证时,页面会动态加载一个 <div> 容器,内部有一个 <canvas> 画布用于绘制验证码图片。通过搜索 canvas 上下文 getContext('2d') 或图片加载相关的 Image 对象,我定位到了图片加载函数。
关键发现是,验证码图片并非直接从一个图片URL加载,而是通过一个特定的API接口获取了一段数据。这个接口的响应不是常见的PNG或JPEG二进制流,而是一个JSON对象,里面包含了一个经过Base64编码的图片数据字符串,以及一个重要的 token (用于后续提交验证的会话标识)和 question (描述需要点击什么文字,如“请依次点击:苹果 香蕉”)。
注意: 这个
token至关重要,它通常与本次验证会话绑定,有时还会与用户浏览器指纹、时间戳进行关联,一次性有效。这意味着你不能简单地复用上一次的图片和token。
前端拿到这个Base64字符串后,会进行解码并绘制到 canvas 上。同时,代码中有一段混淆严重的逻辑,用于生成图片上的干扰元素,如随机颜色的斑点、扭曲的线条。这些干扰是在前端动态添加的,也就是说,即使你拿到相同的Base64图片数据,两次绘制的干扰图案也可能不同,但这通常不影响核心文字的识别。
3.2 点击事件监听与坐标加密
当用户在 canvas 上点击时,会触发 onclick 或 mousedown 事件。逆向的重点就在这里:事件处理函数做了什么?
我通过事件监听器找到了处理函数,发现它首先获取了点击事件相对于 canvas 左上角的坐标 (x, y) 。但这只是第一步。紧接着,坐标被传入一个加密函数。这个函数是混淆的重灾区,但通过调试,我逐步理清了其步骤:
- 坐标归一化: 将绝对像素坐标转换为相对于
canvas宽高的比例坐标。例如,canvas宽400px,点击横坐标100px,则归一化值为0.25。这样做是为了适配不同分辨率的设备。 - 参数拼接: 将归一化后的x、y坐标,与之前获取的
token、当前时间戳、一个看似随机的nonce(一次性随机数)拼接成一个字符串。 - 复杂变换: 对这个字符串进行一系列自定义的位运算、字符编码转换(如Unicode编码移位)。这部分代码被混淆成多个嵌套的小函数,意图就是增加直接阅读和理解的难度。
- 标准加密: 经过自定义变换后,数据最终会使用 AES 或 RSA 等标准算法进行加密。密钥通常隐藏在代码的某个常量字符串中,或者通过另一个API动态获取。在某多多的案例中,我发现了使用 AES-CBC 模式的迹象,密钥和初始向量(IV)被编码后硬写在代码的某个数组里,运行时再还原。
// 示例:还原出的加密流程核心步骤(伪代码)
function encryptClickPoint(normalizedX, normalizedY, token, timestamp, nonce) {
// 1. 拼接原始数据
let rawData = `${normalizedX},${normalizedY}|${token}|${timestamp}|${nonce}`;
// 2. 自定义混淆(例如:每个字符的Unicode码点+1)
let obfuscated = customObfuscate(rawData);
// 3. AES加密(密钥key和IV需从代码中逆向找出)
let encryptedData = aesCbcEncrypt(obfuscated, key, iv);
// 4. 最终可能再进行一次Base64编码
return btoa(encryptedData);
}
实操心得: 面对这种混淆,不要试图完全读懂每一行。关键在于“动态跟踪”。在加密函数入口和出口打上断点,记录输入和输出的值。然后尝试在代码中搜索输出值的特征(如固定的前缀、长度),或者搜索标准加密库的常见函数名(如
CryptoJS、encrypt)。有时,密钥会以字符串形式存在于代码中,但被拆分成多个部分,用Array.join(”)的方式组合。
3.3 网络请求提交
加密后的点击数据,会连同 token 、 question 以及其他一些环境参数(如浏览器指纹的哈希值、页面URL等)一起,通过一个 POST 请求提交到服务器的验证接口。
通过抓包分析,这个请求的 Headers 里通常还包含一些重要的签名信息,比如对请求体(Body)进行某种哈希运算(如 HMAC-SHA256 )后得到的 sign 字段。这个签名用的密钥又是另一个需要逆向的点,它可能来自当前页面的一个全局变量,或者由之前的某个初始化接口返回。
4. 验证逻辑推测与行为模拟关键点
前端逆向完成后,我们对服务器端(Server-Side)的验证逻辑可以进行合理的推测。服务器在收到提交的数据后,大致会进行以下验证:
- 会话有效性校验: 检查
token是否有效、是否过期、是否已被使用过。 - 数据完整性校验: 使用对应的密钥解密数据,验证自定义混淆的逆过程,还原出原始的归一化坐标、时间戳等。
- 坐标容错校验: 将用户提交的归一化坐标,与服务器端保存的正确文字区域的归一化坐标进行比对。这里一定会有一个容错范围(比如±0.05)。只要落在范围内即算正确。
- 行为指纹校验: 这是高级风控的核心。服务器会分析本次验证的多个维度:
- 时间维度: 从图片加载完成到提交答案的总耗时。人类需要时间观察和思考,太快(如<1秒)或太慢(如>30秒)都可能被判定为异常。
- 轨迹维度: 对于需要点击多个目标的验证码,服务器会校验点击顺序是否符合提示,以及每次点击之间的移动轨迹。机器模拟的点击往往是瞬间的直线移动,而人类会有微小的偏移和速度变化。虽然本次请求只提交了坐标点,但浏览器在点击前可能已经通过其他事件(如
mousemove)上报了轨迹信息。 - 环境一致性校验: 提交的浏览器指纹、IP地址等信息是否与初始化验证码时的一致,是否存在冲突。
基于以上分析,要编写一个能够通过验证的模拟程序,关键在于 精准地复现前端加密流程 和 模拟人类行为特征 。
4.1 复现加密流程
这需要将逆向出来的JavaScript加密逻辑,用其他编程语言(如Python)重新实现一遍。难点在于:
- 还原混淆算法: 需要仔细调试,确保自定义的位运算、字符变换在Python中与JavaScript产生完全相同的结果。JavaScript的位运算符(
&,|,<<,>>>)与Python略有不同,需要特别注意。 - 处理加密库差异: 使用Python的
cryptography或pycryptodome库实现AES加密时,要确保模式(CBC)、填充方式(PKCS7)、密钥和IV与前端完全一致。
# Python示例:模拟加密流程(伪代码)
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import hashlib
def custom_obfuscate_js_compatible(data_str):
# 这里需要精确实现JavaScript中的那个customObfuscate函数
# 例如,可能是每个字符Unicode码点+1
obfuscated_chars = [chr(ord(c) + 1) for c in data_str]
return ''.join(obfuscated_chars)
def encrypt_click_point_py(normalized_x, normalized_y, token, timestamp, nonce, key_hex, iv_hex):
# 1. 拼接
raw_data = f"{normalized_x},{normalized_y}|{token}|{timestamp}|{nonce}"
# 2. 混淆
obfuscated = custom_obfuscate_js_compatible(raw_data)
# 3. AES-CBC加密
key = bytes.fromhex(key_hex)
iv = bytes.fromhex(iv_hex)
cipher = AES.new(key, AES.MODE_CBC, iv)
# 注意编码和填充
encrypted_bytes = cipher.encrypt(pad(obfuscated.encode('utf-8'), AES.block_size))
# 4. Base64
return base64.b64encode(encrypted_bytes).decode('utf-8')
4.2 模拟人类行为
仅仅坐标正确是不够的,还需要让整个交互过程看起来像人。
- 识别目标: 首先需要识别图片中目标文字的位置。这属于OCR范畴。可以使用开源的OCR引擎(如
PaddleOCR、Tesseract),但针对验证码特有的扭曲、粘连字体,可能需要自己训练模型或使用图像预处理技术(二值化、去噪、分割)。 - 生成“人性化”坐标: 不要直接点击识别出的文字框中心。应该在正确的区域内随机选取一个点,同时加入微小的随机偏移。
- 控制时间线: 在触发验证、加载图片、识别、点击、提交的每个步骤之间,加入随机的、合理的延迟。例如,识别后等待0.5到1.5秒再模拟点击。
- 模拟轨迹(如果需要): 如果验证码系统会检测鼠标移动,那么就需要用脚本控制鼠标,以带有加速度和轻微抖动的方式移动到目标点,而不是瞬间闪现。
5. 常见问题与排查技巧实录
在实际逆向和模拟过程中,我遇到了不少坑,这里记录下最典型的几个问题和解决思路。
5.1 问题一:加密结果总是对不上
- 现象: 用Python复现的加密函数,输出结果与浏览器中JavaScript加密的结果不一致。
- 排查:
- 检查输入一致性: 确保输入给Python函数和JavaScript函数的 所有参数 (坐标、token、时间戳、nonce)完全一致,一个字符都不能差。时间戳精度(毫秒级)也要注意。
- 分段验证: 不要一次性比较最终结果。在JavaScript加密函数中打多个断点,记录每一步处理后的中间值(如拼接后的字符串、混淆后的字符串、加密前的字节数组)。然后在Python代码中,在对应步骤后打印出中间值,进行逐段比对。问题往往出在“自定义混淆”这一步。
- 关注编码: JavaScript和Python的字符串编码可能不同。确保在需要字节(Bytes)操作时,使用正确的编码(通常是
UTF-8)进行转换。
5.2 问题二:提交后总是返回“验证失败”或“请求非法”
- 现象: 加密结果对了,网络请求也成功发送了,但服务器返回错误。
- 排查:
- 检查请求头(Headers): 除了
Content-Type,仔细比对浏览器发送的请求头和你用脚本发送的是否一致。特别关注Cookie、User-Agent、Referer以及可能的自定义签名头(如X-Sign)。Cookie中的会话信息(如SESSIONID)通常是必须的。 - 验证签名(Sign): 如果请求中存在
sign参数,需要逆向出它的生成算法。它可能是对整个请求体(或包含特定参数的字符串)进行HMAC签名。密钥可能藏在别的JS文件或初始化接口的响应里。 - 检查token状态: 确认你使用的
token是本次验证会话新生成的,且没有过期或被使用过。不要重复使用同一个token。 - 环境指纹: 某些平台会检测无头浏览器(Headless Browser)或自动化工具的特征。尝试在请求头中完善
User-Agent,或者使用puppeteer、selenium等工具控制真实浏览器环境来发送请求,以携带更完整的浏览器指纹。
- 检查请求头(Headers): 除了
5.3 问题三:OCR识别准确率低
- 现象: 对于扭曲、带背景干扰的验证码文字,通用OCR引擎识别效果很差。
- 解决思路:
- 图像预处理: 这是提升识别率最有效且成本最低的方法。尝试以下步骤:
- 灰度化与二值化: 将彩色图转为灰度图,再通过阈值处理转为黑白图,能有效去除颜色干扰。
- 降噪: 使用中值滤波、高斯滤波去除孤立的噪点。
- 锐化: 使用拉普拉斯算子等增强文字边缘。
- 形态学操作: 使用膨胀、腐蚀来连接断裂的笔画或分离粘连的文字。
- 定制化识别: 如果验证码字符集固定(如总是数字、或特定几十个汉字),可以考虑自己训练一个简单的CNN模型。收集几百张验证码图片手动打标,用TensorFlow或PyTorch训练,识别效果会远好于通用OCR。
- 利用上下文: 点选验证码的
question字段会告诉你需要点击什么字。你可以用这个信息来辅助OCR。例如,识别出多个候选字后,优先选择与question匹配的那个。
- 图像预处理: 这是提升识别率最有效且成本最低的方法。尝试以下步骤:
5.4 关键参数速查表
为了方便后续调试,我将逆向过程中发现的关键参数和其可能的位置整理如下:
| 参数名 | 来源 | 作用 | 注意事项 |
|---|---|---|---|
captcha_token / token |
初始化验证码的API响应 | 本次验证会话的唯一标识,用于关联前后请求。 | 一次性有效,需在提交验证的请求中原样带回。 |
question |
初始化验证码的API响应 | 描述需要点击的目标文字。 | 用于提示用户,也可用于辅助OCR识别。 |
image_data (Base64) |
初始化验证码的API响应 | 验证码图片的Base64编码数据。 | 前端解码后绘制到canvas,可能包含动态干扰。 |
click_data / point |
前端加密生成 | 加密后的用户点击坐标数据。 | 是加密流程的核心输出,必须与前端算法一致。 |
sign / x-sign |
请求头(Header) | 对请求体或特定参数的签名,防止请求被篡改。 | 签名算法和密钥需要逆向,缺失或错误会导致请求被拒。 |
nonce / randstr |
前端随机生成或接口返回 | 随机字符串,用于防止重放攻击。 | 每次请求都应不同,常与时间戳一起参与加密或签名。 |
| AES Key & IV | 前端JavaScript代码中 | 用于加密点击数据的对称密钥和初始向量。 | 通常被混淆和分割存储,需要动态调试找出还原逻辑。 |
逆向分析某多多的验证码,是一次对现代Web前端安全防护措施的深度体验。它不仅仅是一张图片,而是一个融合了密码学、前端混淆、行为分析和风控策略的复杂系统。通过这个项目,我深刻体会到,对抗性的技术总是在螺旋上升。作为防御方,需要不断引入新的干扰因素和验证维度;作为研究方,则需要保持耐心,掌握从混沌的代码中梳理出清晰逻辑的能力。
最后分享一个小心得:在进行此类分析时, 保持一个纯净的浏览器环境 非常重要。每次测试前最好开一个无痕窗口,避免浏览器插件、缓存对验证码逻辑造成未知影响。同时,所有分析行为应严格控制在法律允许和个人学习的范围内,理解原理是为了更好地构建防御,而非突破它。
更多推荐



所有评论(0)