付了费,资料包却提示“下载受限”。刷新、清缓存都没用,只能干着急。这往往不是服务器故意拦你,而是前端代码里一道“看得见但改不动”的门——JavaScript 在做本地校验。一把断点,就能撬开。
那个灰掉的按钮,点下去什么都没发生
打开 Chrome DevTools 的 Elements 面板,找到那个灰掉的下载按钮——<button disabled class="dl-btn">。删掉 disabled 属性,顺手把 class 改成 1,按钮亮了。我点了下去。什么也没发生。控制台报了个错:Uncaught (in promise) TypeError: Cannot read properties of undefined。眼熟,像是某个变量还没赋值就被调用了。
按钮虽然能点,但绑定在上面的 click 事件压根没打算让我过去。DevTools 的 Console 里敲 getEventListeners(document.querySelector('.dl-btn')),返回一个对象,click 数组里躺着两个函数:一个做埋点统计,另一个名字叫 ——看名字就知道是正主。
// 在 Console 里直接移除监听器
const btn = document.querySelector('.dl-btn');
const clone = btn.cloneNode(true);
btn.parentNode.replaceChild(clone, btn);
替换节点这招对付内联监听器挺管用,但 是在 Vue 组件的 mounted 里用 绑的,闭包一关,替换掉节点只是换了个壳子。新按钮上没有任何监听器了,可原始组件实例里还存着对旧 DOM 的引用,状态没变。
那就去找 的定义位置。DevTools 的 Sources 面板里全局搜索这个名字,结果出现在一个压缩后的 bundle.js 里,格式化一看:
function handleDownload() {
if (!this.hasAccess) {
this.showPayModal = true;
return;
}
// ... 实际下载逻辑
}
问题来了——this 指向的是 Vue 实例,而 hasAccess 是在 data() 里初始化为 false 的响应式属性。你在 Console 里直接调用 handleDownload(),this 是 window,hasAccess 自然 undefined,弹窗一下又出来了。有人会试 vm.$data.hasAccess = true,但 vm 这个变量在全局通常不可达——除非你在 Sources 里找到组件实例的引用,比如 __vue_app__._instance 之类的挂载点。费了半天劲改完,一刷新全没了。
走到这步,我算是彻底懂了——前端说白了就是防君子不防小人,结果你猜怎么着?“君子”连门锁在哪儿都看不见。最开始那套改 DOM 的把戏,顶多骗骗视觉反馈,真正卡脖子的东西藏在 JavaScript 作用域里,监听器表面挡人,实际就是个纸糊的。后来换了个路子:不跟监听器较劲了,直接在它执行条件判断的那一瞬间,把变量值给它换了。
断点停在 isPaid 那一行,F8 一按,门开了
既然真正的门锁藏在 JavaScript 作用域里,那就把钥匙插进 DevTools 的 Sources 面板。别急着到处下断点,先用全局搜索把'paid'、'download'、'verify'这类关键词扫一遍,快速锁定最可疑的脚本文件。我在格式化后的代码里找到一处叫 checkAccess 的函数,入口就一行判断:if (!isPaid) return。直觉告诉我,这就是那道闸机。给它打上断点,切回页面点下载,浏览器立刻停在这一行,右侧 Scope 面板里清清楚楚躺着 isPaid,值为 false。
// 在 Console 中覆盖变量,再放行
function coverIsPaid() {
let targetFrame = null;
for (const f of window.frames) {
try { targetFrame = f.document.querySelector('.dl-container')?.contentWindow; } catch(e) {}
if (targetFrame?.isPaid !== undefined) break;
}
(targetFrame ?? window).isPaid = true;
}
coverIsPaid();
debugger;
按下 F8,下载流程顺畅跑完。整个过程没碰后端接口,更没动付费接口——只是让前端“误以为”已付款而已。这种绕法谈不上高明,但足够说明问题:前端校验,永远只能防君子。
有些付费资料包挺鸡贼的——它们不给你一个完整的文件链接,而是把资源切成若干段,前端用 HTTP Range 头一段一段去要。服务器对每个片段单独校验,缺一个都不行。这种设计本意是防止盗链盗版,但开发者工具里一抓包,问题就暴露了。打开 Network 面板,找到那个下载请求。别只看第一个请求,往右翻,看看 Request Headers 里有没有自定义字段。我见过一个案例,服务器在每个 Range 请求里都塞了个 X-Token,值是从某个全局变量里读出来的。第一段请求的 token 校验正常,但后面的片段,token 值居然拷贝了同一个。
// 在 Console 里把 token 覆盖成空字符串
const originalFetch = window.fetch;
window.fetch = function(...args) {
if (args[1]?.headers?.['X-Token']) {
args[1].headers['X-Token'] = 'invalid';
}
return originalFetch.apply(this, args);
};
重新触发下载,看看 Network 里后续的 Range 请求。如果服务器只校验了第一段的 token,后面直接放行——那说明它的鉴权逻辑有漏洞。右键点那个请求,选「Copy as fetch」,把复制出来的代码粘贴到 Console 里,手动改掉 Range 值,比如从 bytes=0-1024 改成 bytes=0-,一次把整个文件拉下来。服务器如果没做完整性校验,你就能直接拿到完整内容。真正麻烦的是那种每段都校验 token、而且 token 还是动态生成的。那说明后端在每次请求时都重新计算了签名,前端改不了。不过大部分付费包的作者不会做到这一步——他们连后端都懒得写,直接在 CDN 上挂个静态文件,靠前端脚本控制下载流程。遇到这种,只要把 Range 请求改成不分段,就能绕过。调试到最后你会发现,所谓“分段下载保护”只是让浏览器乖乖一段一段发请求,但服务器根本没防你一次拉完。
把对手的签名规则,写成自己的安全网
当你把一段段 Range 请求拼起来,会发现“保护”常常只做在前端。真正拦住你的,往往是几行不起眼的签名逻辑。把它拆开,再照它的样子自己搭一套,路就宽了。
付费资料包最爱用的,是一个随时间变化的令牌。可能是 X-Token,也可能是 Cookie 里某个字段。顺着 Network 面板找,它的来源通常藏在一个 JSON 配置或全局变量里。遇到动态生成,就在 Sources 里设断点,盯住 Date.now() 或 moment().format('YYYYMMDDHH') 这类调用。看久了你会懂它的公式:uid + 日期 + 固定盐。把这个公式还原到本地,用 Node 跑一遍,你就能在命令行里生成“合法”的令牌。有些站点还会塞一个 X-Device 之类的指纹。多半来自 或 ,偶尔是 Math.random() 打头的一串。你要做的,是把生成路径记录下来,写成一个小模块。以后想批量拉资源,直接喂给它需要的上下文。
最省心的做法,是把计算丢进 Web Worker。主线程只负责发起 fetch,Worker 里把 token、时间窗、设备指纹按原逻辑拼好,再 postMessage 回结果。这样做,既避开了在 Console 里手动改对象的麻烦,也让自动化更稳。下面这个骨架能很快落地:
// worker.js
self.onmessage = function(e) {
const { userId, ts, salt } = e.data;
const token = sha256(`${userId}|${ts}|${salt}`);
self.postMessage({ token });
};
主线程里,给 fetch 套一层,把 Worker 返回的 token 塞进 headers。你会发现,同样的逻辑,换个运行位置,抓包时就不容易被一眼识破。更进一步,可以用 Service Worker 拦截请求,把签名计算完全挪到浏览器后台。
并不是要“防你”,而是让流程更像正常访问。最简单的,是在页面里放一个定时器,每隔一段时间检查 console.log 这类探测点。一旦发现 DevTools 开着,就把关键变量置空或走降级路径。另一个做法,是用 with(new Proxy) 在某个关键点打一点“噪音”,让对方在断点时看到满屏无关堆栈,难以定位真正的入口。这些招数单独看都很弱,叠在一起,就没那么容易被秒破。
前端做得再像,最终都要过服务端的验。最常见的,是对签名做强校验,外加时间窗口和 nonce。就算你把 Range 改成一次性拉取,少了正确的签名,还是拿不到文件。于是,你要把自己的服务端也加上同构的签名逻辑:接收请求后,先用相同的盐和时间窗重算签名,再比对。如果不想每次都算,可以把签名放进缓存,Key 由 user_id + range_start + range_end 组成。配合 Nginx 的 slice 输出,既能抗并发,也能保证大文件分片下发可控。走到这一步,你已经把“绕过”变成了“构建”。对手的规则,成了你自己的安全网。与其偷偷改请求,不如把它写进系统。毕竟,稳定可用,才是业务长期跑下去的前提。
一个能直接跑的结构:Web Worker 算令牌,服务端绑 IP 和 UA
理论说了那么多,不如看一个能直接跑的结构。这个场景很常见:你有一个付费资料包,只想让真正付费的用户下载,并且不想被随手打开 DevTools 的人轻易绕过。核心思路是三层校验:前端用 Web Worker 算一次性令牌,主线程定时检测调试状态,服务端做签名+IP+User-Agent 绑定。任何一个环节被篡改,下载链接就失效。
把令牌生成挪到 Worker 里,Console 里直接访问不到计算函数。即使打断点,也只能看到 postMessage 传来的结构化数据,看不到 sha256 的盐和拼装规则。
// token-worker.js
self.onmessage = function(e) {
const { userId, expireTs, secret } = e.data;
const raw = `${userId}|${expireTs}|${secret}`;
// 用 Web Crypto API 做哈希,避免引入外部库
crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw))
.then(hash => {
const token = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
self.postMessage({ token, expireTs });
});
};
主线程里,点击下载按钮时,生成一个 30 秒后过期的时间戳,连同用户 ID 和预共享密钥一起发给 Worker。收到回传后,把 token 放进 POST 请求的 body 里。
downloadBtn.onclick = function() {
const expireTs = Math.floor(Date.now() / 1000) + 30;
worker.postMessage({
userId: currentUser.id,
expireTs,
secret: SHARED_SECRET
});
};
worker.onmessage = function(e) {
fetch('/api/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: e.data.token,
expireTs: e.data.expireTs
})
})
.then(r => r.json())
.then(res => {
if (res.url) window.location.href = res.url;
});
};
在页面里埋一个循环检测。不是那种一检测到就弹窗警告的粗暴做法——那样只会让对方关掉检测继续调试。更隐蔽的方式是:一旦发现调试器开着,就把 Worker 里的 secret 置空,后续算出来的 token 全部无效。
setInterval(() => {
// 方法一:debugger 语句,检测是否被跳过
const start = performance.now();
debugger;
const cost = performance.now() - start;
if (cost > 100) {
// 说明 debugger 被手动跳过,DevTools 可能开着
SHARED_SECRET = '';
}
// 方法二:检测 console 是否被篡改
const check = () => {};
if (console.log.toString() !== 'function check() {}') {
SHARED_SECRET = '';
}
}, 2000);
这里有个细节:debugger 语句在正常浏览器里执行时,如果 DevTools 关着,几乎是瞬间通过的,cost 通常小于 5ms。一旦开着调试器,开发者会手动点击“继续执行”,那个中断时间轻松超过 100ms。用这个差值做判断,比查 靠谱得多。
服务端才不管你前端画得有多好看,收 POST 过来就干一件事:拿同样的密钥和时间戳再算一遍 token,跟发来的比对。顺带记下 IP 和 User-Agent,把这些信息一起签进下载 URL 里——后面谁拿着这个链接来下载,参数对不上直接就 403 了。
// 伪代码,服务端逻辑
app.post('/api/verify-token', (req, res) => {
const { token, expireTs } = req.body;
const raw = `${req.user.id}|${expireTs}|${SECRET}`;
const expected = sha256(raw);
if (token !== expected || expireTs < now()) {
return res.status(403).json({ error: 'invalid' });
}
const downloadToken = sign({
ip: req.ip,
ua: req.headers['user-agent'],
file: 'premium-pack.zip',
expires: now() + 30
}, SECRET);
res.json({
url: `/download/${downloadToken}`
});
});
下载接口收到请求后,先验签名,再比对当前请求的 IP 和 UA 是否与签名时绑定的一致。任何一个不对,直接 403。30 秒过期时间也防止了 URL 被截获后重放。这一套跑下来,想绕过的人得同时搞定 Worker 里的哈希计算、主线程的反调试检测、服务端的签名校验,还得在 30 秒内伪造 IP 和 UA。成本远远高于直接付费。防调试从来不是要做得滴水不漏,而是让绕过的门槛高过付费的门槛。你不需要赢过所有人,只需要赢过那些只想花五分钟偷懒的人。
评论