逆向工程实战:破解AES加密滑动验证码的前端攻防
1. 项目概述:当滑动验证码遇上AES加密
在当前的网络安全攻防体系中,验证码是区分人机行为的第一道重要防线,而滑动验证码因其良好的用户体验和一定的安全性,被广泛应用于各类网站的登录、注册和防刷场景。然而,当滑动验证码的验证逻辑被前端JavaScript代码承载,并且关键参数(如滑动轨迹、时间戳、位置信息)被使用AES这类对称加密算法加密后提交时,整个验证过程就从简单的“图片比对”升级为一场前端加密逻辑的攻防战。这恰恰是“逆向工程”大显身手的领域。我们这次要实战解析的,就是这样一个典型的、结合了密码学的前端安全案例:破解一个采用AES加密滑动验证数据的验证码系统。
这不仅仅是“找到加密函数”那么简单。一个完整的实战过程,涉及到对前端代码的静态分析与动态调试,定位关键加密函数,理解其调用链和参数构造过程,最终在脱离浏览器环境(例如用Python)下复现整个加密流程,从而实现对验证码的自动化通过。对于安全研究人员、渗透测试工程师或是自动化开发人员来说,掌握这套方法,意味着能够深入理解前端安全机制的实现与局限,评估其真实防护强度。同时,这个过程本身也是对JavaScript逆向、密码学应用和网络协议分析的绝佳综合训练。
2. 核心思路与技术选型
面对一个加密的滑动验证码,我们的核心目标非常明确:在不依赖浏览器渲染和人工操作的前提下,程序化地生成能被服务端成功验证的请求数据包。而数据包中的加密参数,就是我们要攻克的堡垒。
2.1 逆向工程的基本路径
整个逆向工程的路径可以清晰地划分为四个阶段,我称之为“观察-定位-剖析-复现”四步法。
第一步:网络行为观察。这是所有逆向的起点。我们需要使用抓包工具(如Burp Suite、Charles、Fiddler或浏览器开发者工具的Network面板),完整捕获一次成功的人工滑动验证过程。关键不在于滑动本身,而在于滑动后浏览器向服务器发送了哪些请求,特别是那个携带了加密参数的POST请求。我们需要记录下这个请求的URL、Headers以及最重要的Form Data或Request Payload。通常,你会看到一个名为token、data、encryptedData或类似字段,其值是一长串看似随机的Base64编码字符串或十六进制字符串,这就是我们的核心目标——被AES加密后的验证数据。
第二步:关键代码定位。知道加密数据在哪,下一步就是找到生成它的代码。由于加密操作必然发生在浏览器端(客户端),因此所有逻辑都封装在发送给浏览器的JavaScript文件中。我们的任务是从可能多达数百个、经过混淆和压缩的JS文件中,找到负责加密的那几行关键代码。这里主要依赖两种技术:搜索关键字和XHR/ Fetch断点。我们可以尝试在JS文件中搜索“AES”、“CryptoJS”、“encrypt”、“滑动”、“slide”等关键词。更有效的方法是,在开发者工具的Sources面板中,对捕获到的那个加密请求的URL添加“XHR/ Fetch Breakpoint”,这样当JavaScript代码发起该请求时,执行流会自动暂停,我们就能直接看到调用堆栈(Call Stack),从而逆向定位到加密函数所在的代码块。
第三步:加密逻辑剖析。找到加密函数后,真正的挑战才开始。现代前端开发普遍使用Webpack等打包工具,并可能进行代码混淆,变量名可能变成a, b, c, _0x1a2b3c等形式。我们需要耐心地分析这段代码:它调用了哪个加密库(通常是CryptoJS,也可能是自实现的AES)?加密模式是什么(如CBC, ECB)?填充方式是什么(如PKCS7)?密钥(Key)和初始化向量(IV)从哪里来?是硬编码在代码里,还是通过某个接口动态获取?加密前的原始数据(明文)是什么结构?通常它是一个JSON字符串,包含了滑动轨迹的坐标数组、滑动耗时、滑块位置等。
第四步:本地化复现。这是将逆向成果转化为实际能力的一步。我们需要使用一种服务端或脚本语言(如Python),完全复现前端的加密流程。这意味着:1. 用同样的方式生成或模拟滑动轨迹数据;2. 以同样的方式获取或构造密钥和IV;3. 使用相同的AES算法、模式和填充,对原始数据进行加密;4. 将加密结果以同样的格式(如Base64)编码,并放入请求体中发送。成功复现后,我们的程序就具备了“伪造”合法滑动验证请求的能力。
2.2 为什么是AES,以及工具选型考量
目标系统使用AES加密并不令人意外。AES(高级加密标准)作为对称加密算法的标杆,其本身的安全性在已知密钥不泄露的前提下是极高的。前端使用AES加密,其安全假设是:加密密钥和逻辑对客户端不可见或难以提取。但前端代码的开放性本质,使得这个假设在逆向工程面前变得非常脆弱。这与“前端RSA+AES加密安全吗?”这个热词中的讨论息息相关——单纯依赖前端加密无法保证绝对安全,它更多是增加攻击成本和难度,防止明文传输被直接窥探或篡改。
在工具选型上,我倾向于以下组合:
- 抓包与调试:Burp Suite + 浏览器开发者工具。Burp Suite作为代理,可以拦截和修改所有HTTP/HTTPS流量,其Repeater模块对重放测试至关重要。浏览器开发者工具(Chrome DevTools)则是动态调试JS的不二之选,它的断点、单步执行、作用域查看功能无可替代。
- 逆向分析辅助:油猴脚本与Hook技术。对于简单的、密钥硬编码的场景,直接阅读代码即可。对于复杂的、密钥动态生成的场景,可以编写油猴脚本或使用
Fiddler的AutoResponder功能,在关键函数(如CryptoJS.AES.encrypt)执行前将其重写(Hook),打印出输入参数和密钥,这是一种“动态插桩”的高效手段。 - 复现语言:Python。Python拥有极其丰富的密码学库(如
pycryptodome、cryptography)和网络请求库(如requests),语法简洁,非常适合快速实现加密逻辑的复现和自动化测试。Node.js也是一个不错的选择,因其与浏览器JS环境更接近,但在集成到自动化流程时,Python的生态通常更友好。
注意:所有逆向工程实践必须严格在合法授权范围内进行,仅用于安全研究、学习或个人所属系统的安全加固测试。未经授权对他人的系统进行此类操作是违法的。
3. 实战拆解:一步步定位并分析加密核心
理论说得再多,不如一次真枪实弹的演练。我们假设目标网站有一个常见的滑动验证码,滑动成功后,会向/api/verify_slide发送一个POST请求,其中包含一个加密字段enc_data。
3.1 网络抓包与请求分析
首先,打开浏览器开发者工具(F12),切换到Network(网络)面板,并勾选“Preserve log”(保留日志)。然后,在目标页面完成一次滑动验证操作。
操作完成后,在Network面板中,通过过滤/verify_slide或观察最新的POST请求,我们能迅速找到目标。点击该请求,查看其“Headers”和“Payload”。
一个典型的请求负载可能如下:
POST /api/verify_slide HTTP/1.1 ... Content-Type: application/x-www-form-urlencoded; charset=UTF-8 session_id=xxxx&enc_data=U2FsdGVkX1%2B8vLxq5T2K7b1...(很长一串Base64编码字符串)这里,enc_data就是我们的重点。它看起来是Base64编码,但直接解码可能得到乱码,因为它是AES加密后的二进制数据再进行Base64编码的结果。
关键观察点:
- 请求头:注意
Content-Type和可能的自定义头部,如X-Requested-With。 - 其他参数:除了
enc_data,通常还会有session_id、timestamp、captcha_id等参数。这些参数很可能在构造加密明文时被用到,或者用于服务端会话校验。 - 响应:查看服务器返回的成功或失败信息,这有助于后续验证我们复现的加密是否正确。
3.2 逆向追踪加密函数
接下来,我们在该请求上右键,选择“Copy” -> “Copy as cURL (bash)”,将其保存下来以备重放测试。然后,开始逆向追踪。
方法一:搜索关键词。在开发者工具的Sources面板,按Ctrl+Shift+F(Windows)或Cmd+Opt+F(Mac)打开全局搜索。搜索关键词如:“encrypt”、“AES”、“CryptoJS”、“.encrypt”、“slide”。如果代码混淆不严重,可能会直接找到相关函数。
方法二:XHR/ Fetch断点(更高效)。在Sources面板右侧,找到“XHR/ Fetch Breakpoints”区域,点击“+”号,添加一个包含部分请求URL的断点,例如verify_slide。然后,重新滑动一次验证码。当浏览器即将发送这个请求时,代码执行会立即暂停。
此时,查看右侧的“Call Stack”(调用堆栈)。堆栈的最顶部通常是send或fetch这类原生函数,往下找,你会看到属于网站自身脚本文件的函数调用,它们通常以.js结尾,并且函数名可能被混淆。点击这些函数,就能跳转到具体的代码行。
方法三:事件监听器断点。如果滑动验证码的触发是通过鼠标事件,也可以在Sources面板的“Event Listener Breakpoints”中,展开“Mouse”事件,勾选click、mousedown、mouseup等,然后进行滑动操作。这可能会在生成滑动轨迹数据的地方暂停,从而顺着代码找到加密处。
一旦在代码中疑似加密的位置暂停,我们需要仔细分析上下文。一个典型的AES加密调用(以CryptoJS库为例)可能长这样:
// 混淆后的代码可能类似 var _0x123abc = CryptoJS.AES.encrypt(_0x456def, _0x789ghi, { mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7, iv: _0xijk101 }).toString();即使变量名被混淆,CryptoJS.AES.encrypt这个关键调用通常是可识别的。我们需要记录下三个关键信息:明文(_0x456def)、密钥(_0x789ghi)、加密选项(mode, padding, iv)。
3.3 深入剖析加密参数与明文构造
找到加密函数只是开始,理解每个参数的来源才是核心。
1. 密钥(Key)和IV(Initialization Vector)的来源分析:这是安全性的关键。通常有以下几种情况:
- 硬编码在JS中:最简单也最不安全。在加密函数附近搜索字符串或常量,可能会发现一个固定的字符串被用作密钥。有时密钥会被拆散或经过简单运算(如Base64解码、Hex解码)后使用。
- 从初始接口响应中获取:在页面加载时,一个初始的
GET请求(如/api/init_captcha)的响应中,可能就包含了本次会话使用的密钥或密钥的“种子”。需要关联分析多个请求。 - 动态计算生成:密钥可能由
session_id、timestamp等参数通过一个固定的算法(如HMAC-SHA256)计算得出。这需要找到这个生成函数。
2. 明文(Plaintext)的构造分析:加密前的明文通常是一个JSON字符串。我们需要在加密函数之前,找到组装这个JSON对象的地方。它很可能包含以下字段:
{ "track": [[12,45],[15,48],...], // 滑动轨迹的[x,y]坐标数组 "slide_time": 2450, // 滑动总耗时,毫秒 "start_s": 1234567890, // 开始滑动的时间戳 "end_s": 1234567890, // 结束滑动的时间戳 "background_width": 300, // 背景图宽度 "slider_left": 5 // 滑块最终停留的X轴位置(相对于背景图) }我们需要弄清楚这些数据是如何采集的。track数组通常由监听鼠标移动事件mousemove来收集。slide_time和timestamp通过Date.now()计算。slider_left是滑块最终位置与起始位置的差值。
3. 加密选项(Options)确认:AES加密需要指定模式(如ECB, CBC, CFB)和填充方式(如PKCS7)。在CryptoJS中,如果不指定,默认是CBC模式和PKCS7填充。但为了确保复现准确,必须从代码中确认。iv(初始化向量)在CBC等模式下是必需的,需要找到它的值或生成方式。
实操心得:在动态调试时,充分利用开发者工具中“Console”面板的实时执行功能。当代码在断点处暂停时,你可以将当前作用域中的可疑变量(如
_0x456def)用鼠标悬停查看其值,或者直接在Console中输入变量名来打印其完整内容。这是快速理解数据结构的利器。
4. 本地化复现:用Python还原加密流程
经过逆向分析,我们假设已经获得了以下信息:
- 明文构造规则:一个包含轨迹、时间、位置的JSON字符串。
- 密钥:一个固定的字符串
"this_is_a_secret_key"(示例,实际可能是Base64编码的)。 - IV:一个固定的16字节字符串
"1234567890123456"。 - 加密算法:AES-128-CBC,PKCS7填充。
现在,我们用Python来复现这一过程。
4.1 环境准备与依赖安装
首先,确保安装了必要的Python库。我们使用pycryptodome,它是PyCrypto的一个流行分支,功能强大且维护活跃。
pip install pycryptodome requestsrequests库用于后续发送网络请求。
4.2 模拟滑动轨迹生成
为了通过验证,我们需要模拟人类滑动的轨迹。完全匀速的直线滑动很容易被识别为机器行为。一个简单的模拟策略是生成一个带有轻微波动和加速度的轨迹。
import json import time import random def generate_slide_track(distance): """ 生成模拟人类滑动的轨迹数组。 distance: 需要滑动的总距离(像素)。 返回: 一个列表,每个元素是[时间偏移量, x坐标偏移量]。 """ track = [] current = 0 start_time = int(time.time() * 1000) # 模拟初始加速、中间匀速、末端减速 while current < distance: # 剩余距离 remaining = distance - current # 根据剩余距离调整速度:开始快,中间稳,结尾慢 if current < distance * 0.2: speed = random.uniform(3, 6) # 加速段 elif remaining < distance * 0.2: speed = random.uniform(1, 3) # 减速段 else: speed = random.uniform(2, 4) # 匀速段 step = min(speed, remaining) # 这一步移动的距离 current += step elapsed = int(time.time() * 1000) - start_time track.append([elapsed, round(current, 2)]) # [时间戳偏移, x位置] # 添加一个小的随机间隔,更拟人 time.sleep(random.uniform(0.01, 0.05)) # 确保最后一步精确到达终点 if track[-1][1] != distance: track[-1][1] = distance return track # 假设滑动距离是200像素 slide_distance = 200 mock_track = generate_slide_track(slide_distance) print(f"生成的轨迹点数量:{len(mock_track)}") print(f"最后一点位置:{mock_track[-1]}")4.3 AES加密函数复现
根据逆向得到的参数,编写AES加密函数。这里特别注意密钥和IV的处理。前端CryptoJS通常接受字符串作为密钥,并内部处理。在Python中,我们需要确保密钥和IV是字节串(bytes),且长度符合AES-128(16字节)、AES-192(24字节)或AES-256(32字节)的要求。
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 def aes_encrypt(data_string, key_str, iv_str): """ 使用AES-CBC模式加密数据,并返回Base64编码的字符串。 参数: data_string: 要加密的明文字符串。 key_str: 密钥字符串。 iv_str: 初始化向量字符串。 返回: Base64编码的加密字符串。 """ # 将字符串转换为bytes data = data_string.encode('utf-8') key = key_str.encode('utf-8') iv = iv_str.encode('utf-8') # 检查密钥长度,如果不足,CryptoJS可能会用某种方式补齐,这里简单用空格补齐到16/24/32字节 # 更严谨的做法是逆向确认CryptoJS的密钥处理方式(例如使用CryptoJS.enc.Utf8.parse) if len(key) not in [16, 24, 32]: # 这里假设CryptoJS使用UTF-8解析,密钥长度不足时行为可能不一致。 # 一个常见情况:如果key_str是16个字符的字符串,UTF-8编码后可能就是16字节。 # 如果逆向发现是Hex或Base64解码后的字节,这里需要相应调整。 # 本例假设key_str就是16字节的ASCII字符串。 if len(key) < 16: key = key.ljust(16, b'\0') # 用零字节填充 elif len(key) < 24: key = key.ljust(24, b'\0') elif len(key) < 32: key = key.ljust(32, b'\0') else: key = key[:32] # 截断 # 创建AES cipher对象,使用CBC模式和PKCS7填充 cipher = AES.new(key, AES.MODE_CBC, iv) # 对数据进行PKCS7填充并加密 encrypted_bytes = cipher.encrypt(pad(data, AES.block_size)) # 将加密后的字节进行Base64编码,并转换为字符串 encrypted_b64 = base64.b64encode(encrypted_bytes).decode('utf-8') return encrypted_b64 # 测试加密 plaintext_json = json.dumps({ "track": mock_track, "slide_time": mock_track[-1][0], # 总耗时 "start_s": int(time.time() * 1000) - mock_track[-1][0], "end_s": int(time.time() * 1000), "background_width": 300, "slider_left": slide_distance }, separators=(',', ':')) # 压缩JSON,去除空格,与前端生成格式一致 secret_key = "this_is_a_secret_key" # 从逆向分析中获得 iv = "1234567890123456" # 从逆向分析中获得 encrypted_data = aes_encrypt(plaintext_json, secret_key, iv) print(f"明文:{plaintext_json[:100]}...") print(f"加密后(Base64):{encrypted_data[:100]}...")4.4 组装请求并验证
最后,我们使用requests库模拟浏览器发送请求。
import requests # 假设从初始请求获取的会话ID session_id = "your_session_id_from_previous_request" captcha_id = "your_captcha_id" # 目标URL verify_url = "https://target-website.com/api/verify_slide" # 构造请求头,模仿浏览器 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', # 常见于Ajax请求 # 可能还需要其他头部,如Referer, Origin等,需根据抓包结果补充 } # 构造请求体 payload = { 'session_id': session_id, 'captcha_id': captcha_id, 'enc_data': encrypted_data, # 这是我们复现加密得到的数据 # 可能还有其他固定参数 } # 发送POST请求 response = requests.post(verify_url, data=payload, headers=headers) print(f"状态码:{response.status_code}") print(f"响应内容:{response.text}") # 解析响应,通常是一个JSON try: result = response.json() if result.get('code') == 200 or result.get('success'): print("*** 验证成功! ***") else: print(f"验证失败:{result.get('message')}") except: print(f"响应解析失败:{response.text}")5. 常见问题与深度排查技巧
在实际逆向和复现过程中,几乎不可能一帆风顺。下面是我总结的一些典型问题及其排查思路。
5.1 加密结果不一致问题
这是最常遇到的问题。Python加密的结果和前端JavaScript加密的结果对不上。
排查步骤:
确认明文完全一致:这是最容易出错的地方。确保Python中组装的JSON字符串与前端JavaScript中的完全一致,包括字段顺序、空格、缩进。建议在前端加密代码处设置断点,将待加密的明文变量复制出来,与Python生成的字符串进行逐字符比对。可以使用在线Diff工具。特别注意:JavaScript的
JSON.stringify默认会产生空格,而很多前端代码为了节省体积会使用JSON.stringify(obj)或JSON.stringify(obj, null, 0)。在Python中,使用json.dumps(obj, separators=(‘,’, ‘:’))可以生成最紧凑的无空格JSON,这通常是前端混淆后的代码所采用的格式。确认密钥和IV的字节表示:这是核心难点。CryptoJS的
CryptoJS.enc.Utf8.parse(“key”)和Python的”key”.encode(‘utf-8’)在大多数情况下是等价的。但如果密钥本身是Base64或Hex编码的字符串呢?你需要在前端代码中查看密钥在被parse之前的原始形式。如果它是”ABCDEFG123456==”这样的Base64,那么在前端它会被先Base64解码成字节,再用作密钥。在Python中,你需要做同样的事:key = base64.b64decode(key_str_b64)。对于IV也是如此。确认加密参数:模式(CBC/ECB)、填充(PKCS7/ZeroPadding)、密钥长度(128/192/256)必须完全一致。AES-128对应16字节密钥,AES-256对应32字节密钥。如果密钥字符串长度不符合,CryptoJS可能会通过某种哈希函数(如MD5)派生密钥。你需要查看加密函数附近是否有类似
CryptoJS.MD5(key).toString()的代码。输出格式:CryptoJS的
.toString()默认输出一个特殊的OpenSSL格式的字符串(以”U2FsdGVkX1”开头)。如果你需要原始的Base64,应该使用CryptoJS.enc.Base64.stringify(ciphertext)。在Python中,我们直接输出Base64。确保前后端比较的是同一种格式。可以在前端将加密结果分别用toString()和CryptoJS.enc.Base64.stringify()输出,看哪个与Python的Base64结果匹配。
5.2 密钥动态获取问题
如果密钥不是硬编码的,而是通过接口获取或动态计算,你需要找到其来源。
- 从接口获取:在Network面板中,仔细查看滑动验证码加载时(或之前)的所有请求。寻找响应体中包含
key、secret、token等字段的请求。可能需要将session_id与密钥进行关联。 - 动态计算:在JS代码中搜索密钥变量名,查看它的赋值语句。它可能由
window.xxx、localStorage、或者一个函数调用结果赋值。使用断点调试,追踪这个变量的值是如何计算出来的。常见算法包括:对固定字符串进行HMAC-SHA256哈希、对session_id和某个salt进行拼接后哈希等。
5.3 反调试与代码混淆对抗
一些防护较强的网站会采用反调试手段,例如在开发者工具打开时无限debugger、代码混淆得面目全非等。
- 无限Debugger:在Sources面板找到触发debugger的代码行,右键选择“Never pause here”,或者通过条件断点将其禁用。
- 高强度混淆:对于变量名混淆(如
_0x1a2b),耐心是关键。重点关注字符串常量和函数调用。混淆通常不会改变字符串和API调用(如CryptoJS.AES.encrypt、JSON.stringify、XMLHttpRequest.send)。通过搜索这些字符串和API名,可以定位到关键代码区域。然后通过断点,观察这些函数被调用时传入的参数,来理解周围的混淆逻辑。 - 代码流平坦化与控制流混淆:这是更高级的混淆,将代码逻辑打乱成巨大的
switch-case结构。对付这种,动态调试(断点、单步执行)比静态分析更有效。耐心地跟着程序流走,记录下关键的分支和变量变化。
5.4 请求校验与风控绕过
即使加密通过了,服务端还可能有多重校验。
- 请求签名:除了
enc_data,请求头或请求体中可能还有一个signature字段,由其他所有参数(包括enc_data)按照特定规则排序后,加上一个密钥进行HMAC-SHA256等哈希生成。你需要逆向出签名算法。 - 时间戳与时效性:
timestamp参数可能被要求与服务器时间差在一定范围内。你的程序需要获取服务器时间或使用同步的时间戳。 - 请求频率与行为指纹:过于频繁的请求、完全一致的滑动轨迹、缺少正常浏览器指纹(如
User-Agent,Accept-Language等)会被风控系统拦截。需要在自动化脚本中模拟更真实的人类行为,并管理好请求间隔。可以考虑使用selenium或playwright等浏览器自动化工具来执行部分操作,以携带更完整的浏览器环境。
深度排查技巧:建立一个“比对验证”流程。在本地用Node.js搭建一个简单的CryptoJS加密环境,将前端抓取到的密钥、IV、明文,分别在Node.js环境和Python环境中进行加密,比对结果。这能帮你快速定位是密钥处理问题、加密库差异问题还是其他问题。因为Node.js的Crypto模块或直接使用CryptoJS库,其行为与浏览器环境几乎一致,可以作为“标准答案”来校准你的Python复现代码。
逆向工程破解AES加密的滑动验证码,是一个系统性的工程,考验的是耐心、细心和对细节的把握。从网络抓包到代码分析,再到算法复现,每一步都可能遇到坑。成功的关键在于严谨的比对和逻辑推理——确保你的复现环境在每一个输入和参数上都与原始前端环境保持绝对一致。这个过程不仅能让你成功绕过某个具体的验证码,更能深刻理解前端安全措施的实现与局限,提升你的整体安全技术视野与分析能力。记住,技术的提升来自于对每一个技术细节的深究和攻克。
