【三万字记录】AI攻克极验证4代滑块并交付运行实录

以下文档详细记录了基于反混淆后的
gcaptcha4_deobfuscated.js源码,对极验 v4 滑块验证码协议的完整逆向分析过程。 包括:协议流程、缺口识别、坐标换算、轨迹模拟、PoW 求解、加密算法还原、payload 构建,以及完整可运行的 Python + Node.js 实现代码。
书接上回:AI自动混淆还原极验gcaptcha4.js—完整技术文档和代码
首先说明,本次教学完全基于小肩膀教育自营AI,基本上一键完成,欢迎体验使用。另外,欢迎加入小肩膀教育,购买教程完整学习AI逆向。


介绍地址:https://xjbedu.site/token注册地址:https://xjbtoken.site/register淘宝搜索【小肩膀网络科技】购买AI Token欢迎重度claude code opus 4.6 thinking用户来体验尝鲜。可加下方群聊

九、整体架构与协议流程
9.1 极验 v4 滑块验证码工作原理
极验 v4 滑块验证码采用 JSONP 通信(不是常见的 XHR/fetch),整个验证流程分为两个核心 HTTP 请求:
用户浏览器│├─── [1] GET /load ──────────────────► gcaptcha4.geetest.com│ 参数: captcha_id, challenge, ││ client_type, risk_type, lang ││ ◄─────────── JSONP 响应 ──────────┘│ 返回: lot_number, payload, process_token,│ pt, bg(背景图路径), slice(滑块图路径),│ ypos, pow_detail, 公钥等│├─── [2] GET 背景图 ─────────────────► static.geetest.com│ ◄─────────── PNG 图片 ────────────┘│├─── [3] GET 滑块碎片图 ─────────────► static.geetest.com│ ◄─────────── PNG 图片 ────────────┘││ [本地处理]│ ├── 识别缺口位置 (OCR/图像处理)│ ├── 计算缩放比与滑动距离│ ├── 生成拟人化轨迹│ ├── 计算 PoW 工作量证明│ ├── 构建 payload JSON│ └── 加密生成 w 参数│└─── [4] GET /verify ────────────────► gcaptcha4.geetest.com参数: captcha_id, challenge, │lot_number, w, payload, │process_token, pt 等 │◄─────────── JSONP 响应 ──────────┘返回: result="success"/"fail",pass_token, gen_time 等
9.2 通信方式:JSONP
极验 v4 的前端 JS 不使用XMLHttpRequest 或 fetch,而是通过动态创建 <script> 标签注入 JSONP 回调来通信。
JS 源码中的通信机制(还原后 gcaptcha4_deobfuscated.js 行 2132 附近):
// 源码行 2132: JSONP 请求发起// (0, _v1912.jsonp)(_v2177, "verify", u, _v2170).$_JAN(function (_v2189) {// var _v2194 = _v2179.resultAdapt(_v2189);// ...// });//// 通信流程:// 1. 生成唯一回调函数名: geetest_1776962556467// 2. 在 window 上注册该回调// 3. 创建 <script src="https://gcaptcha4.geetest.com/verify?callback=geetest_xxx&...">// 4. 服务端返回的JS: geetest_xxx({status:"success", data:{...}})// 5. 浏览器执行脚本,自动触发回调函数
在我们的 Python 实现中,直接用 requests.get() 请求同样的 URL,然后用正则提取 JSONP 包裹的 JSON 数据:
def parse_jsonp(text):"""解析 JSONP 响应,提取内部 JSON 对象JSONP 格式: geetest_1776962556467({"status":"success","data":{...}})需要去掉外层的函数调用包装,提取括号内的 JSON"""match = re.search(r'\((\{.*\})\)', text, re.DOTALL) # 匹配最外层括号内的JSONif match:return json.loads(match.group(1))return json.loads(text) # 如果不是JSONP格式,尝试直接解析为纯JSON
9.3 项目文件结构
geetest_ctf/├── geetest_solver.py # Python 主控脚本:编排整个8步验证流程├── trajectory.py # 拟人化轨迹生成器:三段式加速模型 + 回弹├── pow_solver.py # PoW 工作量证明计算:暴力搜索 nonce├── gap_detector.py # 图片缺口识别:调用 OCR 平台 API└── node_env/├── package.json # Node.js 依赖配置└── generate_w.js # 加密参数 w 的生成# 包含:轨迹差分编码、Base64、AES-128-CBC、# RSA-1024 PKCS#1v1.5、SM4-CBC、SM2
9.4 关键参数速查表
| 参数名 | 含义 | 来源 | 示例值 ||--------|------|------|--------|| `captcha_id` | 验证码实例ID(每个网站唯一) | 页面嵌入的JS | `24f56dc13c40dc4a02fd0318567caef5` || `challenge` | 本次挑战的唯一令牌 | 客户端用 guid() 生成 | `a3b1c2d4e5f67890` (16位hex) || `lot_number` | 服务端批次号 | load 响应 | `72ce10e53aea4ac6b0b3217285a2a503` || `pt` | 加密方式指示 | load 响应 | `"0"`=Base64, `"1"`=RSA+AES, `"2"`=SM2+SM4 || `payload` | 服务端加密过的会话数据 | load 响应 | 不透明字符串,原样回传 || `process_token` | 会话令牌 | load 响应 | 不透明字符串,原样回传 || `setLeft` | 滑块滑动的像素距离(显示坐标) | 客户端计算 | `128` || `passtime` | 操作总耗时(ms) | 轨迹末尾时间戳 | `1507` || `userresponse` | 原图坐标系的位移+偏移 | `setLeft/scale+2` | `129.24` || `aa` | 轨迹编码字符串 | 差分压缩+自定义编码 | `s!!tuv0!!(/1,` || `w` | 加密后的完整验证数据 | AES加密payload+RSA加密密钥 | 1280字符的hex串 || `pow_msg` | PoW 消息串 | 客户端暴力计算 | `1\|0\|md5\|2026...\|lot...\|cap...\|nonce\|nonce` || `pow_sign` | PoW 哈希签名 | 客户端计算 | md5 hex digest |
十、load 接口分析与图片下载
10.1 challenge 生成
在浏览器端,每次验证前客户端会生成一个 16 位 hex 字符串作为 challenge。
JS 源码(gcaptcha4_deobfuscated.js 行 457-465):
// guid() 函数:生成16位随机hex密钥// 内部函数 e() 生成4位hex(取 65536 范围内随机数的十六进制后4位)// 调用4次 e() 拼接成 16 位var p = function () {function e() {return (65536 * (1 + Math.random()) | 0) // 生成 65536~131071 的整数.toString(16) // 转十六进制(如"1a3f5",5位).substring(1); // 去掉首位"1",剩余4位hex}return function () {return e() + e() + e() + e(); // 4×4 = 16位hex};}();_v80.guid = p;
Python 实现:
def guid_python():"""生成16位随机hex字符串,与JS中的guid()逻辑一致每次调用内部函数e()生成4位hex,调用4次拼接"""def e():# 模拟 JS 的 (65536 * (1 + Math.random()) | 0).toString(16).substring(1)return format((int(65536 * (1 + random.random())) | 0), 'x')[-4:]return e() + e() + e() + e()
10.2 load 接口请求
load 接口是验证流程的第一步,获取验证码的所有配置参数和图片路径。
请求格式:
GET https://gcaptcha4.geetest.com/load?callback=geetest_1776962556467 # JSONP回调函数名&captcha_id=24f56dc13c40dc4a02fd0318567caef5&challenge=a3b1c2d4e5f67890 # 客户端生成的16位hex&client_type=web # 客户端类型&risk_type=slide # 验证码类型:滑块&lang=zh # 语言
Python 实现:
def call_load(session, captcha_id, challenge):"""调用极验 load 接口,获取验证码配置返回 JSONP 格式的响应,解析后得到 lot_number、图片路径、PoW参数等"""callback = generate_callback() # 生成 geetest_{timestamp} 格式的回调名params = {'callback': callback, # JSONP 回调函数名'captcha_id': captcha_id, # 验证码实例ID'challenge': challenge, # 本次挑战令牌'client_type': 'web', # 客户端类型'risk_type': 'slide', # 风险类型:滑块'lang': 'zh', # 语言:中文}resp = session.get(f"https://{API_SERVER}/load",params=params,headers=HEADERS,timeout=15)return parse_jsonp(resp.text) # 解析 JSONP 包装,返回纯 JSON 对象
10.3 load 响应解析
load 接口返回的关键字段:
{"status": "success","data": {"lot_number": "72ce10e53aea4ac6b0b3217285a2a503", // 批次号,后续所有请求都要带上"payload": "eyJ...(base64加密的服务端数据)...", // 不透明数据,原样回传给verify"process_token": "d3a1b2c3...", // 会话令牌,原样回传"pt": "1", // 加密方式: "1" = RSA+AES"payload_protocol": 1, // payload协议版本"captcha_type": "slide", // 验证码类型"bg": "pictures/gt/..../bg.png", // 背景图路径(含缺口)"slice": "pictures/gt/..../slice.png", // 滑块碎片图路径"ypos": 78, // 滑块碎片的Y坐标"guard": true, // 是否启用 geeGuard 指纹"check_device": false, // 是否检查设备指纹"pow_detail": { // PoW 工作量证明参数"version": "1", // PoW 版本"bits": 0, // 难度位数(0=不需要PoW)"hashfunc": "md5", // 哈希算法"datetime": "2026-04-28T12:00:00+08:00", // 服务端时间戳"nonce": "" // nonce前缀(通常为空)}}}
Python 中的解析:
# 从 load 响应中提取所有需要的参数data = load_resp['data']lot_number = data['lot_number'] # 批次号payload = data['payload'] # 不透明数据,原样回传process_token = data['process_token'] # 会话令牌pt = str(data.get('pt', '1')) # 加密方式payload_protocol = data.get('payload_protocol', 1)pow_detail = data.get('pow_detail', {}) # PoW 参数bg_path = data.get('bg', '') # 背景图相对路径slice_path = data.get('slice', '') # 滑块图相对路径ypos = data.get('ypos', 0) # 滑块Y坐标(本实现未使用)
10.4 图片下载与尺寸获取
背景图和滑块图存放在极验的 CDN 上,路径由 load 响应提供。
注意:背景图的原始宽度(bg_w)是后续缩放计算的关键参数。
def get_image_size(png_data):"""从 PNG 文件的二进制数据中提取图片尺寸PNG 文件格式:- 字节 0-7: PNG 签名 (89 50 4E 47 ...)- 字节 8-15: IHDR chunk length + type- 字节 16-19: 宽度(大端序 uint32)- 字节 20-23: 高度(大端序 uint32)"""if png_data[:4] == b'\x89PNG': # 检查 PNG 魔数w = struct.unpack('>I', png_data[16:20])[0] # 大端序读取宽度h = struct.unpack('>I', png_data[20:24])[0] # 大端序读取高度return w, hreturn 300, 200 # 非PNG格式的备用默认值# === 下载图片 ===bg_url = f"{STATIC_SERVER}/{bg_path}" # 拼接完整的背景图URLbg_resp = session.get(bg_url, timeout=15) # 下载背景图bg_w, bg_h = get_image_size(bg_resp.content) # 提取原始尺寸,如 344x212if slice_path:slice_url = f"{STATIC_SERVER}/{slice_path}" # 拼接滑块碎片图URLslice_resp = session.get(slice_url, timeout=15)sl_w, sl_h = get_image_size(slice_resp.content)
背景图的典型尺寸为 344×212 像素(原图),而网页上的显示尺寸会根据容器宽度进行缩放,这个缩放关系在下一节详细分析。
十一、缺口识别与坐标换算
11.1 缺口位置识别(OCR)
背景图中包含一个缺口(凹槽),需要识别缺口的 X 坐标。本实现使用第三方 OCR 平台进行识别。
Python 实现 (gap_detector.py):
import base64import jsonimport requestsdef detect_gap(image_path=None, image_bytes=None, question_type='框出正确位置'):"""识别滑块验证码背景图中的缺口位置:param image_path: 图片文件路径(与 image_bytes 二选一):param image_bytes: 图片二进制数据:param question_type: 识别问题类型,告诉OCR模型要找什么:return: 缺口左边缘的X坐标(像素,原图坐标系)"""if image_bytes is None:if image_path is None:raise ValueError("必须提供 image_path 或 image_bytes")with open(image_path, 'rb') as f:image_bytes = f.read()# 构建 OCR 平台请求payload = {'base64Image': base64.b64encode(image_bytes).decode('utf-8'), # 图片转base64'modelName': '普通模型', # 使用的识别模型'keyCode': 'vRkfuMQl', # API 鉴权密钥'question': question_type, # 识别任务描述'system': ''}# 调用 OCR APIresponse = requests.post('http://gpu1.xinyuocr.xyz:8889/api/qrcode/predict',json=payload,timeout=30)result = response.json()if result.get('err'):raise RuntimeError(f"OCR识别错误: {result['err']}")# 解析响应:提取缺口区域的左上角X坐标# 返回格式示例:# result['msg'] = '[{"label":"1","box":[[223.0,115.0,296.0,187.0]]}]'# 其中 box 是 [[x1,y1,x2,y2]],(x1,y1)=左上角,(x2,y2)=右下角json_obj = json.loads(result['msg'])gap_x = json_obj[0]['box'][0][0] # 取第一个检测结果的左边缘X坐标return gap_x # 返回值是原图坐标系中的像素值
注意:OCR 返回的坐标是在原图(如 344×212 像素)上的坐标,不是网页显示尺寸上的坐标。后续需要进行缩放换算。
11.2 缩放比计算(核心公式)
极验会把原图缩放后显示在网页上。缩放比的计算公式隐藏在 JS 源码中。
JS 源码(gcaptcha4_deobfuscated.js 行 7083-7086):
// 行 7083: 获取容器宽度(通常为 340px)_v7624 = parseInt(_v7627.width || _v7627.nextWidth || this.$_BGC_, 10);// 行 7084: 如果使用 rem 适配,计算实际显示宽度上限var o = _v7627.rem ? 340 * _v7627.rem : 340;o < _v7624 && (_v7624 = o); // 如果容器比 340*rem 还大,则限制为 340*rem// 行 7086: ★★★ 核心缩放公式 ★★★var a = this.$_BHEH = .8876 * _v7624 / _v7628.wrap_w;// 含义:// _v7624 = 容器显示宽度(通常 340px)// _v7628.wrap_w = 背景图原始宽度(通常 344px)// 0.8876 = 极验的固定缩放系数(可能是因为图片实际显示区域占容器的88.76%)// a ($BHEH) = 最终缩放比,后续所有坐标转换都用它
wrap_w 的来源(行 7169-7185):
// setImgs 函数中:wrap_w 从背景图的原始宽度获取// _v7688[0].$_CFy.width 就是 <img> 元素的 naturalWidth// 也就是背景图的原始像素宽度(如 344)
Python 实现:
CONTAINER_WIDTH = 340 # 极验验证码容器的固定宽度(像素)# 计算缩放比:与 JS 源码行 7086 完全对应# scale = 0.8876 * 容器宽度 / 原图宽度scale = 0.8876 * CONTAINER_WIDTH / bg_w # 例: 0.8876 * 340 / 344 = 0.877488...
11.3 滑块初始偏移(SLIDER_OFFSET)
关键发现:滑块小按钮(小方块)并不是从背景图的最左边开始的。在网页上观察可知,滑块的初始位置大约在 X=15px 处。
这意味着 OCR 识别到的缺口 X 坐标(比如 143px),实际滑动距离并不是 143px,而是 143-15=128px(需要减去滑块的初始偏移量)。
背景图(原图坐标系):┌─────────────────────────────────────────────┐│ ┌──┐ ││ │缺│← gap_x = 143px ││ │口│ ││ └──┘ │├─────────────────────────────────────────────┤│ ┌──┐ ││ │滑│← 初始位置约 15px ││ │块│ ││ └──┘ ││ ◄── 实际滑动距离 = 143 - 15 = 128px ──────► │└─────────────────────────────────────────────┘
Python 实现:
SLIDER_OFFSET = 15 # 滑块初始偏移量(像素,原图坐标系)# 完整的坐标换算流程gap_x = detect_gap(image_bytes=bg_resp.content) # OCR识别缺口X(原图),如143adjusted_gap_x = gap_x - SLIDER_OFFSET # 减去滑块偏移,如128slide_distance = adjusted_gap_x * scale # 转为显示坐标系,如128.76setLeft = int(slide_distance) # 取整,如128userresponse = setLeft / scale + 2 # 转回原图坐标+2,如129.24
11.4 setLeft 和 userresponse 的含义
这两个参数的计算逻辑来自 JS 源码的 mouseup 事件处理函数。
JS 源码(gcaptcha4_deobfuscated.js 行 7131-7136):
// mouseup 事件处理函数 $_BGIM:// _v7662 = 鼠标释放时的X偏移像素(显示坐标系)var _v7665 = parseInt(_v7662, 10); // setLeft: 取整的显示坐标位移var _v7666 = {setLeft: _v7665, // 显示坐标系的滑动像素数(整数)passtime: _v7663, // 从mousedown到mouseup的总耗时(毫秒)userresponse: _v7665 / _v7659.$_BHEH + 2// ↑ ↑ ↑// setLeft scale 固定偏移+2// 含义:把显示坐标转回原图坐标系,再加上2// 为什么+2?可能是服务端验证时的容差补偿};
关键:
setLeft
= 显示坐标系的滑动距离(像素),取整 userresponse
= 原图坐标系的滑动距离 + 2 passtime
= 操作总耗时(毫秒)
11.5 完整换算流程示例
假设:原图宽度 344px,容器宽度 340px,OCR 缺口位置 143px
Step 1: 计算缩放比scale = 0.8876 × 340 / 344 = 0.877488Step 2: 减去滑块偏移adjusted_gap_x = 143 - 15 = 128Step 3: 转为显示坐标slide_distance = 128 × 0.877488 = 112.32setLeft = int(112.32) = 112Step 4: 计算 userresponseuserresponse = 112 / 0.877488 + 2 = 129.66Step 5: 轨迹目标距离轨迹生成的目标距离 = int(slide_distance) = 112 像素(显示坐标系)
十二、轨迹生成与编码
12.1 浏览器端的轨迹记录机制
在浏览器中,极验通过 mousedown/mousemove/mouseup 三个事件来记录用户的滑动轨迹。
JS 源码分析(gcaptcha4_deobfuscated.js 行 7097-7130):
// ===== mousedown 事件 (行 7097-7110) =====// 函数名:$_BGFT$_BGFT: function (_v7634) {var _v7639 = this;_v7639.$_BGAW = (0, _v7504.now)(); // 记录起始时间戳// 获取背景图和按钮的位置信息var _v7644 = _v7640(".bg_" + _v7641).$_EAh(); // 背景图的 getBoundingClientRectvar _v7645 = _v7640(".btn_" + _v7641).$_EAh(); // 按钮的 getBoundingClientRect// ★ 创建轨迹记录器,初始点为 [负偏移X, 负偏移Y, 0]// 这个初始点表示:鼠标按下位置相对于滑块按钮的偏移(原图坐标系)_v7639.$_BHJf = new _v7505.default([Math.round((_v7643 - _v7639.$_BHGl) / _v7639.$_BHEH), // (btnLeft - mouseX) / scaleMath.round((_v7642 - _v7639.$_BHIt) / _v7639.$_BHEH), // (btnTop - mouseY) / scale0 // 时间=0(起始点)]).$_JEJ([0, 0, 0]); // ★ 紧接着追加一个 [0,0,0] 作为原点标记// 初始偏移点通常是负数,如 [-25, 3, 0]// 代表鼠标按下时的位置在按钮左上角更偏左偏下的位置},// ===== mousemove 事件 (行 7112-7118) =====// 函数名:$_BGHI$_BGHI: function (_v7646) {var _v7652 = _v7646.$_CGf() - _v7651.$_BHGl; // 当前X - 起始X = X位移_v7651.$_BHJf.$_JEJ([Math.round(_v7652 / _v7651.$_BHEH), // X位移 / scale → 原图坐标Math.round(_v7653 / _v7651.$_BHEH), // Y位移 / scale → 原图坐标(0, _v7504.now)() - _v7651.$_BGAW // 相对于mousedown的时间差(ms)]);},// ===== mouseup 事件 (行 7120-7136) =====// 函数名:$_BGIM$_BGIM: function (_v7654) {// 追加最后一个轨迹点_v7659.$_BHJf.$_JEJ([Math.round(_v7662 / _v7659.$_BHEH), // 最终X位移 / scaleMath.round(_v7664 / _v7659.$_BHEH), // 最终Y位移 / scale_v7659.passtime // 总耗时]);// 构建验证参数var _v7665 = parseInt(_v7662, 10); // setLeft = 显示坐标取整var _v7666 = {setLeft: _v7665,passtime: _v7663,userresponse: _v7665 / _v7659.$_BHEH + 2 // 原图坐标 + 2};}
轨迹数据结构:
[[-25, 3, 0], ← 初始偏移点:鼠标按下位置相对于按钮的偏移(原图坐标)[0, 0, 0], ← 原点标记[5, 0, 32], ← 第1个移动点: X移动5px, Y不动, 经过32ms[12, 1, 48], ← 第2个移动点[25, -1, 65], ← ......[128, 2, 1450], ← 最终位置(原图坐标)[128, 2, 1507], ← mouseup 时的最终确认点]
12.2 拟人化轨迹生成(Python 实现)
为了模拟真人操作,我们实现了三段式速度模型 + 过冲回弹 + Y轴随机抖动。
完整实现 (trajectory.py):
import randomimport mathdef generate_trajectory(distance, scale=1.0):"""生成拟人化的滑块轨迹模拟真实人手的运动特征:1. 三段式速度模型:加速→匀速→减速2. 过冲与回弹:滑过目标后回退(手指惯性)3. Y轴随机抖动:真人的手不可能完全水平滑动4. 不均匀时间间隔:每一步的时间间隔随机波动:param distance: 需要滑动的像素距离(显示坐标系):param scale: 缩放比(未使用,保留参数):return: list of [x, y, timestamp],绝对坐标轨迹"""if distance <= 0:return [[0, 0, 0]]tracks = []current_x = 0.0current_y = 0.0current_t = 0# === 过冲设计 ===# 真人操作时,手指惯性会导致滑过目标2~5像素,然后回退overshoot = random.uniform(2, 5)target = distance + overshoot # 实际要滑到的位置(含过冲)# === 三段式速度模型的分界点 ===phase1_end = target * 0.4 # 加速段:0% ~ 40%phase2_end = target * 0.7 # 匀速段:40% ~ 70%# 减速段:70% ~ 100%tracks.append([0, 0, 0]) # 起始点while current_x < target:remaining = target - current_xif current_x < phase1_end:# === 加速段(0~40%)===# 速度从慢到快,模拟手指从静止开始加速progress = current_x / phase1_end if phase1_end > 0 else 1base_step = 1 + progress * 4 # 步长从1逐渐增大到5elif current_x < phase2_end:# === 匀速段(40%~70%)===# 保持中等速度base_step = random.uniform(3, 6) # 随机步长3~6else:# === 减速段(70%~100%)===# 速度逐渐降低,模拟手指精确定位progress = (current_x - phase2_end) / (target - phase2_end) \if (target - phase2_end) > 0 else 1base_step = max(0.5, 4 * (1 - progress)) # 步长从4逐渐减小到0.5# 添加微小随机扰动,避免步长完全规律step_x = base_step + random.uniform(-0.5, 0.5)step_x = min(step_x, remaining) # 不超过剩余距离step_x = max(0.3, step_x) # 最小步长0.3,避免原地不动# === Y轴抖动 ===# 真人的手不可能完全水平移动,每步有±2px的上下抖动step_y = random.uniform(-2, 2)current_y += step_ycurrent_y = max(-5, min(5, current_y)) # 限制Y轴偏移在±5px以内current_x += step_x# === 不均匀的时间间隔 ===# 加速段:间隔较长(手指刚开始动,反应时间)# 匀速段:间隔较短(手指快速滑动)# 减速段:间隔较长(手指精确微调)if current_x < phase1_end:dt = random.randint(12, 25) # 加速段: 12~25mselif current_x < phase2_end:dt = random.randint(8, 18) # 匀速段: 8~18mselse:dt = random.randint(15, 35) # 减速段: 15~35mscurrent_t += dttracks.append([round(current_x), round(current_y), current_t])# === 回弹模拟 ===# 滑过目标后,手指会回退2~4次for i in range(random.randint(2, 4)):current_x -= random.uniform(0.5, 2) # 每次回退0.5~2pxcurrent_y += random.uniform(-1, 1)current_t += random.randint(20, 40)tracks.append([round(current_x), round(current_y), current_t])# === 精确定位 ===# 最终确保停在精确的目标位置current_t += random.randint(30, 60)tracks.append([round(distance), round(current_y), current_t])# === 松手前的短暂停顿 ===# 真人在松手前会有一个微小的停顿(确认位置正确)current_t += random.randint(50, 150)tracks.append([round(distance), round(current_y), current_t])return tracksdef get_passtime(tracks):"""从轨迹中提取总耗时(最后一个点的时间戳)"""return tracks[-1][2] if tracks else 0
12.3 轨迹坐标系的转换
在 Python 主控脚本中,轨迹生成器输出的是显示坐标系的轨迹,而 JS 源码中记录的轨迹是原图坐标系的。需要做坐标转换。
# 生成显示坐标系的轨迹track_display = generate_trajectory(int(slide_distance)) # 参数是显示坐标的距离passtime = get_passtime(track_display) # 总耗时(ms)# 转换为原图坐标系(除以 scale)# 与 JS 源码行 7118 中 Math.round(_v7652 / _v7651.$_BHEH) 对应track = [[round(p[0] / scale), round(p[1] / scale), p[2]] for p in track_display]# 插入初始偏移点(模拟鼠标按下位置的随机偏移)# 与 JS 源码行 7107 中的初始点对应initial_offset = [random.randint(-30, -10), random.randint(-10, 10), 0]track.insert(0, initial_offset)# 最终轨迹格式: [[-25, 3, 0], [0, 0, 0], [5, 0, 32], [12, 1, 48], ...]
12.4 轨迹编码(差分压缩 + 自定义编码)
轨迹数据不会以原始 JSON 数组传递给服务端,而是经过一套自定义的差分压缩 + 编码算法,最终变成一个紧凑的字符串。
12.4.1 差分压缩 $_BACB
JS 源码(行 1743-1745):
// 差分压缩:把绝对坐标轨迹转为增量 [dx, dy, dt]// 输入: [[0,0,0], [5,0,32], [12,1,48], [25,-1,65], ...]// 输出: [[5,0,32], [7,1,16], [13,-2,17], ...]$_BACB: function (_v1754) {for (var t, s, n, i = [], r = 0, o = 0, a = _v1754.length - 1; o < a; o += 1)t = Math.round(_v1754[o + 1][0] - _v1754[o][0]), // dx = 下一点X - 当前点Xs = Math.round(_v1754[o + 1][1] - _v1754[o][1]), // dy = 下一点Y - 当前点Yn = Math.round(_v1754[o + 1][2] - _v1754[o][2]), // dt = 下一点T - 当前点T// 过滤掉完全没有变化的点 (dx=0, dy=0, dt=0)0 === t && 0 === s && 0 === n || (0 === t && 0 === s? r += n // 如果只有时间变化,累积到下一个有效点: (i.push([t, s, n + r]), // 有位移变化时,输出 [dx, dy, dt+累积时间]r = 0) // 重置累积时间);return 0 !== r && i.push([t, s, r]), // 尾部累积的时间单独输出i;}
Node.js 实现:
// 差分压缩: 绝对坐标 -> [dx, dy, dt] 增量function trackDiff(trackPoints) {var i = [], r = 0; // i=结果数组, r=累积时间for (var o = 0, a = trackPoints.length - 1; o < a; o += 1) {var t = Math.round(trackPoints[o + 1][0] - trackPoints[o][0]); // dxvar s = Math.round(trackPoints[o + 1][1] - trackPoints[o][1]); // dyvar n = Math.round(trackPoints[o + 1][2] - trackPoints[o][2]); // dtif (!(0 === t && 0 === s && 0 === n)) { // 过滤完全无变化的点if (0 === t && 0 === s) {r += n; // 仅时间变化,累积} else {i.push([t, s, n + r]); // 有位移变化,输出并带上累积时间r = 0;}}}if (0 !== r) i.push([t, s, r]); // 尾部累积时间return i;}
12.4.2 方向编码 encodeDirection
对于常见的运动方向,使用单个字符代替 [dx, dy] 两个数值。
JS 源码(行 1748-1750):
// 方向映射表:9种常见运动方向 → 单字符function i(_v1763) {var t = [[1, 0], // 右移1 → 's'[2, 0], // 右移2 → 't'[1, -1], // 右移1+下移1 → 'u'[1, 1], // 右移1+上移1 → 'v'[0, 1], // 上移1 → 'w'[0, -1], // 下移1 → 'x'[3, 0], // 右移3 → 'y'[2, -1], // 右移2+下移1 → 'z'[2, 1] // 右移2+上移1 → '~'];var chars = "stuvwxyz~";for (var s = 0, n = t.length; s < n; s += 1)if (_v1763[0] === t[s][0] && _v1763[1] === t[s][1])return chars[s]; // 匹配到预定义方向,返回对应字符return 0; // 不在预定义方向中,返回0表示需要数值编码}
Node.js 实现:
// 编码单个轨迹点的运动方向// 如果运动方向在9种预定义方向中,返回对应的单字符// 否则返回0,需要用数值编码function encodeDirection(point) {var dirs = [[1, 0], [2, 0], [1, -1], [1, 1], [0, 1],[0, -1], [3, 0], [2, -1], [2, 1]];var chars = "stuvwxyz~";for (var s = 0; s < dirs.length; s++) {if (point[0] === dirs[s][0] && point[1] === dirs[s][1]) return chars[s];}return 0;}
12.4.3 数值编码 encodeValue
对于不能用方向字符表示的数值(dx、dy、dt),使用自定义的 Base64 变种编码。
JS 源码(行 1752-1760):
function a(_v1765) {// 自定义字符集(64个字符)var t = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr",s = t.length, // 64n = "",i = Math.abs(_v1765), // 取绝对值r = parseInt(i / s, 10); // 高位 = 值 / 64s <= r && (r = s - 1); // 如果高位超过64,截断为63(最大编码范围 64*64-1 = 4095)r && (n = t.charAt(r)); // 高位不为0时,输出高位字符var o = "";return _v1765 < 0 && (o += "!"), // 负数前缀 "!"n && (o += "$"), // 有高位时前缀 "$"o + n + t.charAt(i %= s); // 拼接:[!][$][高位字符][低位字符]}
编码规则总结:
|
|
|
|
|---|---|---|
0 |
( |
|
5 |
- |
|
65 |
$)( |
$
|
-3 |
!+ |
!
|
-70 |
!$)( |
|
Node.js 实现:
// 编码单个数值(dx/dy/dt)// 使用自定义64字符集的变长编码function encodeValue(val) {var charset = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr";var base = charset.length; // 64var n = "";var i = Math.abs(val); // 取绝对值var r = parseInt(i / base, 10); // 计算高位if (base <= r) r = base - 1; // 防溢出if (r) n = charset.charAt(r); // 高位字符var o = "";if (val < 0) o += "!"; // 负数标记if (n) o += "$"; // 多位标记return o + n + charset.charAt(i % base); // 拼接最终编码}
12.4.4 完整编码 $_BADe
JS 源码(行 1762-1770):
// 差分压缩后,把轨迹编码为三段式字符串$_BADe: function () {var s = this.$_BACB(this.$_JBb); // 差分压缩var n = [], r = [], o = []; // X部分、Y部分、T部分forEach(s, function (_v1767) {var dirChar = i(_v1767); // 尝试方向编码if (dirChar) {r.push(dirChar); // 方向编码只影响 Y 部分} else {n.push(a(_v1767[0])); // X部分:编码 dxr.push(a(_v1767[1])); // Y部分:编码 dy}o.push(a(_v1767[2])); // T部分:始终编码 dt});// ★ 三段用 "!!" 分隔return n.join("") + "!!" + r.join("") + "!!" + o.join("");}
输出格式示例:
xDeltas!!yDeltas!!tDeltas例: 0-1$)3!!stuv(!x0!!(/1,0-,+
!!
分隔三段:X增量编码 | Y增量编码(含方向字符) | 时间增量编码 -
方向字符(s,t,u,v,w,x,y,z,~)出现在 Y 段中,此时 X 段没有对应元素
Node.js 完整编码实现:
// 完整的轨迹编码:差分压缩 → 分类编码 → 三段拼接function encodeTrack(trackPoints) {var diffed = trackDiff(trackPoints); // Step1: 差分压缩var xParts = []; // X增量编码部分var yParts = []; // Y增量编码部分(含方向字符)var tParts = []; // 时间增量编码部分for (var idx = 0; idx < diffed.length; idx++) {var point = diffed[idx];var dirChar = encodeDirection(point); // Step2: 尝试方向编码if (dirChar) {// 如果匹配预定义方向,Y部分用单字符表示,X部分不输出yParts.push(dirChar);} else {// 否则,X和Y都用数值编码xParts.push(encodeValue(point[0]));yParts.push(encodeValue(point[1]));}// T部分始终用数值编码tParts.push(encodeValue(point[2]));}// 三段用 "!!" 连接return xParts.join("") + "!!" + yParts.join("") + "!!" + tParts.join("");}
十三、PoW 工作量证明
13.1 PoW 机制概述
极验 v4 在 load 响应中会返回 pow_detail,要求客户端在提交 verify 前完成一个工作量证明(Proof of Work)。目的是增加自动化攻击的计算成本。
PoW 的基本原理:客户端需要找到一个随机 nonce,使得特定格式消息的哈希值前 N 位为零。
13.2 PoW 消息格式
JS 源码(gcaptcha4_deobfuscated.js 行 2456-2501):
// PoW 求解函数// 参数含义:// _v2536 = captcha_id 验证码实例ID// _v2537 = lot_number 批次号// _v2538 = hashfunc 哈希算法名("md5"/"sha1"/"sha256")// _v2539 = version 版本号("1")// _v2540 = bits 难度位数// _v2541 = datetime 服务端时间戳// _v2542 = nonce_prefix nonce前缀(通常为空)function n(_v2536, _v2537, _v2538, _v2539, _v2540, _v2541, _v2542) {var a = _v2540 % 4, // 余数位(用于非4整除的难度判定)_ = parseInt(_v2540 / 4, 10), // 需要前导零的hex字符数u = "0".repeat(_), // 构建前导零前缀字符串// ★ 消息格式(注意字段顺序!):// "version|bits|hashfunc|datetime|lot_number|captcha_id|nonce_prefix|"c = _v2539 + "|" + _v2540 + "|" + _v2538 + "|" +_v2541 + "|" + _v2537 + "|" + _v2536 + "|" + _v2542 + "|";// 暴力搜索循环while (1) {var h = guid(), // 生成16位随机hex作为noncep = c + h, // 消息 = 前缀 + noncel = undefined;// 根据指定的哈希算法计算摘要switch (_v2538) {case "md5":l = new _v2528.default.MD5().hex(p);break;case "sha1":l = new _v2528.default.SHA1().hex(p);break;case "sha256":l = new _v2528.default.SHA256().hex(p);break;}// 难度判定if (0 == a) {// bits 是4的整数倍:直接检查前导零if (0 === l.indexOf(u)) return {pow_msg: c + h, // ★ 返回的pow_msg包含完整消息(含nonce)pow_sign: l // 哈希值};} else if (0 === l.indexOf(u)) {// bits 不是4的整数倍:检查前导零后,还需检查下一个字符var f = undefined,d = l[_]; // 零前缀后的第一个字符// 根据余数位确定阈值switch (a) {case 1: f = 7; break; // 1位:下一个字符 ≤ '7' (二进制首位为0)case 2: f = 3; break; // 2位:下一个字符 ≤ '3' (二进制前两位为0)case 3: f = 1; break; // 3位:下一个字符 ≤ '1' (二进制前三位为0)}if (d <= f) return {pow_msg: c + h,pow_sign: l};}}}
13.3 Python PoW 求解实现
完整实现 (pow_solver.py):
import hashlibimport randomdef generate_guid():"""生成16位随机hex字符串与JS中的guid()一致:4次生成4位hex并拼接"""return ''.join(random.choices('0123456789abcdef', k=16))def solve_pow(pow_config):"""解决极验的PoW工作量证明:param pow_config: dict 包含以下字段:- version: PoW版本号(通常为"1")- bits: 难度位数(0=无难度要求,即任意nonce都满足)- hashfunc: 哈希算法名("md5"/"sha1"/"sha256")- datetime: 服务端返回的时间戳字符串- lot_number: 批次号- captcha_id: 验证码实例ID- nonce: nonce前缀(通常为空字符串):return: dict {pow_msg: str, pow_sign: str}消息格式: "version|bits|hashfunc|datetime|lot_number|captcha_id|nonce_prefix|nonce""""version = pow_config.get('version', '1')bits = pow_config.get('bits', 0)hashfunc = pow_config.get('hashfunc', 'md5')datetime_str = pow_config.get('datetime', '')lot_number = pow_config.get('lot_number', '')captcha_id = pow_config.get('captcha_id', '')nonce_prefix = pow_config.get('nonce', '')# === 难度参数计算 ===full_bits = bits % 4 # 余数位(0~3)prefix_len = int(bits / 4) # 需要的前导零hex字符数prefix = '0' * prefix_len # 前导零字符串# === 暴力搜索循环 ===while True:nonce = generate_guid() # 生成随机16位hex nonce# 构建完整的 PoW 消息# 格式严格对应 JS 源码行 2462pow_msg = f"{version}|{bits}|{hashfunc}|{datetime_str}|" \f"{lot_number}|{captcha_id}|{nonce_prefix}|{nonce}"# 计算哈希if hashfunc == 'md5':pow_sign = hashlib.md5(pow_msg.encode()).hexdigest()elif hashfunc == 'sha1':pow_sign = hashlib.sha1(pow_msg.encode()).hexdigest()elif hashfunc == 'sha256':pow_sign = hashlib.sha256(pow_msg.encode()).hexdigest()else:pow_sign = hashlib.md5(pow_msg.encode()).hexdigest()# === 难度判定 ===if full_bits == 0:# bits 是4的整数倍(包括 bits=0 的情况)# bits=0 时 prefix="" ,任何哈希都满足if pow_sign.startswith(prefix):return {'pow_msg': pow_msg, 'pow_sign': pow_sign}else:# bits 不是4的整数倍if pow_sign.startswith(prefix):next_char = pow_sign[prefix_len] # 前导零后的第一个hex字符# 阈值映射(与JS源码行 2486-2493 对应)threshold_map = {1: '7', 2: '3', 3: '1'}threshold = threshold_map.get(full_bits, 'f')if next_char <= threshold: # hex字符比较return {'pow_msg': pow_msg, 'pow_sign': pow_sign}
13.4 PoW 在主流程中的调用
# === 在 geetest_solver.py 中 ===# 从 load 响应中提取 PoW 参数pow_config = {'version': pow_detail.get('version', '1'),'bits': pow_detail.get('bits', 0), # 典型值: 0(无难度要求)'hashfunc': pow_detail.get('hashfunc', 'md5'),'datetime': pow_detail.get('datetime', ''),'lot_number': lot_number, # load 响应中的批次号'captcha_id': captcha_id, # 页面配置的验证码ID'nonce': pow_detail.get('nonce', ''), # 通常为空}pow_result = solve_pow(pow_config)# pow_result 包含:# pow_msg: "1|0|md5|2026-04-28T12:00:00+08:00|72ce...|24f5...||abc123def456"# pow_sign: "d41d8cd98f00b204e9800998ecf8427e"
注意:当 bits=0 时(目前观察到的典型配置),PoW 实际上没有难度要求,第一次随机 nonce 就能通过。但代码必须正确实现完整的 PoW 逻辑,因为服务端可能随时调高难度。
十四、w 参数加密体系
14.1 加密分发机制
w 参数是整个验证流程中最核心的加密参数。根据 load 响应中的 pt 字段,选择不同的加密方案。
JS 源码(gcaptcha4_deobfuscated.js 行 3751-3778):
// w 参数加密入口函数// _v3885 = payload JSON 字符串(明文)// _v3886.options = 配置对象,包含 pt 等参数functioni(_v3885, _v3886) {var s = _v3886.options;// ===== pt="0": 无加密,直接 Base64 URL-safe 编码 =====if(!s.pt || "0" === s.pt)return _v3876.default.urlsafe_encode(_v3885);// 生成16位随机hex密钥var n = (0, _v3881.guid)();// 加密方案映射表var r = {1: {symmetrical: _v3877.default, // AES-128-CBC(对称加密)asymmetric: new _v3878.default() // RSA-1024(非对称加密)},2: {symmetrical: new _v3879.default({ // SM4-CBC(国密对称加密)key: n, mode: "cbc", iv: "0000000000000000"}),asymmetric: _v3880.default // SM2(国密非对称加密)}};if("1" === s.pt || "2" === s.pt) {var o = "1" === s.pt;var a = s.pt;// 用非对称算法加密随机密钥var _ = r[a].asymmetric.encrypt(n);// 对于 RSA (pt=1),密文必须是256字符(128字节hex)// 如果长度不对就重新生成密钥(极少发生)while(o && (!_ || 256 !== _.length))n = (0, _v3881.guid)(),_ = new _v3878.default().encrypt(n);// 用对称算法加密 payloadvar u = r[a].symmetrical.encrypt(_v3885, n);// ★ 最终格式: arrayToHex(对称密文) + 非对称密文return(0, _v3881.arrayToHex)(u) + _;}}
加密方案总结:
| pt 值 | 对称加密 | 非对称加密 | w 格式 ||-------|---------|-----------|--------|| `"0"` | 无 | 无 | `Base64_urlsafe(payload)` || `"1"` | AES-128-CBC | RSA-1024 | `hex(AES(payload, key))` + `RSA(key)` || `"2"` | SM4-CBC | SM2 | `hex(SM4(payload, key))` + `SM2(key)` |
核心思想:混合加密(Hybrid Encryption)—— 对称加密处理大数据,非对称加密保护对称密钥。
14.2 arrayToHex — 字节数组转 Hex 字符串
JS 源码(行 306-312):
// 将字节数组转换为十六进制字符串// 输入: [72, 101, 108, 108, 111]// 输出: "48656c6c6f"_v80.arrayToHex = function _v168(_v167) {// 第一步:将十进制字节值打包到32位整数数组中(每4字节一个int)for (var t = [], s = 0, n = 0; n < 2 * _v167.length; n += 2)t[n >>> 3] |= parseInt(_v167[s], 10) << 24 - n % 8 * 4,s++;// 第二步:从32位整数中逐字节提取,转为两位hexfor (var i = [], r = 0; r < _v167.length; r++) {var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255; // 提取单字节i.push((o >>> 4).toString(16)); // 高4位转hexi.push((15 & o).toString(16)); // 低4位转hex}return i.join("");};
Node.js 实现:
function arrayToHex(arr) {var t = [], s = 0;for (var n = 0; n < 2 * arr.length; n += 2) {t[n >>> 3] |= parseInt(arr[s], 10) << 24 - n % 8 * 4;s++;}var i = [];for (var r = 0; r < arr.length; r++) {var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255;i.push((o >>> 4).toString(16));i.push((15 & o).toString(16));}return i.join("");}
14.3 Base64 编码(pt=”0″ 时使用)
JS 源码(行 3780-3879):
// 标准 Base64 字符集var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";// URL-safe Base64 字符集(将 +/ 替换为 -_)var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
Node.js 实现:
var Base64 = (function() {var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";// 字符串转 UTF-8 字节数组function strToBytes(str) {var bytes = [];for (var i = 0; i < str.length; i++) {var code = str.charCodeAt(i);if (code < 128) {bytes.push(code); // ASCII: 单字节} else if (code < 2048) {bytes.push(192 | code >> 6); // 2字节 UTF-8bytes.push(128 | 63 & code);} else {bytes.push(224 | code >> 12); // 3字节 UTF-8bytes.push(128 | (code >> 6 & 63));bytes.push(128 | 63 & code);}}return bytes;}function encode(str, urlsafe) {var chars = urlsafe ? URLSAFE_CHARS : CHARS;var bytes = strToBytes(str);var result = [];// 每3字节编码为4个 Base64 字符for (var i = 0; i < bytes.length; i += 3) {var b1 = bytes[i];var b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;var b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;result.push(chars.charAt(b1 >> 2)); // 取b1高6位result.push(chars.charAt((3 & b1) << 4 | b2 >> 4)); // b1低2位+b2高4位result.push(i + 1 < bytes.length? chars.charAt((15 & b2) << 2 | b3 >> 6) // b2低4位+b3高2位: (urlsafe ? "" : "=")); // 填充result.push(i + 2 < bytes.length? chars.charAt(63 & b3) // b3低6位: (urlsafe ? "" : "=")); // 填充}return result.join("");}return {encode: function(str) { return encode(str, false); },urlsafe_encode: function(str) { return encode(str, true); } // pt="0"时使用};})();
14.4 AES-128-CBC(pt=”1″ 的对称加密)
JS 源码位置:行 3881-4148,是一个完整的 CryptoJS 风格 AES 实现。
关键参数:
-
算法:AES-128-CBC -
密钥:guid() 生成的 16 位 hex 字符串,以 Latin1 编码为 16 字节(= AES-128 密钥长度) -
IV:固定字符串 "0000000000000000",以 Latin1 编码为 16 字节 -
填充:PKCS7
Node.js 实现(使用 Node.js 内置 crypto 模块替代原始 CryptoJS 实现):
const nodeCrypto = require('crypto');var AES = {encrypt: function(plaintext, key) {// key 是 16 位 hex 字符串(如 "a3b1c2d4e5f67890")// 以 Latin1 编码转为 16 字节 Buffer → AES-128 密钥var keyBuf = Buffer.from(key, 'latin1');// IV 固定为 "0000000000000000" 的 Latin1 编码// 注意:这不是 16 字节的零,而是 ASCII '0'(0x30) 重复 16 次var ivBuf = Buffer.from('0000000000000000', 'latin1');var cipher = nodeCrypto.createCipheriv('aes-128-cbc', keyBuf, ivBuf);// 加密并返回字节数组(不是 hex 字符串)var encrypted = cipher.update(plaintext, 'utf8');var final = cipher.final();var combined = Buffer.concat([encrypted, final]);return Array.from(combined); // 转为普通数组,后续用 arrayToHex 转hex}};
14.5 RSA-1024(pt=”1″ 的非对称加密)
JS 源码位置:行 4149-4510,包含一个完整的大数运算库(BigInteger)和 PKCS#1 v1.5 填充实现。
关键参数(硬编码在 JS 中):
-
密钥长度:1024 位(128 字节) -
公钥 N(模数): 00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81 -
公钥 E(指数): 10001(= 65537,标准 RSA 公钥指数) -
填充:PKCS#1 v1.5(类型 2,随机填充)
PKCS#1 v1.5 填充格式:
0x00 | 0x02 | [随机非零字节填充] | 0x00 | [明文消息]1B 1B 至少8字节 1B N字节
Node.js 实现:
var RSA_N = "00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81";var RSA_E = "10001";var RSA = {encrypt: function(plaintext) {var n = BigInt('0x' + RSA_N);var e = BigInt('0x' + RSA_E);// 密钥长度(字节):去掉前导00后的实际长度var keyLen = (RSA_N.length - (RSA_N.startsWith('00') ? 2 : 0)) / 2; // 128var msgBytes = Buffer.from(plaintext, 'utf8');if (msgBytes.length + 11 > keyLen) {console.error("Message too long for RSA");return null;}// === PKCS#1 v1.5 Type 2 填充 ===var padded = Buffer.alloc(keyLen); // 128 字节padded[0] = 0; // 0x00padded[1] = 2; // 0x02 (公钥加密)// 随机非零字节填充var paddingLen = keyLen - msgBytes.length - 3;var randomBytes = nodeCrypto.randomBytes(paddingLen);for (var i = 0; i < paddingLen; i++) {while (randomBytes[i] === 0) { // 确保非零randomBytes[i] = nodeCrypto.randomBytes(1)[0];}padded[2 + i] = randomBytes[i];}padded[2 + paddingLen] = 0; // 0x00 分隔符msgBytes.copy(padded, 3 + paddingLen); // 复制明文// === 大数幂模运算:c = m^e mod n ===var m = BigInt('0x' + padded.toString('hex'));var c = modPow(m, e, n);var hex = c.toString(16);if (hex.length % 2 !== 0) hex = '0' + hex; // 确保偶数长度return hex; // 返回 256 字符的 hex}};// 快速幂模运算(平方-乘法 算法)function modPow(base, exp, mod) {var result = 1n;base = base % mod;while (exp > 0n) {if (exp % 2n === 1n) {result = (result * base) % mod; // 奇数指数:乘以基数}exp = exp / 2n; // 指数减半base = (base * base) % mod; // 基数平方}return result;}
14.6 SM4-CBC(pt=”2″ 的对称加密)
JS 源码位置:行 4514-4606,SM4 国密分组密码算法。
关键参数:
-
算法:SM4-CBC -
密钥:16 字节(guid() 生成的 16 位 hex,Latin1 编码) -
IV: "0000000000000000"(16 字节) -
填充:PKCS7
SM4 算法结构:
-
分组长度:128 位(16 字节) -
密钥长度:128 位(16 字节) -
32 轮非线性迭代(Feistel 结构变种) -
S-box:256 个字节的替换表 -
线性变换 L:B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)
Node.js 实现(完整的 SM4 纯 JS 实现,不依赖第三方库):
// SM4 S-box 替换表(256字节)var SM4_SBOX = [0xd6, 0x90, 0xe9, 0xfe, 0xcc, 0xe1, 0x3d, 0xb7, /* ... 完整256字节 ... */];// 32个固定密钥(CK常数)var SM4_CK = [0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269,/* ... 完整32个 ... */];// 系统参数(FK常数)var SM4_FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc];// 循环左移function sm4_rotl(x, n) { return ((x << n) | (x >>> (32 - n))) >>> 0; }// 非线性变换 τ:4字节 S-box 替换function sm4_tau(A) {return (SM4_SBOX[(A >>> 24) & 0xff] << 24 |SM4_SBOX[(A >>> 16) & 0xff] << 16 |SM4_SBOX[(A >>> 8) & 0xff] << 8 |SM4_SBOX[A & 0xff]) >>> 0;}// 线性变换 L(加密用)function sm4_L(B) {return (B ^ sm4_rotl(B, 2) ^ sm4_rotl(B, 10) ^sm4_rotl(B, 18) ^ sm4_rotl(B, 24)) >>> 0;}// 线性变换 L'(密钥扩展用)function sm4_L_prime(B) {return (B ^ sm4_rotl(B, 13) ^ sm4_rotl(B, 23)) >>> 0;}// SM4 构造函数:执行密钥扩展function SM4(config) {var keyBytes = strToBytes(config.key);var ivBytes = config.iv ? strToBytes(config.iv) : new Array(16).fill(0);this.key = keyBytes;this.iv = ivBytes;this.mode = config.mode || "cbc";// 密钥扩展:生成32个轮密钥this.encryptRoundKeys = new Array(32);var MK = this.toUint32Block(this.key);var K = [MK[0] ^ SM4_FK[0], MK[1] ^ SM4_FK[1],MK[2] ^ SM4_FK[2], MK[3] ^ SM4_FK[3]];for (var i = 0; i < 32; i++) {var tmp = K[i + 1] ^ K[i + 2] ^ K[i + 3] ^ SM4_CK[i];tmp = sm4_tau(tmp);tmp = sm4_L_prime(tmp);K.push((K[i] ^ tmp) >>> 0);this.encryptRoundKeys[i] = K[i + 4];}}// 16字节 → 4个 uint32SM4.prototype.toUint32Block = function(bytes, offset) {offset = offset || 0;return [(bytes[offset]<<24 | bytes[offset+1]<<16 | bytes[offset+2]<<8 | bytes[offset+3]) >>> 0,(bytes[offset+4]<<24 | bytes[offset+5]<<16 | bytes[offset+6]<<8 | bytes[offset+7]) >>> 0,(bytes[offset+8]<<24 | bytes[offset+9]<<16 | bytes[offset+10]<<8 | bytes[offset+11]) >>> 0,(bytes[offset+12]<<24 | bytes[offset+13]<<16 | bytes[offset+14]<<8 | bytes[offset+15]) >>> 0];};// 单块加密(32轮迭代)SM4.prototype.doBlockCrypt = function(block, roundKeys) {var X = block.slice();for (var i = 0; i < 32; i++) {var tmp = X[i+1] ^ X[i+2] ^ X[i+3] ^ roundKeys[i];tmp = sm4_tau(tmp);tmp = sm4_L(tmp);X.push((X[i] ^ tmp) >>> 0);}return [X[35], X[34], X[33], X[32]]; // 反序输出};// PKCS7 填充SM4.prototype.padding = function(bytes) {var padLen = 16 - bytes.length % 16;var padded = bytes.slice();for (var i = 0; i < padLen; i++) padded.push(padLen);return padded;};// CBC 模式加密SM4.prototype.encrypt = function(plaintext) {var bytes = strToBytes(plaintext);var padded = this.padding(bytes);var blockCount = padded.length / 16;var result = new Array(padded.length);var prev = this.toUint32Block(this.iv); // 初始向量for (var i = 0; i < blockCount; i++) {var offset = 16 * i;var block = this.toUint32Block(padded, offset);// CBC: 明文块 ⊕ 前一密文块(或IV)block[0] ^= prev[0]; block[1] ^= prev[1];block[2] ^= prev[2]; block[3] ^= prev[3];var encrypted = this.doBlockCrypt(block, this.encryptRoundKeys);prev = encrypted;// uint32 → 字节数组for (var c = 0; c < 16; c++) {result[offset + c] = encrypted[parseInt(c/4)] >> (3-c)%4*8 & 255;}}return result;};
14.7 SM2 加密(pt=”2″ 的非对称加密)
JS 源码位置:行 4607-6624(约 2000 行椭圆曲线密码学代码),行 5840-5879 为加密入口。
SM2 实现极其复杂(包含完整的大数运算、椭圆曲线点运算、KDF 等),在 Node.js 中使用 sm-crypto 第三方库替代。
硬编码公钥:
9a4ea935b2576f37516d9b29cd8d8cc9bffe548ba6853253ba20f4ba44fba8c9e97a398882769aa0dd1e3e1b5601429287303880ca17bd244ed73bf702a68fc7
Node.js 实现:
var SM2_PUBLIC_KEY = "9a4ea935b2576f37516d9b29cd8d8cc9bffe548ba6853253" +"ba20f4ba44fba8c9e97a398882769aa0dd1e3e1b56014292" +"87303880ca17bd244ed73bf702a68fc7";var SM2 = {encrypt: function(plaintext, publicKey) {publicKey = publicKey || SM2_PUBLIC_KEY;var smCrypto = require('sm-crypto');// '04' 前缀表示非压缩格式的公钥// 第二个参数 1 表示使用 C1C3C2 密文格式var result = smCrypto.sm2.doEncrypt(plaintext, '04' + publicKey, 1);return result;}};
14.8 完整的 w 加密主函数
Node.js 实现:
function encryptW(payloadJson, pt) {pt = pt || "0";// === pt="0": 纯 Base64 URL-safe 编码 ===if (!pt || pt === "0") {return Base64.urlsafe_encode(payloadJson);}// 生成16位随机hex密钥var key = guid();if (pt === "1") {// === pt="1": RSA + AES 混合加密 ===// 1. 用 RSA 加密随机密钥var rsaEncryptedKey = RSA.encrypt(key);// 确保 RSA 密文长度为 256 字符(128字节hex)while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {key = guid();rsaEncryptedKey = RSA.encrypt(key);}// 2. 用 AES-128-CBC 加密 payloadvar aesCiphertext = AES.encrypt(payloadJson, key);// 3. 拼接:AES密文hex + RSA密文hexreturn arrayToHex(aesCiphertext) + rsaEncryptedKey;}if (pt === "2") {// === pt="2": SM2 + SM4 混合加密 ===var sm2EncryptedKey = SM2.encrypt(key);var sm4 = new SM4({ key: key, mode: "cbc", iv: "0000000000000000" });var sm4Ciphertext = sm4.encrypt(payloadJson);return arrayToHex(sm4Ciphertext) + sm2EncryptedKey;}// 未知 pt 值,降级为 Base64return Base64.urlsafe_encode(payloadJson);}
w 参数的最终结构(以 pt=”1″ 为例):
w = hex(AES_CBC(payload_json, random_key)) + RSA_1024(random_key)└── 可变长度(取决于payload大小)──┘ └── 固定256字符 ──┘总长度约为 1024~1280 字符
十五、verify 提交与 payload 构建
15.1 payload(w 参数加密前的明文)构建
在加密成 w 之前,需要构建一个 JSON 对象作为 payload。这个 JSON 的字段和顺序至关重要。
JS 源码(gcaptcha4_deobfuscated.js 行 2097-2117):
// 行 2097-2101: 合并基础字段(0, _v1911.$_BBF)(_v2160, {device_id: _v2169.deviceId, // 设备ID(通常为空)lot_number: _v2169.lotNumber, // 批次号pow_msg: _v2167.options.powMsg, // PoW 消息pow_sign: _v2167.options.powSign // PoW 签名});// 行 2102: 调用 gee_guard 指纹采集_v2167.$_BBEb(_v2160);// 行 2108-2109: 合并 gee_guard 和 _lib 数据(0, _v1911.$_BBF)(_v2176, {gee_guard: _v2177.geeGuard // ★ 当 geeGuard 为 undefined 时,// JSON.stringify 会跳过这个字段});(0, _v1911.$_BBF)(_v2176, window._lib ? window._lib : {});// window._lib = {"1a8R": "daC2"} // 全局注入的字段// 行 2111-2117: lot_number 混淆映射// window.lib._abo = {"n[3:5]+n[9:11]": "n[7:12]"}// 这个映射用 lot_number 的子串作为 key,设置 payload 中的嵌套属性var i = getStringByIndexes(_v2177.lot, _v2177.lotNumber);var r = getStringByIndexes(_v2177.lotRes, _v2177.lotNumber);// 例如 lot_number = "72ce10e53aea4ac6b0b3217285a2a503"// lot 解析 "n[3:5]+n[9:11]" → lot_number[3:5] + "." + lot_number[9:11]// lotRes 解析 "n[7:12]" → lot_number[7:12]// 产生动态字段,嵌套设置到 payload 中// 行 2117: 设置 em = {} 并清空设备指纹_v2176.em = {};// 行 2118: 最终加密var _v2181 = (0, _v1921.default)(_v1918.default.stringify(_v2176), // payload → JSON 字符串_v2179 // 配置对象(含 pt));
15.2 我们的 payload 构建
经过分析,我们只需要构建核心字段即可通过验证。gee_guard(当 undefined 时 JSON.stringify 会跳过它)和 _lib 中的 "1a8R":"daC2" 在目前的验证中不是必需的。
Node.js 中的 payload 构建 (generate_w.js):
function processInput(jsonStr) {var params = JSON.parse(jsonStr);// 构建 payload 对象// 字段顺序按照 JS 源码的合并顺序var payload = {setLeft: params.setLeft, // 滑动距离(显示坐标,整数)passtime: params.passtime, // 操作耗时(毫秒)userresponse: params.userresponse // 原图坐标位移+2|| (params.setLeft / (params.scale || 1) + 2),device_id: params.device_id || "", // 设备ID(空字符串)lot_number: params.lot_number || "", // 批次号pow_msg: params.pow_msg || "", // PoW 消息pow_sign: params.pow_sign || "", // PoW 签名em: params.em || {}, // 设备指纹(空对象)};// ★ 注意:不包含 gee_guard 字段// JS 源码中 geeGuard 为 undefined 时,JSON.stringify 会自动跳过// 如果我们设 gee_guard: null,JSON 会输出 "gee_guard":null,导致校验失败!// 轨迹编码if (params.track && params.track.length > 0) {payload.aa = encodeTrack(params.track); // 差分压缩 + 自定义编码}// JSON 序列化并加密var payloadJson = JSON.stringify(payload);var pt = params.pt || "1";var w = encryptW(payloadJson, pt);// 返回加密后的 w 和调试用的明文 payloadprocess.stdout.write(JSON.stringify({w: w,payload_debug: payload}));}
最终 payload JSON 示例:
{"setLeft": 128,"passtime": 1507,"userresponse": 129.24,"device_id": "","lot_number": "72ce10e53aea4ac6b0b3217285a2a503","pow_msg": "1|0|md5|2026-04-28T12:00:00+08:00|72ce...|24f5...||abc123","pow_sign": "d41d8cd98f00b204e9800998ecf8427e","em": {},"aa": "0-1$)3!!stuv(!x0!!(/1,0-,+"}
15.3 verify 接口请求
verify 是整个流程的最后一步,将所有计算结果提交给服务端验证。
请求格式:
GET https://gcaptcha4.geetest.com/verify?callback=geetest_1776962556789&captcha_id=24f56dc13c40dc4a02fd0318567caef5&challenge=a3b1c2d4e5f67890&client_type=web&lot_number=72ce10e53aea4ac6b0b3217285a2a503&risk_type=slide&payload=eyJ... # load 返回的不透明数据,原样回传&process_token=d3a1b2c3... # load 返回的会话令牌,原样回传&payload_protocol=1&pt=1 # 加密方式&w=a3b1c2d4...(1280字符的加密数据) # 核心加密参数
Python 实现:
def call_verify(session, captcha_id, challenge, verify_params):"""调用极验 verify 接口,提交验证数据:param verify_params: dict 包含:- lot_number: 批次号- payload: load 返回的不透明数据- process_token: 会话令牌- payload_protocol: 协议版本- pt: 加密方式- w: 加密后的验证数据"""callback = generate_callback()params = {'callback': callback,'captcha_id': captcha_id,'challenge': challenge,'client_type': 'web','lot_number': verify_params['lot_number'],'risk_type': 'slide','payload': verify_params['payload'],'process_token': verify_params['process_token'],'payload_protocol': verify_params.get('payload_protocol', 1),'pt': verify_params.get('pt', '1'),'w': verify_params['w'],}resp = session.get(f"https://{API_SERVER}/verify",params=params,headers=HEADERS,timeout=15)return parse_jsonp(resp.text)
15.4 verify 响应解析
成功响应:
{"status": "success","data": {"result": "success","seccode": {"pass_token": "a1b2c3d4e5f6...","gen_time": "2026-04-28 12:00:05","lot_number": "72ce10e53aea4ac6b0b3217285a2a503","captcha_output": "..."}}}
失败响应:
{"status": "success","data": {"result": "fail","score": "0"}}
Python 判定逻辑:
status = result.get('status', 'unknown')res_data = result.get('data', {})inner_result = res_data.get('result', 'unknown')if status == 'success' and inner_result == 'success':print("验证通过!")seccode = res_data.get('seccode', {})pass_token = seccode.get('pass_token', '') # 验证成功的凭证gen_time = seccode.get('gen_time', '') # 生成时间else:print("验证未通过!")
十六、Python 主控流程完整代码
以下是 geetest_solver.py 的完整实现,包含所有 8 个步骤的详细注释。
"""极验 GeeTest v4 滑块验证码自动求解器完整流程:load → 下载图片 → 识别缺口 → 生成轨迹 → PoW → 加密w → verify"""import jsonimport reimport timeimport randomimport subprocessimport osimport structimport requestsfrom trajectory import generate_trajectory, get_passtime # 轨迹生成模块from pow_solver import solve_pow # PoW 求解模块from gap_detector import detect_gap # 缺口识别模块# Node.js 加密脚本的绝对路径NODE_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),'node_env', 'generate_w.js')# ===== 配置常量 =====CAPTCHA_ID = "24f56dc13c40dc4a02fd0318567caef5" # 极验演示站的验证码IDAPI_SERVER = "gcaptcha4.geetest.com" # 极验API服务器STATIC_SERVER = "https://static.geetest.com" # 极验静态资源CDNCONTAINER_WIDTH = 340 # 验证码容器宽度(像素)# 模拟 Chrome 浏览器的请求头HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ''AppleWebKit/537.36 (KHTML, like Gecko) ''Chrome/120.0.0.0 Safari/537.36','Accept': '*/*','Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8','Referer': 'https://www.geetest.com/adaptive-captcha',}SEP = "=" * 60 # 分隔线def generate_callback():"""生成 JSONP 回调函数名:geetest_{毫秒时间戳}"""return f"geetest_{int(time.time() * 1000)}"def guid_python():"""生成16位随机hex字符串(Python版)与 JS 的 guid() 函数等价"""def e():return format((int(65536 * (1 + random.random())) | 0), 'x')[-4:]return e() + e() + e() + e()def parse_jsonp(text):"""解析 JSONP 响应输入: 'geetest_123({"status":"success","data":{...}})'输出: {"status":"success","data":{...}}"""match = re.search(r'\((\{.*\})\)', text, re.DOTALL)if match:return json.loads(match.group(1))return json.loads(text)def get_image_size(png_data):"""从 PNG 二进制数据中提取宽高PNG IHDR chunk: 字节16-19=宽度, 字节20-23=高度(大端序uint32)"""if png_data[:4] == b'\x89PNG':w = struct.unpack('>I', png_data[16:20])[0]h = struct.unpack('>I', png_data[20:24])[0]return w, hreturn 300, 200def call_load(session, captcha_id, challenge):"""Step 1: 调用 load 接口获取验证码配置返回: lot_number, 图片路径, PoW参数等"""callback = generate_callback()params = {'callback': callback,'captcha_id': captcha_id,'challenge': challenge,'client_type': 'web','risk_type': 'slide','lang': 'zh',}resp = session.get(f"https://{API_SERVER}/load",params=params, headers=HEADERS, timeout=15)return parse_jsonp(resp.text)def call_verify(session, captcha_id, challenge, verify_params):"""Step 8: 调用 verify 接口提交验证数据"""callback = generate_callback()params = {'callback': callback,'captcha_id': captcha_id,'challenge': challenge,'client_type': 'web','lot_number': verify_params['lot_number'],'risk_type': 'slide','payload': verify_params['payload'],'process_token': verify_params['process_token'],'payload_protocol': verify_params.get('payload_protocol', 1),'pt': verify_params.get('pt', '1'),'w': verify_params['w'],}resp = session.get(f"https://{API_SERVER}/verify",params=params, headers=HEADERS, timeout=15)return parse_jsonp(resp.text)def generate_w_via_node(params):"""调用 Node.js 子进程生成加密参数 w通过 stdin 传入 JSON 参数,stdout 接收加密结果"""input_json = json.dumps(params)result = subprocess.run(['node', NODE_SCRIPT],input=input_json,capture_output=True,text=True,timeout=30)if result.returncode != 0:raise RuntimeError(f"Node.js error: {result.stderr}")output = json.loads(result.stdout)return output['w'], output.get('payload_debug', {})def solve_captcha(captcha_id=None):"""主流程:完成极验 v4 滑块验证码的自动求解返回 verify 接口的响应结果"""captcha_id = captcha_id or CAPTCHA_IDsession = requests.Session()session.headers.update(HEADERS)challenge = guid_python() # 生成本次挑战的唯一令牌# === Step 1: 调用 load 接口 ===load_resp = call_load(session, captcha_id, challenge)if load_resp.get('status') != 'success':return Nonedata = load_resp['data']lot_number = data['lot_number']payload = data['payload']process_token = data['process_token']pt = str(data.get('pt', '1'))payload_protocol = data.get('payload_protocol', 1)pow_detail = data.get('pow_detail', {})bg_path = data.get('bg', '')slice_path = data.get('slice', '')# === Step 2: 下载背景图和滑块碎片图 ===bg_url = f"{STATIC_SERVER}/{bg_path}"bg_resp = session.get(bg_url, timeout=15)bg_w, bg_h = get_image_size(bg_resp.content) # 获取原图尺寸(如344x212)# === Step 3: 识别缺口位置 ===try:gap_x = detect_gap(image_bytes=bg_resp.content)except Exception:gap_x = random.randint(100, 250) # OCR失败时的备用方案# === 坐标换算 ===SLIDER_OFFSET = 15 # 滑块初始偏移(像素)scale = 0.8876 * CONTAINER_WIDTH / bg_w # 缩放比adjusted_gap_x = gap_x - SLIDER_OFFSET # 减去滑块偏移slide_distance = adjusted_gap_x * scale # 转为显示坐标系setLeft = int(slide_distance) # 取整userresponse = setLeft / scale + 2 # 转回原图坐标+2# === Step 4: 模拟思考延时 ===delay = random.uniform(1.5, 3.0)time.sleep(delay)# === Step 5: 生成拟人化轨迹 ===track_display = generate_trajectory(int(slide_distance))passtime = get_passtime(track_display)# 显示坐标→原图坐标系track = [[round(p[0] / scale), round(p[1] / scale), p[2]]for p in track_display]# 插入初始偏移点initial_offset = [random.randint(-30, -10), random.randint(-10, 10), 0]track.insert(0, initial_offset)# === Step 6: 计算 PoW ===pow_config = {'version': pow_detail.get('version', '1'),'bits': pow_detail.get('bits', 0),'hashfunc': pow_detail.get('hashfunc', 'md5'),'datetime': pow_detail.get('datetime', ''),'lot_number': lot_number,'captcha_id': captcha_id,'nonce': pow_detail.get('nonce', ''),}pow_result = solve_pow(pow_config)# === Step 7: 调用 Node.js 生成加密参数 w ===w_params = {'setLeft': setLeft,'passtime': passtime,'userresponse': userresponse,'device_id': '','lot_number': lot_number,'pow_msg': pow_result['pow_msg'],'pow_sign': pow_result['pow_sign'],'em': {},'track': track,'pt': pt,'scale': scale,}w, payload_debug = generate_w_via_node(w_params)# === Step 8: 提交 verify 请求 ===result = call_verify(session, captcha_id, challenge, {'lot_number': lot_number,'payload': payload,'process_token': process_token,'payload_protocol': payload_protocol,'pt': pt,'w': w,})# 判定验证结果status = result.get('status', 'unknown')res_data = result.get('data', {})inner_result = res_data.get('result', 'unknown')if status == 'success' and inner_result == 'success':print("验证通过!")else:print("验证未通过!")return resultif __name__ == "__main__":solve_captcha()
十七、Node.js 加密模块完整代码
以下是 node_env/generate_w.js 的完整实现。由于代码较长,分为 6 个部分逐段说明。
17.1 基础工具函数:guid 与 arrayToHex
'use strict';const nodeCrypto = require('crypto'); // Node.js 内置加密模块// ============================// guid() - 生成16位随机hex密钥// 对应源码: gcaptcha4_deobfuscated.js 行 457-464// 用途: 生成对称加密的随机密钥(AES/SM4的key)// ============================function guid() {function e() {// 生成 65536~131071 范围的整数,取十六进制后4位return (65536 * (1 + Math.random()) | 0).toString(16).substring(1);}return e() + e() + e() + e(); // 4×4 = 16位hex}// ============================// arrayToHex() - 字节数组转hex字符串// 对应源码: 行 306-312// 用途: 将 AES/SM4 加密输出的字节数组转为hex字符串// 示例: [72, 101, 108] → "48656c"// ============================function arrayToHex(arr) {// 第一步:将字节值打包到32位整数数组中var t = [], s = 0;for (var n = 0; n < 2 * arr.length; n += 2) {t[n >>> 3] |= parseInt(arr[s], 10) << 24 - n % 8 * 4;s++;}// 第二步:逐字节提取,转为两位hex字符var i = [];for (var r = 0; r < arr.length; r++) {var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255; // 提取单字节i.push((o >>> 4).toString(16)); // 高4位i.push((15 & o).toString(16)); // 低4位}return i.join("");}
17.2 轨迹编码:差分压缩 + 方向映射 + 数值编码
// ============================// 轨迹编码模块// 对应源码: 行 1736-1771// 流程: 原始轨迹 → 差分压缩 → 分类编码 → 三段拼接// ============================// --- 差分压缩 ---// 将绝对坐标轨迹转为增量 [dx, dy, dt]// 如果连续多个点只有时间变化没有位移,累积时间到下一个有效点function trackDiff(trackPoints) {var i = [], // 结果数组r = 0; // 累积的静止时间for (var o = 0, a = trackPoints.length - 1; o < a; o += 1) {var t = Math.round(trackPoints[o + 1][0] - trackPoints[o][0]); // dxvar s = Math.round(trackPoints[o + 1][1] - trackPoints[o][1]); // dyvar n = Math.round(trackPoints[o + 1][2] - trackPoints[o][2]); // dtif (!(0 === t && 0 === s && 0 === n)) { // 过滤完全无变化的点if (0 === t && 0 === s) {r += n; // 仅时间变化,累积不输出} else {i.push([t, s, n + r]); // 有位移,带上累积时间r = 0; // 重置累积器}}}if (0 !== r) i.push([t, s, r]); // 尾部残余时间return i;}// --- 方向编码 ---// 9种常见运动方向用单字符表示,节省编码空间// 如果不匹配则返回0,需要用数值编码function encodeDirection(point) {var dirs = [[1, 0], // 右移1 → 's'[2, 0], // 右移2 → 't'[1, -1], // 右1下1 → 'u'[1, 1], // 右1上1 → 'v'[0, 1], // 上移1 → 'w'[0, -1], // 下移1 → 'x'[3, 0], // 右移3 → 'y'[2, -1], // 右2下1 → 'z'[2, 1] // 右2上1 → '~'];var chars = "stuvwxyz~";for (var s = 0; s < dirs.length; s++) {if (point[0] === dirs[s][0] && point[1] === dirs[s][1])return chars[s];}return 0; // 不在预定义方向中}// --- 数值编码 ---// 使用自定义64字符集的变长编码// 编码规则:// 正数小于64: 直接 charset[val] → 1字符// 正数>=64: "$" + charset[高位] + charset[低位] → 3字符// 负数: "!" 前缀 + 上述编码 → 2或4字符function encodeValue(val) {var charset = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr";var base = charset.length; // 64var n = "";var i = Math.abs(val);var r = parseInt(i / base, 10); // 高位if (base <= r) r = base - 1; // 防溢出(最大编码 64×64-1)if (r) n = charset.charAt(r); // 高位字符var o = "";if (val < 0) o += "!"; // 负数标记if (n) o += "$"; // 多字节标记return o + n + charset.charAt(i % base); // 拼接}// --- 完整编码 ---// 差分 → 分类(方向/数值) → 三段用"!!"连接function encodeTrack(trackPoints) {var diffed = trackDiff(trackPoints);var xParts = []; // X增量编码var yParts = []; // Y增量编码(含方向字符)var tParts = []; // 时间增量编码for (var idx = 0; idx < diffed.length; idx++) {var point = diffed[idx];var dirChar = encodeDirection(point);if (dirChar) {yParts.push(dirChar); // 方向字符只放Y段,X段不输出} else {xParts.push(encodeValue(point[0])); // X段:数值编码dxyParts.push(encodeValue(point[1])); // Y段:数值编码dy}tParts.push(encodeValue(point[2])); // T段:始终编码dt}// 最终格式: "X编码!!Y编码!!T编码"return xParts.join("") + "!!" + yParts.join("") + "!!" + tParts.join("");}
17.3 Base64 编码器 + AES-128-CBC
// ============================// Base64 编码器(标准 + URL-safe 两种变体)// 对应源码: 行 3780-3879// pt="0" 时使用 urlsafe_encode// ============================var Base64 = (function() {var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +"abcdefghijklmnopqrstuvwxyz" +"0123456789+/"; // 标准 Base64 字符集var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +"abcdefghijklmnopqrstuvwxyz" +"0123456789-_"; // URL-safe 变体(+→-, /→_)// 字符串 → UTF-8 字节数组functionstrToBytes(str) {var bytes = [];for(var i = 0; i < str.length; i++) {var code = str.charCodeAt(i);if(code < 128) {bytes.push(code); // 单字节 ASCII} else if (code < 2048) {bytes.push(192 | code >> 6); // 2字节 UTF-8bytes.push(128 | 63 & code);} else {bytes.push(224 | code >> 12); // 3字节 UTF-8bytes.push(128 | (code >> 6 & 63));bytes.push(128 | 63 & code);}}return bytes;}// 编码主函数:每3字节输出4个Base64字符functionencode(str, urlsafe) {var chars = urlsafe ? URLSAFE_CHARS : CHARS;var bytes = strToBytes(str);var result = [];for(var i = 0; i < bytes.length; i += 3) {var b1 = bytes[i];var b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;var b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;result.push(chars.charAt(b1 >> 2));result.push(chars.charAt((3 & b1) << 4 | b2 >> 4));result.push(i + 1 < bytes.length? chars.charAt((15 & b2) << 2 | b3 >> 6): (urlsafe ? "" : "="));result.push(i + 2 < bytes.length? chars.charAt(63 & b3): (urlsafe ? "": "="));}return result.join("");}return {encode: function(str) { return encode(str, false); },urlsafe_encode: function(str) { return encode(str, true); }};})();// ============================// AES-128-CBC 加密// 对应源码: 行 3881-4148(CryptoJS 风格实现)// 简化: 使用 Node.js crypto 模块替代//// 参数说明:// 密钥 = guid() 生成的16位hex,Latin1编码 → 16字节 = AES-128// IV = "0000000000000000" Latin1编码 → 16字节 (0x30重复16次,不是0x00)// 填充 = PKCS7(Node.js crypto 默认)// ============================var AES = {encrypt: function(plaintext, key) {var keyBuf = Buffer.from(key, 'latin1'); // 16位hex → 16字节var ivBuf = Buffer.from('0000000000000000', 'latin1'); // ASCII '0' × 16var cipher = nodeCrypto.createCipheriv('aes-128-cbc', keyBuf, ivBuf);var encrypted = cipher.update(plaintext, 'utf8');var final = cipher.final();var combined = Buffer.concat([encrypted, final]);return Array.from(combined); // 返回字节数组}};
17.4 RSA-1024 (PKCS#1 v1.5)
// ============================// RSA-1024 加密// 对应源码: 行 4149-4510// 公钥硬编码在 JS 中,无需从服务端获取// ============================// RSA 公钥(硬编码)var RSA_N = "00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947" +"713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29AC" +"B6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221" +"AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B5970" +"6592A9219D0BF05C9F65023A21D2330807252AE0066D59CE" +"EFA5F2748EA80BAB81";var RSA_E = "10001"; // 65537(标准RSA公钥指数)var RSA = {encrypt: function(plaintext) {var n = BigInt('0x' + RSA_N);var e = BigInt('0x' + RSA_E);// 密钥长度:去掉前导"00"后除以2 → 128字节(1024位)var keyLen = (RSA_N.length - (RSA_N.startsWith('00') ? 2 : 0)) / 2;var msgBytes = Buffer.from(plaintext, 'utf8');if (msgBytes.length + 11 > keyLen) { // PKCS#1 v1.5 最少需要11字节开销console.error("Message too long for RSA");return null;}// === PKCS#1 v1.5 Type 2 填充 ===// 格式: 0x00 | 0x02 | [随机非零字节] | 0x00 | [明文]var padded = Buffer.alloc(keyLen);padded[0] = 0; // 固定字节 0x00padded[1] = 2; // Type 2(公钥加密)// 填充随机非零字节(必须非零,否则解密时无法确定分隔位置)var paddingLen = keyLen - msgBytes.length - 3;var randomBytes = nodeCrypto.randomBytes(paddingLen);for (var i = 0; i < paddingLen; i++) {while (randomBytes[i] === 0) {randomBytes[i] = nodeCrypto.randomBytes(1)[0];}padded[2 + i] = randomBytes[i];}padded[2 + paddingLen] = 0; // 分隔符 0x00msgBytes.copy(padded, 3 + paddingLen); // 复制明文// === RSA 核心运算: c = m^e mod n ===var m = BigInt('0x' + padded.toString('hex'));var c = modPow(m, e, n);var hex = c.toString(16);if (hex.length % 2 !== 0) hex = '0' + hex; // 确保偶数长度return hex; // 应为256字符}};// 快速幂模运算(平方-乘法算法)// 计算 base^exp mod mod,使用 BigInt 避免精度丢失function modPow(base, exp, mod) {var result = 1n;base = base % mod;while (exp > 0n) {if (exp % 2n === 1n) { // 奇数指数result = (result * base) % mod;}exp = exp / 2n; // 指数减半base = (base * base) % mod; // 基数平方}return result;}
17.5 SM4-CBC(国密对称加密,核心常量表)
// ============================// SM4-CBC 加密// 对应源码: 行 4514-4606// 完整的国密分组密码纯JS实现// ============================// SM4 S-box(256字节替换表)var SM4_SBOX = [0xd6, 0x90, 0xe9, 0xfe, 0xcc, 0xe1, 0x3d, 0xb7,0x16, 0xb6, 0x14, 0xc2, 0x28, 0xfb, 0x2c, 0x05,0x2b, 0x67, 0x9a, 0x76, 0x2a, 0xbe, 0x04, 0xc3,0xaa, 0x44, 0x13, 0x26, 0x49, 0x86, 0x06, 0x99,0x9c, 0x42, 0x50, 0xf4, 0x91, 0xef, 0x98, 0x7a,0x33, 0x54, 0x0b, 0x43, 0xed, 0xcf, 0xac, 0x62,0xe4, 0xb3, 0x1c, 0xa9, 0xc9, 0x08, 0xe8, 0x95,0x80, 0xdf, 0x94, 0xfa, 0x75, 0x8f, 0x3f, 0xa6,0x47, 0x07, 0xa7, 0xfc, 0xf3, 0x73, 0x17, 0xba,0x83, 0x59, 0x3c, 0x19, 0xe6, 0x85, 0x4f, 0xa8,0x68, 0x6b, 0x81, 0xb2, 0x71, 0x64, 0xda, 0x8b,0xf8, 0xeb, 0x0f, 0x4b, 0x70, 0x56, 0x9d, 0x35,0x1e, 0x24, 0x0e, 0x5e, 0x63, 0x58, 0xd1, 0xa2,0x25, 0x22, 0x7c, 0x3b, 0x01, 0x21, 0x78, 0x87,0xd4, 0x00, 0x46, 0x57, 0x9f, 0xd3, 0x27, 0x52,0x4c, 0x36, 0x02, 0xe7, 0xa0, 0xc4, 0xc8, 0x9e,0xea, 0xbf, 0x8a, 0xd2, 0x40, 0xc7, 0x38, 0xb5,0xa3, 0xf7, 0xf2, 0xce, 0xf9, 0x61, 0x15, 0xa1,0xe0, 0xae, 0x5d, 0xa4, 0x9b, 0x34, 0x1a, 0x55,0xad, 0x93, 0x32, 0x30, 0xf5, 0x8c, 0xb1, 0xe3,0x1d, 0xf6, 0xe2, 0x2e, 0x82, 0x66, 0xca, 0x60,0xc0, 0x29, 0x23, 0xab, 0x0d, 0x53, 0x4e, 0x6f,0xd5, 0xdb, 0x37, 0x45, 0xde, 0xfd, 0x8e, 0x2f,0x03, 0xff, 0x6a, 0x72, 0x6d, 0x6c, 0x5b, 0x51,0x8d, 0x1b, 0xaf, 0x92, 0xbb, 0xdd, 0xbc, 0x7f,0x11, 0xd9, 0x5c, 0x41, 0x1f, 0x10, 0x5a, 0xd8,0x0a, 0xc1, 0x31, 0x88, 0xa5, 0xcd, 0x7b, 0xbd,0x2d, 0x74, 0xd0, 0x12, 0xb8, 0xe5, 0xb4, 0xb0,0x89, 0x69, 0x97, 0x4a, 0x0c, 0x96, 0x77, 0x7e,0x65, 0xb9, 0xf1, 0x09, 0xc5, 0x6e, 0xc6, 0x84,0x18, 0xf0, 0x7d, 0xec, 0x3a, 0xdc, 0x4d, 0x20,0x79, 0xee, 0x5f, 0x3e, 0xd7, 0xcb, 0x39, 0x48];// 32个固定密钥常量 CKvar SM4_CK = [0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269,0x70777e85, 0x8c939aa1, 0xa8afb6bd, 0xc4cbd2d9,0xe0e7eef5, 0xfc030a11, 0x181f262d, 0x343b4249,0x50575e65, 0x6c737a81, 0x888f969d, 0xa4abb2b9,0xc0c7ced5, 0xdce3eaf1, 0xf8ff060d, 0x141b2229,0x30373e45, 0x4c535a61, 0x686f767d, 0x848b9299,0xa0a7aeb5, 0xbcc3cad1, 0xd8dfe6ed, 0xf4fb0209,0x10171e25, 0x2c333a41, 0x484f565d, 0x646b7279];// 系统参数 FKvar SM4_FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc];
17.6 SM4 核心运算函数
// 循环左移(32位无符号)function sm4_rotl(x, n) {return ((x << n) | (x >>> (32 - n))) >>> 0;}// 非线性变换 τ:对32位输入的每个字节做 S-box 替换function sm4_tau(A) {return (SM4_SBOX[(A >>> 24) & 0xff] << 24 |SM4_SBOX[(A >>> 16) & 0xff] << 16 |SM4_SBOX[(A >>> 8) & 0xff] << 8 |SM4_SBOX[A & 0xff]) >>> 0;}// 线性变换 L(加密轮函数使用)function sm4_L(B) {return (B ^ sm4_rotl(B, 2) ^ sm4_rotl(B, 10) ^sm4_rotl(B, 18) ^ sm4_rotl(B, 24)) >>> 0;}// 线性变换 L'(密钥扩展使用,与 L 不同)function sm4_L_prime(B) {return (B ^ sm4_rotl(B, 13) ^ sm4_rotl(B, 23)) >>> 0;}// 字符串 → UTF-8 字节数组(SM4 的辅助函数)function strToBytes(str) {var bytes = [];for (var i = 0; i < str.length; i++) {var code = str.charCodeAt(i);if (code < 128) bytes.push(code);else if (code < 2048) {bytes.push(192 | code >> 6);bytes.push(128 | 63 & code);} else {bytes.push(224 | code >> 12);bytes.push(128 | (code >> 6 & 63));bytes.push(128 | 63 & code);}}return bytes;}// SM4 构造函数:初始化密钥扩展(生成32个轮密钥)function SM4(config) {var keyBytes = strToBytes(config.key);if (keyBytes.length !== 16) throw new Error("SM4 key must be 16 bytes");this.key = keyBytes;var ivBytes = config.iv ? strToBytes(config.iv) : new Array(16).fill(0);this.iv = ivBytes;this.mode = config.mode || "cbc";// 密钥扩展:从128位主密钥生成32个32位轮密钥this.encryptRoundKeys = new Array(32);var MK = this.toUint32Block(this.key); // 主密钥拆为4个uint32var K = [MK[0] ^ SM4_FK[0], MK[1] ^ SM4_FK[1], // 与系统参数FK异或MK[2] ^ SM4_FK[2], MK[3] ^ SM4_FK[3]];for (var i = 0; i < 32; i++) {var tmp = K[i + 1] ^ K[i + 2] ^ K[i + 3] ^ SM4_CK[i];tmp = sm4_tau(tmp); // S-box 替换tmp = sm4_L_prime(tmp); // L' 线性变换K.push((K[i] ^ tmp) >>> 0);this.encryptRoundKeys[i] = K[i + 4]; // 保存轮密钥}}// 16字节 → 4个 uint32(大端序)SM4.prototype.toUint32Block = function(bytes, offset) {offset = offset || 0;return [(bytes[offset]<<24 | bytes[offset+1]<<16 |bytes[offset+2]<<8 | bytes[offset+3]) >>> 0,(bytes[offset+4]<<24 | bytes[offset+5]<<16 |bytes[offset+6]<<8 | bytes[offset+7]) >>> 0,(bytes[offset+8]<<24 | bytes[offset+9]<<16 |bytes[offset+10]<<8 | bytes[offset+11]) >>> 0,(bytes[offset+12]<<24 | bytes[offset+13]<<16 |bytes[offset+14]<<8 | bytes[offset+15]) >>> 0];};// 单块加密:32轮非线性迭代SM4.prototype.doBlockCrypt = function(block, roundKeys) {var X = block.slice();for (var i = 0; i < 32; i++) {var tmp = X[i+1] ^ X[i+2] ^ X[i+3] ^ roundKeys[i];tmp = sm4_tau(tmp);tmp = sm4_L(tmp);X.push((X[i] ^ tmp) >>> 0);}return [X[35], X[34], X[33], X[32]]; // 反序输出};// PKCS7 填充SM4.prototype.padding = function(bytes) {var padLen = 16 - bytes.length % 16;var padded = bytes.slice();for (var i = 0; i < padLen; i++) padded.push(padLen);return padded;};// CBC 模式加密入口SM4.prototype.encrypt = function(plaintext) {var bytes = strToBytes(plaintext);var padded = this.padding(bytes);var blockCount = padded.length / 16;var result = new Array(padded.length);if (this.mode === "cbc") {var prev = this.toUint32Block(this.iv); // 初始向量for (var i = 0; i < blockCount; i++) {var offset = 16 * i;var block = this.toUint32Block(padded, offset);// CBC: 明文块 ⊕ 前一密文块block[0] ^= prev[0]; block[1] ^= prev[1];block[2] ^= prev[2]; block[3] ^= prev[3];var encrypted = this.doBlockCrypt(block, this.encryptRoundKeys);prev = encrypted;// uint32 → 字节for (var c = 0; c < 16; c++) {result[offset + c] =encrypted[parseInt(c / 4)] >> (3 - c) % 4 * 8 & 255;}}}return result;};
17.7 SM2 加密封装 + 加密入口函数 + 主入口
SM2 公钥与加密封装:
SM2 是国密椭圆曲线公钥加密算法,原始 JS 实现约 2000 行(行 4607-6624),极其复杂。 实际使用中通过 sm-crypto npm 包替代,只需传入硬编码公钥即可。
// ============================// SM2 加密// 源码: 行 5840-5879 (简化版)// 完整实现: 行 4607-6624 (~2000行椭圆曲线代码)// ============================// 硬编码 SM2 公钥(64字节,不含04前缀)// 源码位置: 行 5858var SM2_PUBLIC_KEY ="9a4ea935b2576f37516d9b29cd8d8cc9" +"bffe548ba6853253ba20f4ba44fba8c9" +"e97a398882769aa0dd1e3e1b56014292" +"87303880ca17bd244ed73bf702a68fc7";var SM2 = {encrypt: function(plaintext, publicKey) {publicKey = publicKey || SM2_PUBLIC_KEY;try {// 使用 sm-crypto 第三方库// npm install sm-cryptovar smCrypto = require('sm-crypto');// doEncrypt(明文, 公钥, 密文格式)// 公钥需要加 "04" 前缀表示非压缩点// 第三个参数 1 = C1C3C2 格式(国标推荐)var result = smCrypto.sm2.doEncrypt(plaintext,'04' + publicKey,1 // 输出格式: C1C3C2);return result;} catch(e) {console.error("SM2 加密需要 sm-crypto 包");return null;}}};
SM2 密文格式说明:
C1: 椭圆曲线上的随机点(64字节) C3: SM3 哈希值(32字节) C2: 加密后的数据(与明文等长) 输出为 hex 字符串,格式:C1 || C3 || C2
w 参数加密入口函数 encryptW():
这是整个加密流程的调度中心。根据服务端返回的 pt 值选择不同的加密方案:
// ============================// w 参数加密主函数// 源码: 行 3751-3778// ============================function encryptW(payloadJson, pt) {pt = pt || "0";// pt = "0": 不加密,仅 Base64 URL-safe 编码if (!pt || pt === "0") {return Base64.urlsafe_encode(payloadJson);}// 生成随机对称密钥(16位hex = 16字节Latin1)var key = guid();if (pt === "1") {// === 国际标准方案: RSA + AES ===// 1. RSA 加密对称密钥(PKCS#1 v1.5)var rsaEncryptedKey = RSA.encrypt(key);// 确保 RSA 密文恰好 256 个 hex 字符(128字节 = 1024位)while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {key = guid();rsaEncryptedKey = RSA.encrypt(key);}// 2. AES-128-CBC 加密 payloadvar aesCiphertext = AES.encrypt(payloadJson, key);// 3. 拼接: AES密文hex + RSA加密的密钥hexreturn arrayToHex(aesCiphertext) + rsaEncryptedKey;}if (pt === "2") {// === 国密标准方案: SM2 + SM4 ===// 1. SM2 加密对称密钥var sm2EncryptedKey = SM2.encrypt(key);// 2. SM4-CBC 加密 payloadvar sm4 = new SM4({key: key,mode: "cbc",iv: "0000000000000000" // 固定全零ASCII IV});var sm4Ciphertext = sm4.encrypt(payloadJson);// 3. 拼接: SM4密文hex + SM2加密的密钥hexreturn arrayToHex(sm4Ciphertext) + sm2EncryptedKey;}// 兜底: 纯 Base64return Base64.urlsafe_encode(payloadJson);}
加密流程对照表:
> | pt值 | 对称加密 | 非对称加密 | 输出格式 |> |------|---------|-----------|---------|> | "0" | 无 | 无 | Base64 URL-safe |> | "1" | AES-128-CBC | RSA-1024 | `AES密文hex + RSA(key)hex` |> | "2" | SM4-CBC | SM2 | `SM4密文hex + SM2(key)hex` |
processInput 主入口函数:
这是 Node.js 脚本的核心入口,接收 Python 传来的 JSON 参数,构建 payload,调用加密,输出结果:
// ============================// 主入口: 从 stdin 读取参数,构建 payload,加密生成 w// ============================function main() {var inputData = process.argv[2];if (!inputData) {// 从 stdin 读取(Python subprocess 通过 stdin 传参)var chunks = [];process.stdin.setEncoding('utf8');process.stdin.on('data', function(chunk) {chunks.push(chunk);});process.stdin.on('end', function() {processInput(chunks.join(''));});return;}processInput(inputData);}function processInput(jsonStr) {try {var params = JSON.parse(jsonStr);// 构建 payload 对象// 字段顺序严格按照 JS 源码 行 7133-7136 + 2097-2117// 注意: 字段顺序影响 JSON.stringify 输出,进而影响密文var payload = {setLeft: params.setLeft, // 滑动像素距离(取整)passtime: params.passtime, // 操作总耗时(ms)userresponse: params.userresponse // setLeft/scale + 2|| (params.setLeft / (params.scale || 1) + 2),device_id: params.device_id || "", // 设备指纹(可为空)lot_number: params.lot_number || "",pow_msg: params.pow_msg || "",pow_sign: params.pow_sign || "",em: params.em || {}, // 环境信息(可为空对象)};// 关键: gee_guard 字段不能出现在 payload 中// 不设置 = undefined → JSON.stringify 自动跳过// 如果设为 null → 输出 "gee_guard":null → 验证失败!// 轨迹编码: 原始坐标数组 → 差分压缩 → 字符编码if (params.track && params.track.length > 0) {payload.aa = encodeTrack(params.track);}// 支持额外字段扩展if (params.extra_payload) {Object.assign(payload, params.extra_payload);}// 序列化 + 加密var payloadJson = JSON.stringify(payload);var pt = params.pt || "1";var w = encryptW(payloadJson, pt);// 输出结果(JSON格式,供 Python 解析)var result = {w: w, // 加密后的 w 参数payload_debug: payload // 调试用: 加密前的明文 payload};process.stdout.write(JSON.stringify(result));} catch(e) {process.stderr.write("Error: " + e.message + "\n" + e.stack);process.exit(1);}}// 启动if (require.main === module) {main();}// 模块导出(供单元测试使用)module.exports = {guid, arrayToHex, encodeTrack, trackDiff,Base64, AES, RSA, SM4, SM2, encryptW};
Python ↔ Node.js 通信协议:
Python → stdin → JSON字符串:{"setLeft": 182,"passtime": 1247,"userresponse": 211.37,"track": [[-20, 5, 0], [0, 0, 0], [3, 0, 18], ...],"lot_number": "72ce...","pow_msg": "1|0|md5|...","pow_sign": "abc123...","pt": "1","scale": 0.8776}Node.js → stdout → JSON字符串:{"w": "a3f2e1...(AES密文hex + RSA(key)hex)","payload_debug": { ... 加密前明文 ... }}
十八、调试踩坑记录与总结
在整个逆向分析和自动化实现过程中,遇到了多个关键问题。本节记录这些踩坑经验,帮助后续开发者避免重复犯错。
18.1 gee_guard 字段:null vs undefined 的致命区别
问题现象: verify 请求返回失败,服务端校验不通过,但 payload 结构看起来没问题。
根因分析:
在 JS 源码中,gee_guard 字段在某些条件下会被设置为 undefined:
// 源码行 2107 附近var payload = {setLeft: setLeft,passtime: passtime,userresponse: userresponse,// ... 其他字段 ...gee_guard: someCondition ? value : undefined};
JavaScript 中 JSON.stringify 对 undefined 和 null 的处理完全不同:
// undefined → 字段被跳过,不出现在 JSON 中JSON.stringify({ a: 1, b: undefined })// 输出: '{"a":1}'// null → 字段保留,值为 nullJSON.stringify({ a: 1, b: null })// 输出: '{"a":1,"b":null}'
踩坑过程: 最初在 Node.js 实现中手动设置了 gee_guard: null,导致加密后的 payload 包含 "gee_guard":null 字段。服务端严格校验了 payload 结构,多出这个字段直接导致验证失败。
解决方案: 不在 payload 对象中设置 gee_guard 字段。不设置 = undefined → JSON.stringify 自动跳过 → 与浏览器行为一致。
// ❌ 错误写法var payload = {setLeft: setLeft,gee_guard: null // 会输出 "gee_guard":null};// ✅ 正确写法var payload = {setLeft: setLeft// 不设置 gee_guard → undefined → JSON.stringify 跳过};
教训: 逆向还原时,不仅要关注有哪些字段,还要关注哪些字段不应该出现。JSON.stringify 的 undefined 跳过行为是 JS 特有的语义细节。
18.2 userresponse 计算公式:一个 “+2” 引发的血案
问题现象: 缺口位置识别正确,轨迹生成正常,但 verify 始终返回失败。
根因分析:
userresponse 是服务端校验滑动距离的关键字段。正确公式藏在源码行 7136:
// 源码行 7136n.userresponse = S(n.setLeft, r.challenge);
跳转到 S 函数定义(行 7072 附近):
// S 函数的简化逻辑function S(setLeft, challenge) {// 实际就是把显示坐标还原为原图坐标,再加一个固定偏移return setLeft / scale + 2;}
踩坑过程: 最初错误地以为 userresponse = gap_x + 2(直接用原图缺口坐标加2),这完全搞错了计算路径:
❌ 错误理解:userresponse = gap_x + 2 (OCR原图坐标 + 2)✅ 正确理解:setLeft = parseInt(slide_distance)= parseInt((gap_x - SLIDER_OFFSET) * scale)userresponse = setLeft / scale + 2= 从显示坐标反算回原图坐标 + 固定偏移2
数值示例:
gap_x = 223px (OCR识别的原图缺口X)SLIDER_OFFSET = 15px (滑块初始偏移)scale = 0.8776 (缩放比)setLeft = parseInt((223 - 15) * 0.8776) = parseInt(182.54) = 182userresponse = 182 / 0.8776 + 2 = 209.44 (≠ 223 + 2 = 225!)
注意 userresponse ≈ 209.44,而不是 225。差了将近 16,这是因为 parseInt 截断和 SLIDER_OFFSET 的双重影响。服务端对这个值的容差很小,偏差过大直接判定为异常。
解决方案:
# Python 中的正确计算setLeft = int(slide_distance) # 取整截断,不是四舍五入userresponse = setLeft / scale + 2 # 用 setLeft 反算,不是用 gap_x
教训:userresponse 这个名字让人以为是”用户的响应位置”,实际上是一个经过 int截断 → 除以scale → 加2 的数学变换结果。逆向时必须严格追踪数据流,不能望文生义。
18.3 滑块初始偏移 15px:看不见的起点
问题现象: 即使 OCR 缺口识别完全准确,滑动距离总是偏大约 15px。
根因分析:
在极验的 UI 布局中,滑块碎片(slice)并不是从 x=0 开始的。CSS 设置了初始偏移(通过 left 或 transform),大约 15px:
|<-- 15px -->|滑块碎片|| || 背景图区域
也就是说:
-
OCR 返回的 gap_x是缺口在原图中的绝对坐标 -
滑块的起始位置不是 0,而是约 15px -
实际需要滑动的距离 = gap_x - 15(原图坐标系),再乘以缩放比
SLIDER_OFFSET = 15 # 滑块初始偏移(像素,原图坐标系)adjusted_gap_x = gap_x - SLIDER_OFFSET # 实际需要移动的原图距离slide_distance = adjusted_gap_x * scale # 转换为显示坐标setLeft = int(slide_distance) # 取整
如何发现这个偏移:
-
在浏览器中用 DevTools 检查滑块元素的初始 CSS left/transform值 -
或对比 OCR 返回的缺口 X 坐标与实际抓包 setLeft的差异 -
反推: setLeft_抓包 ≈ (gap_x - 15) * scale
教训: 滑块验证码的坐标系不是从0开始的。这个 15px 偏移在源码中不是一个明确的常量,而是通过 CSS 布局隐式引入的,需要通过抓包对比来确定。
18.4 缩放比公式:0.8876 的由来
问题现象: 直接用 display_width / original_width 作为缩放比,计算出的滑动距离总是有微小偏差。
根因分析:
极验的缩放比并不是简单的 容器宽度 / 原图宽度。源码行 7073-7079 揭示了真正的公式:
// 源码行 7073-7079// 容器宽度 340px,原图宽度 344px// 但实际显示时图片两侧各有间距var scale = 0.8876 * containerWidth / bgWidth;
这个 0.8876 是一个经验系数,来源于 UI 布局:
-
验证码容器宽度:340px -
图片在容器内不是完全贴边的,有 padding/margin -
实际图片显示宽度 ≈ 340 × 0.8876 ≈ 301.8px -
对于 344px 原图:scale ≈ 301.8 / 344 ≈ 0.8773
# Python 中的缩放比计算CONTAINER_WIDTH = 340scale = 0.8876 * CONTAINER_WIDTH / bg_w# 当 bg_w = 344 时: scale ≈ 0.8776
验证方法:
-
在浏览器中拖拽滑块到缺口位置 -
抓包获取 setLeft值 -
反推 scale = setLeft / (gap_x - offset) -
与公式计算值对比
教训: 前端渲染的实际尺寸可能受多层 CSS 影响(父容器 padding、图片 max-width、transform scale 等)。不要假设图片会铺满容器,要从源码中提取精确的缩放系数。
18.5 轨迹坐标系混乱:显示坐标 vs 原图坐标
问题现象: 生成的轨迹在 Python 中看起来合理,但提交后服务端判定轨迹异常。
根因分析:
轨迹坐标存在两套坐标系,容易混淆:
显示坐标系 (浏览器渲染后的像素)├── 轨迹生成目标距离: slide_distance = (gap_x - offset) * scale├── 轨迹点中的 [x, y, t] 是在显示坐标系下的└── 这是 mousemove 事件实际捕获的像素值原图坐标系 (背景图的原始像素)├── gap_x: OCR 返回的缺口位置├── userresponse: setLeft / scale + 2 (反算回原图坐标)└── 服务端校验用的坐标
关键转换: 轨迹生成在显示坐标系下完成,但传给 encodeTrack() 之前需要转换回原图坐标系:
# 1. 先在显示坐标系下生成轨迹 (目标: slide_distance px)track_display = generate_trajectory(int(slide_distance))# 2. 转换为原图坐标系 (除以 scale)track = [[round(p[0] / scale), round(p[1] / scale), p[2]]for p in track_display]# 3. 插入初始偏移点 (mousedown 时的随机偏移)initial_offset = [random.randint(-30, -10), random.randint(-10, 10), 0]track.insert(0, initial_offset)
为什么要转换? 因为浏览器端的轨迹编码函数 $_BBFj 接收的是原图坐标系的轨迹。浏览器端在 mousemove 事件中收集的本来就是显示坐标,但在提交前做了 /scale 的转换(源码行 7115 附近)。我们的 Python 实现必须复现这一步。
教训: 在多层坐标变换的系统中,每个中间变量都要标注它属于哪个坐标系。建议变量命名中带坐标系后缀,如 track_display vs track_original。
18.6 轨迹初始偏移点:mousedown 的随机位置
问题现象: 轨迹编码后与抓包对比,发现起始段的编码有差异。
根因分析:
真实用户的 mousedown 事件不会精确地发生在滑块中心。浏览器端记录的第一个轨迹点是相对于滑块中心的随机偏移:
// 源码行 7083-7090 (mousedown 处理)// 记录鼠标按下时相对于滑块中心的偏移var offsetX = event.clientX - sliderCenter.x; // 通常 -30 ~ -10var offsetY = event.clientY - sliderCenter.y; // 通常 -10 ~ +10track.push([offsetX, offsetY, 0]); // 时间戳 0
然后是滑动开始的 [0, 0, 0] 归零点,之后才是实际的增量轨迹:
# 完整的轨迹结构track = [[-22, 3, 0], # 初始偏移 (mousedown相对滑块中心)[0, 0, 0], # 归零点 (开始滑动的基准)[3, 0, 18], # 第一步增量[5, 1, 32], # 第二步增量# ... 更多增量 ...[1, 0, 1247], # 最后一步 (mouseup)]
为什么重要: 差分编码 trackDiff() 会计算相邻点的差值。如果缺少初始偏移点,第一个差值就会不自然(从 [0,0,0] 直接跳到大位移),服务端的轨迹分析会判定为机器行为。
解决方案:
# 随机生成合理的初始偏移initial_offset = [random.randint(-30, -10), # X: 鼠标在滑块左侧偏上random.randint(-10, 10), # Y: 轻微上下偏移0 # 时间: 0 (起始时刻)]track.insert(0, initial_offset)
18.7 AES 密钥编码:Latin1 而非 UTF-8
问题现象: 在 Python 中用 key.encode('utf-8') 生成 AES 密钥,加密结果与浏览器端不一致。
根因分析:
guid() 生成的是 16 位 hex 字符串,如 "a3f2e1b4c5d6e7f8"。这个字符串作为 AES-128 密钥时,使用的是 Latin1 (ISO-8859-1) 编码,而非 UTF-8:
// JS 中的密钥编码 (CryptoJS 风格)// 源码行 3891-3895var keyBuf = Buffer.from(key, 'latin1'); // 每个字符 → 1字节var ivBuf = Buffer.from('0000000000000000', 'latin1'); // IV 同理
对于纯 ASCII 的 hex 字符串,latin1 和 utf-8 的结果相同。但这是一个巧合,不是保证。 如果未来密钥生成逻辑变化(比如包含非 ASCII 字符),编码方式的差异就会导致密钥不匹配。
标准做法:
# Python 中严格使用 latin1 编码key_bytes = key.encode('latin1') # 16字符 → 16字节iv_bytes = b'0000000000000000' # 16字节全零(ASCII '0')
注意 IV 不是 b'\x00' * 16(16 个 null 字节),而是 b'0000000000000000'(16 个 ASCII ‘0’ 字符,即 0x30 重复 16 次)。
18.8 RSA 密文长度校验:必须恰好 256 个 hex 字符
问题现象: 偶尔 RSA 加密后的密文长度不是 256 个 hex 字符,导致 verify 失败。
根因分析:
RSA-1024 的密文应该是 128 字节 = 256 个 hex 字符。但由于大数运算的特性,模幂结果的最高位可能为 0,导致 hex 表示的前导零被省略:
// 大数模幂后转 hexvar c = modPow(m, e, n);var hex = c.toString(16);// 如果 c 的最高字节小于 0x10,hex 长度会是奇数// 如果 c 的最高字节为 0x00,hex 长度会少于 256
源码的处理方式是重试直到得到正确长度:
// 源码行 3759-3762var rsaEncryptedKey = RSA.encrypt(key);while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {key = guid(); // 换一个密钥rsaEncryptedKey = RSA.encrypt(key); // 重新加密}
为什么不是补前导零? 理论上可以 padStart(256, '0') 补齐,但原始 JS 源码选择了重试策略。为了严格还原行为,我们的实现也采用重试。
18.9 JSONP 回调名称格式
问题现象: 使用自定义 callback 名称时,极验服务器返回错误。
根因分析:
极验的 JSONP callback 名称有固定格式:geetest_ + 13位时间戳:
def generate_callback():return f"geetest_{int(time.time() * 1000)}"# 示例: "geetest_1714233456789"
服务端会校验 callback 名称的格式。如果使用 callback_123 或 jQuery_xxx 这样的名称,请求可能被拒绝或返回格式不同的响应。
JSONP 响应解析:
def parse_jsonp(text):# 响应格式: geetest_1714233456789({"status":"success","data":{...}})match = re.search(r'\((\{.*\})\)', text, re.DOTALL)if match:return json.loads(match.group(1))# 兜底: 有时返回纯 JSON(非 JSONP)return json.loads(text)
18.10 PoW 难度的非整数位处理
问题现象: 当 bits 不是 4 的倍数时(如 bits=5),简单的 startswith('0' * bits) 检查不正确。
根因分析:
PoW 的难度由 bits 字段控制,要求 hash 的前 bits 位为 0。一个 hex 字符代表 4 个二进制位,所以:
bits=4
→ hash 以 "0"开头(1个零hex字符)bits=8
→ hash 以 "00"开头(2个零hex字符)bits=5
→ hash 前5位为零 → 第一个hex字符必须是 "0",第二个hex字符的最高1位必须为0(即 ≤"7")
源码行 2476-2498 处理了这种非整除情况:
full_bits = bits % 4 # 余数部分 (0-3)prefix_len = int(bits / 4) # 完整hex零的个数prefix = '0' * prefix_len # 需要匹配的前缀# ...计算 hash...if full_bits == 0:# 整除: 只需检查前缀if pow_sign.startswith(prefix):return resultelse:# 非整除: 前缀匹配 + 下一个字符需要满足阈值if pow_sign.startswith(prefix):next_char = pow_sign[prefix_len]# full_bits=1 → 最高1位为0 → 字符 ≤ '7' (0xxx)# full_bits=2 → 最高2位为0 → 字符 ≤ '3' (00xx)# full_bits=3 → 最高3位为0 → 字符 ≤ '1' (000x)threshold_map = {1: '7', 2: '3', 3: '1'}threshold = threshold_map.get(full_bits, 'f')if next_char <= threshold:return result
教训: 很多人实现 PoW 时只考虑 bits 是 4 的倍数的情况。极验的实际部署中,bits 常常为 0(几乎不需要计算),但逻辑必须支持任意值的情况。
18.11 踩坑总结清单
| 序号 | 踩坑点 | 错误做法 | 正确做法 | 影响 ||------|---------|---------|---------|------|| 1 | gee_guard 字段 | `gee_guard: null` | 不设置该字段 | 验证失败 || 2 | userresponse 公式 | `gap_x + 2` | `setLeft / scale + 2` | 验证失败 || 3 | 滑块初始偏移 | 从 x=0 开始计算 | 减去 15px 偏移 | 滑动过头 || 4 | 缩放系数 | `containerW / imgW` | `0.8876 * containerW / imgW` | 位置偏差 || 5 | 轨迹坐标系 | 全程用显示坐标 | 提交前转原图坐标 | 轨迹异常 || 6 | 初始偏移点 | 省略不加 | 必须在 track[0] 插入 | 轨迹异常 || 7 | AES 密钥编码 | UTF-8 | Latin1 | 解密失败 || 8 | RSA 密文长度 | 不校验 | 必须 256 hex 字符 | 验证失败 || 9 | JSONP callback | 随意命名 | `geetest_` + 毫秒戳 | 请求被拒 || 10 | PoW 非整数位 | 只检查前缀 | 前缀 + 阈值字符 | 罕见场景 |
十九、完整验证流程时序图
用户浏览器 Python主控 极验服务器│ │ ││ │──── GET /load ────────────>││ │<─── JSONP(lot_number, ││ │ bg, slice, pow_detail) ││ │ ││ │──── GET bg.png ──────────>│ (static.geetest.com)│ │<─── 背景图二进制 ──────────││ │ ││ │──── GET slice.png ────────>││ │<─── 滑块碎片图二进制 ──────││ │ ││ │──── POST OCR ────────────>│ (xinyuocr)│ │<─── gap_x = 223 ──────────││ │ ││ │ [本地计算] ││ │ ├── scale = 0.8876*340/344││ │ ├── setLeft = int((223-15)*scale)│ │ ├── userresponse = setLeft/scale+2│ │ ├── 生成轨迹 track[] ││ │ ├── 计算 PoW ││ │ └── Node.js 加密 → w ││ │ ││ │──── GET /verify ──────────>││ │ (lot_number, w, ││ │ payload, process_token)│ │<─── JSONP(result:success, ││ │ pass_token, gen_time) ││ │ │
二十、相关文件清单与功能说明
geetest_ctf/├── geetest_solver.py # 主控脚本: 8步验证流程│ # - load请求 → 下载图片 → OCR → 轨迹 → PoW → 加密 → verify│├── trajectory.py # 拟人化轨迹生成器│ # - 三段式速度模型(加速/匀速/减速)│ # - 回弹模拟 + Y轴抖动│├── pow_solver.py # PoW 工作量证明求解器│ # - 支持 md5/sha1/sha256│ # - 处理非4倍数bits难度│├── gap_detector.py # 缺口位置识别│ # - 调用 OCR 平台 API│ # - 返回原图坐标系的 gap_x│└── node_env/├── generate_w.js # Node.js 加密模块│ # - guid (随机密钥生成)│ # - arrayToHex (字节转hex)│ # - trackDiff + encodeTrack (轨迹编码)│ # - Base64 (URL-safe编码)│ # - AES-128-CBC (对称加密)│ # - RSA-1024 PKCS#1 v1.5 (非对称加密)│ # - SM4-CBC (国密对称加密)│ # - SM2 (国密非对称加密)│ # - encryptW (加密调度入口)│ # - processInput (stdin→加密→stdout)│└── package.json # Node.js 依赖 (sm-crypto for SM2)
附录 A:关键源码行号速查表
以下行号均基于 gcaptcha4_deobfuscated.js(还原后的 JS 文件):
| 行号范围 | 功能模块 | 关键函数/变量 ||----------|---------|-------------|| 105-144 | 全局注入 | `_lib`, `_abo`, UMD包装 || 306-312 | 工具函数 | `arrayToHex()` 字节转hex || 457-465 | 工具函数 | `guid()` 16位随机hex || 1735-1779 | 轨迹编码 | `trackDiff()`, `encodeDirection()`, `encodeValue()`, `encodeTrack()` || 2087-2146 | 验证提交 | `$_BBFj` payload 构建 || 2455-2504 | PoW | 工作量证明求解 || 3750-3778 | 加密入口 | `encryptW()` pt 分发 || 3780-3829 | 编码 | Base64 标准/URL-safe 编码 || 3881-4148 | 对称加密 | AES-128-CBC (CryptoJS风格) || 4149-4510 | 非对称加密 | RSA-1024 PKCS#1 v1.5 || 4514-4606 | 国密对称 | SM4-CBC 加密 || 4607-6624 | 国密非对称 | SM2 椭圆曲线 (~2000行) || 5840-5879 | SM2入口 | SM2 公钥加密调用 || 7070-7149 | 事件处理 | mousedown/move/up, scale, setLeft, userresponse |
附录 B:常用调试命令
# 运行完整验证流程python geetest_solver.py# 单独测试 PoWpython pow_solver.py# 单独测试轨迹生成python trajectory.py# 单独测试 Node.js 加密echo '{"setLeft":182,"passtime":1200,"track":[[0,0,0],[5,0,30]],"lot_number":"test","pow_msg":"1|0|md5|||test||abc","pow_sign":"d41d8cd98f00b204e9800998ecf8427e","pt":"1","scale":0.8776}' | node node_env/generate_w.js# 安装 SM2 依赖 (pt=2 场景需要)cd node_env && npm install sm-crypto
文档完成于 2026年4月28日基于极验 GeeTest v4 滑块验证码 gcaptcha4.js 逆向分析—-小肩膀教育

官网:https://xjbedu.site/
https://xjbedu.site/proxy

夜雨聆风