目标网站–官网demo

aHR0cHM6Ly9ndDQuZ2VldGVzdC5jb20v


一、协议逆向分析

1.1 抓包分析与接口识别

两大核心接口:

接口
路径
作用
Load接口
/load
加载验证码资源,返回五子棋盘/滑块背景图及关键参数
Verify接口
/verify
提交验证,核心参数为 w

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加密算法信息,含bitsdatetimehashfuncversion

1.3 Verify接口分析

  • • 请求参数:除 w 外,其余参数均可在 load 接口的入参或返回值中找到。
  • • 核心参数 w:唯一的加密参数,是逆向的终极目标

二、AST解混淆

说明:gcaptcha4.js 每隔几天就会变化(变量名、函数名随机),但混淆结构和位置固定。因此解混淆的核心思路是:按结构特征匹配,而不是按名字匹配,实现自动化提取与还原。

2.1 混淆特征总览

特征
说明
变量名混淆
使用 Unicode 稀有字符(CJK 兼容区)作为变量名,如 _ᕺᖘᖄᖘ
字符串加密(核心)
所有字符串常量被加密为整数参数,运行时通过解密函数还原
解密函数句柄传递
解密函数从全局对象属性取出,经数组包装后赋给混淆别名

字符串加密示例:

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 文件:

1. 浏览器开发者工具 → Network 定位 gcaptcha4.js
2. 网络请求抓取并保存为本地 gcaptcha4.js

现在先手动定位gcaptcha4.js并保存为本地gcaptcha4.js

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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)。

定位步骤:

1. 在浏览器对启动器打断点,跟栈分析,找到字符串被还原的位置。
2. 观察解密调用处代码(以稀有字符变量为例):

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)
3. 将稀有字符替换为 abc 样式便于阅读,可发现:
• c(1514)a(13)a(1571) 、c(13)是混淆后的解密调用
• var a = _ᖈᕴᖙᕶ.$_Cl → _ᖈᕴᖙᕶ.$_Cl 就是最原始的解密函数
• b = ["$_FJGJ_"].concat(a) → d[1] = b
• c = b[1] → c = a,即 c 和 a 等价
4. 全局搜索该全局对象(如 _ᖈᕴᖙᕶ),在文件最前面可找到其函数声明——这里就是解密函数本体
某验4五子棋逆向py纯算(AST解混淆/自动化提取)

2.5 验证解密函数

目的: 确认本地还原的解密函数逻辑与网站一致。

步骤:

  1. 1. 将解密函数本体拷贝到本地,补上空函数占位后自执行:

    _ᖈᕴᖙᕶ.$_AO = function(){};// 原函数自行拷贝
    _ᖈᕴᖙᕶ.$_Bn = function(){};// 原函数自行拷贝
    _ᖈᕴᖙᕶ.$_Cl = function(){};// 原函数自行拷贝
    _ᖈᕴᖙᕶ.$_Dl = function(){};// 原函数自行拷贝
    function _ᖈᕴᖙᕶ() {};
    for (var a = 0; a < 200; a++) {
        console.log(`_ᖈᕴᖙᕶ.$_Cl(${a})-->${_ᖈᕴᖙᕶ.$_Cl(a)}`);
    }
    
2. 在浏览器控制台断点断住后,执行同样的循环打印。
3. 对比 Node 与浏览器的输出,一致则说明逻辑正确。
某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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)

特征要点:

1. type === VariableDeclaration
2. declarations 长度为 3,且每个元素都是 VariableDeclarator
3. declarations[0].init 是 MemberExpression(即 全局对象.解密方法
4. declarations[0].init 的源码必须等于 target_decrypt_fn(即自动提取出的解密方法名)
5. 收集解密函数ac
6. 获取当前作用于下ac的绑定节点
7. 遍历替换,注意条件判断
1. 判断父节点是否存在
2. type是否为CallExpression
3. 调用表达式参数length1
4. 调用表达式参数type是否为NumericLiteral
5. 收集完成后eval收集的代码,并执行替换

⚠️ 因为 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),按同样逻辑补遍历即可。

混淆前后对比

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

2.7 解密校验

将 output.js 替换网页中的 gcaptcha4.js,刷新验证码,确认能正常加载与校验通过。

以下是张gif图片,地址:https://mmbiz.qpic.cn/mmbiz_gif/yGOpnM3p9MRna2m0WX124utibVvT19iaL7pK2Z3PI1NZ4I3yGg2lYsPsE1UeZ8bTbvWMVcxDVdwzlqADYtibcoaOic4loM3YIaasIlcS2CHkCZA/640?wx_fmt=gif&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1#imgIndex=5

三、w值逆向

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

3.1 分析载荷

w 是核心加密参数,全局搜索 w 无意义(成千上万处)。应搜索 captcha_idlot_number 等字段定位。

3.2 定位 w 的生成位置

步骤:

  1. 1. 以 captcha_id 为例搜索,共有 7 处匹配。
  2. 2. 对最后两个符合处打断点,刷新页面并触发一次校验:
    • • 第 7 个:刷新验证码时调用
    • • 第 6 个:校验验证码时调用(即我们要找的)
  3. 3. 断点命中后,观察 w 的赋值语句:
var w = (0, _ᕺᖈᕴᖁ['default'])(_ᖆᖃᖉᖉ['default']['stringify'](_ᖄᖃᖙᖁ), _ᖀᖚᖙᖈ),
//    改写成
var w = (0, _ᕺᖈᕴᖁ['default'])(_ᖆᖃᖉᖉ['default']['stringify'](a), b),

参数含义:

  • • a:待加密的明文字典(含 pow_msgpow_sign 等,见 3.3)。
  • • b:未知,后续逆向时发现此参数没起到作用。

3.3 分析明文参数 a 的构成

w 的明文 a 由固定字段 + 需逆向字段组成。

3.3.1 参数 a 字段表

参数名
说明
来源
7822f0 {"a4b2": "2f0ab495"}

 变化值,键值均取自 lot_number,切片位置在gcaptcha4.js固定位置提取
动态 提取(见 3.4.2)
biht
固定值”1426265548″`
dQFB BoHp

变化值,在gcaptcha4.js中固定位置提取
见 3.4.2
device_id
固定空串
em
固定对象
见 3.4.1
ep
固定值 "123"
gee_guard
固定对象
见 3.4.1
geetest
固定 "captcha"
lang
固定 "zh"
lot_number
load 接口返回
passtime
验证耗时,随机 500~3000即可,没有硬性要求
随机生成
pow_msg
需逆向
见 3.3.2
pow_sign
需逆向
见 3.3.2
userresponse
五子棋棋子移动坐标
见 3.3.3

3.3.2 pow_msg 与 pow_sign 逆向

步骤:

  1. 1. pow_msg = _powFunc(options, captchaId, hashfunc, version, bits, datetime, '')全局搜索pow_msg,一共5处,全部打上断点之后刷新验证码,在此处断住。

    ***注:***打上断点后,刷新验证码可以发现:pow_msgpow_signload接口刷新验证码时生成的,而非校验验证码时生成。

    某验4五子棋逆向py纯算(AST解混淆/自动化提取)
     _ᖘᕵᖆᖗ = (0, _ᖉᕹᕾᖀ['default'])(_ᖀᖚᖙᖈ, _ᖂᖗᖙᕴ, _ᖈᕸᕷᕵ['hashfunc'], _ᖈᕸᕷᕵ['version'], _ᖈᕸᕷᕵ['bits'], _ᖈᕸᕷᕵ['datetime'], '')
    _ᕺᕷᖉᖘ = _ᖘᕵᖆᖗ['pow_msg'],
    _ᖈᕵᖘᕶ = _ᖘᕵᖆᖗ['pow_sign'];
    • • hashfuncversionbitsdatetime 均来自 load 接口响应的 pow_detail
    • • captchaIdlotNumber 来自 load 响应
  2. 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. 3. 最终拼接格式:version|bits|hashfunc|datetime|captchaId|lotNumber|空字符串|随机串(h)例:1|8|sha256|2026-08-17T17:25:39.433108+08:00|54088bb07d2df3c46b79f80300b0abbe|b22929400f68432688a1341b031e71e1||dee6e1e9729f644d

  4. 4. 校验:pow_sign 必须以 00 开头才有效,否则换随机串重试。

    某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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
某验4五子棋逆向py纯算(AST解混淆/自动化提取)

3.4 w 值加密分析

w = encrypt(JSON.stringify(a), key),拆分为三步:

  1. 1. new_a = JSON.stringify(a) —— 纯 JSON 序列化(无加盐)
  2. 2. encrypt_func = encrypt_module —— 即下方的 AES + RSA 组合
  3. 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=(',', ':'))
某验4五子棋逆向py纯算(AST解混淆/自动化提取)

其中

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

这两个键值是需要我们在gcaptcha4.js中提取的。

跟栈可以发现,目标值是已经生成的了

var i = (0, _ᕴᖗᖗᖆ['getStringByIndexes'])(_ᖃᕹᖙᕶ['lot'], _ᖃᕹᖙᕶ['lotNumber']),
    r = (0, _ᕴᖗᖗᖆ['getStringByIndexes'])(_ᖃᕹᖙᕶ['lotRes'], _ᖃᕹᖙᕶ['lotNumber']),
    o = i['split']('.'), a = {};
某验4五子棋逆向py纯算(AST解混淆/自动化提取)
  1. 1. getStringByIndexes分析,先获取切片字符串,后面通过split和正则进行拆分匹配。

    某验4五子棋逆向py纯算(AST解混淆/自动化提取)
  2. 2. 全局搜索切片值,能够直接定位
某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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. 核心语法规则
符号
名称
作用
+
拼接符
将同一层级内的多个切片结果横向拼接,组成一个完整的字符串(键或值)。
. 层级分隔符(核心)
表示 JSON 结构的嵌套层级。每遇到一个点号,就“往内新开一层对象”,点号左侧的内容作为当前层的键,右侧内容进入下一层作为其值。
:
赋值符
冒号右侧的内容(切片结果)作为当前最内层键对应的最终值
n[m:n]
切片指令
对目标字符串的截取用n[m:n]表示(含尾索引,即取第 m 到第 n 个字符),其底层实际执行等价于 str.slice(m, n+1)(左闭右开 [m, n+1))
3. 所有切片操作的明细表(按执行顺序)

根据补充的 slice 调用逻辑,实际切片边界与结果如下(左闭右开):

编号
表达式中的写法
实际切片操作
截取索引范围
提取结果
用途说明
n[5:7] slice(5, 8)
5, 6, 7
"b14"
拼接为最外层键的左半部分
n[7:9] slice(7, 10)
7, 8, 9
"4e5"
拼接为最外层键的右半部分
n[20:27] slice(20, 28)
20 ~ 27
"e1ce149f"
直接作为第二层键
n[10:10] slice(10, 11)
10
"c"
拼接为最内层键的第1个字符
n[12:12] slice(12, 13)
12
"4"
拼接为最内层键的第2个字符
n[3:3] slice(3, 4)
3
"9"
拼接为最内层键的第3个字符
n[7:7] slice(7, 8)
7
"4"
拼接为最内层键的第4个字符
n[7:14]

(冒号右侧)
slice(7, 15)
7 ~ 14
"4e5cb46a"
直接作为最内层键对应的值

4. 利用点号(.)进行层级拼接

将原始表达式按点号拆解为三个层级块(忽略+.+中的加号,加号仅用于块内拼接):

(块①+块②) . (块③) . (块④+块⑤+块⑥+块⑦) : (块⑧)

逐层计算各块内容:

  • • 第1层块(最外层键):① + ② = "b14" + "4e5" = "b144e5"
  • • 第2层块(第二层键):③ = "e1ce149f"
  • • 第3层块-键(最内层键):④ + ⑤ + ⑥ + ⑦ = "c" + "4" + "9" + "4" = "c494"
  • • 第3层块-值(最内层值):⑧ = "4e5cb46a"

5. 最终组装逻辑(点号的层级钻进)

程序处理点号时遵循“从左到右,遇点则进”的原则,按以下步骤构建 JSON:

  1. 1. 第一个 . 出现:将第1层结果 "b144e5" 作为初始键,其值必须是一个新的对象 { ... }(进入第2层)。
  2. 2. 第二个 . 出现:在第2层的对象中,将第2层结果 "e1ce149f" 作为键,其值必须又是一个新的对象 { ... }(进入第3层)。
  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) + _

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

① 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);
某验4五子棋逆向py纯算(AST解混淆/自动化提取)
  • • 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()

四、全流程总结

某验4五子棋逆向py纯算(AST解混淆/自动化提取)
  1. 1. 请求 load 接口,获取五子棋棋盘及 lot_numbercaptchaIdpow_detailprocess_token 、static_path等字段。
  2. 2. 判断gcaptcha4.jsoutput.jsextracted_values.json文件是否存在,都存在的话检查json文件日期是否为当天
  3. 3. 非当天:重新下载gcaptcha4.js,调用step1.js进行ast解混淆,输出output.js文件,然后使用正则提取键值对和切片表达式另存为extracted_values.json文件供后续使用
  4. 4. 拼接 pow_msgversion|bits|hashfunc|datetime|captchaId|lotNumber|空字符串|16 位随机十六进制串
  5. 5. pow_msg 经 sha256 加密后得到 pow_sign,需判断 pow_sign 是否以 00 开头,否则换随机串重试。
  6. 6. 将 pow_msgpow_sign 及固定字段拼成字典dic
  7. 7. dic参数需要补充extracted_values.json中提取的键值对以及切片值, 通过切片值对lotNumber进行切片处理,获取新的键值对后添加到dic中,然后进行序列化得到参数a
  8. 8. 生成16 位随机十六进制串 key
  9. 9. 对 a 进行 AES-CBC 加密(iv 固定 0000000000000000,key 为第 8 步的 key),得 res1
  10. 10. 对第8步的 key 进行 RSA 加密(公钥、指数固定),得 res2
  11. 11. w = res1 + res2,调用 verify 接口验证。

五、说明

某验4各类型验证码的加密逻辑是一样的,无感滑块消消乐文字点选svg等,区别只是3.3步骤中a参数里的userresponse值不同,

无感验证码没有userresponse、滑块userresponse是滑块距离、消消乐userresponse是图片移动坐标、文字点选userresponse是文字坐标,并且需要对接打码平台或训练模型。

其中滑块和文字点选可能存在偏移量。

下图附上无感、滑块、消消乐、文字点选验证通过,以及实际逆向过程中遇到某验4验证码的验证流程

无感:

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

消消乐:

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

文字点选:

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

六、业务中实际应用

目标地址:aHR0cHM6Ly9hY2NvdW50Lndwcy5jbi8j ,选择手机登录

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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

某验4五子棋逆向py纯算(AST解混淆/自动化提取)

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

某验4五子棋逆向py纯算(AST解混淆/自动化提取)
本文由 抓包刮大乐透  投稿

🔧本篇配套源码,回复关键词 geetest 自动获取网盘下载地址。


 

长期征稿

稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:

1000 元一篇,把你的逆向笔记变现

猿人学的业务:

1、猿人学爬虫逆向课

2、海外代理IP(不限量、动态/静态住宅IP)

3、youtube 等音视频低成本下载回流

4、大模型预训练数据

5、SERP (google/bing等搜索结果)

6、海外电商/社媒数据

若有朋友需要可以在菜单栏里加我微信,谢谢。

猿人学banner宣传图

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。

***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...