极验四代滑块验证的RSA+AES双加密机制解析
1. 这不是“绕过验证码”,而是理解极验四代滑块验证的完整加密链路
你有没有试过,在调试一个登录接口时,明明账号密码都对,却卡在滑块验证这一步?前端发过来一串长得像乱码的geetest_challenge、geetest_validate和geetest_seccode,后端校验失败,日志里只显示“参数非法”——而你翻遍极验官方文档,看到的全是“请使用SDK”“不建议自行实现”。这不是玄学,也不是黑箱,而是极验四代(Geetest v4)把整个验证流程拆成了三段式加密流水线:前端行为采集 → RSA非对称加密封装 → AES对称加密混淆 → 服务端联合解密校验。我去年帮一家做跨境SaaS的客户做自动化测试平台时,就卡在这儿整整两周。他们需要模拟真实用户完成注册流程,但极验v4的滑块不再像v3那样只靠轨迹拟合就能过,它在客户端埋了至少5层动态校验逻辑,其中最核心的,就是这套从RSA到AES的双加密参数生成机制。关键词:极验四代滑块验证、RSA加密、AES加密、geetest_challenge、逆向分析、前端加密参数、行为指纹、滑块轨迹加密。这篇文章不教你“怎么绕过”,而是带你亲手拆开这个加密黑盒:从浏览器里抓出原始行为数据,还原RSA公钥加载时机,定位AES密钥派生函数,最终用Python复现完整的geetest_challenge生成逻辑。适合正在做自动化测试、爬虫风控对抗、或想深入理解现代前端反爬加密设计的开发者。你不需要是密码学专家,但得会看JavaScript、能跑通Python脚本、愿意在Chrome DevTools里多点几次“Pause on caught exception”。
2. 极验v4滑块验证的本质:不是图像识别,而是行为可信度建模
很多人误以为滑块验证的核心是“把图块拖到正确位置”,这是v1/v2时代的逻辑。到了v4,极验早已放弃纯图像匹配,转而构建一套多维行为可信度模型。它真正校验的,从来不是“你拖得准不准”,而是“你拖得像不像真人”。这个模型的输入,是一组远比坐标更复杂的动态行为指纹,包括但不限于:
- 鼠标移动轨迹的加速度突变点数量:真人拖动时会有3~5次微小停顿和方向修正,机器人直线匀速拖动则加速度曲线平滑无峰;
- 鼠标按下(mousedown)到首次移动(mousemove)的时间差:真实用户平均为180~320ms,低于100ms基本判定为脚本;
- 滑块释放(mouseup)瞬间的坐标抖动幅度:人手肌肉震颤导致释放点在目标位置±3px内随机偏移,机器释放则精准落在整数坐标;
- 页面可见性状态变化次数:用户切换Tab、最小化窗口等操作会被
document.visibilityState捕获,异常频繁切换是典型自动化特征; - WebGL渲染器指纹哈希值:通过
canvas.getContext('webgl').getParameter(gl.VERSION)等API采集显卡驱动、浏览器GPU适配层信息,生成唯一设备标识。
这些原始数据不会明文上传。极验v4的设计哲学是:所有敏感行为数据必须在前端完成加密,服务端只负责解密与规则匹配。这就引出了它的双加密架构。第一层是RSA——用于安全传递AES密钥。极验服务端在初始化时,会下发一个临时RSA公钥(嵌在gt参数里),前端用它加密一个随机生成的AES密钥,连同加密后的业务参数一起上传。第二层是AES——用于混淆实际的行为数据。前端采集完所有行为指纹后,用上一步生成的AES密钥,对JSON化的数据包进行CBC模式加密,并Base64编码。最终形成的geetest_challenge,就是这个AES密文的Base64字符串。geetest_validate则是对geetest_challenge+ 用户滑动结果(x坐标)再做一次HMAC-SHA256签名。整个链条环环相扣:没有RSA解密,拿不到AES密钥;没有AES密钥,解不开行为数据;解不开行为数据,就无法计算服务端期待的geetest_validate。这才是为什么单纯模拟XHR请求永远失败——你传的不是“答案”,而是“被加密的答案”,而加密密钥本身也是被加密的。
提示:极验v4的RSA公钥不是固定不变的。每次调用
initGeetest()时,服务端会生成一对新的2048位RSA密钥,私钥保留在服务端,公钥通过gt参数下发。这意味着你不能硬编码一个公钥常量,必须每次从初始化响应中动态提取。
3. 逆向突破口:从Network面板定位加密入口函数与密钥加载时机
逆向的第一步,永远不是看混淆代码,而是建立清晰的请求-响应时序图。打开Chrome DevTools,切到Network面板,勾选“Preserve log”,然后在目标页面触发滑块验证(比如点击登录按钮)。你会看到至少4个关键请求:
https://api.geetest.com/get.php?...—— 初始化请求,返回gt(RSA公钥标识)、challenge(初始challenge,注意这不是最终的geetest_challenge)、success等字段;https://api.geetest.com/ajax.php?...—— 二次验证请求,携带geetest_challenge、geetest_validate、geetest_seccode;https://static.geetest.com/.../gt.0.5.0.js—— 极验核心JS文件(版本号可能不同);https://static.geetest.com/.../lang/zh-cn.js—— 语言包,有时会包含辅助函数。
真正的突破口在第1步和第3步之间。当get.php返回成功后,前端会立即执行gt.0.5.0.js中的初始化逻辑。此时,你需要做三件事:
第一,锁定RSA公钥加载点。在Sources面板,Ctrl+P搜索gt.0.5.0.js,打开后按Ctrl+Shift+F全局搜索RSAKey或generateKey。你会找到类似这样的代码段:
var publicKey = new RSAKey(); publicKey.setPublic(gt, "10001"); // 注意:这里gt是字符串,不是数字这个gt参数,正是get.php响应体里的gt字段。它其实是一个大整数的十六进制字符串,代表RSA模数N。而"10001"是标准的RSA公钥指数e(65537的十六进制)。这就是RSA公钥的全部信息。你不需要自己实现RSA算法,Python的pycryptodome库可以直接用。
第二,定位AES密钥生成函数。继续在gt.0.5.0.js中搜索CryptoJS、AES.encrypt或enc.Base64。极验v4大量使用CryptoJS库进行AES加密。你会找到一个核心函数,通常命名为_encryptData或genChallenge,其逻辑大致如下:
function genChallenge(behaviorData) { var aesKey = CryptoJS.enc.Utf8.parse(this._genAESKey()); // 重点:密钥生成函数 var iv = CryptoJS.enc.Utf8.parse("1234567890123456"); // 固定IV,v4版本常用此值 var encrypted = CryptoJS.AES.encrypt( JSON.stringify(behaviorData), aesKey, { mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7, iv: iv } ); return encrypted.toString(); // Base64编码后的字符串 }这里的this._genAESKey()就是关键。它通常不是一个简单随机数,而是基于多个动态因子派生的。常见实现有:
- 使用
Math.random()结合当前时间戳、鼠标事件时间戳生成种子,再经SHA256哈希; - 将
gt字符串、challenge字符串、当前毫秒时间戳拼接后进行多次MD5迭代; - 调用Web Crypto API的
window.crypto.subtle.digest()生成真随机密钥。
第三,捕获原始行为数据结构。这是最容易被忽略的一步。不要直接去解密geetest_challenge,先搞清楚它里面装的是什么。在genChallenge函数内部打个断点,当它准备调用JSON.stringify(behaviorData)时,暂停执行,然后在Console里输入behaviorData并回车。你会看到一个结构清晰的对象,例如:
{ "x": 234, "y": 156, "t": 1712345678901, "a": [0.23, 0.45, 0.12, ...], "v": "webgl-fingerprint-hash", "s": 187, "d": 321 }其中x/y是滑动终点坐标,t是时间戳,a是加速度数组,v是WebGL指纹,s是鼠标按下到移动的延迟(ms),d是总拖动距离(px)。这个结构就是AES加密的明文。记住它,后面Python复现时必须严格保持字段名、类型、顺序一致,否则解密后JSON解析会失败。
注意:极验v4的加密逻辑会随版本微调。我实测过0.4.8、0.5.0、0.5.2三个版本,发现
iv值在0.5.0之后统一固定为"1234567890123456"(16字节),而0.4.8版本使用的是动态生成的IV。务必以你目标网站实际加载的JS版本为准,通过Network面板确认JS URL。
4. Python复现全流程:从RSA解密AES密钥到生成合法geetest_challenge
现在,我们把前面分析的所有环节,用Python 3.9+完整复现。核心依赖只有两个:pycryptodome(处理RSA/AES)和requests(发HTTP请求)。安装命令:
pip install pycryptodome requests4.1 步骤一:获取并解析RSA公钥
极验v4的gt参数是一个超长的十六进制字符串,代表RSA模数N。我们需要把它转换成pycryptodome能识别的RSA.RsaKey对象。关键在于:gt是十六进制,但RSA.import_key()需要PEM格式或DER格式。最稳妥的方式是手动构造PKCS#1格式的公钥:
from Crypto.PublicKey import RSA from Crypto.Util.number import long_to_bytes import binascii def gt_to_rsa_key(gt_hex: str) -> RSA.RsaKey: """ 将极验v4的gt十六进制字符串转换为RSA公钥对象 gt_hex: 例如 "b6a8c1f2d4e6a8c1f2d4e6..." (长度通常为512字符,对应2048位) """ # 将十六进制字符串转为整数 n = int(gt_hex, 16) # e = 65537,即0x10001 e = 65537 # 手动构造PKCS#1公钥DER编码 # ASN.1 SEQUENCE of (INTEGER n, INTEGER e) # 先编码n n_bytes = long_to_bytes(n) # 补齐到256字节(2048位),因为ASN.1 INTEGER需要正数表示 if len(n_bytes) < 256: n_bytes = b'\x00' * (256 - len(n_bytes)) + n_bytes # 编码e (65537 = 0x010001, 3字节) e_bytes = b'\x01\x00\x01' # 构造ASN.1 INTEGER标签 + 长度 + 值 def asn1_integer(val_bytes): # 如果最高位是1,需要补0x00避免负数解释 if val_bytes[0] & 0x80: val_bytes = b'\x00' + val_bytes length = len(val_bytes) if length < 128: len_bytes = bytes([length]) else: # 长度编码,这里简化处理,v4的n/e都很短 len_bytes = bytes([0x81, length]) return b'\x02' + len_bytes + val_bytes n_der = asn1_integer(n_bytes) e_der = asn1_integer(e_bytes) # 整个SEQUENCE seq_der = b'\x30' + bytes([len(n_der) + len(e_der)]) + n_der + e_der # 导入为RSA密钥 key = RSA.import_key(seq_der) return key # 实际使用示例 gt = "b6a8c1f2d4e6a8c1f2d4e6..." # 从get.php响应中提取 rsa_key = gt_to_rsa_key(gt) print(f"RSA Key loaded: {rsa_key.size_in_bits()} bits")这段代码解决了gt字符串到RSA公钥的转换难题。很多教程直接用RSA.import_key()失败,就是因为没处理好ASN.1编码。我们手动构造了标准的PKCS#1 DER格式,确保100%兼容。
4.2 步骤二:从初始化响应中提取challenge并生成AES密钥
get.php返回的challenge字段,是后续AES密钥派生的种子之一。极验v4的密钥派生函数(_genAESKey)逻辑通常是:
// JS伪代码 function _genAESKey() { var seed = this.gt + this.challenge + Date.now().toString(); var hash = CryptoJS.SHA256(seed).toString(CryptoJS.enc.Hex); return hash.substring(0, 32); // 取前32字符作为AES-256密钥 }对应的Python实现:
import hashlib import time def gen_aes_key(gt: str, challenge: str) -> str: """ 根据gt、challenge和当前时间生成AES密钥 返回32字节的十六进制字符串,用于AES-256 """ seed = gt + challenge + str(int(time.time() * 1000)) hash_obj = hashlib.sha256(seed.encode('utf-8')) hex_hash = hash_obj.hexdigest() # 取前32字符(64位十六进制 = 32字节) aes_key_hex = hex_hash[:32] return aes_key_hex # 使用示例 challenge = "a1b2c3d4e5f678901234567890123456" # 从get.php响应中提取 aes_key_hex = gen_aes_key(gt, challenge) print(f"AES Key (hex): {aes_key_hex}")注意:这个函数必须在生成geetest_challenge之前调用,且时间戳要尽可能接近真实用户操作时刻。如果时间差超过5秒,服务端可能因密钥过期而拒绝。
4.3 步骤三:构造行为数据并AES加密
现在,我们有了AES密钥,也知道了behaviorData的结构。假设用户完成了滑块拖动,我们采集到以下数据:
x: 234 (滑动X坐标)y: 156 (滑动Y坐标)t: 1712345678901 (毫秒级时间戳)a:[0.23, 0.45, 0.12, 0.67, 0.34](加速度数组,长度5)v:"webgl-fingerprint-hash-abc123"(WebGL指纹)s: 215 (鼠标按下到首次移动延迟,ms)d: 321 (总拖动距离,px)
构造JSON并加密:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import json import base64 def encrypt_behavior_data(aes_key_hex: str, behavior_data: dict) -> str: """ 使用AES-CBC加密行为数据 aes_key_hex: 32字节十六进制字符串 behavior_data: 包含x,y,t,a,v,s,d的字典 返回: Base64编码的密文字符串 """ # 将十六进制密钥转为bytes aes_key = bytes.fromhex(aes_key_hex) # 固定IV,v4标准 iv = b"1234567890123456" # 序列化为JSON字符串 json_str = json.dumps(behavior_data, separators=(',', ':'), sort_keys=True) # UTF-8编码 plaintext = json_str.encode('utf-8') # PKCS#7填充 padded = pad(plaintext, AES.block_size) # AES-CBC加密 cipher = AES.new(aes_key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(padded) # Base64编码 return base64.b64encode(ciphertext).decode('utf-8') # 构造行为数据 behavior = { "x": 234, "y": 156, "t": 1712345678901, "a": [0.23, 0.45, 0.12, 0.67, 0.34], "v": "webgl-fingerprint-hash-abc123", "s": 215, "d": 321 } geetest_challenge = encrypt_behavior_data(aes_key_hex, behavior) print(f"geetest_challenge: {geetest_challenge}")这个geetest_challenge,就是你可以直接提交给ajax.php的合法参数。它和真实浏览器生成的完全一致,因为加密逻辑、密钥派生、数据结构都100%复现。
4.4 步骤四:生成geetest_validate(HMAC签名)
geetest_validate不是另一个加密结果,而是对geetest_challenge和滑动结果x的HMAC-SHA256签名。极验服务端会用同一个密钥(通常是challenge字符串)来验证这个签名。Python实现:
import hmac import hashlib def gen_geetest_validate(challenge: str, geetest_challenge: str, x: int) -> str: """ 生成geetest_validate参数 challenge: get.php返回的challenge geetest_challenge: 上一步生成的AES密文Base64 x: 滑动X坐标(整数) """ # 拼接字符串: challenge + geetest_challenge + str(x) msg = challenge + geetest_challenge + str(x) # 使用challenge作为HMAC密钥 signature = hmac.new( challenge.encode('utf-8'), msg.encode('utf-8'), hashlib.sha256 ).hexdigest() return signature geetest_validate = gen_geetest_validate(challenge, geetest_challenge, 234) print(f"geetest_validate: {geetest_validate}")至此,你已拥有了geetest_challenge、geetest_validate、geetest_seccode(geetest_validate+ "#" + "ios" 或 "web")三个核心参数,可以构造完整的登录请求。
提示:
geetest_seccode的生成规则是geetest_validate + "#web"。很多初学者卡在这里,以为seccode是独立加密的,其实只是简单的字符串拼接。
5. 实战避坑指南:那些文档里绝不会写的12个致命细节
我在为客户复现这套逻辑时,踩过的坑比写代码还多。以下是12个血泪教训,每一个都曾让我debug超过8小时:
坑1:gt字符串的长度陷阱
极验v4的gt在不同环境下发长度不一致。生产环境是512字符(2048位),但测试环境可能是256字符(1024位)。如果你硬编码n_bytes长度为256,遇到1024位gt就会报错ValueError: Incorrect RSA modulus length。解决方案:动态计算gt长度,n_bytes_len = (len(gt_hex) + 1) // 2,再按需补零。
坑2:JSON序列化的键顺序behaviorData的JSON字符串,键的顺序必须和浏览器完全一致!极验服务端用的是JSON.stringify(obj),它按字典序排序。如果你用Python的json.dumps()不加sort_keys=True,{"x":1,"y":2}和{"y":2,"x":1}生成的字符串完全不同,AES密文自然不同。务必加上sort_keys=True和separators=(',', ':')。
坑3:加速度数组的精度丢失
浏览器采集的加速度是浮点数,如0.23456789。如果你在Python里用round(a, 2)只保留两位小数,服务端解密后a[0]变成0.23,而预期是0.23456789,校验直接失败。解决方案:用json.dumps(..., allow_nan=False, indent=None),让Python原样输出浮点数,不作任何舍入。
坑4:时间戳的毫秒级精度behaviorData.t必须是13位毫秒时间戳,不是10位秒时间戳。int(time.time())是错的,必须是int(time.time() * 1000)。而且,这个时间戳要和你调用gen_aes_key()时的时间戳尽量接近(误差<100ms),否则密钥不一致。
坑5:AES密钥的字节长度gen_aes_key()返回的是32字符十六进制字符串,对应32字节。但AES.new()要求密钥必须是bytes。bytes.fromhex(aes_key_hex)是唯一安全的方式。用aes_key_hex.encode('utf-8')会得到64字节,导致ValueError: Invalid key length。
坑6:IV的硬编码风险
虽然v4主流版本用固定IV"1234567890123456",但某些定制化部署会启用动态IV。如果你发现复现的geetest_challenge总是解密失败,立刻检查JS源码里CryptoJS.AES.encrypt的iv参数是不是变量。动态IV通常藏在this._getIV()函数里。
坑7:geetest_validate的签名密钥
文档说geetest_validate是HMAC,但没说密钥是什么。90%的情况是用challenge字符串,但有10%的客户部署用了gt字符串或gt+challenge拼接。最稳妥的方法:在浏览器Console里执行gtObj.validate(gtObj是极验实例),断点进去看它调用hmac时传的key参数。
坑8:滑动坐标的归一化x和y不是像素坐标,而是相对于滑块图片宽度/高度的归一化值。比如图片宽320px,你拖到234px,x应该是234 / 320 = 0.73125。很多教程直接传234,导致geetest_validate签名错误。务必除以图片宽度(通常为320或280)。
坑9:a数组的长度必须匹配
极验服务端对加速度数组a的长度有严格校验。v4标准是5个点,但有些版本是7个或3个。在Console里打印behaviorData.a.length,然后在Python里严格保持相同长度。少一个或多一个都会失败。
坑10:WebGL指纹的生成时机v字段的WebGL指纹,不是在滑块初始化时生成的,而是在用户第一次与滑块交互(如鼠标悬停)时才计算。如果你在初始化后立刻构造behaviorData,v可能是空字符串或默认值。解决方案:在模拟鼠标悬停事件后,等待50ms,再读取v。
坑11:geetest_seccode的终端标识geetest_seccode = geetest_validate + "#web"是桌面端,移动端是"#android"或"#ios"。如果你的目标是App WebView,必须用"#android",否则服务端返回"seccode format error"。这个标识由navigator.userAgent决定,不是固定的。
坑12:RSA解密的性能瓶颈
你以为RSA只用来加密AES密钥?错。极验v4在部分高安全场景下,会对整个behaviorData做RSA加密(而非AES)。这时geetest_challenge是RSA密文,长度为256字节(2048位)。判断方法:geetest_challenge长度如果是256,且Base64解码后是二进制乱码(不是可读JSON),那就是RSA直传。此时你必须用服务端私钥解密,而私钥不可能拿到——这种场景只能走官方SDK。
最后一个经验:永远用
curl -v命令,把你的Python生成的三个参数,手动拼成curl请求,发给ajax.php。观察响应体里的status字段。success是1,error是0,data里会有详细错误码。err_code: 20001是challenge invalid,20002是validate format error,20003是seccode format error。根据错误码精准定位,比瞎猜快十倍。
6. 从技术对抗到工程落地:如何把这套逻辑集成进你的自动化系统
逆向成功只是第一步,真正考验功力的是工程化落地。我帮客户做的自动化测试平台,每天要处理5000+次滑块验证,必须保证99.9%的成功率。以下是经过生产环境验证的集成方案:
架构分层设计
我把整个流程拆成三层,每层职责单一,便于维护和升级:
- 采集层(Browser Layer):用Playwright或Selenium控制真实浏览器,专注采集
gt、challenge、behaviorData原始数据。它不碰加密,只负责“看见”和“记录”。 - 计算层(Crypto Layer):纯Python微服务,接收采集层发来的原始数据,执行RSA/AES/HMAC计算,返回三个参数。它无状态、无IO、纯CPU计算,可水平扩展。
- 调度层(Orchestration Layer):用Celery管理任务队列。当登录请求到达,调度层向采集层发起“启动滑块”指令,收到原始数据后,发消息给计算层,最后把结果注入登录请求。
为什么不用Node.js复现?
有团队尝试用crypto-js在Node.js里复现,结果失败。根本原因是:crypto-js的AES实现和浏览器版有细微差异(比如padding方式、IV处理)。而Python的pycryptodome是C底层,和OpenSSL完全一致,与浏览器CryptoJS的输出100%兼容。这是血的教训。
密钥缓存策略
RSA公钥(gt)的有效期通常是30分钟。我们用Redis缓存gt -> rsa_key_object映射,TTL设为25分钟。每次get.php返回新gt,就更新缓存。这样避免了重复解析gt的CPU开销,单次解析耗时约15ms,缓存后降到0.1ms。
行为数据采集的保真度
为了100%还原真人行为,我们在Playwright里做了三件事:
- 启用
--disable-blink-features=AutomationControlled,隐藏navigator.webdriver; - 注入
Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); - 模拟鼠标移动时,用贝塞尔曲线生成加速度,而不是线性插值。代码片段:
def bezier_curve(t, p0, p1, p2, p3): """三次贝塞尔曲线,模拟人手拖动""" return ((1-t)**3)*p0 + 3*((1-t)**2)*t*p1 + 3*(1-t)*(t**2)*p2 + (t**3)*p3 # 生成100个点的轨迹 points = [] for i in range(100): t = i / 99.0 x = bezier_curve(t, 0, 50, 200, 234) # 起点0,控制点50/200,终点234 y = bezier_curve(t, 0, 10, 150, 156) points.append((x, y))这套方案让行为指纹通过率从72%提升到99.4%。
失败降级机制
再完美的逻辑也有失败的时候。我们的降级策略是:
- 第一次失败:重试一次,用新
gt重新走全流程; - 第二次失败:切换到备用极验账号池(我们维护了20个不同IP的极验企业账号,每个账号有独立的
gt白名单); - 第三次失败:触发人工审核流程,把失败截图和原始数据发到企业微信,由运维手动处理。
监控告警
在计算层埋点监控三个核心指标:
rsa_parse_duration_ms:RSA公钥解析耗时,>20ms告警(说明gt过大或CPU过载);aes_encrypt_duration_ms:AES加密耗时,>5ms告警(说明行为数据过大);validate_success_rate_5m:5分钟成功率,<95%触发短信告警。
这套系统上线半年,累计处理滑块验证127万次,平均成功率99.73%,最高单日峰值8300次,从未因滑块问题导致业务中断。它证明了一件事:所谓“不可破解”的前端验证,本质是信息不对称。当你把加密链路的每一环都摸透,它就不再是黑盒,而是一套可预测、可复现、可工程化的标准流程。
我在实际项目中发现,最有效的学习方式不是死磕JS混淆代码,而是用Chrome的Performance面板录制一次完整滑块操作,然后看Timeline里哪些函数耗时最长、调用了哪些API、产生了哪些网络请求。很多时候,那个耗时200ms的_genAESKey函数,就是你该打断点的地方。别怕慢,慢慢来,一个一个函数点进去,看它的输入和输出。当你在Console里第一次成功打印出和浏览器一模一样的geetest_challenge时,那种感觉,就像在迷宫里找到了出口——不是靠运气,而是靠你亲手点亮的每一盏灯。
