ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入Cloudflare 5秒盾逆向:从零搭建补环境框架的完整请求链路解析

深入Cloudflare 5秒盾逆向:从零搭建补环境框架的完整请求链路解析 先说一句这篇文章写的是我在处理一个挂满 Cloudflare 5 秒盾的站点时完整走过的一遍逆向流程。目标不是为了教大家怎么攻击别人的网站而是想讲清楚“补环境”这套框架从零开始怎么搭、13 次请求链路里每一步都在干什么。如果你也碰到过那种“浏览器秒开、脚本一敲就 403”的局面这篇文章大概率能帮上忙。先说结论我最后没有去硬抠 Cloudflare 挑战脚本里的混淆算法而是搭了一个 Python 调度的 Node 沙箱补环境框架把挑战脚本丢进去让它自己把答案算出来。整个调试过程里最有价值的不是某一个请求怎么构造而是理解这 13 次请求之间的状态依赖关系——一旦把这些状态理清了后面无非是些补 window、补 document 的体力活。适合读这篇文章的人一种是刚接触 JS 逆向、被各种加密参数搞得头大的新手另一种是已经会用 Playwright 但觉得太重、想走轻量级纯代码方案的开发者。我会把代码骨架和踩坑记录都放出来至少能帮你少走两三天的弯路。1. 5秒盾的13次请求链路先看明白我们在对抗什么很多人一提到 Cloudflare 5 秒盾第一反应是“有个 JS 文件算个值带上 cookie 就行”。实际操作一下就会发现完全不是这么回事。我这次抓包是在自己搭建的测试环境里进行的目标站开了 Im Under Attack Mode也就是俗称的5秒盾整个通过挑战的过程从第一次请求到最终拿到干净页面我在连续会话里记录了 13 个关键 HTTP 请求。这里先强调一下13 次不是一个固定数字不同版本的防护脚本、不同站点配置都会有差异我这里指的是某个典型完整会话。1.1 抓包日志里实际发生了什么我把 curl 开启-c cookies.txt -b cookies.txt后的请求日志简化成下面这样方便观察# 1: 首次访问目标页 GET / → 403, 响应体是 challenge HTML # 2: 页面内加载 orchestrate 配置 GET /cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1?rayxxx → 403, 返回 JS 配置片段 # 3: 拉取主挑战脚本obfuscated JS GET /cdn-cgi/challenge-platform/h/b/scripts/0.1.1/main.js → 200, JS 文本 # 4: 再次请求 orchestrate获取动态参数 GET /cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1?... → 403, 新配置 # 5~8: 脚本运行过程中产生的资源请求/信标上报 POST /cdn-cgi/challenge-platform/h/b/orchestrate/.../v1?... GET /cdn-cgi/challenge-platform/h/b/.../v1?... ... # 9: 提交挑战计算结果 POST /cdn-cgi/challenge-platform/h/b/.../v1?... → 302 / 200, 响应头带 set-cookie # 10: 此时 cookies.txt 里多了 cf_clearance # 11: 重放首页请求 GET / → 200, 返回真实 HTML # 12~13: 后续静态资源和二次校验请求看到这个链路后第一个结论是真正决定成败的不是第一个 challenge HTML也不是第三部的 main.js而是第 9 步提交结果后服务器怎么判断你给的值对不对。前面的请求基本都是在“喂”给挑战脚本各种上下文和配置。1.2 两个关键 Cookie 的分工整条链路运行结束后你的浏览器或请求库里会留下两类 cookie__cf_bm和cf_clearance。__cf_bm是 Cloudflare 的 bot management cookie通常在第一次访问时就种下页面里的脚本会读取它并参与计算。它更像一个会话标识如果你的 User-AgentUA变了或者 IP 变了这个 cookie 就会失效。cf_clearance是挑战通过的凭证只有当你提交的挑战答案被服务端验证通过后才会下发。这个 cookie 有时效性短则几十分钟长则数小时而且它不是“种下就完事”——后续的每一次请求如果 TLS 指纹或 UA 和当时生成 cookie 的环境不一致服务端仍然会拒绝。我最早犯的错误就是只关注cf_clearance完全无视__cf_bm。结果 cookie 种上了但带上它重放请求依然被 403。后来把__cf_bm也纳入会话管理才把状态调通。1.3 13次请求背后的核心矛盾为什么挑战脚本要发起这么多请求因为它要收集环境信息脚本运行时的navigator属性、window上的各种 API、canvas 渲染痕迹、时区、字体列表、甚至 webgl 的 vendor 字符串。这些信息一部分用来生成提交参数一部分会被服务端用于比对“真实浏览器”的基线。所以 13 次请求表面上是一串 URL实际上是一条“环境信息收集链”。你缺哪个 API脚本就会在那个位置停下或者计算出错误答案。理解了这一点你就知道为什么纯静态分析那套做法很难走通——你分析完算法人家换个版本就废了但补环境就不一样你只要把“环境”补得足够像脚本自己会帮你把算法跑完。2. 三条技术路线对比为什么我选了补环境在我开始动手前其实评估过三种常见方案这里直接放出我自己的对比结论。方案优点缺点适用场景纯静态分析加算法还原不依赖执行环境一次逆向可长期复用挑战脚本高度混淆且频繁更新分析成本极高极少数算法稳定、更新不频繁的目标浏览器自动化Playwright/Selenium能通过绝大多数环境检测开发快资源占用大、并发能力差、维护成本高低频次、低并发、追求快速上线补环境框架Python调度 JS沙箱轻量、并发能力强、可精细控制环境信息需要逐个补齐 API初期调试周期长中高频请求、需要并发、希望稳定可控我这次之所以选补环境核心原因是目标站的访问频率要求不低多线程跑 Playwright 不现实而且每次启动一个浏览器实例的成本实在太高。补环境框架一旦跑通就是纯内存里执行 JS单机并发几十上百个任务毫无压力。2.1 补环境的本质让“答案”自己跑出来补环境的基本思想非常朴素挑战脚本不是要检测浏览器环境吗那我就给它一个假的浏览器环境让它在里面正常执行计算出服务器期望的答案。整个过程中我不需要知道答案是怎么算出来的只需要确保它运行时不报错。这有点像你在公司里给一个只认邮箱的同事发文件他要求必须用公司邮箱发。你不需要知道他为什么有这种要求只需要假装用一个公司邮箱把文件发过去。补环境就是把这个“假装”做到足够逼真。2.2 补环境做不到的事情补环境不是万能的。如果你遇到的是需要处理 WebGL 真实渲染结果、需要读取系统字体列表、或者需要和硬件设备交互的检测纯 JS 沙箱很难模拟得一模一样。这时候要么妥协用浏览器自动化要么在沙箱里引入更底层的模拟能力。另外补环境框架对调试能力要求比较高。挑战脚本一旦在某个 API 处抛出异常你得能从堆栈里定位到具体缺了哪个属性这个过程对新手来说并不友好。不过一旦你经历过两三个这种断点后面基本就是条件反射了。3. 框架整体设计Python 调度 Node 沙箱的执行架构我这次搭的框架没有用单一语言而是让 Python 负责网络请求、cookie 管理、并发调度让 Node.js 负责运行挑战脚本。两个进程之间通过标准输入输出通信协议用 JSON。这个设计的理由很简单Python 写任务调度和 HTTP 操作非常顺手而 Node 的vm模块原生支持创建隔离上下文跑 JS 代码不需要额外依赖。3.1 目录结构cf5s-solver/ ├── main.py # 主入口调度完整13次请求 ├── http_client.py # HTTP 层维护 cookie、UA、TLS 指纹 ├── js_bridge.py # Python 与 Node 的 JSON-RPC 桥 ├── webenv.js # 补环境定义挂载 window/document/navigator ├── sandbox.js # Node 沙箱读取 stdin 执行代码 └── logs/ └── session.log # 调试日志3.2 Python 到 Node 的桥接代码这是最核心的骨架代码。js_bridge.py负责拉起 Node 子进程然后把要执行的 JS 文本发给它再读回结果import json import subprocess class JsBridge: def __init__(self, sandbox_scriptsandbox.js): self.proc subprocess.Popen( [node, sandbox_script], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, bufsize1, encodingutf-8 ) def execute(self, js_code, timeout10): payload json.dumps({type: exec, code: js_code}) self.proc.stdin.write(payload \n) self.proc.stdin.flush() # 注意真实场景建议用 select/poll 做超时控制 response self.proc.stdout.readline() return json.loads(response) def close(self): self.proc.terminate()3.3 Node 沙箱的初始化sandbox.js里的核心逻辑是读取 stdin 命令然后在每次执行前创建一个干净的vm上下文const vm require(vm); const readline require(readline); const { createWebEnv } require(./webenv); const rl readline.createInterface({ input: process.stdin }); rl.on(line, (line) { const req JSON.parse(line); if (req.type exec) { try { const context vm.createContext(createWebEnv()); const script new vm.Script(req.code, { timeout: 5000 }); const result script.runInContext(context); process.stdout.write(JSON.stringify({ ok: true, result }) \n); } catch (e) { process.stdout.write(JSON.stringify({ ok: false, error: e.stack }) \n); } } });这里关键一点是vm.Script的timeout参数。挑战脚本里偶尔会有无限循环或极耗时的计算设置超时能保证沙箱进程不会卡死。我一般设 5 秒实际执行中大多数脚本在 1~2 秒内都能出结果。3.4 主流程怎么串起来主程序里的流程大致是1. 发起 GET /拿 challenge HTML 2. 从 HTML/script 中提取 orchestrate 路径和 main.js 路径 3. 下载 main.js 4. 把 main.js 和页面配置通过 js_bridge 发给 Node 5. Node 在补好环境之后执行脚本收集计算输出 6. Python 拿到计算结果后构造提交请求 7. 带上 set-cookie 的 cf_clearance 重放 GET /只要顺序别乱成功率会高很多。我见过很多人在第 5 步之前就急着提交结果当然不行——因为脚本还没跑完结果都没生成你提交啥呢。4. 补环境拦截器window、document、navigator 的高频断层补环境框架里最花时间的部分不是网络层而是给 JS 提供一个“看起来像真实浏览器”的假环境。下面几个补法是我这次调试中实际用到并且反复修改过的直接放出来供参考。4.1 用一个 Proxy 把 window 包起来挑战脚本对window属性的访问非常频繁而且很多属性在普通 Node 环境里根本不存在。假如你只定义一个普通对象脚本一访问到不存在的属性就返回undefined加上后续逻辑可能直接抛异常。更稳妥的方式是用Proxy让脚本在读取任何属性时都不至于崩溃function createWindow() { const target { location: fakeLocation(), navigator: fakeNavigator(), document: fakeDocument(), localStorage: fakeStorage(), sessionStorage: fakeStorage(), innerWidth: 1920, innerHeight: 937, devicePixelRatio: 1, }; return new Proxy(target, { get(obj, prop) { if (prop in obj) return obj[prop]; // 如果访问了未定义的属性返回一个可调用函数 // 这样脚本执行 fn() 时不会直接抛 TypeError return function () {}; }, set(obj, prop, value) { obj[prop] value; return true; } }); }这里容易踩的坑是有些脚本会直接用window.someProp || window.someFunc做特性检测。如果你把所有不存在的属性都返回一个函数那基于布尔判断的逻辑可能被误导。所以更好的策略是先把你已知的、脚本里高频使用的属性显式挂上去只有那些未知的“杂项”才返回空函数兜底。4.2 伪 document 的 getElementById 与 createElement挑战脚本必然会操作 DOM例如读取某个隐藏输入的value或者动态创建一个 canvas 去做指纹计算。这一步没法省只能造一个最小可用的假 DOMfunction fakeDocument() { const elements {}; return { getElementById(id) { if (!elements[id]) { elements[id] { id, value: , innerHTML: , style: {}, setAttribute() {}, getAttribute() { return null; }, appendChild() {}, addEventListener() {}, // 很多脚本会检查 offsetWidth/offsetHeight/offsetParent offsetWidth: 300, offsetHeight: 150, offsetParent: null, }; } return elements[id]; }, createElement(tag) { return { tagName: (tag || ).toUpperCase(), style: {}, width: 300, height: 150, getContext() { return fakeCanvasContext(); }, toDataURL() { return data:image/png;base64,; }, addEventListener() {}, appendChild() {}, setAttribute() {}, getAttribute() { return null; }, }; }, documentElement: { style: {} }, body: { appendChild() {}, style: {} }, addEventListener() {}, querySelector() { return null; }, querySelectorAll() { return []; }, }; }4.3 canvas 指纹的兜底这个我在第一版框架里漏了导致脚本跑了半天报错。很多挑战脚本会拿 canvas 去做指纹创建画布、填充文字、读取像素数据。在 Node 环境里没有真实的 canvas 实现只能返回一个假的上下文对象function fakeCanvasContext() { return { font: , fillStyle: , strokeStyle: , measureText(text) { return { width: (text || ).length * 8 }; }, fillText() {}, strokeText() {}, getImageData() { return { data: new Array(4), width: 300, height: 150 }; }, createLinearGradient() { return { addColorStop() {} }; }, }; }注意getImageData返回的data数组长度很关键。如果脚本里做 hash 时依赖这个数组的长度你只给一个空数组可能导致后续算法结果异常。我一般给一个长度随机的数组模拟真实图像数据。4.4 navigator 和 location 的参数补齐这两个相对简单但必须和 HTTP 请求头里的 UA 保持一致。例如你在 HTTP 层用的是 Chrome 的 UA就不要在navigator.userAgent里放一个 Firefox 的值否则服务端一比对就露馅function fakeNavigator() { return { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, platform: Win32, language: zh-CN, languages: [zh-CN, zh, en], cookieEnabled: true, webdriver: false, hardwareConcurrency: 8, deviceMemory: 8, maxTouchPoints: 0, vendor: Google Inc., }; } function fakeLocation() { return { href: https://target.example/, protocol: https:, host: target.example, hostname: target.example, pathname: /, search: , hash: , origin: https://target.example, }; }4.5 setTimeout 和存储类 API挑战脚本经常会用setTimeout做延迟轮询或者用localStorage存中间状态。在沙箱里没有必要真的等待直接让它同步执行或立即返回即可function fakeStorage() { const store {}; return { getItem(key) { return store[key] ! undefined ? store[key] : null; }, setItem(key, value) { store[key] String(value); }, removeItem(key) { delete store[key]; }, clear() { for (const k in store) delete store[k]; }, key(index) { return Object.keys(store)[index] || null; }, get length() { return Object.keys(store).length; }, }; } // 在 createWebEnv 里覆盖 setTimeout const originalSetTimeout globalThis.setTimeout; const fakeSetTimeout (fn, delay) { // 大多数挑战场景下延迟执行是为了等某个异步资源 // 这里选择立即执行相对安全 return originalSetTimeout(fn, Math.min(delay || 0, 50)); };实测下来setTimeout立即执行比真的等满时间要稳因为挑战脚本里的延时往往只是为了照顾慢速网络并不是算法依赖的时序条件。5. 从执行到放行拼接 13 次请求的最后阶段补环境跑通之后最后一步是把脚本算出的值变成服务器认可的 cookie。这一步看似简单实际上藏着不少细节。5.1 脚本执行完结果去哪了挑战脚本通常会把结果放在某个全局变量里或者通过fetch/XHR把结果发回到服务器。我们在 Node 沙箱里跑脚本时需要主动去读取关键全局变量或者拦截它发出的“假请求”把 URL 和 body 报告给 Python。我是这么做的在webenv.js里挂一个假的fetchfunction createWebEnv() { return { window: createWindow(), document: fakeDocument(), navigator: fakeNavigator(), location: fakeLocation(), localStorage: fakeStorage(), sessionStorage: fakeStorage(), fetch(input, init) { // 把请求内容汇报给外层桥接层 process.stdout.write(JSON.stringify({ type: fetch, url: String(input), options: init || {} }) \n); return Promise.resolve({ ok: true, status: 200, text: () Promise.resolve(), json: () Promise.resolve({}), headers: new Map(), }); }, }; }这样挑战脚本里发出的每个“网络请求”都能被我们捕获Python 侧拿到这些 URL 和参数后再用真实的 HTTP 客户端去发送。5.2 Cookie 提取与合并策略当挑战脚本发出提交请求后Python 这边用真正的 HTTP 请求跟着发一遍记录响应头里的set-cookie。这里有一个常见的坑set-cookie除了cf_clearance之外还可能有__cf_bm的刷新版本。所以不要只更新cf_clearance建议把整个响应里的set-cookie全部解析入库再合并进会话。我用的策略是1. 用 http.cookiejar 管理 cookie 2. 每次响应头里有 set-cookie 就立即加载到 jar 3. 提交挑战答案后检查 jar 里是否存在 cf_clearance 4. 若存在等待 2~3 秒再重放 GET /等待 2~3 秒并不是必须的但我在测试中发现服务器下发 cookie 后的极短时间内立即重放偶尔会遇到“cookie 还未生效”的情况。加一个极短延迟能显著提升稳定性。5.3 重放验证不能只看状态码重放GET /得到 200 并不意味着大功告成。有些网站会返回一个 200 的空页面然后通过 JS 再跳转一次。我建议至少检查两件事响应体里是否还包含challenge-platform相关字样或cf-chl标记响应头里的cf-mitigated是否还是challenge正常应为空或not challenged。如果响应体里仍然能找到 challenge 脚本内容说明cf_clearance被判定无效需要检查 UA、TLS 指纹或者 cookie 是否完整。5.4 UA 与 TLS 指纹的一致性前面提到了 UA 一致性这里再补充一个很多人忽略的点TLS 指纹。Python 的requests库或httpx的默认 TLS 指纹与 Chrome 有明显差异Cloudflare 这类防护系统会做 TLS 指纹检测。如果你用requests去提交挑战和重放可能在第一步就被拦下。我这次是用curl_cffi库模拟 Chrome 的 TLS 指纹才跑通的。在http_client.py里初始化时直接指定impersonatechromefrom curl_cffi import requests as cffi_requests session cffi_requests.Session(impersonatechrome)这一步很关键建议重视。否则就算你把 JS 环境补得再完美HTTP 层还是会被识别成非浏览器客户端。6. 调试过程中最折磨人的四个断点最后这部分是我最想分享的因为代码写出来是一回事真正跑通又是另一回事。我在这个项目里踩的坑基本都集中在下面这四个问题上每一个都花了至少半天时间排查。6.1 挑战脚本执行后返回 undefined症状Node 沙箱执行 main.js 后结果为空脚本没有任何输出。排查链路先在挑战脚本里找到它把结果写到哪个变量。我当初的做法是在webenv.js里增加一个“最后写入属性监听”利用 Proxy 的set钩子记录脚本运行时向 window 写入的所有自定义属性set(obj, prop, value) { obj[prop] value; if (typeof value string || typeof value number) { process.stdout.write(JSON.stringify({ type: console, level: debug, message: [window.${prop} ${value}] }) \n); } return true; }跑完一轮之后看到脚本向window._cf_chl_opt等变量写入了配置对象于是定位到它真正的执行入口不是main.js顶层代码而是等待某个事件回调。我调整了沙箱里的事件触发逻辑模拟页面加载完成后主动触发DOMContentLoaded脚本才开始计算并输出结果。经验很多挑战脚本不是加载完立刻执行而是等待页面事件。补环境时一定要把document.addEventListener捕获下来并在合适时机手动触发。6.2 set-cookie 出来了但重放还是 403症状第 9 步响应头里能看到set-cookie也拿到了cf_clearance但重放首页仍然 403。排查链路我一开始以为是 cookie 过期太快后来对比浏览器的正常流程发现自己的会话里少了一个关键请求在提交挑战答案之前脚本还向一个内部接口做了一次“环境信息上报”。那次上报的响应带出了另一个 cookie和cf_clearance是配套使用的。解决方法是把完整 13 步链路里的每一条响应头都打印出来逐个检查有没有被忽略的set-cookie。找到了之后把 cookie 全部合并再重放就成功了。6.3 页面能过但接口 403症状首页已经能返回 200但访问目标站点的某个 API 接口时仍然 403。排查链路这类问题通常是接口单独做了二次校验校验参数来自页面里的某个 meta 标签或全局变量。也就是说你不仅要在挑战阶段补环境还要在访问接口时从页面里提取动态 token。做法从首页 HTML 里用正则提取出 meta 标签中的_cf_chl_opt或类似配置把站点级别的动态 token 在后续请求中作为参数带上。这一步可以把“过盾”和“正常访问”两件事解耦开避免每次访问接口都重新过一遍盾。6.4 时区与 CPU 核数导致的随机失败症状十次运行里偶尔有两三次失败失败时提交的答案对不上。排查链路这是因为我在navigator里设置的hardwareConcurrency是 8但本地 Node 进程实际运行时某些环境检测拿到的数值不一致导致计算结果出现随机性。另外Date相关的时区数据也会影响部分脚本里的 timezone offset 计算。解决把navigator.hardwareConcurrency、deviceMemory、platform等字段全部固定为常量并且在 Node 启动参数里强制指定TZAsia/Shanghai从根源上消除随机变量。改完这个问题后连续跑了 200 次失败率降到 2% 以内。最后说点个人体会整个项目做下来我最深的感受是Cloudflare 5 秒盾的复杂度不在于某一个算法有多难而在于链条特别长、状态特别多。13 次请求、两个 cookie、十多个环境属性任何一个环节对不上最终结果就是一句冷冰冰的 403。补环境的核心是“把可控的变量全部固定住”让脚本每次运行都处在一个完全一致的环境里这样答案才会稳定。如果你打算在自己拥有或已获得授权的站点上做类似验证我建议先从抓包开始把每一次请求的 URL、请求头、响应头、set-cookie 全部记录下来形成属于自己的“链路地图”。有了这张地图再往里面填补环境代码效率会高很多。最后再分享一个小技巧给沙箱里的每个补的 API 都加上日志。这样当挑战脚本访问某个还缺失的属性时你能第一时间在日志里看到访问路径和调用栈不用靠猜。我就是靠这个办法把一个又一个断点快速找到并补全的。希望这份记录能让你少走一些我走过的弯路。
返回列表