目标网站–官网demo
aHR0cHM6Ly9ndDQuZ2VldGVzdC5jb20v
一、协议逆向分析
1.1 抓包分析与接口识别
两大核心接口:
|
|
|
|
|---|---|---|
|
|
/load |
|
|
|
/verify |
|
1.2 Load接口分析
初次请求参数:
-
• callback:geetest_ + 13位时间戳拼接 -
• captcha_id:每个网站固定不变 -
• challenge:UUID动态生成 -
• client_type:请求方式,默认 web -
• risk_type:验证码类型,五子棋为winlinze -
• lang:语言,默认zh
后续请求参数(首次失败或刷新后出现):
-
• pt:上一次请求响应结果中携带 -
• lang:上一次响应携带,默认zh -
• payload:上一次响应携带 -
• process_token:上一次响应携带 -
• payload_protocol:固定1
关键返回字段:
-
• lot_number:后续加密必需 -
• payload/process_token:后续 verify 接口使用 -
• ques:五子棋布局列表(消消乐则返回对应图片列表) -
• pow_detail:加密算法信息,含bits、datetime、hashfunc、version
1.3 Verify接口分析
-
• 请求参数:除 w外,其余参数均可在 load 接口的入参或返回值中找到。 -
• 核心参数 w:唯一的加密参数,是逆向的终极目标。
二、AST解混淆
说明:gcaptcha4.js 每隔几天就会变化(变量名、函数名随机),但混淆结构和位置固定。因此解混淆的核心思路是:按结构特征匹配,而不是按名字匹配,实现自动化提取与还原。
2.1 混淆特征总览
|
|
|
|---|---|
|
|
_ᕺᖘᖄᖘ |
|
|
|
|
|
|
字符串加密示例:
var _ᕺᕶᕹᕸ = _ᖈᕴᖙᕶ.$_Cl
, _ᖄᖃᖙᖁ = ["$_FJGJ_"].concat(_ᕺᕶᕹᕸ)
, _ᖆᖆᖆᕺ = _ᖄᖃᖙᖁ[1];
_ᖄᖃᖙᖁ.shift();
var _ᖁᕹᖘᖁ = _ᖄᖃᖙᖁ[0];
var _ᖃᕹᖙᕶ = this;
_ᖃᕹᖙᕶ[_ᖆᖆᖆᕺ(1514)] = new (_ᖈᕵᖘᕶ[_ᕺᕶᕹᕸ(13)])(document),
_ᖃᕹᖙᕶ[_ᕺᕶᕹᕸ(1571)] = new (_ᖈᕵᖘᕶ[_ᖆᖆᖆᕺ(13)])(window)
//以上为截取的源码,为了方便识别,将变量名替换成abcde样式
var a = _ᖈᕴᖙᕶ.$_Cl //定义变量a,接收解密函数
, b = ["$_FJGJ_"].concat(a) //字符串$_FJGJ_和a组成数组['$_FJGJ_',a]
, c = b[1]; //取a函数,赋值给c
b.shift();
var _ᖁᕹᖘᖁ = b[0];
var _ᖃᕹᖙᕶ = this;
_ᖃᕹᖙᕶ[c(1514)] = new (_ᖈᕵᖘᕶ[a(13)])(document), //后续就是直接调用a和c 其实都等于直接调用_ᖈᕴᖙᕶ.$_Cl
_ᖃᕹᖙᕶ[a(1571)] = new (_ᖈᕵᖘᕶ[c(13)])(window)
2.2 还原准备工作
开发环境:
node --version
npm install @babel/parser @babel/traverse @babel/types @babel/generator
获取混淆 JS 文件:
gcaptcha4.jsgcaptcha4.js现在先手动定位gcaptcha4.js并保存为本地gcaptcha4.js

2.3 解析与初始化(固定框架)
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;
const path = require('path');
const baseDir = __dirname;
const js_code = fs.readFileSync(path.join(baseDir, 'code.js'), 'utf-8');
const ast = parser.parse(js_code, {
tokens: true,
sourceType: 'module',
ranges: true,
locations: true
});
traverse(ast, {
// 解密逻辑写在这里,后续章节只贴此节点内容
});
// 输出中间文件
const code = generator(ast, {
compact: false,
minified: false,
comments: true,
retainLines: false,
jsescOption: { quotes: 'single' }
}).code;
fs.writeFileSync(path.join(baseDir, 'output.js'), code);
console.log('第一步完成,已生成 output.js');
2.4 定位解密函数(结构分析)
目标: 找到字符串解密函数的本体(全局对象上的方法,如 _X.$_Cc)。
定位步骤:
var _ᕺᕶᕹᕸ = _ᖈᕴᖙᕶ.$_Cl
, _ᖄᖃᖙᖁ = ["$_FJGJ_"].concat(_ᕺᕶᕹᕸ)
, _ᖆᖆᖆᕺ = _ᖄᖃᖙᖁ[1];
_ᖄᖃᖙᖁ.shift();
var _ᖁᕹᖘᖁ = _ᖄᖃᖙᖁ[0];
var _ᖃᕹᖙᕶ = this;
_ᖃᕹᖙᕶ[_ᖆᖆᖆᕺ(1514)] = new (_ᖈᕵᖘᕶ[_ᕺᕶᕹᕸ(13)])(document),
_ᖃᕹᖙᕶ[_ᕺᕶᕹᕸ(1571)] = new (_ᖈᕵᖘᕶ[_ᖆᖆᖆᕺ(13)])(window)
//以上为截取的源码,为了方便识别,将变量名替换成abcde样式
var a = _ᖈᕴᖙᕶ.$_Cl //定义变量a,接收解密函数
, b = ["$_FJGJ_"].concat(a) //字符串$_FJGJ_和a组成数组['$_FJGJ_',a]
, c = b[1]; //取a函数,赋值给c
b.shift();
var _ᖁᕹᖘᖁ = b[0];
var _ᖃᕹᖙᕶ = this;
_ᖃᕹᖙᕶ[c(1514)] = new (_ᖈᕵᖘᕶ[a(13)])(document), //后续就是直接调用a和c 其实都等于直接调用_ᖈᕴᖙᕶ.$_Cl
_ᖃᕹᖙᕶ[a(1571)] = new (_ᖈᕵᖘᕶ[c(13)])(window)
c(1514)、a(13)、a(1571) 、c(13)是混淆后的解密调用var a = _ᖈᕴᖙᕶ.$_Cl → _ᖈᕴᖙᕶ.$_Cl 就是最原始的解密函数b = ["$_FJGJ_"].concat(a) → d[1] = bc = b[1] → c = a,即 c 和 a 等价_ᖈᕴᖙᕶ),在文件最前面可找到其函数声明——这里就是解密函数本体。
2.5 验证解密函数
目的: 确认本地还原的解密函数逻辑与网站一致。
步骤:
-
1. 将解密函数本体拷贝到本地,补上空函数占位后自执行: _ᖈᕴᖙᕶ.$_AO = function(){};// 原函数自行拷贝 _ᖈᕴᖙᕶ.$_Bn = function(){};// 原函数自行拷贝 _ᖈᕴᖙᕶ.$_Cl = function(){};// 原函数自行拷贝 _ᖈᕴᖙᕶ.$_Dl = function(){};// 原函数自行拷贝 function _ᖈᕴᖙᕶ() {}; for (var a = 0; a < 200; a++) { console.log(`_ᖈᕴᖙᕶ.$_Cl(${a})-->${_ᖈᕴᖙᕶ.$_Cl(a)}`); }

2.6 整理解密模式,编写自动还原代码
2.6.1 收集解密函数并自执行
通过 前面分析及AST 树可知,解密逻辑位于 Program body 的前 5 个代码块。
function get_decryptFunc(ast){
const body = ast.program.body.slice(0, 5); // 取前 N 个代码块
const newAst = {
type: 'File',
program: {
type: 'Program',
body: body
}
};
const eval_code = generator(newAst).code;
// target_decrypt_fn 用于后续判断,提取解密函数赋值表达式
// 例如 body[2] 中:_ᖈᕴᖙᕶ.$_Cl 的源码字符串
const target_decrypt_fn = generator(body[2].expression.left).code;
return { eval_code, target_decrypt_fn };
}
const { eval_code, target_decrypt_fn } = get_decryptFunc(ast);
eval(eval_code);
2.6.2 识别混淆调用特征
解密函数的标准调用结构(变量名会变化,结构固定):
var a = decrypt // a = 全局对象.解密方法
, b = ['字符串'].concat(a) // 数组包装
, c = b[1]; // c 是 a 的别名
// 后续调用:c(1), c(2), c(3) 或 a(1), a(2), a(3)
特征要点:
type === VariableDeclarationdeclarations 长度为 3,且每个元素都是 VariableDeclaratordeclarations[0].init 是 MemberExpression(即 全局对象.解密方法)declarations[0].init 的源码必须等于 target_decrypt_fn(即自动提取出的解密方法名)a和ca和c的绑定节点type是否为CallExpressionlength为1type是否为NumericLiteraleval收集的代码,并执行替换⚠️ 因为 gcaptcha4.js 动态变化,
target_decrypt_fn需自动提取(见 2.7.1),不能写死。
2.6.3 收集混淆代码并解密(核心还原)
完整遍历逻辑:
traverse(ast,{
VariableDeclaration(path){
//判断长度不为3的直接return
if(path.node.declarations.length!==3) return;
let declarations_array = path.node.declarations;
//判断数组中三个元素type是否全部为VariableDeclarator
if(declarations_array[0].type !== 'VariableDeclarator') return;
if(declarations_array[1].type !== 'VariableDeclarator') return;
if(declarations_array[2].type !== 'VariableDeclarator') return;
//判断数组第0个元素是否有init节点,并且type=MemberExpression,否则return
if(!declarations_array[0].init) return ;
if(declarations_array[0].init.type!=='MemberExpression') return;
//定义解密函数名称,等同于decrypt_name = _ᖉᕵᖉᕶ.$_Cc
let decrypt_name = generator(declarations_array[0].init).code;
if(decrypt_name!==target_decrypt_fn) return ;
// console.log(decrypt_name)
const decrypt_fn_a = declarations_array[0].id.name;
const decrypt_fn_c = declarations_array[2].id.name;
let decrypt_fn_a_bind_name = path.scope.getBinding(decrypt_fn_a);
let decrypt_fn_c_bind_name = path.scope.getBinding(decrypt_fn_c);
eval(path+'')
decrypt_fn_a_bind_name.referencePaths.map((_path)=>{
//判断父节点path是否存在
if(!_path.parentPath) return ;
//判断引用节点的父节点是否为调用表达式
if(_path.parentPath.node.type!=='CallExpression') return ;
//判断调用表达式参数个数是否为1
if(_path.parentPath.node.arguments.length!==1) return ;
//判断调用表达式参数类型是否为NumericLiteral
if(_path.parentPath.node.arguments[0].type!=='NumericLiteral') return ;
let encrypt_value = _path.parentPath+'';
let decrypt_value = eval(_path.parentPath+'');
// console.log(encrypt_value,'-->',decrypt_value);
_path.parentPath.replaceInline(t.valueToNode(decrypt_value));
count++
})
decrypt_fn_c_bind_name.referencePaths.map((_path)=>{
//判断父节点path是否存在
if(!_path.parentPath) return ;
//判断引用节点的父节点是否为调用表达式
if(_path.parentPath.node.type!=='CallExpression') return ;
//判断调用表达式参数个数是否为1
if(_path.parentPath.node.arguments.length!==1) return ;
//判断调用表达式参数类型是否为NumericLiteral
if(_path.parentPath.node.arguments[0].type!=='NumericLiteral') return ;
let encrypt_value = _path.parentPath+'';
let decrypt_value = eval(_path.parentPath+'');
// console.log(encrypt_value,'-->',decrypt_value);
_path.parentPath.replaceInline(t.valueToNode(decrypt_value));
count++
})
}
})
关键 API 说明:
-
• path.scope.getBinding(name):获取变量在作用域中的绑定对象,内含所有引用点.referencePaths。 -
• referencePaths.map(...):遍历a(1)、c(1)等所有调用位置。 -
• t.valueToNode(value):将运行时值(字符串/数字/布尔)转回 AST 节点。 -
• replaceInline(node):用新节点原地替换旧节点(不额外包一层)。
执行结果: 输出 output.js,混淆字符串已全部还原为明文。若仍有少量未解密,通常是漏遍历了某个别名(如 c),按同样逻辑补遍历即可。
混淆前后对比

2.7 解密校验
将 output.js 替换网页中的 gcaptcha4.js,刷新验证码,确认能正常加载与校验通过。
三、w值逆向

3.1 分析载荷
w 是核心加密参数,全局搜索 w 无意义(成千上万处)。应搜索 captcha_id、lot_number 等字段定位。
3.2 定位 w 的生成位置
步骤:
-
1. 以 captcha_id为例搜索,共有 7 处匹配。 -
2. 对最后两个符合处打断点,刷新页面并触发一次校验: -
• 第 7 个:刷新验证码时调用 -
• 第 6 个:校验验证码时调用(即我们要找的)
-
-
3. 断点命中后,观察 w的赋值语句:
var w = (0, _ᕺᖈᕴᖁ['default'])(_ᖆᖃᖉᖉ['default']['stringify'](_ᖄᖃᖙᖁ), _ᖀᖚᖙᖈ),
// 改写成
var w = (0, _ᕺᖈᕴᖁ['default'])(_ᖆᖃᖉᖉ['default']['stringify'](a), b),
参数含义:
-
• a:待加密的明文字典(含pow_msg、pow_sign等,见 3.3)。 -
• b:未知,后续逆向时发现此参数没起到作用。
3.3 分析明文参数 a 的构成
w 的明文 a 由固定字段 + 需逆向字段组成。
3.3.1 参数 a 字段表
|
|
|
|
|---|---|---|
7822f0 |
{"a4b2": "2f0ab495"}
gcaptcha4.js固定位置提取 |
|
biht |
|
|
dQFB |
BoHp
gcaptcha4.js中固定位置提取 |
|
device_id |
|
|
em |
|
|
ep |
"123" |
|
gee_guard |
|
|
geetest |
"captcha" |
|
lang |
"zh" |
|
lot_number |
|
|
passtime |
|
|
pow_msg |
|
|
pow_sign |
|
|
userresponse |
|
|
3.3.2 pow_msg 与 pow_sign 逆向
步骤:
-
1. pow_msg = _powFunc(options, captchaId, hashfunc, version, bits, datetime, '')全局搜索pow_msg,一共5处,全部打上断点之后刷新验证码,在此处断住。***注:***打上断点后,刷新验证码可以发现:
pow_msg和pow_sign是load接口刷新验证码时生成的,而非校验验证码时生成。
_ᖘᕵᖆᖗ = (0, _ᖉᕹᕾᖀ['default'])(_ᖀᖚᖙᖈ, _ᖂᖗᖙᕴ, _ᖈᕸᕷᕵ['hashfunc'], _ᖈᕸᕷᕵ['version'], _ᖈᕸᕷᕵ['bits'], _ᖈᕸᕷᕵ['datetime'], '') _ᕺᕷᖉᖘ = _ᖘᕵᖆᖗ['pow_msg'], _ᖈᕵᖘᕶ = _ᖘᕵᖆᖗ['pow_sign'];-
• hashfunc、version、bits、datetime均来自 load 接口响应的pow_detail -
• captchaId、lotNumber来自 load 响应
-
-
2. 单步跟进 _powFunc内部逻辑:-
• while (1) { var h = (0, _ᖂᖗᖙᕴ['guid'])(), l = _ + h, p = void 0; switch (_ᖃᕹᖙᕶ) { //_ᖃᕹᖙᕶ 就是load接口响应中返回的pow_detail['hashfunc'],现版本都是sha256 case 'md5': p = new _ᖀᖚᖙᖈ['default']['MD5']()['hex'](l); break; case 'sha1': p = new _ᖀᖚᖙᖈ['default']['SHA1']()['hex'](l); break; case 'sha256': p = new _ᖀᖚᖙᖈ['default']['SHA256']()['hex'](l); } if (0 == a) { //a=0 条件恒为true if (0 === p['indexOf'](c)) //c='00' return { pow_msg: _ + h, pow_sign: p }; }; }; -
• h= 16 位随机十六进制串(guid) -
• pow_msg = _ + h -
• pow_sign = sha256(pow_msg)
-
-
3. 最终拼接格式: version|bits|hashfunc|datetime|captchaId|lotNumber|空字符串|随机串(h)例:1|8|sha256|2026-08-17T17:25:39.433108+08:00|54088bb07d2df3c46b79f80300b0abbe|b22929400f68432688a1341b031e71e1||dee6e1e9729f644d -
4. 校验: pow_sign必须以00开头才有效,否则换随机串重试。
Python 实现:
import random, hashlib
def generate_key():
return ''.join(random.randint(1, 65535).to_bytes(2, 'big').hex() for _ in range(4))
def sha256(text: str) -> str:
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def get_power_key(captchaId, lotNumber, pow_detail):
while True:
random_key = generate_key()
pow_msg = f"1|8|sha256|{pow_detail['datetime']}|{captchaId}|{lotNumber}||{random_key}"
pow_sign = sha256(pow_msg)
if pow_sign.startswith('00'):
return pow_msg, pow_sign
3.3.3 userresponse 坐标计算
棋盘来自load接口的ques,棋盘以 5×5 数组展现,需计算棋子移动的起始与目标坐标([[from_y, from_x], [to_y, to_x]])。
def find_move(ques):
grid = [row[:] for row in ques]
def check_five(num):
for i in range(5):
if all(grid[i][j] == num for j in range(5)): return True
if all(grid[j][i] == num for j in range(5)): return True
if all(grid[i][i] == num for i in range(5)): return True
if all(grid[i][4-i] == num for i in range(5)): return True
return False
for target in range(1, 5):
positions = [(y, x) for y in range(5) for x in range(5) if grid[y][x] == target]
for from_y, from_x in positions:
grid[from_y][from_x] = 0
for to_y in range(5):
for to_x in range(5):
if grid[to_y][to_x] != 0: continue
grid[to_y][to_x] = target
if check_five(target):
return [[from_y, from_x], [to_y, to_x]]
grid[to_y][to_x] = 0
grid[from_y][from_x] = target
return None

3.4 w 值加密分析
w = encrypt(JSON.stringify(a), key),拆分为三步:
-
1. new_a = JSON.stringify(a)—— 纯 JSON 序列化(无加盐) -
2. encrypt_func = encrypt_module—— 即下方的 AES + RSA 组合 -
3. w = encrypt_func(new_a, key)
3.4.1 a 参数序列化(Python)
import json, random
def stringfy(userresponse, lotNumber, pow_msg, pow_sign):
text = {
"passtime": random.randint(500, 3000),
"userresponse": userresponse if userresponse is not None else "",
"device_id": "",
"lot_number": lotNumber,
"pow_msg": pow_msg,
"pow_sign": pow_sign,
"geetest": "captcha",
"lang": "zh",
"ep": "123",
"biht": "1426265548",
"gee_guard": {"roe": {"aup": "3", "sep": "3", "egp": "3", "auh": "3", "rew": "3", "snh": "3", "res": "3", "cdc": "3"}},
"dQFB": "BoHp",
"9dcc8d": { "131ab750":
{
"445c": "c8d4940e"
}
},
"em": {"ph": 0, "cp": 0, "ek": "11", "wd": 1, "nt": 0, "si": 0, "sc": 0},
}
return json.dumps(text, separators=(',', ':'))

其中

这两个键值是需要我们在gcaptcha4.js中提取的。
跟栈可以发现,目标值是已经生成的了
var i = (0, _ᕴᖗᖗᖆ['getStringByIndexes'])(_ᖃᕹᖙᕶ['lot'], _ᖃᕹᖙᕶ['lotNumber']),
r = (0, _ᕴᖗᖗᖆ['getStringByIndexes'])(_ᖃᕹᖙᕶ['lotRes'], _ᖃᕹᖙᕶ['lotNumber']),
o = i['split']('.'), a = {};

-
1. getStringByIndexes分析,先获取切片字符串,后面通过split和正则进行拆分匹配。
-
2. 全局搜索切片值,能够直接定位

3.打上日志断点,和实际值对比

日志输出和后面a参数的切片值是一致的,验证通过

3.4.2 dQFB 与动态切片字段提取
这两个字段缺失或错误会导致 verify验证直接失败:
-
• dQFB:固定值(如"BoHp"),从 gcaptcha4.js 中提取(需经第二章 AST 解混淆后) -
• 动态切片字段( "07bbe7":{"c5da2cf5":{"641b":"be76c408"}}):键名与切片起始位置均由 gcaptcha4.js 中定义,从lot_number切片得到;不同版本 js 中键名会变,需在解混淆后定位真实代码提取
1. 目标字符串lotNumber与切片表达式
-
• 目标字符串(lotNumber): "8869eb14e5cb46a9a117e1ce149f9a4a"(长度32,索引从0开始) -
• 切片表达式: "(n[5:7]+n[7:9])+.+(n[20:27])+.+(n[10:10]+n[12:12]+n[3:3]+n[7:7])": 'n[7:14]' -
• 最终产物:
{
"b144e5": { // ← 第1层键(由n[5:7]+n[7:9])拼接)
"e1ce149f": { // ← 第2层键(由(n[20:27])直接得到)
"c494": "4e5cb46a" // ← 第3层键值对(键=n[10:10]+n[12:12]+n[3:3]+n[7:7],值=n[7:14])
}
}
}
2. 核心语法规则
|
|
|
|
|---|---|---|
+ |
|
|
. |
层级分隔符(核心) |
|
: |
|
|
n[m:n] |
|
n[m:n]表示(含尾索引,即取第 m 到第 n 个字符),其底层实际执行等价于 str.slice(m, n+1)(左闭右开 [m, n+1))。 |
3. 所有切片操作的明细表(按执行顺序)
根据补充的 slice 调用逻辑,实际切片边界与结果如下(左闭右开):
|
|
|
|
|
|
|
|---|---|---|---|---|---|
|
|
n[5:7] |
slice(5, 8) |
|
"b14" |
|
|
|
n[7:9] |
slice(7, 10) |
|
"4e5" |
|
|
|
n[20:27] |
slice(20, 28) |
|
"e1ce149f" |
|
|
|
n[10:10] |
slice(10, 11) |
|
"c" |
|
|
|
n[12:12] |
slice(12, 13) |
|
"4" |
|
|
|
n[3:3] |
slice(3, 4) |
|
"9" |
|
|
|
n[7:7] |
slice(7, 8) |
|
"4" |
|
|
|
n[7:14]
|
slice(7, 15) |
|
"4e5cb46a" |
|
4. 利用点号(.)进行层级拼接
将原始表达式按点号拆解为三个层级块(忽略+.+中的加号,加号仅用于块内拼接):
(块①+块②) . (块③) . (块④+块⑤+块⑥+块⑦) : (块⑧)
逐层计算各块内容:
-
• 第1层块(最外层键):① + ② = "b14" + "4e5"="b144e5" -
• 第2层块(第二层键):③ = "e1ce149f" -
• 第3层块-键(最内层键):④ + ⑤ + ⑥ + ⑦ = "c" + "4" + "9" + "4"="c494" -
• 第3层块-值(最内层值):⑧ = "4e5cb46a"
5. 最终组装逻辑(点号的层级钻进)
程序处理点号时遵循“从左到右,遇点则进”的原则,按以下步骤构建 JSON:
-
1. 第一个 .出现:将第1层结果"b144e5"作为初始键,其值必须是一个新的对象{ ... }(进入第2层)。 -
2. 第二个 .出现:在第2层的对象中,将第2层结果"e1ce149f"作为键,其值必须又是一个新的对象{ ... }(进入第3层)。 -
3. 遇到 ::在第3层的对象中,将第3层键"c494"与第3层值"4e5cb46a"组成最终的键值对。
由此形成清晰的树状嵌套结构:
{
"b144e5": { // ← 第1层键(由①+②拼接)
"e1ce149f": { // ← 第2层键(由③直接得到)
"c494": "4e5cb46a" // ← 第3层键值对(键=④+⑤+⑥+⑦,值=⑧)
}
}
}
6. 核心逻辑总结
-
• 加号( +):同一层内平铺拼接,组成完整字符串。 -
• 点号( .):宣告层级递进,前面是当前层键,后面是下一层对象。 -
• 冒号( :):停止嵌套,右侧切片结果作为最里层键的最终值。 -
• 切片边界:严格遵循补充说明中的 slice(start, end)左闭右开区间,切勿直接用表达式中的简写索引。
所以为了能够自动提取固定键值对及切片值,实现后续请求自动化,不用手动更改键值对及切片值,需要对gcaptcha4.js进行ast解混淆,并且解混淆后通过正则表达式或者ast实现自动提取。
以正则表达式为例:
import re
import os
def extract_lib_and_abo(js_path):
with open(js_path, 'r', encoding='utf-8') as f:
text = f.read()
result = {'_lib': None, '_abo': None}
lib_re = re.compile(r"\['_lib'\]\s*=\s*(\{.*?\})(?=,|;)")
m = lib_re.search(text)
if m:
result['_lib'] = m.group(1)
abo_re = re.compile(r"\['lib'\]\['_abo'\]\s*=\s*(\{.*?\})(?=,|;)")
m = abo_re.search(text)
if m:
result['_abo'] = m.group(1)
return result
if __name__ == '__main__':
base = os.path.dirname(os.path.abspath(__file__))
js_file = os.path.join(base, 'ast解析', 'output.js')
data = extract_lib_and_abo(js_file)
print('_lib:', data['_lib'])
print('_abo:', data['_abo'])
stringfy 函数需补充切片逻辑,并补充键值对、切片处理函数(键名/切片位置以实际提取为准):
注:为方便后续持续使用切片值,可以将正则提取的内容另存为json文件,每次使用直接从json文件提取即可。同时为保证gcaptcha4.js能够实现自动下载、ast解混淆及正则自动提取,需要判断gcaptcha4.js 或者 json文件的创建日期,如果是当天就跳过,直接取值处理,如果非当天,就重新下载gcaptcha4.js,进行ast解混淆、正则提取,结果另存为json操作,实现完全自动化。
把3.4.2中提到的核心逻辑发给ai自动生成提取代码即可
def stringfy(userresponse,lotNumber,pow_msg,pow_sign, js_url=None):
text = {
"passtime": random.randint(500, 3000),
"userresponse": userresponse if userresponse is not None else "",
"device_id": "",
"lot_number": lotNumber,
"pow_msg": pow_msg,
"pow_sign": pow_sign,
"geetest": "captcha",
"lang": "zh",
"ep": "123",
"biht": "1426265548",
"gee_guard": {"roe": {"aup": "3", "sep": "3", "egp": "3", "auh": "3", "rew": "3", "snh": "3", "res": "3", "cdc": "3"}},
"em": {"ph": 0, "cp": 0, "ek": "11", "wd": 1, "nt": 0, "si": 0, "sc": 0},
}
# 提取 _lib / _abo(带当天缓存)并合并进 text
_extracted = load_extracted_values(js_url)
if _extracted.get('_lib'):
text.update(parse_lib(_extracted['_lib']))
if _extracted.get('_abo'):
text.update(build_abo(_extracted['_abo'], lotNumber))
return json.dumps(text,separators=(',', ':'))
def _extract_from_js(js_path):
"""从 js 文件中用正则提取 _lib / _abo 的原始字符串。"""
with open(js_path, 'r', encoding='utf-8') as f:
text = f.read()
result = {'_lib': None, '_abo': None}
m = re.search(r"\['_lib'\]\s*=\s*(\{.*?\})(?=,|;)", text)
if m:
result['_lib'] = m.group(1)
m = re.search(r"\['lib'\]\['_abo'\]\s*=\s*(\{.*?\})(?=,|;)", text)
if m:
result['_abo'] = m.group(1)
return result
def _download_js(js_url=None):
"""下载最新 gcaptcha4.js 到 ast解析 目录。
优先使用 js_url(来自 load 接口的 static_path),失败则回退默认地址;
全部失败则保留本地已有文件。"""
urls = [u for u in (js_url, GCPATCHA_JS_URL) if u]
for url in urls:
try:
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code == 200 and len(r.text) > 1000:
with open(GCPATCHA_JS, 'w', encoding='utf-8') as f:
f.write(r.text)
return True
except Exception:
continue
return False
def _rebuild_output_js():
"""尝试用 node 重跑 step1.js 对 gcaptcha4.js 解混淆生成 output.js;
失败则保留旧文件。"""
step1 = os.path.join(_AST_DIR, 'step1.js')
if not os.path.exists(step1):
return
try:
subprocess.run(['node', step1], cwd=_AST_DIR, timeout=120,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
def load_extracted_values(js_url=None):
"""读取提取结果,按以下规则处理:
1. gcaptcha4.js / extracted_values.json 任一不存在
→ 下载 js、解混淆、提取、写回 json(含 date);
2. json 存在但 date 非当天
→ 重新下载 js、解混淆、提取、写回 json;
3. json 存在且为当天
→ 直接返回,不再下载/解混淆/提取。
"""
today = datetime.date.today().isoformat()
# ---------- 检查记录输出 ----------
js_exists = os.path.exists(GCPATCHA_JS)
json_exists = os.path.exists(EXTRACTED_JSON)
output_exists = os.path.exists(OUTPUT_JS)
print(f"[检查] gcaptcha4.js 是否存在: {'是' if js_exists else '否'} ({GCPATCHA_JS})")
print(f"[检查] output.js(解混淆) 是否存在: {'是' if output_exists else '否'} ({OUTPUT_JS})")
print(f"[检查] extracted_values.json 是否存在: {'是' if json_exists else '否'} ({EXTRACTED_JSON})")
# 情况3:当天已提取 → 直接读取
if json_exists:
json_date = None
try:
with open(EXTRACTED_JSON, 'r', encoding='utf-8') as f:
data = json.load(f)
json_date = data.get('date')
is_today = (json_date == today)
has_lib_abo = bool(data.get('_lib') and data.get('_abo'))
print(f"[检查] json 提取日期: {json_date} 今天: {today} "
f"{'→ 是当天' if is_today else '→ 非当天'}"
f"{'' if has_lib_abo else ' (但 _lib/_abo 缺失)'}")
if is_today and has_lib_abo:
print("[结果] 使用当天已提取的 json,无需重新下载/解混淆。")
return data
except Exception as e:
print(f"[检查] 读取 json 失败: {e},将重新提取。")
else:
print("[检查] json 不存在,需要下载 js 并解混淆提取。")
# 情况1 / 2:需要重新生成
# 确保 gcaptcha4.js 存在;js 不存在,或给了最新 js_url(非当天需更新)则重新下载
if not js_exists or js_url:
print(f"[动作] 下载 gcaptcha4.js (js_url={'提供' if js_url else '默认'}) ...")
_download_js(js_url)
else:
print("[检查] gcaptcha4.js 已存在且无需更新,跳过下载。")
# 解混淆生成 output.js(js 变化或 output 缺失时重跑)
print("[动作] 调用 step1.js 解混淆生成 output.js ...")
_rebuild_output_js()
# 解混淆后再次确认 output.js 是否真正生成(有它才能提取)
if os.path.exists(OUTPUT_JS):
print(f"[检查] output.js 已生成,可用于提取。 ({OUTPUT_JS})")
else:
print(f"[警告] output.js 未生成(step1.js/node 可能缺失),将回退从 gcaptcha4.js 提取。")
src = OUTPUT_JS if os.path.exists(OUTPUT_JS) else GCPATCHA_JS
print(f"[动作] 从 {src} 提取 _lib / _abo ...")
extracted = _extract_from_js(src)
extracted['date'] = today
with open(EXTRACTED_JSON, 'w', encoding='utf-8') as f:
json.dump(extracted, f, ensure_ascii=False, indent=2)
print(f"[结果] 已重新提取并写入 json(date={today})。")
return extracted
def parse_lib(lib_str):
"""解析 _lib 对象字面量(如 {dQDSAFB: 'BoHp'}),返回 dict。
兼容单/双引号字符串、数字、true/false/null。"""
d = {}
if not lib_str:
return d
inner = lib_str.strip().strip('{}').strip()
if not inner:
return d
pattern = re.compile(
r"([A-Za-z_$][\w$]*)\s*:\s*"
r"(?:'([^']*)'|\"([^\"]*)\"|(-?\d+(?:\.\d+)?)|(true|false|null))"
)
for m in pattern.finditer(inner):
key = m.group(1)
if m.group(2) is not None:
val = m.group(2)
elif m.group(3) is not None:
val = m.group(3)
elif m.group(4) is not None:
val = float(m.group(4)) if '.' in m.group(4) else int(m.group(4))
elif m.group(5) is not None:
val = {'true': True, 'false': False, 'null': None}[m.group(5)]
else:
val = None
d[key] = val
return d
def _slice_expr(expr, target):
"""解析单个层级表达式(如 (n[5:7]+n[7:9])),对 target 切片并拼接返回字符串。
规则:n[a:b] 实际取 target[a : b+1](与官方示例一致)。"""
out = ''
for part in expr.split('+'):
part = part.strip()
if not part:
continue
m = re.search(r'n\[(\d+):(\d+)\]', part)
if not m:
continue
a, b = int(m.group(1)), int(m.group(2))
out += target[a:b + 1]
return out
def build_abo(abo_str, target):
"""解析 _abo 对象(如 {"(n..)+.+..": 'n[..]'}),按规则对 target 切片,
生成嵌套 dict 并返回。'.' 表示层级递进,'+' 表示字符拼接。"""
if not abo_str:
return {}
obj = ast.literal_eval(abo_str) # 单引号字符串可被 literal_eval 解析
nested = {}
for key_rule, val_rule in obj.items():
# 层级分隔为 '+.+'(点前后均为拼接符)
levels = re.split(r'\+\.\+', key_rule)
level_vals = [_slice_expr(lv, target) for lv in levels]
m = re.search(r'n\[(\d+):(\d+)\]', val_rule)
leaf = target[int(m.group(1)):int(m.group(2)) + 1] if m else val_rule
cur = nested
for i, lv in enumerate(level_vals):
if i == len(level_vals) - 1:
cur[lv] = leaf
else:
cur = cur.setdefault(lv, {})
return nested
3.4.3 encrypt 函数分析
w 由两部分拼接而成:arrayToHex(u) + _

① arrayToHex(u) 部分(AES-CBC):
s = 16位随机十六进制串(guid)
var c = r[a]['symmetrical']['encrypt'](序列化原文, s);
-
• AES-CBC 模式, iv固定0000000000000000 -
• key= 随机 16 位字符串 -
• 加密后为数组,经 arrayToHex转十六进制串
Python 实现:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
def _parse_hex(hex_str: str) -> bytes:
t = len(hex_str)
s = [0] * (t // 4 + 1)
for n in range(t):
s[n >> 2] |= (ord(hex_str[n]) & 0xff) << (24 - n % 4 * 8)
result = bytearray()
for word in s:
result.extend(word.to_bytes(4, 'big'))
return bytes(result[:16])
def aes_encrypt(text: str, key_hex: str, iv_hex: str = '0000000000000000') -> str:
key = _parse_hex(key_hex)
iv = _parse_hex(iv_hex)
cipher = AES.new(key, AES.MODE_CBC, iv)
return cipher.encrypt(pad(text.encode('utf-8'), AES.block_size)).hex()
② u 部分(RSA):
u = new _ᕺᕷᖉᖘ['default']()['encrypt'](s);

-
• RSA 加密,公钥与指数固定 -
• 注意:此处RSA加密用到的的 s必须与上面 AES 的s保持完全一致
Python 实现:
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
RSA_MODULUS = '00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81'
RSA_EXPONENT = '10001'
def rsa_encrypt(message: str) -> str:
n = int(RSA_MODULUS, 16)
e = int(RSA_EXPONENT, 16)
key = RSA.construct((n, e))
cipher = PKCS1_v1_5.new(key)
return cipher.encrypt(message.encode('utf-8')).hex()
四、全流程总结

-
1. 请求 load接口,获取五子棋棋盘及lot_number、captchaId、pow_detail、process_token、static_path等字段。 -
2. 判断 gcaptcha4.js、output.js、extracted_values.json文件是否存在,都存在的话检查json文件日期是否为当天 -
3. 非当天:重新下载 gcaptcha4.js,调用step1.js进行ast解混淆,输出output.js文件,然后使用正则提取键值对和切片表达式另存为extracted_values.json文件供后续使用 -
4. 拼接 pow_msg:version|bits|hashfunc|datetime|captchaId|lotNumber|空字符串|16 位随机十六进制串。 -
5. pow_msg经 sha256 加密后得到pow_sign,需判断pow_sign是否以00开头,否则换随机串重试。 -
6. 将 pow_msg、pow_sign及固定字段拼成字典dic。 -
7. dic参数需要补充extracted_values.json中提取的键值对以及切片值, 通过切片值对lotNumber进行切片处理,获取新的键值对后添加到dic中,然后进行序列化得到参数a -
8. 生成16 位随机十六进制串 key。 -
9. 对 a进行 AES-CBC 加密(iv 固定0000000000000000,key 为第 8 步的key),得res1。 -
10. 对第8步的 key进行 RSA 加密(公钥、指数固定),得res2。 -
11. w = res1 + res2,调用 verify 接口验证。
五、说明
某验4各类型验证码的加密逻辑是一样的,无感、滑块、消消乐、文字点选、svg等,区别只是3.3步骤中a参数里的userresponse值不同,
无感验证码没有userresponse、滑块userresponse是滑块距离、消消乐userresponse是图片移动坐标、文字点选userresponse是文字坐标,并且需要对接打码平台或训练模型。
其中滑块和文字点选可能存在偏移量。
下图附上无感、滑块、消消乐、文字点选验证通过,以及实际逆向过程中遇到某验4验证码的验证流程
无感:

滑块:成功率偏低,只用了ddddocr识别模块,追求高成功率的话,需要对接自己训练模型或接平台

消消乐:

文字点选:

六、业务中实际应用
目标地址:aHR0cHM6Ly9hY2NvdW50Lndwcy5jbi8j ,选择手机登录

抓包或者html提取,获取到该网站某验验证码的captcha_id =703298677ada65c569195d173645cde7

调用sms接口,发送短信,其中圈起来的部分,就是滑块验证码通过后返回的值,获取到之后直接发起sms请求

🔧本篇配套源码,回复关键词 geetest 自动获取网盘下载地址。
长期征稿
稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:
2、海外代理IP(不限量、动态/静态住宅IP)
3、youtube 等音视频低成本下载回流
4、大模型预训练数据
5、SERP (google/bing等搜索结果)
6、海外电商/社媒数据
若有朋友需要可以在菜单栏里加我微信,谢谢。

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...