自单页面应用(SPA)兴起以来,前端路由逐渐由后端转向客户端,浏览器原生的 History APIpushState / 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.pushStatelocation.replace,从而在父窗口的历史栈中插入额外条目。用户在主窗口点击返回时,实际返回的是 iframe 的历史,导致页面 “鬼畜” 式跳转。例如:

<iframe id="trap" style="display:none" src="/fake-page"></iframe>

iframe 内部的脚本可以随意操作父窗口的 history,且不易被主页面脚本检测。移动端浏览器尤其容易受此影响,因为返回手势往往先交给 iframe 处理。

3. 检测方法

要在自己的代码库中发现潜在的劫持行为,可以通过 包装原生 API运行时监控 两种思路实现。

3.1 包装原生 API

通过在页面加载初期替换 history.pushStatehistory.replaceStatewindow.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 的本质是 对浏览器历史的恶意篡改,其技术实现主要包括:

  1. history.pushState 篡改:页面加载即压入虚假状态或循环压入;
  2. popstate 事件劫持:在返回时拦截并自行处理,甚至重新写入状态;
  3. 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》讨论中提供的检测技巧