Vercel Challenge逆向:从黑盒到白盒的完整拆解
发布时间:2026/10/5 7:41:21 作者:尧图编辑部 阅读量:1,286

项目标题是 x-vercel-challenge-solution 逆向说人话就是把一个用来处理 Vercel Challenge 的解算方案从黑盒变成白盒。我最早碰到 Vercel Challenge 是在给某个部署在 Vercel 上的内部接口做自动化测试时直接请求返回 403浏览器打开却正常。这就是 challenge 页面的典型表现它不跟你讲道理先丢一段 JS 让你算算完把结果写进 Cookie 再刷新才放你进去。这篇文章适合三类人一是做接口自动化或压测时总被前端校验卡住的测试开发二是刚接触 JS 逆向想找一个真实案例练手的安全爱好者三是想搞明白“用 Node 直接算 challenge”和“用浏览器渲染页面”到底差在哪里的后端工程师。我会从项目思路、调试工具、核心算法还原、常见坑位四个维度完整拆一遍尽量把每一步为什么这么做讲清楚。1. 内容整体设计与思路拆解1.1 项目到底在解决什么问题Vercel 的 challenge 机制本质上是一个基于 JS 的轻量请求校验。服务端在判定请求“不太像浏览器”时会返回一段带混淆的脚本让浏览器执行一个短计算得到一个结果然后把这个结果写进 Cookie。下次请求带上这个 Cookie服务端验算通过才返回真实资源。x-vercel-challenge-solution 这个项目的核心价值就是把这个“必须由浏览器完成”的计算挪到了纯 Node 环境里。它不去启动浏览器不依赖 Playwright不靠图片识别而是直接把 challenge 响应里的参数抠出来把 JS 里的算法逆出来再用 Node 的 crypto 模块算出一个合法结果拼成 Cookie 发回去。这个思路和我之前做过的很多逆向任务是一样的先找到入口再找到出口最后把中间的线牵起来。所谓入口就是服务端下发的 challenge 参数出口就是浏览器脚本最终写入 Cookie 的那个值中间的线就是从参数到 Cookie 的那段计算逻辑。只要这三件事都搞明白了整条链路就是可复现的。1.2 为什么选“逆向”而不是“上浏览器”很多人第一反应是直接用 Puppeteer 打开页面让它自己去跑 challenge然后拿到 Cookie 不就行了这个方法确实能通很多内部工具也是这么做的。但它有一个绕不开的问题慢而且重。一个无头浏览器从启动到执行完 challenge通常需要几百毫秒甚至几秒。如果只是偶发一次访问这个成本还能接受。但如果你的自动化脚本要频繁换账号、换请求头、持续压测每个请求都拉起一个浏览器资源开销会非常夸张。逆向方案的优势在于算一个 challenge 通常是几十毫秒的事几乎可以忽略不计。它不需要图形环境不需要等待页面加载也不需要处理浏览器崩溃、内存泄漏之类的问题。只要算法还原正确结果就是确定性的。当然逆向方案也有代价你需要读懂一段或多段混淆过的 JS可能要花不少时间去调试而且 Vercel 如果更新了 challenge 逻辑你的算法也要跟着更新。所以实际选择时我会先用无头浏览器做“快速验证”确认整个链路是通的再去逆向算法做“降本优化”。不要一上来就硬啃混淆代码。1.3 逆向的主线协议、算法、环境整个 x-vercel-challenge-solution 的逆向工作我把它分成三条线协议线服务端返回什么格式的 challenge 数据challengeId、salt、difficulty 这些字段放在哪里是 HTML 里的全局变量还是 JSON 响应或者是某个接口单独返回算法线从这些字段到 Cookie 值的计算过程是什么用的是什么哈希算法拼接顺序是什么目标条件是什么环境线服务端除了校验 Cookie还会不会校验 User-Agent、请求头顺序、TLS 指纹等浏览器环境特征如果校验就必须把这些特征一起带上。很多人在逆向时只关注算法线结果算法完全正确请求还是被拒就是忽略了环境线。Vercel 的 challenge 在大部分情况下对请求头有基础要求尤其是 User-Agent 必须带有时候还要 Accept-Language、Sec-Ch-Ua 这类的“浏览器味”请求头。这个不是玄学而是服务端会把 Cookie 的签发方和请求方绑定起来校验。2. 核心细节解析与实操要点2.1 先抓一份完整 challenge 响应逆向的第一步永远不是看代码而是抓包。我习惯用 curl 先跑一遍把最原始的响应存下来。curl -I https://your-app.vercel.app/api/xxxx \ -H User-Agent: Mozilla/5.0 ... \ -H Accept: text/html第一次请求通常会看到 403响应头里可能带x-vercel-challenge或者类似标记。接着再用普通请求拿 bodycurl https://your-app.vercel.app/api/xxxx \ -H User-Agent: Mozilla/5.0 ... \ --output challenge.html拿到这个 challenge 响应之后立刻做的事情不是看 JS而是先搜字符串。用 grep 或者编辑器搜索challenge、token、salt、difficulty、sha256、pow这些关键词基本就能定位出参数藏在哪。有的响应会直接生成一个全局变量比如window.__CHALLENGE__ {challengeId:abc123,salt:9f8e...,difficulty:4}有的会把参数嵌在脚本里比如var challenge xxx。这一步的价值是先把协议字段摸清后面调试算法才有据可依。注意不要只抓一次。多换几个 UA、多从不同网络环境抓几次对比参数格式有没有变化。如果同一次 challenge 在不同时间点格式一致说明是静态模板如果每次参数都变说明服务端是动态生成的逆向时就要把参数提取做成动态逻辑。2.2 在 DevTools 里定位生成 Cookie 的代码拿到 HTML 和 JS 之后真正要啃的是那段混淆或半混淆的脚本。我不会建议你一上来就从头读到尾而是先找终点。放到 Chrome DevTools 的 Sources 面板里打开对应的 JS 文件格式化之后按 CtrlF 搜cookie。重点看有没有类似document.cookie 的赋值语句那就是整个脚本的出口。只要在这个位置打上断点刷新页面执行流会停在那里。断点停住后看右侧 Scope 面板里面会列出当前作用域里的所有变量。这时候会出现几个关键值计算完成的 answer / result / token原始 challengeIdsalt 或 nonce过期时间戳把这些值抄下来和 HTML 里搜到的参数做对应。一般到此就可以反过来推导它是把这些输入拼接成一个字符串然后算 sha256并要求结果满足某个前缀条件。2.3 动、静结合读混淆读混淆代码有两种主要方式我建议结合着用。静态分析适合用来“找入口、看结构”也就是从参数定义一路搜到函数调用关系。但遇到变量名全是_0x3f2a这种类型、函数层层嵌套的情况静态读会非常痛苦。动态调试更适合用来“看输入、看输出”在关键位置打断点看每一步执行前后的变量变化比人肉追踪快得多。我自己的习惯是四步走格式化脚本先搜关键词找到 cookie 赋值的位置。在该位置打断点刷新页面确认断点能停住。停住后沿着调用栈往上翻找到最外层那个函数看函数入参是什么。把入参和出参记录成一组测试用例跑通 Node 里的本地复刻。这个方法的好处是你不必完全理解混淆代码的每一行只要把**“输入是什么、输出是什么、计算规则是什么”**三层信息提取出来就够了。3. 实操过程与核心环节实现3.1 把 challenge 参数从 HTML 里抠出来我实际操作时会把 challenge 响应保存成文件然后写一个临时脚本把参数解析出来。如果 HTML 结构简单可以直接用正则如果结构复杂用 cheerio 或者直接放进浏览器 DOM 里取。const fs require(fs); const html fs.readFileSync(./challenge.html, utf8); // 假设参数以 window.__CHALLENGE__ 的形式存在 const match html.match(/window\.__CHALLENGE__\s*\s*(\{.*?\});/); if (match) { const config JSON.parse(match[1]); console.log(config); }这里有个小细节正则里的.*?是非贪婪匹配遇到第一个};就停。如果参数里本身有嵌套对象或字符串带分号可能会截断。所以更稳的方式是直接找后面到/script之前的整段内容再结合 JSON 解析处理。如果 challenge 参数不是全局变量而是通过某个异步接口返回的那就需要在浏览器 Network 面板里找到对应请求把响应复制出来分析。原理是一样的先拿到最原始的协议数据。3.2 用断点把混淆代码“拨开”我拿一个简化过的思想来演示。假设浏览器里的核心逻辑大致是这样的function compute(challengeId, salt, difficulty) { let nonce 0; while (true) { const input challengeId : salt : nonce; const hash sha256(input); if (hash.startsWith(0.repeat(difficulty))) { return nonce; } nonce; } }当然真实脚本不会写得这么干净它可能把challengeId换成一个压缩后的数组下标把sha256包在一个自定义函数里把前缀条件从一个隐藏 DOM 节点的>const crypto require(crypto); function sha256Hex(input) { return crypto.createHash(sha256).update(input, utf8).digest(hex); } function solveChallenge({ challengeId, salt, difficulty }) { const prefix 0.repeat(difficulty); let nonce 0; while (true) { const input ${challengeId}:${salt}:${nonce}; const hash sha256Hex(input); if (hash.startsWith(prefix)) { return { nonce, hash, input }; } nonce; } }这段代码只是一个示意模型关键不在于算法本身而在于三个最容易出错的地方拼接顺序是challengeId : salt : nonce还是先放 salt 再放 nonce差一点结果就完全不同。字符编码如果参数里有非 ASCII 字符浏览器里用的是 UTF-8Node 里也必须显式指定 UTF-8否则可能因为编码不一致算出不同的 hash。结束条件是要求 hash 前 N 位是 0还是要求 hash 后 N 位满足某个范围必须回到断点里确认不能凭猜。写完之后先用上一节从浏览器里拿到的 challengeId、salt、difficulty 做一次单测看算出来的 nonce 能不能复现浏览器生成的 Cookie。能复现说明算法模型对了不能就回去继续追拼接细节。3.4 把 Cookie 带回请求链路算法通了之后就是组装请求。这一步的核心是把 challenge 响应里的 Cookie、过期时间、路径等属性都处理好不能只拼一个 token。const response await fetch(url, { headers: { User-Agent: userAgent, Accept-Language: zh-CN,zh;q0.9, Accept: text/html,application/xhtmlxml }, redirect: manual }); const html await response.text(); const challenge extractChallenge(html); const result solveChallenge(challenge); // 假设服务端要求的 Cookie 名是 __vc_challenge const cookie __vc_challenge${result.hash}; Path/; Secure; const retry await fetch(url, { headers: { ...headers, Cookie: cookie }, redirect: manual });这里最容易踩坑的是很多人拿到 Cookie 之后直接用一个新的 User-Agent 去访问结果服务端不认。因为 challenge 的验权可能不仅看 Cookie 值还会对比签发时和请求时的请求头一致度。所以两次请求必须复用同一套浏览器特征头尤其注意 UA 不能变。另外如果服务端返回 302 而不是 200要确认重定向链路是否继续携带了 Cookie。很多 HTTP 客户端在重定向时默认不会带第一次响应的 Set-Cookie需要手动处理。4. 常见问题与排查技巧实录4.1 常见问题速查表我把实际操作中遇到的典型问题整理成了一张表方便排查现象可能原因排查/解决方式Node 算出的结果浏览器不认拼接顺序或编码不一致对比浏览器断点里的 input 字符串逐字符检查请求头带了 Cookie 但还是 403UA 或 headers 和签发时不一致两次请求复用同一套 headers包括 UA、Accept-Languagechallenge 参数一直为空正则没有匹配到正确字段手动看 HTML 结构改用 cheerio 或 DOM 解析接口偶尔能通偶尔不通Cookie 或 challenge 过期检查过期时间过期后重新执行一次 challenge页面打开正常脚本单独跑没反应代码里依赖 DOM 环境不要在纯 JS 里模拟整个 DOM改用真实浏览器定位调用点断点被debugger反复打断反调试技巧右键 Never pause here或条件断点跳过算得很慢CPU 占用高difficulty 太大或死循环先确认前缀位数再考虑算法是否写错这张表是我自己踩出来的不算全面但覆盖了这类 challenge 逆向里 80% 的坑。4.2 三个让我印象深刻的坑第一个坑是拼接顺序。我第一次还原算法时以为参数是先拼 challengeId 再拼 salt结果怎么算都不对。后来在断点里把一个中间变量打印出来才发现浏览器脚本里是先对 salt 做了 URL decode再按salt|challengeId|nonce的顺序拼接。差一个顺序结果完全不同。第二个坑是隐藏的空格。混淆代码里有个字符串看起来是sha256(input)实际上 input 的两边被加了肉眼几乎看不见的空格或换行符。这种问题靠眼睛是看不出来的一定要在浏览器端把完整的 input 内容复制出来用 JSON.stringify 查看真实字符。第三个坑是响应头里的 Cookie 属性。服务端返回的 Cookie 可能带了HttpOnly、Secure、SameSiteLax等属性。Node 的 fetch 里如果只是简单地把响应头的Set-Cookie抓下来直接拼到下一次请求有时会因为属性格式问题导致服务端解析失败。最稳妥的做法是解析 Set-Cookie提取 name 和 value再按自己的请求逻辑重新组装 Cookie 头。4.3 AI 辅助逆向的正确用法最近很多朋友都在聊用 AI 做快速逆向我自己也试了。把一段混淆 JS 丢给大模型让它分析变量关系、提取逻辑确实能省不少时间。但这里有一个很重要的前提AI 的结论必须用断点验证。我踩过一个大模型的坑它非常自信地告诉我某个函数是 base64 解码实际上那个函数只是个字符串截取。如果直接信了它的结果去写脚本整个请求都会废掉。我现在的习惯是把局部代码片段喂给 AI而不是整个几百 KB 的文件同时附上我在断点里观察到的输入和输出样例让 AI 先描述逻辑不要急着让它生成完整脚本拿到逻辑描述后回到浏览器里验证关键变量再自己写实现。这样做的好处是AI 能帮你把混淆代码翻译成人话但最终判断权还是在你手里。逆向最忌讳的就是盲目相信某个工具的输出不管是动态调试器还是 AI 都一样。5. 边界能碰的和别碰的5.1 授权范围内的测试写到这里我必须把边界说清楚。这套方法更适合用在自己的项目、自己部署的测试环境、自己收到 challenge 响应的接口上或者是 CTF 和漏洞靶场里做学习研究。拿别人线上的服务当实验场不管出于什么目的都不应该。我见过一些人把这套逻辑封装成工具拿去批量访问不熟悉的站点。这种事短期内可能能跑通但一旦造成对方服务压力过大或触发安全告警后果是很麻烦的。逆向的价值在于理解机制而不是用来破坏别人的边界。这个项目本身也是为了解决“请求被判为异常流量”的问题而不是帮助异常流量四处扩散。5.2 后续可以怎么扩展如果你已经能稳定通过 Vercel Challenge下一步可以做几件事把解算逻辑封装成一个独立函数输入是 challenge 响应的 HTML 或 JSON输出是 Cookie方便在自动化测试框架里调用。做成一个小型 Cookie 池定时刷新避免每次请求都重新算。把断点分析和算法还原记录下来形成自己的逆向模板以后遇到类似 challenge 能更快上手。给解算脚本加上监控一旦发现服务端算法更新导致解算失败及时告警。我自己现在处理这类问题一定先从最笨的断点开始而不是直接抄成品脚本。逆向这件事真正值钱的就是“搞清楚它怎么想”的过程而不是最终那几行代码。x-vercel-challenge-solution 这个项目给我最大的启发也在这里它把一次具体的逆向经历沉淀成了可复用的方案让后来者不必再从零踩坑。这种沉淀方式比代码本身更值得学习。