目录
- 一、题目概览
- 二、抓包分析:定位加密入口
- 三、破解反调试机制
- [四、手动分析 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)混淆 实战题,考察点涵盖:
- 反调试(Anti-debugging) :多处
debugger陷阱 +toString()自检防篡改 - 字符串数组混淆 :所有字符串 base64 编码后存入
encode_arr,通过解码函数按下标取用 - 控制流平坦化 :核心逻辑被拆成
switch-case分发器,顺序打乱 - 签名参数(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页通过
init接口获取,不需要 signature - 第2页起通过
page接口获取,必须携带 signature 参数,否则返回 403 - 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(本地覆盖)功能,将修改后的脚本映射到浏览器中。操作如下:
-
在 Sources 面板选择 Overrides 并通过 Select folder for overrides 指定本地文件夹
-
允许浏览器访问权限

-
将去除了 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 个呢 ?我们不需要提前知道。用以下方法:
- Ctrl+F 搜索
case:在 switch 块范围内搜索,数一下有多少个 case,心里有个大概量级 - 找到入口 state 值 :switch 前面一定有
var state = 某个初始值;------ 这就是第一个执行的 case - 跟着 state 跳 :每个 case 末尾会设置
state = 下一个值; break;,你只需要一直跟着跳转就行 - 遇到
return或无跳转就是终点:不是所有 case 都会被执行,有些是死代码或分支代码
三遍法:从粗到细逐层还原
-
第一遍:画出执行链。不看 case 内部的逻辑,只关注跳转关系。在纸上或文本编辑器中记录:
textstate=18 → state=7 → state=24 → state=3 → ... → return result这一遍的目标是搞清楚实际有多少步、走什么顺序。
-
第二遍:逐 case 翻译。按照第一遍画出的执行链顺序,对每个 case 做翻译:
- 将
OOoOO0oo.xVxVxV这类混淆属性名替换为解码后的明文(用你之前建好的映射表) - 将
decode(42)替换为实际字符串 - 将混淆算术
3764559944-(3764559944-2)求值为2
翻译后每个 case 通常就是一行简单的操作,比如
result = btoa(input)。 - 将
-
第三遍:串联成完整算法。把翻译后的步骤按执行顺序排列,忽略 switch-case 结构,得到的就是原始算法:
textstep 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)
实用调试技巧 :条件断点快速定位 case 在 switch(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 运行环境。两种方式得到的接口请求效果是一致的,可以根据场景自由选择。