做数据采集和安全测试的同学,十有八九都遇到过 Cloudflare 5秒盾这道门。访问目标站时页面先弹一个“正在验证您的浏览器...”的遮罩,转几秒才放你进去。它不是简单的等待,而是那段藏在页面里的 JS 在真实浏览器环境里跑完了一整套计算,最后给你发一张“通行证”。标题里说的“13次请求”,是我在一台授权测试站点上抓下来的一次完整访问链路,恰好13个HTTP请求,从第一次碰主页到拿到业务数据,一条线走完。这篇文章不是给你一个“跑一遍就出结果”的现成脚本,而是把背后那套 Python 补环境框架的搭建思路完整拆开:为什么要补环境、补哪些东西、13次请求怎么串联、踩过的坑长什么样。适合已经写过一点 Python、接触过 JS 逆向但还没系统试过补环境的人,看完能自己上手搭建一套可复现的反5秒盾模拟流程。提前说清楚,这篇文章的所有方法只用于自己持有授权或搭建的测试环境,上线前务必确认合规边界。
1. 5秒盾为什么难绕:它到底在校验什么
1.1 5秒盾的本质:一个JS谜题加一套指纹取证
5秒盾在防护体系里叫 Managed Challenge,也就是“托管质询”。服务端在返回页面时,除了正常HTML,还会塞进一段挑战脚本。浏览器拿到这段脚本后,需要在极短时间内执行一个由多种加密算法拼起来的任务,得到类似cf_clearance的Cookie或token,然后带着这个凭证重新请求目标地址。服务端校验通过才返回真实业务内容。
难点不在那个加密算法本身。Cloudflare 在设计时就把“人”和“机器”的差异也编进了算法输入——脚本会读取浏览器的navigator、screen、location、时间、Canvas、WebGL、AudioContext 等一大堆运行环境数据,把它们序列化后混入计算载荷。你在纯命令行环境里用Python发请求,这些环境一个都不存在,等于考生进考场连笔都没带。所以“逆向”5秒盾,很大一部分工作不是把算法还原成数学公式,而是先把丢失的“浏览器环境”补回来,让脚本在模拟环境里能完整跑完,拿到的凭证才可能通过服务端校验。
1.2 三套主流方案怎么选:还原算法、浏览器自动化、补环境
我在项目初期试过两条路,后来才走到补环境。先讲清楚三条路线的取舍,你就不容易走弯。
第一条,纯算法还原。把挑战脚本拿下来,格式化、去混淆、控制流平坦化,一行行读懂,再自己用Python重写整个计算逻辑。这条路的优点是一旦还原成功,执行速度极快、稳定性极高。但缺点很明显:工作量巨大,而且Cloudflare的混淆版本更新频繁,今天还原的算法,明天可能就换了入口函数。对绝大多数项目来说,投入产出比太差。
第二条,浏览器自动化,比如 Selenium、Playwright。优点是“环境天然真实”,不用补什么,脚本自己会跑;缺点是性能和稳定性都吃亏,每处理一次质询都要拉起一个完整浏览器,内存开销高,启动慢,而且自动化痕迹容易被检测到,头部、WebDriver标记、鼠标轨迹都可能是判定特征。
第三条就是本文用的补环境模拟。核心思路是:把挑战JS直接丢进一个高性能 JS 引擎(比如 V8),然后用Python写一套“伪浏览器环境”注入进去,脚本读window我给window,读navigator我给navigator,缺什么补什么,直到脚本能正常跑完并生成凭证。它保留了真实JS逻辑,又甩掉了浏览器重量级的渲染开销。这是目前对抗5秒盾性价比最高的一条路,也是接下来要重点展开的主体。
1.3 13次请求的流水线设计逻辑
很多人以为逆向5秒盾就是“打开浏览器开发者工具,复制一段JS,execjs跑一下”。真实项目远不是这样,它是一次完整的工程调度。一次成功的访问,从输入URL到拿到目标数据,中间隔着大量页面资源请求、挑战资源请求、业务接口请求,我在测试站上记录的这次正好是13个HTTP请求。
| 序号 | 请求 | 作用 |
|---|---|---|
| 1 | GET / | 获取challenge HTML页面 |
| 2 | GET /cdn-cgi/challenge-platform/... | 拉取挑战JS主脚本 |
| 3 | GET /cdn-cgi/challenge-platform/.../asset.js | 拉取挑战辅助脚本 |
| 4 | GET /favicon.ico | 浏览器自动产生的静态请求 |
| 5 | POST /cdn-cgi/challenge-platform/.../execute | 提交JS执行结果、换取放行凭证 |
| 6 | GET / 带上cf_clearance | 重新访问主页验证凭证 |
| 7 | GET /assets/vendor.js | 加载页面主资源 |
| 8 | GET /assets/main.css | 加载样式资源 |
| 9 | GET /api/session | 获取会话信息 |
| 10 | POST /api/login | 登录业务系统 |
| 11 | GET /api/list?page=1 | 目标业务请求1 |
| 12 | GET /api/detail?id=123 | 目标业务请求2 |
| 13 | GET /api/list?page=2 | 目标业务请求3 |
不同网站的请求数量和顺序会有差异,但骨架逻辑是共通的:先拿质询页,再拉挑战JS,执行JS取得凭证,重新访问验证,然后进入正常业务请求。后面实操章节,我会把这13步一条龙走一遍。
2. 补环境框架的搭建思路
2.1 补哪些环境:从第一次报错到能跑通的逐步反推
补环境的第一个原则是“不要一次补完”。一开始就试图把完整的浏览器对象模型全塞进去,不但工作量大,还容易补出一套自相矛盾的“环境指纹”,反而被服务端一眼识破。正确做法是跑一步看一个报错,缺谁补谁。
我习惯按梯队排优先级。第一梯队是几乎所有挑战JS都会读取的基础对象:window、document、navigator、location、screen。这些不补,脚本连启动都会挂。第二梯队是跨域存储和渲染相关:localStorage、sessionStorage、cookie、CanvasRenderingContext2D、WebGLRenderingContext、AudioContext。第三梯队才是行为感知类的:MutationObserver、IntersectionObserver、requestAnimationFrame、XMLHttpRequest、fetch。
每一梯队内部,也不是随便补。比如navigator里面至少有几十个字段,实际挑战脚本可能只读其中几个。通过报错定位最有效——脚本抛Cannot read properties of undefined (reading 'webdriver'),你就知道它要读navigator.webdriver;它不报错,你就不用补。
2.2 运行时选型:PyMiniRacer、execjs还是js2py
补环境框架的执行引擎,我前后试过三个,做个对照:
| 运行时 | 底层引擎 | 优点 | 缺点 |
|---|---|---|---|
| PyMiniRacer | V8 | 速度快、无Node依赖、API简洁 | Windows下安装依赖编译工具,稍折腾 |
| execjs | Node.js | 代码直观、社区资料多、JS兼容性强 | 每次调用有进程开销,需预装Node |
| js2py | 纯Python | 免安装 | 执行慢,对ES6+语法支持差,挑战JS易挂 |
最终我选的是 PyMiniRacer。原因很直接:5秒盾挑战JS基本是ES2015+语法,V8的兼容性最好,且PyMiniRacer能直接在Python里创建上下文、注入代码、调用JS函数,整个流程非常流畅。它的缺点是安装时对编译环境有一定要求,如果卡住了可以退而求其次用execjs,牺牲点性能换来省心。
还有个参数选择的问题。V8默认内存和时间没有硬上限,但挑战脚本有时会埋一些行为检测的循环、定时器,你不希望Python进程被拖死。PyMiniRacer没有现成的超时参数,可以在Python侧用concurrent.futures包一层超时控制,或者定期用ctx.eval探活。实际执行一次挑战脚本通常在0.1秒到1秒之间,和网速相比可以忽略。
2.3 原型链补环境:补的是宿主对象,不是内置对象
补环境这个圈子里,“原型链补环境”是个高频词。简单说,挑战脚本不一定直接读window.xxx,它可能通过原型链一路往上找方法。比如常见的[].constructor.constructor("return this")()这种写法,本质是通过数组的构造函数拿到 Function 构造函数,再借 Function 构造器直接取到全局对象。如果你只顾着伪造window,没注意Function.prototype、Object.prototype这些内置对象不能乱动,就容易被这种技巧“抄了后路”。
所以补环境有一条非常清晰的边界:补的是宿主对象,不是内置对象。宿主对象是浏览器提供的那套API,内置对象是V8引擎自带的Object、Function、Array、Promise这些。前者可以伪造、覆盖,后者尽量保持原样,否则引擎内部逻辑会乱。比如你为了补某个属性,把Object.defineProperty整个覆写掉,很可能导致后面所有对象的属性描述调用失败。
另外,热词里还有个“iv8补环境”,那是安卓App逆向里常用的一类思路,原理和这里一样——通过V8或类似JS引擎去模拟目标App缺失的Java/Web环境。遇到5秒盾这类Web挑战时,很多手法是互通的。
3. 核心环节实现:13次请求逐步跑通
3.1 抓请求瀑布、识别挑战资源
正式动手前,先把测试站点的请求瀑布抓下来。我用的是 Chrome 开发者工具,开一个无痕窗口,勾选 Preserve log,清空缓存后访问目标地址。让页面完整经过一次5秒盾,等它放行并加载出业务页面,不要急着操作,直接看 Network 面板里的完整请求列表。
抓包时重点找两类东西:一类是/cdn-cgi/challenge-platform/路径下的脚本,通常就是挑战JS主体和相关辅助资源;另一类是第一个HTML响应体里的内联脚本。很多时候挑战入口就藏在那段内联JS里,它会动态加载后续脚本。把这些请求按出现顺序记录下来,就是后面要编排的流水线骨架。我在测试站上抓到的完整序列刚好13个,前面表格就是那次的实际记录。
这个阶段需要留意的是:Risk请求顺序和Cookie变化同步看。比如第1个请求响应里Set-Cookie了一个__cf_bm,第2个请求拉JS时又带上了别的Cookie,这些都要记录。后面的框架代码要靠这些信息去设置header和cookie。
3.2 用Python执行挑战JS生成cf_clearance
拿到挑战JS后,搭建一个最小可用的补环境框架。下面这段代码是我在测试环境里能跑通的演示框架,只保留核心结构,真实网站上你需要根据抓包结果替换JS路径和入口函数。
# -*- coding: utf-8 -*- """ Cloudflare 5秒盾补环境演示框架 仅用于授权测试与安全研究 """ from py_mini_racer import MiniRacer CHALLENGE_JS_PATH = "./challenge.js" # 最小浏览器环境脚本,按报错逐步追加 ENV_PREFIX = """ // ---------- 基础全局对象 ---------- var window = this; var globalThis = window; var self = window; var top = window; var parent = window; // ---------- Navigator ---------- var navigator = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", appVersion: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", platform: "Win32", vendor: "Google Inc.", language: "zh-CN", languages: ["zh-CN", "zh", "en"], cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8, maxTouchPoints: 0, plugins: [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai" }, { name: "Native Client", filename: "internal-nacl-plugin" } ], webdriver: false, javaEnabled: function() { return false; }, sendBeacon: function() { return true; } }; // ---------- Screen ---------- var screen = { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24, orientation: { type: "landscape-primary", angle: 0 } }; // ---------- Location ---------- var location = { href: "https://target.example.com/", protocol: "https:", host: "target.example.com", hostname: "target.example.com", pathname: "/", search: "", hash: "", reload: function() {} }; // ---------- Date / 时区 ---------- // 这里示例固定为东八区偏移,实际抓包时按目标站点的访问者时区改 var RealTimezoneOffset = -480; Date.prototype.getTimezoneOffset = function() { return RealTimezoneOffset; }; """ def load_env(ctx: MiniRacer): ctx.eval(ENV_PREFIX) def load_challenge_js(ctx: MiniRacer): with open(CHALLENGE_JS_PATH, "r", encoding="utf-8") as fp: code = fp.read() ctx.eval(code) def main(): ctx = MiniRacer() load_env(ctx) load_challenge_js(ctx) # 挑战脚本执行完后,把生成的cookie从JS侧取回来 # 不同站点的入口函数名和cookie名不同,以抓包结果为准 cookie = ctx.eval("window.cf_clearance || ''") if cookie: print("cf_clearance ->", cookie[:80]) else: # 如果脚本通过回调函数输出结果,改用 ctx.call("入口函数名", ...) print("未在window上取到cf_clearance,检查挑战脚本入口") if __name__ == "__main__": main()这段框架的调试过程是这样的:先跑一次,看第一个报错,比如ReferenceError: document is not defined,我就去ENV_PREFIX里补var document = { ... }。再跑,报Cannot read properties of undefined (reading 'createElement'),就给document补createElement方法。如此循环,直到load_challenge_js不报错,并且能从window上取到目标cookie。
取到cookie只是第一步。挑战脚本的执行结果往往不会直接放在window上,而是构造一个POST请求体,发到/cdn-cgi/challenge-platform/.../execute这个接口。也就是说,你需要在JS侧捕获“发送请求”的动作,把计算出的载荷通过回调吐给Python,再由Python来发那个execute请求。这一步是框架的核心环节,也是最花费时间调试的地方。
3.3 串联13次请求:用Session保持Cookie和Header
框架能生成凭证后,下一步是把13次请求编排成一条流水线。我用requests.Session维护Cookie和Header的一致性,这是最基础也是最重要的操作。
import requests session = requests.Session() session.headers.update({ "User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", }) # 第1步:拿质询页 resp = session.get(BASE_URL) # 解析出 challenge script 地址 + 所需的cookie,示例略 # 第2~4步:拉取挑战JS相关资源 # 第5步:执行JS并提交execute请求 # 这里调用上一小节的补环境框架得到载荷,再POST到execute接口 # 第6步:重新访问主页,此时应带cf_clearance并返回真实页面 resp2 = session.get(BASE_URL) # 检查resp2.text里是否还有challenge特征,有则说明凭证未被接受 # 第7~13步:正常业务请求 resp_list = session.get(API_LIST, params={"page": 1})这里有个细节值得展开:cf_clearance和请求时用的IP、User-Agent是绑定的,换IP或换UA都会导致它失效。所以整个13步流程中,Session的headers一旦设置好就不要变,尤其是User-Agent。另外,Cookie的Domain和Path也要保持一致,不能把Challenge域的Cookie直接塞到业务域名上,那样requests虽然会带上Cookie,但服务端识别不了。
cf_clearance的有效期没有固定值,取决于站点配置,短则几十分钟,长则几天。实际使用中不要在一条流水线里复用太久,尤其是业务请求频率高时,更建议定时重新拉一次质询页面刷新凭证。
4. 常见问题与排查技巧实录
4.1 快速定位JS报错:靠日志和探针
补环境框架调试中最耗时间的就是JS报错。报错信息经常是undefined is not a function,你根本不知道是哪个对象、哪一行出的问题。我的经验是在注入环境时加“探针”——把关键对象的访问打上日志。比如给window和document的取值、调用函数都包一层日志,一旦脚本访问到某个未定义的属性,日志会明确告诉你它在找什么。
// 注入探针示例 var _origDocument = document; document = new Proxy(_origDocument, { get: function(target, prop) { console.log("[document get]", prop); if (!(prop in target)) { throw new Error("Missing document." + prop); } return target[prop]; } });4.2 常见报错速查表
| 报错信息 | 原因 | 处理思路 |
|---|---|---|
ReferenceError: window is not defined | 环境前缀没注入成功 | 确认ENV_PREFIX已执行,且var window = this在脚本加载前完成 |
Cannot read properties of undefined (reading 'createElement') | document对象缺方法 | 给document补对应方法,返回一个最小Element对象 |
TextEncoder is not defined | 缺Web API编码类 | 注入TextEncoder/TextDecoder的最小实现 |
process is not defined | 脚本疑似检测Node环境 | 补var process = { versions: { node: '20.0.0' }, env: {} } |
Execution timed out | 脚本陷入循环或定时器 | 用超时控制定位卡点,检查是否缺少定时器清空逻辑 |
403 Access Denied | 凭证未被服务端接受 | 优先检查IP、UA、时区是否和生成cookie时一致 |
429 Too Many Requests | 请求频率过高 | 降低并发,加随机延时 |
4.3 指纹检测不通过怎么办
有时候JS不报错,cookie也拿到了,但重访时服务端依然拒绝。大概率是环境指纹“太假”或者“不一致”。比如前面给screen填了1920x1080,但navigator.userAgent写的是小屏手机UA;或者JS执行时读到的当前时区与UA语言矛盾。面对这种情况,要回头把环境变量整体看一遍,确保风格统一,而不是单个字段越真实越好。
有些检测会读取 Canvas 指纹、WebGL 渲染器信息,这些在补环境里很难做到100%还原。我的经验是返回“稳定且普遍”的默认值,比如Canvas的toDataURL接口返回一个固定的base64字符串,WebGL的getParameter返回常见显卡厂商的字符串。关键是同一套环境每次执行的结果必须一致,一旦前后不一致,后端比对立刻现形。
4.4 请求编排中的频率控制与合规边界
最后聊点工程化的东西。就算补环境框架已经稳定跑通,也要控制请求节奏。5秒盾本身是防流量洪峰和恶意流量设计的,如果你在它上面跑几百上千个并发,即便技术层面能过,业务频控也会触发,甚至可能拖累源站。实际项目中我会在请求之间加随机延时,时间范围根据业务接口的承载能力调整,而不是追求极致的“快”。
我在实际项目中还有个小技巧:把补环境框架的产物(cookie/token)做成一个定时刷新的服务,单独部署一台机器,业务侧只通过接口获取最新凭证,避免每次业务请求都重新做一遍完整的13步流程。这样既控制了对目标站的请求量,也让业务代码和反爬逻辑解耦,后续框架升级也方便。
回过头来说,补环境这条路看着是“逆向”,其实考验的是你对浏览器环境的熟悉程度,以及在报错面前能不能沉住气一步步补全。我这个项目从抓包到最终跑通,中途大概重写了三版环境前缀,每次都是因为检测逻辑升级。不要一开始就追求一次补到位,先让脚本“能跑”,再让它“跑得真”,这个节奏才是补环境框架落地的正路。