SpiderDemo 第5题:OB混淆实战 —— 反调试绕过与 signature 签名还原

目录

  • 一、题目概览
  • 二、抓包分析:定位加密入口
  • 三、破解反调试机制
  • [四、手动分析 signature 算法并用 Python 复现](#四、手动分析 signature 算法并用 Python 复现)
    • [4.1 方法论:手动分析 OB 混淆的通用思路](#4.1 方法论:手动分析 OB 混淆的通用思路)
    • [4.2 分析核心函数 O0o0O0O0() 的混淆结构](#4.2 分析核心函数 O0o0O0O0() 的混淆结构)
    • [4.3 还原签名算法](#4.3 还原签名算法)
    • [4.4 算法总结](#4.4 算法总结)
    • [4.5 Python 复现](#4.5 Python 复现)
    • [4.6 扩展方案:复用扣好的 JS + execjs 调用](#4.6 扩展方案:复用扣好的 JS + execjs 调用)

注意事项:sessionid 与挑战初始化 。本题同样依赖登录后的 sessionid。正式代码中不要把浏览器里的真实 sessionid 直接写死在脚本里,而是沿用前面题目的统一方式:从仓库根目录下的 .local/spiderdemo.json 读取:

json 复制代码
{
  "sessionid": "paste-your-sessionid-here"
}

不同用户、不同登录会话的 sessionid 都不一样,并且可能会过期;示例中的 paste-your-sessionid-here 只是占位内容,实际测试时不能直接照抄。

另外,本题第 1 页数据通过 init 接口获取,不需要 signature;从第 2 页起才需要携带动态生成的 signature 参数。如果 sessionid 过期,或者浏览器中已经提交过答案导致当前挑战状态被服务端清理,直接请求分页接口 /authentication/api/ob1_challenge/page/{page}/ 时,可能会返回 403、need_init请先初始化 之类的提示。处理思路仍然是先使用同一个 requests.Session 访问题目页,再调用初始化接口,最后再请求分页数据:

text 复制代码
GET /authentication/ob1_challenge/?challenge_type=ob1_challenge
GET /authentication/api/ob1_challenge/init/?challenge_type=ob1_challenge

这样可以模拟浏览器重新进入题目的过程,避免每次提交答案后都必须手动 Ctrl + F5 刷新页面。因此代码里应保留 warmup()init_challenge() 两步,先完成挑战初始化并收集第 1 页,再循环请求第 2 到第 100 页。

一、题目概览

题目地址:https://www.spiderdemo.cn/authentication/ob1_challenge/?challenge_type=ob1_challenge

本题是一道 OB(obfuscator.io)混淆 实战题,考察点涵盖:

  1. 反调试(Anti-debugging) :多处 debugger 陷阱 + toString() 自检防篡改
  2. 字符串数组混淆 :所有字符串 base64 编码后存入 encode_arr,通过解码函数按下标取用
  3. 控制流平坦化 :核心逻辑被拆成 switch-case 分发器,顺序打乱
  4. 签名参数(signature) :每次请求数据需携带动态计算的 signature 参数

题目同样要求对 100 页数据求和并提交结果。本文只保留一条主线:绕过反调试 → 分析 OB 混淆结构 → 还原 signature 生成算法 → Python 复现请求

二、抓包分析:定位加密入口

① 登录网站,先观察正常数据流 。按 F12 打开 DevTools,立刻触发了 debugger 断点,说明 JS 中设置了反调试陷阱,如下图:

先不处理 debugger,禁用所有断点,点击 Sources 面板右上角的 Deactivate breakpoints 按钮:

抓取翻页请求:

复制代码
第1页(初始化): GET /authentication/api/ob1_challenge/init/?challenge_type=ob1_challenge
第2页:         GET /authentication/api/ob1_challenge/page/2/?challenge_type=ob1_challenge&signature=xxx
第3页:         GET /authentication/api/ob1_challenge/page/3/?challenge_type=ob1_challenge&signature=xxx
.....

关键发现:

  1. 第1页通过 init 接口获取,不需要 signature
  2. 第2页起通过 page 接口获取,必须携带 signature 参数,否则返回 403
  3. signature 是一个很长的 base64 字符串,每次请求都不同

② 定位 signature 生成位置 。Ctrl + Shift + F 全局搜索 signature,定位到 ob1_challenge.js 文件:

找到 API 请求函数 apiGetPageData

javascript 复制代码
async function apiGetPageData(page, type=challengeType) {
    try {
        debugger ;const signature = O0o0O0O0();
        const url = `/authentication/api/ob1_challenge/page/${page}/?challenge_type=${encodeURIComponent(type)}&signature=${encodeURIComponent(signature)}`;
        const response = await fetch(url, {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                "X-Requested-With": "XMLHttpRequest"
            }
        });
        const data = await response.json();
        if (!response.ok) {
            debugger ;const errorMessage = data.error || `HTTP ${response.status}: ${response.statusText}`;
            throw new Error(errorMessage)
        }
        return data
    } catch (error) {
        debugger ;console.error("\u83B7\u53D6\u9875\u9762\u6570\u636E\u5931\u8D25:", error);
        throw error
    }
}

所以核心目标就是:还原 O0o0O0O0() 函数的逻辑 。虽然通过静态搜索定位到了关键函数 O0o0O0O0(),但这目前仅停留在逻辑猜想阶段。为了证实该函数确实参与了翻页过程中的加密逻辑,最可靠的方法是挂载断点并触发翻页操作。但为了规避目标脚本中的 debugger 检测,开启了 Deactivate breakpoints 模式,这导致正常断点无法生效;若关闭此模式,浏览器又会立即陷入反调试陷阱。因此,先行绕过反调试机制是推进分析的前提,只有在彻底解除反调试检测后,才能恢复断点分析,进而验证 O0o0O0O0() 是否为 Signature 的生成入口,并完整复现其内部执行过程。

三、破解反调试机制

在关闭 Deactivate breakpoints 并通过 Ctrl + F5 强制刷新页面后,浏览器立即触发了断点。

通过观察源码(见上图),可以发现代码中多处硬编码了 debugger 语句。针对这种简单的反调试手段,首选思路是 静态替换:将脚本拷贝到本地,全局搜索并替换所有的 debugger 语句(防止断住),如下图所示:

随后,利用 Chrome 开发者工具的 Overrides(本地覆盖)功能,将修改后的脚本映射到浏览器中。操作如下:

  1. 在 Sources 面板选择 Overrides 并通过 Select folder for overrides 指定本地文件夹

  2. 允许浏览器访问权限

  3. 将去除了 debugger 的代码全选覆盖原 ob1_challenge.js 并保存(此时文件名上出现紫色圆点,表示覆盖生效)

然而,再次刷新页面后发现异常:页面数据加载失败,翻页操作也无响应。为了排查原因,开启了 Pause on caught exceptions (捕获异常断点):

点击 按钮进行调试,程序运行后停在了以下位置:

可以看到,代码执行逻辑 命中了抛出异常的分支。我们在 Console 中查看该异常的具体信息,以确认触发报错的原因:

由此推测,页面功能异常极有可能是因为移除 debugger 的操作触发了某种 代码自检(Anti-Tamper)机制。为了验证这一猜想,我们审视一下报错位置附近的逻辑:

javascript 复制代码
function initializePage() {
    const _fnStr_check = initializePage["toString"]();
    const _first50_check = _fnStr_check["substring"](0, 50);
    if (!_first50_check["includes"]("deb")) {
        throw new Error("\u68C0\u6D4B\u5230\u53CD\u8C03\u8BD5\u4EE3\u7801\u88AB\u4FEE\u6539")
    }
    challengeType = getChallengeTypeFromUrl();
    console.log("\u5F53\u524D\u6311\u6218\u7C7B\u578B:", challengeType);
    updatePageTitle();
    loadPageData(1);
    initEventListeners()
}

这是一种 toString() 自检 反篡改机制,函数通过 toString() 读取自身源码的前 50 个字符,检查其中是否包含 "deb" 字符串(来自 debugger),如果删掉 debugger 来绕过断点,自检发现源码被修改,直接抛异常,可以看到 ob1_challenge.js 文件中每个函数开头都有这样的模板。绕过方法也比较简单:只需让报错分支失效。我们将 !_first50_check["includes"]("deb") 修改为 false,使其永远不会执行 throw 语句。同理,在本地修改完 ob1_challenge.js 后进行全量覆盖,看看能否成功绕过。

随后取消异常捕获并强制刷新页面,数据请求已恢复正常,验证了绕过策略的有效性。接着,我们在 const signature = O0o0O0O0(); 处设置断点并尝试翻页,程序成功在此处断住,确认了该处即为 signature 生成的核心入口。至此,我们已彻底清除反调试障碍,可以顺利进入 O0o0O0O0 函数内部,对其加密算法一探究竟。

四、手动分析 signature 算法并用 Python 复现

4.1 方法论:手动分析 OB 混淆的通用思路

整体思路:「定位入口 → 理解结构 → 逐层拆解」

复制代码
抓包定位加密参数
       ↓
搜索定位加密函数入口
       ↓
识别混淆类型(OB三件套: 字符串数组 + 旋转 + 解码函数)
       ↓
手动解码字符串数组,建立索引↔明文映射表
       ↓
分析控制流平坦化的 switch-case,追踪执行顺序
       ↓
按执行顺序逐个 case 翻译,替换混淆属性名
       ↓
还原出完整算法 → Python 复现

应对每种混淆手段的具体技巧

混淆手段 识别特征 手动还原方法
字符串数组 + base64 文件开头有大数组,元素全是 = 结尾的字符串 在控制台中 arr.map(atob) 一次性解码
数组旋转(shuffle) IIFE 包裹的 push(shift()) 循环 计算旋转次数(算术表达式求值),在控制台中模拟
解码函数 function xxx(idx) { return atob(arr[idx]) } 识别后直接替换所有调用为解码结果
属性名映射表 大量 obj.xVxVxV = decode(42) 的赋值 建立映射表,批量查找替换
switch-case 平坦化 while(true){ switch(state){ case N: ... state=M; break; } } 追踪 state 跳转顺序,排列出真实执行流
混淆算术 3764559944-(3764559944-2) 这种 直接求值:结果是 2
toString() 自检 fn.toString().includes("deb") 删除 debugger 后同步修改自检条件,避免触发反篡改

手动追踪 switch-case 控制流平坦化 :控制流平坦化是 OB 混淆的核心难点。代码的真实执行顺序被打散进 while(true) { switch(state) { ... } } 的多个 case 中,你看到的代码顺序和实际执行顺序完全无关。

我怎么知道总共有多少个 case?万一有 100/1000 个呢我们不需要提前知道。用以下方法:

  1. Ctrl+F 搜索 case :在 switch 块范围内搜索,数一下有多少个 case,心里有个大概量级
  2. 找到入口 state 值 :switch 前面一定有 var state = 某个初始值; ------ 这就是第一个执行的 case
  3. 跟着 state 跳 :每个 case 末尾会设置 state = 下一个值; break;,你只需要一直跟着跳转就行
  4. 遇到 return 或无跳转就是终点:不是所有 case 都会被执行,有些是死代码或分支代码

三遍法:从粗到细逐层还原

  1. 第一遍:画出执行链。不看 case 内部的逻辑,只关注跳转关系。在纸上或文本编辑器中记录:

    text 复制代码
    state=18 → state=7 → state=24 → state=3 → ... → return result

    这一遍的目标是搞清楚实际有多少步、走什么顺序。

  2. 第二遍:逐 case 翻译。按照第一遍画出的执行链顺序,对每个 case 做翻译:

    • OOoOO0oo.xVxVxV 这类混淆属性名替换为解码后的明文(用你之前建好的映射表)
    • decode(42) 替换为实际字符串
    • 将混淆算术 3764559944-(3764559944-2) 求值为 2

    翻译后每个 case 通常就是一行简单的操作,比如 result = btoa(input)

  3. 第三遍:串联成完整算法。把翻译后的步骤按执行顺序排列,忽略 switch-case 结构,得到的就是原始算法:

    text 复制代码
    step 1: params = Object.keys(data).sort().map(k => k+'='+data[k]).join('&')
    step 2: text = btoa(params) + '@#' + url + '@#' + timestamp + '@#3'
    step 3: key = getCanvasFingerprint()
    step 4: encrypted = xor(text, key)
    step 5: signature = btoa(btoa(encrypted) + timestamp)

实用调试技巧条件断点快速定位 caseswitch(state) 那行设置条件断点 state >= 20,可以跳过前面已经分析过的 case,直接从特定阶段开始调试。在浏览器中调试 vs 静态分析 :浏览器中可以单步看变量值,静态分析需要你在纸上跟踪(累),结合控制台做局部验证,先静态画出执行链(第一遍),再到浏览器中对不确定的 case 设条件断点验证。对付循环中的 case:有些 case 会在循环中反复执行(state 跳转形成环),注意观察循环终止条件,不要无限追踪。

4.2 分析核心函数 O0o0O0O0() 的混淆结构

字符串数组,文件开头定义了一个 base64 编码的字符串数组:

javascript 复制代码
var encode_arr = ["MTRweCBBcmlhbA==", "YWxwaGFiZXRpYw==", ...];

紧接着是 解码函数 OoO0ooo0(index)

javascript 复制代码
function OoO0ooo0(index) {
    const _fnStr_check = OoO0ooo0["toString"]();
    const _first50_check = _fnStr_check["substring"](0, 50);
    if (false) {
        throw new Error("\u68C0\u6D4B\u5230\u53CD\u8C03\u8BD5\u4EE3\u7801\u88AB\u4FEE\u6539")
    }
    for (var OooOooOO = 3764559944 - (3764559944 - 2); OooOooOO !== 587680605 - (587680605 - 0); ) {
        switch (OooOooOO) {
        case 3793311298 - (3793311298 - 4):
            return Oo000ooo;
            OooOooOO = 2882498261 - (2882498261 - 0);
            break;
        case 2467507125 - (2467507125 - 2):
            var o0O00Oo0 = encode_arr
              , Oo000ooo = atob(o0O00Oo0[index]);
            OooOooOO += 32641650 - (32641650 - 2);
            break
        }
    }
}
// 简化之后的逻辑就是: 
function OoO0ooo0(index) {
    return atob(encode_arr[index]);  // base64 解码
}

然后是一个 数组旋转函数(IIFE),将数组 shift 了 16 次:

javascript 复制代码
(function(myarr, num) {
    var fn = function(nums) { while(--nums) { myarr.push(myarr.shift()) } };
    fn(++num);
})(encode_arr, 16);  // 实际上 16 经过了混淆算术: 1372660614-(1372660614-16)

方法论 :遇到 OB 混淆,首先识别这三件套:字符串数组定义 → 数组旋转 → 解码函数。解码函数在后续代码中反复调用,形如 o00oOo0O(42) 实际上就是 atob(encode_arr[42])控制流平坦化(Switch-Case 分发器)O0o0O0O0() 内部的核心逻辑被拆成了一个 for + switch 结构:

方法论 :case 值本身也经过了混淆算术(如 3820145045-(3820145045-197) 实际就是 197)。分析时的关键是 追踪状态变量的跳转顺序,按实际执行路径把 case 串起来。手动追踪执行顺序:

复制代码
16 → 27 → 51 → 62 → 63 → 87 → 104 → 107 → 115 → 127 → 145 → 166 → 188 → 197 → 202 → 211 → 224 → 241 → 252 → 262 → 286 → 294 → 295 → 319(return)

4.3 还原签名算法

javascript 复制代码
// ① 找到ob三件套
// 数组
var encode_arr = ["MTRweCBBcmlhbA==", "YWxwaGFiZXRpYw==", "ZmlsbFN0eWxl", "I2Y2MA==", "ZmlsbFJlY3Q=", "IzA2OQ==", "ZmlsbFRleHQ=", "QnJvd3NlciBmaW5nZXJwcmludA==", "cmdiYSgxMDIsIDIwNCwgMCwgMC43KQ==", "Z2V0VGltZQ==", "X0FVVE9NQVRJT05fREVURUNURURf", "X0xBTkdVQUdFX0RFVEVDVEVEXw==", "eHl6NTE3Y2RhOTZlZmdo", "Zmxvb3I=", "cmFuZG9t", "eHl6NTE3Y2RhOTZlZmho", "", "L2F1dGhlbnRpY2F0aW9uL2FwaS9vYjFfY2hhbGxlbmdlL3BhZ2U=", "Z2V0", "YXBwbGljYXRpb24vanNvbiwgdGV4dC9wbGFpbiwgKi8q", "ZG93bg==", "NTAwMA==", "aXBob25l", "b25l", "cGFpZA==", "ZW5jb2RlVVJJQ29tcG9uZW50", "cmVwbGFjZQ==", "MHg=", "YnRvYQ==", "QnVmZmVy", "ZnJvbQ==", "dG9TdHJpbmc=", "YmFzZTY0", "cGx1Z2lucw==", "bGVuZ3Ro", "bG9jYXRpb24=", "aHJlZg==", "YWJvdXQ6Ymxhbms=", "Y2xvc2U=", "bGFuZ3VhZ2Vz", "ZW4tVVM=", "NjY=", "NzI=", "NmY=", "NmQ=", "NDM=", "Njg=", "NjE=", "NjQ=", "NjU=", "Zm9yRWFjaA==", "dW5lc2NhcGU=", "JXUwMA==", "U3RyaW5n", "c3BsaXQ=", "Y2hhckNvZGVBdA==", "am9pbg==", "b2IxX2NoZWNrPUExc2RSVUlRQ2h0eGVuOHBJMGRBTmk4emNYNXpIQmwrWW5FaEx5WklQeHc4V2tWUlZSbGlZR0JGVlZkZVNGa3VYQmM=", "YUFFRUFCQWhWVkZOUVFWQmFjVjVBVlZwRUIzeFVWMGNlQ3laUGFnY1NRRXB2QVdkVlNrY3NTejFMQkI0UUhCdFRYRjBHRFVWU1drbFdCUnNDSEFrY0JCb1k7IA==", "IFVTRVJJTkZPPUdqVlkwSXRPN0dDY01RcUpRZGlKY0hucXdtclQlMkZNeU5sM0lPJTJGJTJGTzExS1JSdGl0bk1kWTJFV21lUld6RkhIb0tOU0pm", "M2s2VXJkMEt4M3VrQ2JsZWdmNE1OWEJXbWxoRDBnOElBVEtNS00lMkZwa1hoJTJCQWM4JTJGdDFTQWppVjJmb2pTYiUyRkNBenZYR2lkNHZSTHBNZg==", "YnclMkZnQSUzRCUzRDsgQVVUSEtFWT1ZJTJCZ1JZZFJSc3lORGhBMGRydSUyQlF6RVphbllVT1lLb05KR2FQajdWaTN5bVdPZ1hkdDJCQSUyQkolMg==", "RkdlNzhaMm9QZlpqYWpHak03MjV5VUpkTU1zdHJqeXpWVWhZZldPaWNmRmdiSFFIRSUyQkJwZ0Nzb1A0Z0l4VTh3JTNEJTNEOw==", "IGFkYTM1NTc3MTgyNjUwZjFfZ3JfbGFzdF9zZW50X2NzMT1vYjEyMjQxNDA4NTk2NDsgYXNvX3VjZW50ZXI9NWJhYnlrV0g2QW5LcmJ6YzlkMHVnR1NPaG1PUQ==", "ME41bSUyRmtTQlhyYTlxbW10RzZBdVR2R2R6Zm16c2Y4ZXclMkJLJTJCdiUyRk07IGFkYTM1NTc3MTgyNjUwZjFfZ3Jfc2Vzc2lvbl9pZD0=", "MzQ5ODM1ZTktNWU3My00Y2NhLTgyM2MtZDVmMzc3MDk2YzFmOyBhZGEzNTU3NzE4MjY1MGYxX2dyX2xhc3Rfc2VudF9zaWRfd2l0aF9jczE9", "MzQ5ODM1ZTktNWU3My00Y2NhLTgyM2MtZDVmMzc3MDk2YzFmOyBhZGEzNTU3NzE4MjY1MGYxX2dyX3Nlc3Npb25faWRfc2VudF92c3Q9", "MzQ5ODM1ZTktNWU3My00Y2NhLTgyM2MtZDVmMzc3MDk2YzFmOyBhZGEzNTU3NzE4MjY1MGYxX2dyX2NzMT1vYjEyMjQxNDA4NTk2NDsg", "c3luY3Q9", "bm93", "LjIyMzsgc3luY2Q9LTE1MzA=", "UmVnRXhw", "KF58ICk=", "PShbXjtdKikoO3wkKQ==", "bWF0Y2g=", "c3luY2Q=", "RGF0ZQ==", "QCM=", "T2JqZWN0", "a2V5cw==", "cGFyYW1z", "YW5hbHlzaXM=", "aGFzT3duUHJvcGVydHk=", "cHVzaA==", "c29ydA==", "dXJs", "YmFzZVVSTA==", "Z2V0T3duUHJvcGVydHlEZXNjcmlwdG9y", "ZG9jdW1lbnQ=", "aW5kZXhPZg==", "bmF0aXZlIGNvZGU=", "Y3JlYXRlRWxlbWVudA==", "Y2FudmFz", "Z2V0Q29udGV4dA==", "MmQ=", "dGV4dEJhc2VsaW5l", "dG9w", "Zm9udA=="];

// 解码函数
function OoO0ooo0(index) {
    return atob(encode_arr[index]);
}

// 数组旋转函数
(function (myarr, num) {
    var fn = function (nums) {
        while (--nums) {
            myarr.push(myarr.shift())
        }
    };
    fn(++num);
})(encode_arr, 16);  // 实际上 16 经过了混淆算术: 1372660614-(1372660614-16)
console.log(OoO0ooo0(41))
// case 流程
// 16 27 51 62 63 87 104 107 115 127 145 166 188 197 202 211 224 241 252 262 286 294 295 319

function O0o0O0O0() {
    // 我们需要调用的函数
    // 16 → 27 → 51 → 62
    var o00oOo0O = OoO0ooo0;
    var o0oooOO0 = {
        "v0": 0,
        "v1": 1,
        "v2": 2,
        "v3": 3,
        "v4": 4,
        "v5": 5,
        "v6": 6,
        "v7": 7,
        "v8": 8,
        "v9": 0,
        "v10": 59,
        "v11": 60,
        "v12": 61,
        "v13": 62,
        "v14": 63,
        "v15": 64,
        "v16": 34,
        "v17": 65,
        "v18": 64,
        "v19": 66,
        "v20": 67,
        "v21": 64,
        "v22": 68,
        "v23": 40,
        "v24": 69,
        "v25": 10,
        "v26": 70,
        "v27": 71,
        "v28": 72,
        "v29": 2,
        "v30": 0,
        "v31": 73,
        "v32": 74,
        "v33": 75,
        "v34": 76,
        "v35": 77,
        "v36": 78,
        "v37": 79,
        "v38": 80,
        "v39": 81,
        "v40": 82,
        "v41": 79,
        "v42": 83,
        "v43": 84,
        "v44": 85,
        "v45": 86,
        "v46": 84,
        "v47": 87,
        "v48": 88,
        "v49": 89,
        "v50": 84,
        "v51": 90,
        "v52": 88,
        "v53": 89,
        "v54": 17,
        "v55": 18,
        "v56": 91,
        "v57": 92,
        "v58": 23,
        "v59": 23,
        "v60": 18,
        "v61": 23,
        "v62": 24,
        "v63": 91,
        "v64": 93,
        "v65": 94,
        "v66": 95,
        "v67": 96,
        "v68": 97,
        "v69": 95,
        "v70": 96,
        "v71": 91
    };
    var O0ooooO0 = {
        "vVVVvxxvxv": "",
        "VxVVVxVxxV": "/authentication/api/ob1_challenge/page",
        "VxVxvVvxxV": "get",
        "xVxxVxvvxV": "application/json, text/plain, */*",
        "vVvvVVvVvV": "down",
        "xvvvxVvVxv": "5000",
        "xxvVvxvxxx": "iphone",
        "xxxvvxVxVV": "one",
        "VxvxvxvvVv": "paid",
        "VxvVvVvvvv": "",
        "VVxVvvvvvv": "syncd",
        "vxVvvvVxxv": "Date",
        "vvVVxxvxvx": "@#",
        "VVVvxvVvVV": "Object",
        "VvVxxVvxVx": "keys",
        "vVxVxvvvxx": "params",
        "vVxvVvvvVv": "forEach",
        "VVvxvvxvxv": "analysis",
        "vxVVvxvxVx": "params",
        "xVvxvVvxxv": "hasOwnProperty",
        "VVVvVVvxxV": "push",
        "VxxvVVVvxV": "params",
        "xVvvxvvvxx": "sort",
        "vVxvvVVvxv": "join",
        "vVxvvVxVxx": "url",
        "vxvxxxxvvx": "replace",
        "VxxxVxvxVx": "baseURL",
        "VVvvVVxVVV": "getOwnPropertyDescriptor",
        "xVxVxVvxvx": "document",
        "xxxvvxxVvx": "get",
        "vvVxVxVxVV": "",
        "VVxVvxvvxx": "indexOf",
        "vxxvxVVxxx": "native code",
        "vVvxVVvvVV": "createElement",
        "vvVVVxvxxV": "canvas",
        "VvVxxvxVxv": "getContext",
        "vVxxvVxVVv": "2d",
        "vxVVvvxVxx": "textBaseline",
        "vvvvVvvxxx": "top",
        "xxxvxxVVxv": "font",
        "Vxxvvvvxvv": "14px Arial",
        "xxxxxVvxVx": "textBaseline",
        "vxxvvxxVvV": "alphabetic",
        "VVvvvxxVVv": "fillStyle",
        "VvxvvVxvxx": "#f60",
        "xxxVvVxvvV": "fillRect",
        "vxxxxVxVxv": "/authentication/api/ob1_challenge/page",
        "VvvxvvVvvv": "Object",
        "VvxVVxVxvx": "fillStyle",
        "VvVxVVvxxv": "#069",
        "VxxxxxvvVx": "fillText",
        "xxxxvVVVVx": "Browser fingerprint",
        "vxxvvxVVVV": "get",
        "vxVxxVxxxx": "fillStyle",
        "xVvVvVxVVx": "rgba(102, 204, 0, 0.7)",
        "vvVVvVVVvx": "fillText",
        "xVxVvVvvxV": "Browser fingerprint",
        "vVvxxxVxVx": "down",
        "xvVVvxVxVv": "plugins",
        "xvvxxxVvxv": "plugins",
        "vVvVvVVvvV": "length",
        "vxxvxxvvxV": "getTime",
        "vxVxVVVxVv": "_AUTOMATION_DETECTED_",
        "VvvVVxVvvv": "languages",
        "VVVxxxvvvV": "languages",
        "xVxvxvVvVv": "length",
        "xxxVxvxVvx": "languages",
        "vvxVVvxvVv": "en-US",
        "VVvxvvvVVv": "getTime",
        "xxVvxVxvvV": "_LANGUAGE_DETECTED_",
        "xvVvvvVxvV": "xyz517cda96efgh",
        "vVvVxxvxvv": "floor",
        "vvxxvVVvvx": "random",
        "VvVVvvVxxv": "xyz517cda96efhh",
        "xxVxVxVvVx": "floor",
        "VvVVVvVvvx": "random",
        "VxxVvVVvvv": "getTime"
    };

    // var globalThis = window; Node中没有window这里先暂时不管
    var oOO00OoO = 0;
    var OOoO0oo0 = O0ooooO0.vvVxVxVxVV;
    var oo00o00O = {
        "url": "/authentication/api/ob1_challenge/page",
        "method": "get",
        "headers": {
            "common": {
                "Accept": "application/json, text/plain, */*"
            }
        },
        "params": {
            "float": "down",
            "genre": "5000",
            "device": "iphone",
            "type": "one",
            "brand": "paid"
        },
        "baseURL": "",
        "timeout": 30000
    }
    console.log(OOoO0oo0);

    // 63是这样的 反正这个函数应该就是拼接 然后返回base64编码
    function O00oO0oo(t) {
        // 定义变量
        var oOO0oo00 = o00oOo0O;
        var oOooOOOo = {
            v0: 9,
            v1: 10,
            v2: 11,
            v3: 12,
            v4: 13,
            v5: 14,
            v6: 15,
            v7: 16
        };
        var oooO0oOo = {
            "vxvVvVxxvx": "encodeURIComponent",
            "VVVxxxvvvV": "replace",
            "vvxVxVvxvx": "0x",
            "xvxVVVvVxx": "btoa",
            "VvVvvvvxxv": "Buffer",
            "vVvvvvVVvV": "from",
            "VVvVxVxxVV": "toString",
            "VvvVxVVxxv": "base64"
        };
        t = globalThis['encodeURIComponent'](t)['replace'](/%([0-9A-F]{2})/g, function (n, t) {
            return ooOoO0oo('0x' + t)
        });
        // 这是浏览器走的分支 btoa编码
        return globalThis['btoa'](t)
    }

    // 87
    function ooOoO0oo(n) {
        var Oo000oOo = o00oOo0O;
        var OO00OOo0 = {
            v0: 2926981959 - (2926981959 - 17),
            v1: 701527382 - (701527382 - 18),
            v2: 3451112596 - (3451112596 - 19),
            v3: 1248571740 - (1248571740 - 20),
            v4: 3199530610 - (3199530610 - 21),
            v5: 2545289926 - (2545289926 - 22),
            v6: 1766822528 - (1766822528 - 23),
            v7: 3347697778 - (3347697778 - 23),
            v8: 2330380872 - (2330380872 - 18),
            v9: 327362237 - (327362237 - 23),
            v10: 2055169971 - (2055169971 - 24),
            v11: 3238125739 - (3238125739 - 19),
            v12: 4263166090 - (4263166090 - 20),
            v13: 2319569770 - (2319569770 - 21),
            v14: 676716216 - (676716216 - 22),
            v15: 2781366287 - (2781366287 - 25),
            v16: 3886149332 - (3886149332 - 26),
            v17: 3046450671 - (3046450671 - 27),
            v18: 3330644265 - (3330644265 - 28),
            v19: 1061515416 - (1061515416 - 29),
            v20: 4096565787 - (4096565787 - 30),
            v21: 1791763267 - (1791763267 - 31),
            v22: 2100061639 - (2100061639 - 26),
            v23: 1470588032 - (1470588032 - 29),
            v24: 2295643972 - (2295643972 - 27),
            v25: 2741696248 - (2741696248 - 32),
            v26: 4105910718 - (4105910718 - 33),
            v27: 1343586319 - (1343586319 - 34),
            v28: 3086198310 - (3086198310 - 35),
            v29: 652604802 - (652604802 - 36),
            v30: 1270989237 - (1270989237 - 37)
        };
        var OOoOO0oo = {
            "xxxxVxVvvV": "plugins",
            "xVxVxVvxVx": "length",
            "vxxxxVxvVv": "location",
            "xVxxvVVvvx": "href",
            "xVvxvxVxxv": "about:blank",
            "VvxxxvVVVV": "close",
            "VxxvxVvxxx": "languages",
            "xVVVxVvvVV": "languages",
            "VvvvvvvxVv": "length",
            "vVVxxxVVvV": "languages",
            "xVvVVVxvxV": "en-US",
            "VVvVvvVvvv": "location",
            "VVxxxVVxxv": "href",
            "VvxxVVVvvV": "about:blank",
            "VVxvxxxxvx": "close",
            "VxvVvxVxVv": "66",
            "vvvxxvvxvV": "72",
            "xxVVxxVxVV": "6f",
            "vVvxVxvxVv": "6d",
            "vxxvVxVvVv": "43",
            "xxVxxVvVvx": "68",
            "VVvVVvvxvx": "61",
            "VvVVvxVxvV": "72",
            "xvVVvxvVVx": "43",
            "xvxxxxxxxV": "6f",
            "vVvxvxxVvv": "64",
            "xVvvxVvxxx": "65",
            "xVxvxvxxxv": "forEach",
            "VVvvvxVxvV": "unescape",
            "vxVVvVvxvx": "%u00",
            "xxVvVxxxVV": "String"
        };
        // 检测
        // navigator["plugins"]["length"] === 0 && (window["location"]["href"] = "about:blank",
        //     window["close"]());

        // 检测
        // navigator["languages"] && navigator["languages"]["length"] === 1 && navigator["languages"][0] === "en-US" && (window["location"]["href"] = "about:blank",
        //     window["close"]());
        var OooOOOO0 = "";
        ["66", "72", "6f", "6d", "43", "68", "61", "72", "43", "6f", "64", "65"].forEach(function (n) {
            // globalThis ==> window
            OooOOOO0 += globalThis["unescape"]("%u00" + n)
        });
        var O0O0O00O = OooOOOO0;
        // globalThis ==> window
        return globalThis["String"]["fromCharCode"](n);
    }

    // 104
    function oO0oOO0O(n, t) {
        var oooooO0o = o00oOo0O;
        var ooOoOOo0 = {
            "v0": 38,
            "v1": 18,
            "v2": 18,
            "v3": 39,
            "v4": 39,
            "v5": 39,
            "v6": 40
        }
        var oOOO0O00 = {
            "xxxVxVxvxx": "split",
            "VxVvVvvxvV": "length",
            "VvvxVvvvvV": "length",
            "VxvxxVxVxV": "charCodeAt",
            "xvxVvVxxxV": "charCodeAt",
            "VVvvvVvvVv": "charCodeAt",
            "vVvvxvxVvV": "join"
        }
        // o0OoOOOO现在还没有定义
        t = t || o0OoOOOO();

        for (var o0O0O0O0 = (n = n["split"](""))["length"], oo0oO0O0 = t["length"], oOoO0Oo0 = "charCodeAt", oo0OoOOO = oOO00OoO; oo0OoOOO < o0O0O0O0; oo0OoOOO++) {
            n[oo0OoOOO] = ooOoO0oo(n[oo0OoOOO]["charCodeAt"](oOO00OoO) ^ t[(oo0OoOOO + 10) % oo0oO0O0]["charCodeAt"](oOO00OoO))
        }

        return n["join"]("");
        //
    }

    // 107
    function o0OoOOOO() {
        var oOO00OO0 = o00oOo0O;
        var Oo000o0O = {
            "v0": 41,
            "v1": 42,
            "v2": 43,
            "v3": 44,
            "v4": 45,
            "v5": 46,
            "v6": 47,
            "v7": 48,
            "v8": 49,
            "v9": 50,
            "v10": 51,
            "v11": 52,
            "v12": 53,
            "v13": 54
        }
        var OoooOOo0 = {
            "vVVvxVVVvx": "ob1_check=A1sdRUIQChtxen8pI0dANi8zcX5zHBl+YnEhLyZIPxw8WkVRVRliYGBFVVdeSFkuXBc",
            "vvxxVvxvvv": "aAEEABAhVVFNQQVBacV5AVVpEB3xUV0ceCyZPagcSQEpvAWdVSkcsSz1LBB4QHBtTXF0GDUVSWklWBRsCHAkcBBoY; ",
            "vVxxVVxxvx": " USERINFO=GjVY0ItO7GCcMQqJQdiJcHnqwmrT%2FMyNl3IO%2F%2FO11KRRtitnMdY2EWmeRWzFHHoKNSJf",
            "vVVVxvVVVv": "3k6Urd0Kx3ukCblegf4MNXBWmlhD0g8IATKMKM%2FpkXh%2BAc8%2Ft1SAjiV2fojSb%2FCAzvXGid4vRLpMf",
            "xxvVVVxxxv": "bw%2FgA%3D%3D; AUTHKEY=Y%2BgRYdRRsyNDhA0dru%2BQzEZanYUOYKoNJGaPj7Vi3ymWOgXdt2BA%2BJ%2",
            "vvvxxVvxvv": "FGe78Z2oPfZjajGjM725yUJdMMstrjyzVUhYfWOicfFgbHQHE%2BBpgCsoP4gIxU8w%3D%3D;",
            "vVxvvxvVVV": " ada35577182650f1_gr_last_sent_cs1=ob122414085964; aso_ucenter=5babykWH6AnKrbzc9d0ugGSOhmOQ",
            "xVvvvxxxVv": "0N5m%2FkSBXra9qmmtG6AuTvGdzfmzsf8ew%2BK%2Bv%2FM; ada35577182650f1_gr_session_id=",
            "VvvvvvvVvv": "349835e9-5e73-4cca-823c-d5f377096c1f; ada35577182650f1_gr_last_sent_sid_with_cs1=",
            "xVvxxvVxvv": "349835e9-5e73-4cca-823c-d5f377096c1f; ada35577182650f1_gr_session_id_sent_vst=",
            "vVVxvxvvxv": "349835e9-5e73-4cca-823c-d5f377096c1f; ada35577182650f1_gr_cs1=ob122414085964; ",
            "xVVvxvVVvx": "synct=",
            "vvvxvVVvxV": "now",
            "vxvVVxxvVv": ".223; syncd=-1530"
        }
        var OO000oOO = OoooOOo0.vVVvxVVVvx
            , o00o0ooo = OoooOOo0.vvxxVvxvvv
            , O000oooO = OoooOOo0.vVxxVVxxvx
            , o0o00OOo = OoooOOo0.vVVVxvVVVv
            , o0000000 = OoooOOo0.xxvVVVxxxv
            , o0oO00o0 = OoooOOo0.vvvxxVvxvv
            , o0ooo0oO = OoooOOo0.vVxvvxvVVV
            , ooo0Oo0O = OoooOOo0.xVvvvxxxVv
            , oo0oOoOO = OoooOOo0.VvvvvvvVvv
            , oooo000o = OoooOOo0.xVvxxvVxvv
            , oooo0000 = OoooOOo0.vVVxvxvvxv
            , Oo0ooOO0 = OoooOOo0.xVVvxvVVvx + Date[OoooOOo0.vvvxvVVvxV]() + OoooOOo0.vxvVVxxvVv;
        oo0oOoOO;
        return OO000oOO + o00o0ooo + O000oooO + o0o00OOo + o0000000 + o0oO00o0 + o0ooo0oO + ooo0Oo0O + oo0oOoOO + oooo000o + oooo0000 + Oo0ooOO0;
    }

    // 115
    var OoOOooOo = o0OoOOOO();

    // 127
    function O0oooO00(O0OOOO0o) {
        var o0O0oooO = o00oOo0O;
        var oOOOOoOO = {
            "v0": 55,
            "v1": 56,
            "v2": 57,
            "v3": 58,
            "v4": 35,
            "v5": 0
        }
        var oOoo000O = {
            "VVxvxvvxvv": "RegExp",
            "vxVvVVvvvx": "(^| )",
            "xvvxxvvxVx": "=([^;]*)(;|$)",
            "VVxVxxvxxv": "match",
            "xvVvVxxVxv": "unescape",
            "vvVvxVxVxv": ""
        }
        // globalThis window
        var O0OOOO0o = new globalThis["RegExp"](oOoo000O.vxVvVVvvvx + O0OOOO0o + oOoo000O.xvvxxvvxVx);
        return (O0OOOO0o = OoOOooOo[oOoo000O.VVxVxxvxxv](O0OOOO0o)) ? globalThis["unescape"](O0OOOO0o[2]) : oOoo000O.vvVvxVxVxv;
        // var O0OOOO0o = new globalThis["RegExp"]("(^| )syncd=([^;]*)(;|$)");
    }

    // 145
    var O0o0OO0O = -O0oooO00(O0ooooO0.VVxVvvvvvv);
    // 166 globalThis window
    var oOoO0o00 = +new globalThis["Date"] - (O0o0OO0O || oOO00OoO) - 1661224081041;

     // 188
    var OOO00oO0 = O0ooooO0.vvVVxxvxvx;
     // 197
    var oOOO0oo0 = [];
    // 202
    globalThis["Object"]["keys"](oo00o00O["params"])["forEach"](function (n) {
        if (n == "analysis")
            return false;
        oo00o00O["params"]["hasOwnProperty"](n) && oOOO0oo0["push"](oo00o00O["params"][n])
    });

    // 211
    oOOO0oo0 = oOOO0oo0["sort"]()["join"](OOoO0oo0);
    // 224
    oOOO0oo0 = O00oO0oo(oOOO0oo0);
    // 241
    // "@#"@#116949562478@#3
    // NTAwMGRvd25pcGhvbmVvbmVwYWlk@#/authentication/api/ob1_challenge/page
    oOOO0oo0 = (oOOO0oo0 += OOO00oO0 + oo00o00O["url"]["replace"](oo00o00O["baseURL"], OOoO0oo0)) + (OOO00oO0 + oOoO0o00) + (OOO00oO0 + 3);
    // 252 检测document
    // if ((Object["getOwnPropertyDescriptor"](window, "document")["get"] + O0ooooO0.vvVxVxVxVV)["indexOf"]("native code") === -1) {
    //     return false
    // }

    // 检测 canvas
    // var Oo0OoO0O = document["createElement"]("canvas");
    // var OO00Oo00 = Oo0OoO0O["getContext"]("2d");
    // OO00Oo00["textBaseline"] = "top";
    // OO00Oo00["font"] = "14px Arial";
    // OO00Oo00["textBaseline"] = "alphabetic";
    // OO00Oo00["fillStyle"] = "#f60";
    // OO00Oo00["fillRect"](125, 1, 62, 20);
    // OO00Oo00["fillStyle"] = "#069";
    // OO00Oo00["fillText"]("Browser fingerprint", 2, 15);
    // OO00Oo00["fillStyle"] = "rgba(102, 204, 0, 0.7)";
    // OO00Oo00["fillText"]("Browser fingerprint", 4, 17);
    // Oo0ooOOO: 就是一个固定字符串+随机数
    // xyz517cda96efgh + ''
    var Oo0ooOOO = O0ooooO0.xvVvvvVxvV + Math["floor"](Math["random"]() * 10)
    console.log('1:', oOOO0oo0)
    console.log("2:", Oo0ooOOO)
    // console.log(Oo0ooOOO)

    // 262
    // oOOO0oo0先是这样 5000downiphoneonepaid
    // 经过编码变成了这样: NTAwMGRvd25pcGhvbmVvbmVwYWlk
    // 进入了函数
    // NTAwMGRvd25pcGhvbmVvbmVwYWlk@#/authentication/api/ob1_challenge/page@#/authentication/api/ob1_challenge/page@#116949562478@#3
    // 这个是动态的 = 当前时间-1661224081041-1530
    var o0Oo0OOo = O00oO0oo(oO0oOO0O(oOOO0oo0, Oo0ooOOO));
    // 286 OOo0oOo0: 时间戳
    var OOo0oOo0 = new Date()["getTime"]();
    console.log(OOo0oOo0)
    // 294
    var OOO0oOOo = o0Oo0OOo + OOo0oOo0;
    // 295
    var ooO000OO = btoa(OOO0oOOo);
    return ooO000OO;
}

O0o0O0O0();

/*
*  ① 先有字符串
*   时间戳自己生成
*   NTAwMGRvd25pcGhvbmVvbmVwYWlk@#/authentication/api/ob1_challenge/page@#/authentication/api/ob1_challenge/page@#116949562478@#3
* ② 传入函数 这个函数实现了异或运算
*
*
* */

4.4 算法总结

复制代码
┌─────────────────────────────────────────────────────────┐
│                   signature 生成流程                      │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  params = {float:down, genre:5000, device:iphone,       │
│            type:one, brand:paid}                        │
│                        ↓                                │
│        取 values → sort → join("")                    │
│  → "5000downiphoneonepaid"                     │
│                        ↓                                │
│                    btoa(...)                             │
│  → "NTAwMGRvd25pcGhvbmVvbmVwYWlk"         │
│                        ↓                                │
│  拼接: btoa_params + "@#" + url + "@#" + tsOffset + "@#3│
│                        ↓                                │
│            XOR(明文, canvasKey)                          │
│  canvasKey = "xyz517cda96efgh" + random(0-9)            │
│  XOR偏移: key[(i+10) % key.length]                      │
│                        ↓                                │
│                    btoa(XOR结果)                         │
│                        ↓                                │
│            拼接: btoa结果 + Date.now()                   │
│                        ↓                                │
│              btoa(拼接结果) → signature                  │
│                                                         │
└─────────────────────────────────────────────────────────┘

4.5 Python 复现

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : solve.py
@Author  : XAMO Lab
@Date    : 2026/6/13 14:23
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : SpiderDemo challenge 05 OB obfuscation + anti-debug + signature solution.
"""
import base64
import json
import math
import random
import re
import sys
import time
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any

import requests
from loguru import logger

CHALLENGE_TYPE = "ob1_challenge"
PLACEHOLDER_SESSIONID = "paste-your-sessionid-here"


def setup_logger() -> None:
    logger.remove()
    logger.add(
        sys.stderr,
        level="INFO",
        colorize=True,
        format="<green>{time:HH:mm:ss}</green> | <level>{level:<8}</level> | <level>{message}</level>",
    )


def find_repo_root(start: Path) -> Path:
    for path in (start, *start.parents):
        if (path / ".git").exists():
            return path
    raise RuntimeError("Cannot find repository root from current file path.")


def load_spiderdemo_sessionid() -> str:
    """Load SpiderDemo sessionid from `.local/spiderdemo.json` under repo root."""
    repo_root = find_repo_root(Path(__file__).resolve())
    secret_file = repo_root / ".local" / "spiderdemo.json"

    if not secret_file.exists():
        raise FileNotFoundError(
            f"Missing local secret file: {secret_file}. "
            "Create it and add your latest sessionid."
        )

    data = json.loads(secret_file.read_text(encoding="utf-8"))
    sessionid = data.get("sessionid", "").strip()
    if not sessionid or sessionid == PLACEHOLDER_SESSIONID:
        raise RuntimeError(f"Please update sessionid in {secret_file}.")
    return sessionid


class Ob1ChallengeClient:
    """SpiderDemo challenge 05: OB obfuscation + anti-debug + signature."""

    BASE_API_URL = "https://www.spiderdemo.cn/authentication/api/ob1_challenge"
    TEMPLATE_URL = f"{BASE_API_URL}/page/{{}}/"
    INIT_URL = f"{BASE_API_URL}/init/"
    PAGE_URL = (
        "https://www.spiderdemo.cn/authentication/ob1_challenge/"
        f"?challenge_type={CHALLENGE_TYPE}"
    )

    # signature 明文中固定参与拼接的常量(还原自 OB 混淆后的 O0o0O0O0 函数)。
    API_PATH = "/authentication/api/ob1_challenge/page"
    PARAMS_VALUES = sorted(["down", "5000", "iphone", "one", "paid"])
    CANVAS_KEY_PREFIX = "xyz517cda96efgh"
    SYNCD = 1530
    TIME_BASE = 1661224081041

    def __init__(self, session_id: str, max_workers: int = 5, timeout: float = 20.0):
        self.session = requests.Session()
        self.session.cookies.update({"sessionid": session_id})
        self.headers = {
            "accept": "application/json, text/plain, */*",
            "accept-language": "zh-CN,zh;q=0.9",
            "referer": self.PAGE_URL,
            "sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"Windows"',
            "sec-fetch-dest": "empty",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "same-origin",
            "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
            "x-requested-with": "XMLHttpRequest",
        }
        self.max_workers = max_workers
        self.timeout = timeout
        self.session.headers.update(self.headers)

    @staticmethod
    def _btoa(text: str) -> str:
        """Equivalent to JS btoa: base64 over the latin-1 byte view of the string."""
        return base64.b64encode(text.encode("latin-1")).decode("ascii")

    @classmethod
    def _encode_uri_btoa(cls, text: str) -> str:
        """对齐 JS O00oO0oo: encodeURIComponent -> replace(%XX->单字节) -> btoa。"""
        quoted = urllib.parse.quote(text, safe="")
        unescaped = re.sub(
            r"%([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)), quoted
        )
        return cls._btoa(unescaped)

    @classmethod
    def _xor(cls, text: str, key: str) -> str:
        """对齐 JS oO0oOO0O:逐字符异或,key 下标为 (i + 10) % len(key)。"""
        key_len = len(key)
        return "".join(
            chr(ord(char) ^ ord(key[(i + 10) % key_len]))
            for i, char in enumerate(text)
        )

    def build_signature(self) -> str:
        """还原 OB 混淆后的 O0o0O0O0():sort+join -> btoa -> 拼接 -> XOR -> btoa -> 拼时间戳 -> btoa。"""
        btoa_params = self._btoa("".join(self.PARAMS_VALUES))
        ts_offset = int(time.time() * 1000) - self.SYNCD - self.TIME_BASE
        plain = f"{btoa_params}@#{self.API_PATH}@#{ts_offset}@#3"

        canvas_key = self.CANVAS_KEY_PREFIX + str(math.floor(random.random() * 10))
        encrypted = self._encode_uri_btoa(self._xor(plain, canvas_key))
        combined = encrypted + str(int(time.time() * 1000))
        return self._btoa(combined)

    def warmup(self) -> None:
        headers = {
            **self.headers,
            "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
            "sec-fetch-dest": "document",
            "sec-fetch-mode": "navigate",
            "sec-fetch-site": "same-origin",
            "sec-fetch-user": "?1",
            "upgrade-insecure-requests": "1",
        }
        response = self.session.get(self.PAGE_URL, headers=headers, timeout=self.timeout)
        logger.info("Warmup challenge page | status={} | url={}", response.status_code, response.url)
        response.raise_for_status()

    def init_challenge(self) -> int:
        """第 1 页数据直接在 init 接口的 page_data 中返回。"""
        response = self.session.get(
            self.INIT_URL,
            params={"challenge_type": CHALLENGE_TYPE},
            headers=self.headers,
            timeout=self.timeout,
        )
        logger.info("Init challenge | status={} | url={}", response.status_code, response.url)
        if response.status_code >= 400:
            logger.error("Init response body: {}", response.text[:500])
        response.raise_for_status()

        payload = response.json()
        page_data = payload.get("page_data", [])
        if not isinstance(page_data, list):
            raise ValueError(f"Unexpected init page_data type: {type(page_data).__name__}")

        page_sum = sum(page_data)
        logger.info("Page 001 | count={} | sum={} | source=init", len(page_data), page_sum)
        return page_sum

    def fetch_page(self, page: int) -> int:
        params: dict[str, Any] = {
            "challenge_type": CHALLENGE_TYPE,
            "signature": self.build_signature(),
        }
        response = self.session.get(
            self.TEMPLATE_URL.format(page),
            params=params,
            headers=self.headers,
            timeout=self.timeout,
        )
        if response.status_code >= 400:
            logger.error("HTTP {} | url={}", response.status_code, response.url)
            logger.error("Response body: {}", response.text[:500])
        response.raise_for_status()

        payload = response.json()
        page_data = payload.get("page_data", [])
        if not isinstance(page_data, list):
            raise ValueError(f"Unexpected page_data type on page {page}: {type(page_data).__name__}")

        page_sum = sum(page_data)
        logger.info("Page {:03d} | count={} | sum={}", page, len(page_data), page_sum)
        return page_sum

    def run(self, start: int = 1, end: int = 100) -> int:
        logger.info("Start crawling SpiderDemo ob1 challenge pages {}-{}", start, end)
        self.warmup()

        # 第 1 页直接来自 init 接口,从第 2 页起才需要 signature。
        total = self.init_challenge()
        concurrent_start = max(start + 1, 2)
        if concurrent_start > end:
            logger.success("Total sum: {}", total)
            return total

        with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
            futures = {
                pool.submit(self.fetch_page, page): page
                for page in range(concurrent_start, end + 1)
            }
            for future in as_completed(futures):
                page = futures[future]
                try:
                    total += future.result()
                except Exception as exc:
                    logger.error("Page {:03d} failed: {}", page, exc)
                    raise

        logger.success("Total sum: {}", total)
        return total


if __name__ == "__main__":
    setup_logger()
    sessionid = load_spiderdemo_sessionid()
    client = Ob1ChallengeClient(session_id=sessionid)
    client.run()

提交结果如下:

4.6 扩展方案:复用扣好的 JS + execjs 调用

前面通过手动分析 OB 混淆,已经把 O0o0O0O0() 的真实算法(sort+join → btoa → 拼接 → XOR → btoa → 拼时间戳 → btoa)逐步还原成了 Python。这种纯 Python 写法的好处是把算法细节沉淀了下来,依赖也最少。但在实战中,如果只是想先快速跑通接口,也可以采用第二种方式:把还原后的核心 JS 逻辑扣到本地文件,再由 Python 通过 execjs 调用

需要强调的是,这里扣的 JS 不是原始那段满是 debugger、字符串数组和 switch-case 平坦化的混淆代码,而是我们在 6.4.3 中已经分析、简化后的等价逻辑:去掉反调试与自检,去掉字符串数组下标取值,只保留真正参与 signature 生成的几步。这样扣出来的 JS 可读性强,也便于和上面的纯 Python 版本逐行对照验证。

本题扣出来的签名逻辑单独放在 js/ob1_challenge.js 中:

javascript 复制代码
// SpiderDemo challenge 05 ob1_challenge signature logic.
// 这是从 OB 混淆代码中扣取并还原后的核心签名逻辑:
// 去掉了反调试 debugger、toString 自检、字符串数组与控制流平坦化,
// 只保留 O0o0O0O0() 真正参与 signature 生成的几步。

// execjs 默认走 Node 运行时;老版本 Node 没有全局 btoa,这里用 Buffer 兜底。
if (typeof btoa === "undefined") {
    global.btoa = function (s) {
        return Buffer.from(s, "latin1").toString("base64");
    };
}

const API_PATH = "/authentication/api/ob1_challenge/page";
const PARAMS_VALUES = ["down", "5000", "iphone", "one", "paid"];
const CANVAS_KEY_PREFIX = "xyz517cda96efgh";
const SYNCD = 1530;
const TIME_BASE = 1661224081041;

// 对应 OB 混淆中的 O00oO0oo:encodeURIComponent -> %XX 还原为单字节 -> btoa
function encodeUriBtoa(t) {
    t = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, function (n, t) {
        return String.fromCharCode(parseInt("0x" + t));
    });
    return btoa(t);
}

// 对应 OB 混淆中的 oO0oOO0O:逐字符异或,key 下标为 (i + 10) % key.length
function xorEncrypt(text, key) {
    const chars = text.split("");
    const keyLen = key.length;
    for (let i = 0; i < chars.length; i++) {
        chars[i] = String.fromCharCode(
            chars[i].charCodeAt(0) ^ key[(i + 10) % keyLen].charCodeAt(0)
        );
    }
    return chars.join("");
}

// 还原后的 O0o0O0O0():sort+join -> btoa -> 拼接 -> XOR -> btoa -> 拼时间戳 -> btoa
function getSignature() {
    // ① 取 params 的值 -> sort -> join("")  => "5000downiphoneonepaid"
    const joined = PARAMS_VALUES.slice().sort().join("");
    // ② btoa 编码 => "NTAwMGRvd25pcGhvbmVvbmVwYWlk"
    const btoaParams = btoa(joined);
    // ③ 拼接 btoa_params + "@#" + url + "@#" + tsOffset + "@#3"
    const tsOffset = Date.now() - SYNCD - TIME_BASE;
    const text = btoaParams + "@#" + API_PATH + "@#" + tsOffset + "@#3";
    // ④ canvas 指纹 key = 固定前缀 + 随机数(0-9)
    const canvasKey = CANVAS_KEY_PREFIX + Math.floor(Math.random() * 10);
    // ⑤ XOR -> encodeURIComponent + btoa
    const encrypted = encodeUriBtoa(xorEncrypt(text, canvasKey));
    // ⑥ 拼接时间戳 -> btoa
    const combined = encrypted + Date.now();
    return btoa(combined);
}

这里有一个小细节:execjs 默认调用 Node 运行时,而浏览器里的 btoa 在部分 Node 版本中并不是全局函数,因此文件开头用 Buffer.from(s, "latin1").toString("base64") 做了兜底,保证 btoa 的行为和浏览器一致(按 latin-1 字节视图做 base64)。

Python 侧只需要加载这个 JS 文件,然后在每次请求时调用 getSignature() 生成 signature 即可。其余的 warmup()init_challenge()、并发采集逻辑与纯 Python 版本完全一致:

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : solve_execjs.py
@Author  : XAMO Lab
@Date    : 2026/6/13 14:50
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : SpiderDemo challenge 05 solution with extracted JS + execjs.
"""
import json
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any

import execjs
import requests
from loguru import logger

CHALLENGE_TYPE = "ob1_challenge"
PLACEHOLDER_SESSIONID = "paste-your-sessionid-here"


def setup_logger() -> None:
    logger.remove()
    logger.add(
        sys.stderr,
        level="INFO",
        colorize=True,
        format="<green>{time:HH:mm:ss}</green> | <level>{level:<8}</level> | <level>{message}</level>",
    )


def find_repo_root(start: Path) -> Path:
    for path in (start, *start.parents):
        if (path / ".git").exists():
            return path
    raise RuntimeError("Cannot find repository root from current file path.")


def load_spiderdemo_sessionid() -> str:
    """Load SpiderDemo sessionid from `.local/spiderdemo.json` under repo root."""
    repo_root = find_repo_root(Path(__file__).resolve())
    secret_file = repo_root / ".local" / "spiderdemo.json"

    if not secret_file.exists():
        raise FileNotFoundError(
            f"Missing local secret file: {secret_file}. "
            "Create it and add your latest sessionid."
        )

    data = json.loads(secret_file.read_text(encoding="utf-8"))
    sessionid = data.get("sessionid", "").strip()
    if not sessionid or sessionid == PLACEHOLDER_SESSIONID:
        raise RuntimeError(f"Please update sessionid in {secret_file}.")
    return sessionid


class Ob1ChallengeExecJsClient:
    """SpiderDemo challenge 05: call extracted signature code from Python."""

    BASE_API_URL = "https://www.spiderdemo.cn/authentication/api/ob1_challenge"
    TEMPLATE_URL = f"{BASE_API_URL}/page/{{}}/"
    INIT_URL = f"{BASE_API_URL}/init/"
    PAGE_URL = (
        "https://www.spiderdemo.cn/authentication/ob1_challenge/"
        f"?challenge_type={CHALLENGE_TYPE}"
    )

    def __init__(self, session_id: str, js_path: Path | None = None, max_workers: int = 5, timeout: float = 20.0):
        self.session = requests.Session()
        self.session.cookies.update({"sessionid": session_id})
        self.headers = {
            "accept": "application/json, text/plain, */*",
            "accept-language": "zh-CN,zh;q=0.9",
            "referer": self.PAGE_URL,
            "sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"Windows"',
            "sec-fetch-dest": "empty",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "same-origin",
            "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
            "x-requested-with": "XMLHttpRequest",
        }
        self.js_path = js_path or Path(__file__).resolve().parent / "js" / "ob1_challenge.js"
        self.ctx = self._load_js_context(self.js_path)
        self.max_workers = max_workers
        self.timeout = timeout
        self.session.headers.update(self.headers)

    @staticmethod
    def _read_required_file(path: Path) -> str:
        if not path.exists():
            raise FileNotFoundError(f"Missing required JavaScript file: {path}")
        return path.read_text(encoding="utf-8")

    @classmethod
    def _load_js_context(cls, js_path: Path) -> Any:
        logger.info("Load challenge JavaScript file: {}", js_path)
        return execjs.compile(cls._read_required_file(js_path))

    def warmup(self) -> None:
        headers = {
            **self.headers,
            "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
            "sec-fetch-dest": "document",
            "sec-fetch-mode": "navigate",
            "sec-fetch-site": "same-origin",
            "sec-fetch-user": "?1",
            "upgrade-insecure-requests": "1",
        }
        response = self.session.get(self.PAGE_URL, headers=headers, timeout=self.timeout)
        logger.info("Warmup challenge page | status={} | url={}", response.status_code, response.url)
        response.raise_for_status()

    def init_challenge(self) -> int:
        """第 1 页数据直接在 init 接口的 page_data 中返回。"""
        response = self.session.get(
            self.INIT_URL,
            params={"challenge_type": CHALLENGE_TYPE},
            headers=self.headers,
            timeout=self.timeout,
        )
        logger.info("Init challenge | status={} | url={}", response.status_code, response.url)
        if response.status_code >= 400:
            logger.error("Init response body: {}", response.text[:500])
        response.raise_for_status()

        payload = response.json()
        page_data = payload.get("page_data", [])
        if not isinstance(page_data, list):
            raise ValueError(f"Unexpected init page_data type: {type(page_data).__name__}")

        page_sum = sum(page_data)
        logger.info("Page 001 | count={} | sum={} | source=init", len(page_data), page_sum)
        return page_sum

    def fetch_page(self, page: int) -> int:
        params: dict[str, Any] = {
            "challenge_type": CHALLENGE_TYPE,
            "signature": self.ctx.call("getSignature"),
        }
        response = self.session.get(
            self.TEMPLATE_URL.format(page),
            params=params,
            headers=self.headers,
            timeout=self.timeout,
        )
        if response.status_code >= 400:
            logger.error("HTTP {} | url={}", response.status_code, response.url)
            logger.error("Response body: {}", response.text[:500])
        response.raise_for_status()

        payload = response.json()
        page_data = payload.get("page_data", [])
        if not isinstance(page_data, list):
            raise ValueError(f"Unexpected page_data type on page {page}: {type(page_data).__name__}")

        page_sum = sum(page_data)
        logger.info("Page {:03d} | count={} | sum={}", page, len(page_data), page_sum)
        return page_sum

    def run(self, start: int = 1, end: int = 100) -> int:
        logger.info("Start crawling SpiderDemo ob1 challenge pages {}-{}", start, end)
        self.warmup()

        # 第 1 页直接来自 init 接口,从第 2 页起才需要 signature。
        total = self.init_challenge()
        concurrent_start = max(start + 1, 2)
        if concurrent_start > end:
            logger.success("Total sum: {}", total)
            return total

        with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
            futures = {
                pool.submit(self.fetch_page, page): page
                for page in range(concurrent_start, end + 1)
            }
            for future in as_completed(futures):
                page = futures[future]
                try:
                    total += future.result()
                except Exception as exc:
                    logger.error("Page {:03d} failed: {}", page, exc)
                    raise

        logger.success("Total sum: {}", total)
        return total


if __name__ == "__main__":
    setup_logger()
    sessionid = load_spiderdemo_sessionid()
    client = Ob1ChallengeExecJsClient(session_id=sessionid)
    client.run()

提交结果如下:

这种写法本质上是扣 JS 运行,适合在已经还原出核心逻辑、且本地有 Node 环境时快速复现;纯 Python 写法(6.4.5)更适合沉淀算法细节、脱离 JS 运行环境。两种方式得到的接口请求效果是一致的,可以根据场景自由选择。

相关推荐
copyer_xyf1 小时前
Agent 结构化输出
后端·python·agent
FBI HackerHarry浩2 小时前
Ollama如何安装到D盘
python·ai
DXM05212 小时前
第13期|遥感语义分割模型:U-Net核心原理+遥感落地优势
人工智能·python·深度学习·目标检测·随机森林·机器学习·支持向量机
码来的小朋友2 小时前
[python] 我开发了一个有20个关卡随机地图的迷宫游戏
python·游戏·pygame
夏天测2 小时前
微信小程序自动化漏洞挖掘流水线:从缓存提取到密钥验证全流程实战
python·网络安全·微信小程序·漏洞挖掘
叫我:松哥3 小时前
基于Python的共享单车租赁数据分析与预测系统,技术栈flask+boostrap+随机森林+XGBoost
人工智能·python·深度学习·算法·随机森林·数据分析·flask
Li#3 小时前
web端电商项目自动下单发货评价晒图需要用到的能力
python·自动化
雨辰AI3 小时前
从零搭建大模型本地运行环境|Python+CUDA 基础配置避坑大全
大数据·开发语言·人工智能·python·ai·ai编程·ai写作
DogDaoDao3 小时前
【第 05 篇】Python的字典与集合
开发语言·python·集合·字典