技术实践

看完这篇文章恍然大悟,Web 终极拦截技巧(全是骚操作)

计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson

羊先生的头像
羊先生2024.03.10 · 13 分钟阅读 · 98 阅读
看完这篇文章恍然大悟,Web 终极拦截技巧(全是骚操作)封面
文章正文还需 13 分钟

今天整理工具发现这篇文章

拦截的价值

计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson

如果系统的控制权、代码完全被掌控,很容易添加中间层;
现实情况我们往往无法控制系统的所有环节,所以需要使用一些  “非常规”(拦截)  手段来增加中间层

拦截的方法

拦截/覆写 浏览器 API

最常见的场景有通过拦截 console 实现错误上报。

复制代码
const _error = console.error;
console.error = (...args) => {
  _error.apply(console, args);

  console.info('在此处上报错误信息...');
};

// 其它代码打印错误
console.error('error message');

项目中通常会基于 axios 此类的网络库,做一些统一处理逻辑
但在某些场景,我们无法修改项目代码,就能通过拦截 fetch, xhr 来达到目的。

复制代码
// 接口性能监控,打开 https://example.com/, 在控制台执行以下代码
const _fetch = window.fetch;
window.fetch = (...args) => {
  const startTime = performance.now();
  return _fetch(...args).finally(() => {
    console.info('接口耗时:', Math.round(performance.now() - startTime), 'ms');
  });
};

await fetch('//example.com');

你可以选择第三方库(比如 xhook (opens new window))来快速实现 fetch, xhr 拦截功能。

浏览器中大多数 API 都是可以覆写的,打开脑洞,可以实现非常多的神奇功能:

  • 网络 API (xhr, fetch, WebSocket

    • 性能监控、统一错误码处理
    • 添加额外 HTTP 参数(header, query)实现接口染色功能
    • 修改 Host 将接口自动转向代理服务,实现远程调试接口、Mock 数据
  • 修改原型 (Array.prototype.at = ...

    • polyfill 库的必备手段
  • 页面跳转 API (window.openhistory.go back pushState

    • 修改跳转的目的页面
    • 自动添加页面跳转埋点
  • 删除特定 API 禁用浏览器功能

    • 禁止 js 访问摄像头 navigator.mediaDevices.getDisplayMedia = null
    • 禁止 p2p 连接 window.RTCPeerConnection = null

拦截/篡改 事件、DOM 元素

浏览器也会提供一些具备拦截性质的 API,允许开发者实现特定功能。

一个 DOM 元素经常会绑定许多事件,如果你想让特定的事件回调函数先执行,以便添加一些前置逻辑或取消后续事件的执行;
可以了解 addEventListener#usecapture (opens new window)的用法。

复制代码
// 禁止响应页面的所有点击事件(危险⚠️),第三个参数(usecapture)设为 true
document.body.addEventListener(
  'click',
  (evt) => {
    evt.preventDefault();
    evt.stopPropagation();
  },
  true
);

许多 DOM 元素都是在运行时动态创建的,如果需要修改动态创建的 DOM 元素可使用 MutationObserver(opens new window)
比如,拦截所有超链接(a 标签),给目标链接添加 _source 参数

复制代码
const observer = new MutationObserver((mutationsList) => {
  for (const mutation of mutationsList) {
    if (mutation.type !== 'childList' || !mutation.addedNodes) return;
    mutation.addedNodes.forEach((item) => {
      if (!item.nodeName === 'A') return;
      const targetUrl = new URL(item.href, location.href);
      targetUrl.searchParams.append('_source', 'any string');
      item.href = targetUrl.href;
    });
  }
});
observer.observe(document.body, {
  attributes: true,
  childList: true,
  subtree: true,
});

TIP

MutationObserver 同样适应于修改 iframe, img 的链接,或其它任意 DOM 元素的属性

调试小技巧

如果你的页面因未知代码陷入了快速刷新的死循环,可在项目中添加以下以下代码;
页面刷新前会进入 debug 状态,在 devtools 中查看调用堆栈(call stack)即可了解刷新的原因

复制代码
window.addEventListener('beforeunload', () => {
  debugger;
});

TIP

http 302 属于非代码导致页面跳转,上述代码无法拦截

当调试第三方代码时,需要监听某个不符合期望的对象属性值

复制代码
// debug 状态下任意可访问对象
const obj = { prop: 1 };

// 在 devtools -> console 中执行以下代码
_obj_prop = obj.prop;
Object.defineProperty(obj, 'prop', {
  set: (v) => {
    _obj_prop = v;
    // 每次赋值都会进入 debug 状态
    debugger;
  },
  get: () => _obj_prop,
});
// 试试执行 obj.prop = 2
// 后续可在 console 中随时访问 _obj_prop 的当前值

如果需要监听某个对象所有属性值被读写的消息,可以使用 Proxy

复制代码
const obj = { prop: 1 };

const obj2 = new Proxy(obj, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    debugger;
    return Reflect.set(target, key, value, receiver);
  },
});

// 试试执行 obj2.abc = 2

注意差异

  • Object.defineProperty 没有改变 obj 的引用,Proxy 生成了新对象 obj2
  • 使用 Proxy 可以监听对象(obj2)所有属性的读写,而 Object.defineProperty 一次只能监听一个属性(prop

ServiceWorker 拦截

前端开发者可能会使用 ServiceWorker 来实现离线可用、缓存资源、加速页面访问等功能。

复制代码
// 安装时缓存资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches
      .open('v1')
      .then((cache) => cache.addAll(['/index.html', '/style.css', '/app.js']))
  );
});
// 拦截页面资源请求,使用缓存响应(也可使用自定义内容响应请求)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response;
    })
  );
});

ServiceWorker 不仅是一个具备拦截性质的 API,它还是独立 js 运行环境,是前端页面到服务器之间的中间层,它能拦截同域名下的所有请求,缓存或篡改请求结果,能实现的功能远不止离线或加速访问这么简单。
后文会从 WebContainer 原理分析 ServiceWorker 的高端玩法。

服务器拦截

前面介绍的技巧都局限在客户端(浏览器)中,如果你掌握了真正的服务控制权,即配置服务器和内网 DNS 域名等权限(一般由公司内的工程效率团队或运维负责),再配合前面介绍的浏览器拦截技巧,可玩性将大大增加。

一个 HTTP 请求往往会经过多个服务器节点,每个节点就是一个中间层

  1. DNS 决定 HTTP 请求由哪个网关(Nginx)处理

    1. 如果你能控制 DNS 服务,则可以在网关之前再加一个中间层
  2. 服务器节点(网关、Service)节点能获取、篡改 HTTP 请求中的所有信息:Header、Cookie、Body

    1. 根据 HTTP 信息,可将请求转发到本地目录(静态资源),或转发到其他远程服务
    2. 添加 Cookie 追踪用户,实现灰度、AB 实验分流等功能
    3. 实现业务层无感知注入代码
    4. 动态篡改数据,实现 Mock 功能

拦截篡改 HTTP Response

使用 Nginx 修改 html 的示例,往 Response body 中注入一个 js 脚本(ff-sdk.js

复制代码
sub_filter <head> "<head><script src='/ff-sdk.js'></script>";

TIP

  • 注入到 head 标签后面,确保脚本优先执行
  • 然后通过注入的脚本在运行时实现任意功能(拦截篡改系统 API、事件、DOM 元素等等)

如何注入拦截代码

前文介绍的客户端拦截技巧都需要向浏览器中注入代码,原理是修改 html 或 js 资源的内容。

有以下方式注入代码在浏览器环境中运行

  • 向 html 中添加一个 script 标签,src 指向特定的 js 地址(前文的 /ff-sdk.js

    • 简单易维护,优先使用该方法
  • 向 html 注入一个完整的 script 标签

    • 如 Nginx 注入 sub_filter <head> "<head><script>alert('注入成功')</script>"
  • 修改 js 资源内容,在开始位置插入代码

    • ';(function(){ alert('注入成功') })();' + <js file body>
    • 注入代码包裹在自执行函数中,注意前后加上分号

以下列举注入代码的时机,根据目的和能获取的权限决定中哪个阶段注入

  1. 源码注入

    • 如果你有源码控制权,那你可以对项目做任何事情,确保拦截代码优先执行即可
    • 优点:灵活可控;缺点:通用性不好,侵入业务
  2. 构建、推送服务注入

    • 工程团队提供构建、推送服务,可编写脚本在构建产物中注入代码(比如 html 中添加 script 标签)
    • 优点:业务无感知,通用性好;缺点:不一定有权限
  3. 网关注入

    • Nginx 向 html 中注入 script 也很简单(参考前文 拦截篡改 HTTP Response
    • 优点同上,一般限于在开发、测试环境,不会上生产环境
  4. 浏览器插件、devtools 注入

    • 如果你啥权限都没有(普通用户),又想干一些“坏事”,则可选择使用插件注入代码,或临时在 devtools 的 console 面板直接写代码
    • 优点:万能注入、无法阻挡;缺点:只能影响当前浏览器,难以跨浏览器、设备、用户
    • 常用的脚本管理插件:篡改猴 (opens new window),也可考虑自己写浏览插件

尽量让被注入的代码早于业务代码执行,比如实现拦截并上报错误信息,如果被注入的代码执行时机较晚,则会丢失执行前已产生的错误信息。