自单页面应用(SPA)兴起以来,前端路由逐渐由后端转向客户端,浏览器原生的 History API(pushState / replaceState)成为实现无刷新导航的核心。与此同时,一些站点利用这些 API 或辅助技术 “劫持” 浏览器的返回按钮,使用户点击 Back 时被迫停留在当前页面或被重新引导到其他内容,这种行为被称为 Back Button Hijacking。Google 已于 2026 年 4 月将 back button hijacking 列为恶意行为并于 6 月 15 日执行处罚 [1]。针对 SPA 中的 history manipulación,社区也提供了若干检测技巧 [2]。本文从技术根因出发,系统梳理三种常见劫持手段,并给出可直接落地的检测与防御方案。
1. 背景与监管
在传统多页面网站,浏览器的前进 / 后退只受 HTTP 缓存与服务器重定向控制。SPA 通过 HTML5 History API 自行维护历史栈,理论上可以让用户在单页内 “无缝” 切换视图。但正是这种自行管理的能力,被部分站点滥用于:
- 阻止返回:在用户点击返回时注入新状态,使其再次跳转到当前页面或广告页;
- 伪造成历史:把用户从未访问过的页面写入历史,制造 “返回” 错觉;
- 强制弹窗:返回时弹出营销弹窗、强制登录框等。
这类行为不仅违反用户体验,也被 Google 搜索政策标记为 spam,可能导致站点被手动处罚或在搜索结果中降权。
2. 常见劫持机制
2.1 history.pushState 篡改
history.pushState(state, title, url) 能在不刷新页面的情况下向浏览器历史栈压入新条目。攻击者通常在页面加载完成后立即调用 pushState,甚至在用户未做任何交互的情况下重复压入同一 URL,形成 “环形” 历史,使得返回按钮永远停留在同一条目。例如:
// 页面加载时自动压入当前页面的 URL
history.pushState({page: 'home'}, '', window.location.href);
当用户尝试返回时,浏览器只会弹出上一个状态,而该状态往往又是同一个页面,导致 “永远回不去”。更恶意的做法是每次触发 popstate 时立即再次 pushState,形成无限循环:
window.addEventListener('popstate', () => {
// 阻止真正的返回,重新写入当前页面
history.pushState({page: 'current'}, '', window.location.href);
});
这种模式在部分营销插件、广告联盟的脚本中尤为常见。
2.2 popstate 事件劫持
浏览器在用户点击返回 / 前进按钮时触发 popstate 事件,事件对象包含 state(即当年 pushState 时传入的数据)。正常实现应当在 popstate 中恢复先前的视图,但劫持者可以在回调里 拦截 并 阻止默认行为,比如:
window.addEventListener('popstate', (e) => {
// 判断是否在特定业务流程
if (isPaymentFlow) {
e.preventDefault(); // 取消返回
showPaymentModal(); // 强制弹窗
// 再次写入当前状态,防止后续返回
history.pushState({step: 'payment'}, '', window.location.href);
}
});
这类实现会让用户产生 “明明点了返回,却弹出了支付页面” 的困惑。常见于 营销弹窗、AB 测试、强留页面等场景。
2.3 iframe 注入
一种更隐蔽的做法是 隐藏 iframe。攻击者在页面中嵌入一个不可见的 <iframe>,并让 iframe 内部执行 history.pushState 或 location.replace,从而在父窗口的历史栈中插入额外条目。用户在主窗口点击返回时,实际返回的是 iframe 的历史,导致页面 “鬼畜” 式跳转。例如:
<iframe id="trap" style="display:none" src="/fake-page"></iframe>
iframe 内部的脚本可以随意操作父窗口的 history,且不易被主页面脚本检测。移动端浏览器尤其容易受此影响,因为返回手势往往先交给 iframe 处理。
3. 检测方法
要在自己的代码库中发现潜在的劫持行为,可以通过 包装原生 API 与 运行时监控 两种思路实现。
3.1 包装原生 API
通过在页面加载初期替换 history.pushState、history.replaceState、window.onpopstate,记录每一次调用的堆栈与时间戳,可快速定位异常调用:
(function() {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
const calls = [];
history.pushState = function(...args) {
calls.push({ method: 'pushState', args, stack: new Error().stack, time: Date.now() });
return originalPushState.apply(this, args);
};
history.replaceState = function(...args) {
calls.push({ method: 'replaceState', args, stack: new Error().stack, time: Date.now() });
return originalReplaceState.apply(this, args);
};
// 将调用记录挂载到全局,供调试时查看
window.__historyCalls = calls;
})();
在控制台执行 console.table(window.__historyCalls),如果看到页面加载瞬间出现 pushState(且没有对应的用户点击),则极有可能是劫持脚本。
3.2 运行时行为监控
利用 PerformanceObserver 监听 history 相关的执行,结合 popstate 事件的触发次数,判断是否存在 “返回后再次写入”:
let popstateCount = 0;
window.addEventListener('popstate', () => {
popstateCount++;
console.warn('popstate 触发次数:', popstateCount);
// 如果在极短时间内 popstate 多次触发,可能在劫持
if (popstateCount > 5) {
console.error('检测到异常的 popstate 重复触发,可能存在劫持');
}
});
配合前面的 API 包装,能够形成完整的 调用链路 与 异常行为 日志,方便安全审计。
4. 防御实践
4.1 仅在用户感知到的导航时调用 pushState
原则:凡是对用户而言相当于 “新页面” 的操作才使用 pushState,否则应使用 replaceState 或内部状态管理。常见误区:
- 切换标签页、展开折叠面板等 UI 交互不应产生历史条目;
- 搜索过滤、分页等参数变化如果不需要用户通过返回恢复,则使用
replaceState。
// 正确示例:用户点击真正的导航链接
document.querySelectorAll('a[data-spa]').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const url = link.getAttribute('href');
history.pushState({path: url}, '', url); // 产生新历史
render(url);
});
});
4.2 popstate 只做视图恢复
在 popstate 回调中,只读取 event.state 并渲染对应的视图,绝对不要再调用 pushState:
window.addEventListener('popstate', e => {
if (e.state) {
render(e.state.path); // 根据保存的状态恢复视图
} else {
// 兜底:没有 state 时返回首页
render('/');
}
});
若业务必须在特定页面拦截返回(如结算流程),可以采用 自定义返回提示 而非阻止浏览器行为,例如弹出确认框让用户自行决定是否离开:
window.addEventListener('popstate', e => {
if (isPaymentFlow && !confirm('确定要放弃支付吗?')) {
// 手动重新压入当前状态,保持原地
history.pushState({step: 'payment'}, '', window.location.href);
}
});
4.3 第三方脚本审计
大多数劫持案例来源于 广告联盟、AB Test、社交插件 等第三方库。建议:
- 在 CSP(Content Security Policy) 中限制
script-src,避免未知脚本注入; - 使用 Subresource Integrity(SRI) 校验第三方资源完整性;
- 定期审计
window.addEventListener('popstate')、history.pushState的调用方,可通过前述包装脚本导出调用栈进行排查。
4.4 防御 checklist(可直接复制到项目文档)
| 项目 | 检查点 | 验证方式 |
|---|---|---|
| pushState 调用时机 | 只在用户点击链接或明确导航时调用 | 代码审查 + 包装脚本日志 |
| replaceState 使用 | UI 状态变化(如筛选、弹窗关闭)使用 replaceState | 搜索项目中 replaceState 覆盖范围 |
| popstate 处理 | 只做视图恢复,禁止再次 pushState | 检查 popstate 回调是否包含 history.pushState |
| 第三方脚本 | CSP 限制 + SRI 校验 | 检查 HTML 中 <script> 标签属性 |
| iframe 使用 | 页面不主动嵌入未授权 iframe,或通过 sandbox 限制 | 搜索 <iframe> 标签,审查 src |
| 返回拦截 | 不阻止返回,仅提供确认对话框 | 业务代码中搜索 e.preventDefault() 并配合 popstate |
| 性能监控 | 记录 popstate 触发次数,异常时告警 | 部署监控脚本,阈值设为 3 次 / 秒 |
5. 总结
Back Button Hijacking 的本质是 对浏览器历史的恶意篡改,其技术实现主要包括:
- history.pushState 篡改:页面加载即压入虚假状态或循环压入;
- popstate 事件劫持:在返回时拦截并自行处理,甚至重新写入状态;
- iframe 注入:利用隐藏 iframe 操作父窗口历史。
通过 包装原生 API、运行时行为监控 与 代码审计,工程师可以在上线前发现并定位这些异常。对策的核心是 严格区分 “用户感知到的页面” 与 “内部 UI 状态”,只在前者使用 pushState,并在 popstate 中仅恢复视图,绝不再写入新历史。配合 CSP 与第三方脚本审查,可从根本上降低被搜索降权或用户流失的风险。
参考
[1] Google Search 博客:《Introducing a new spam policy for "back button hijacking"》(2026 年 4 月)
[2] Stack Overflow 社区:《History pushState and popstate not working properly》讨论中提供的检测技巧