likes
comments
collection
share

如何检测 JavaScript 原生函数 是否被打过 猴子补丁

作者站长头像
站长
· 阅读数 41

本文为翻译

原文标题:Checking if a JavaScript native function is monkey patched

原文作者:Mazzarolo Matteo

原文地址:mmazzarolo.com/blog/2022-0…

简单讲:如何确定 JavaScript 的 原生函数有没有被重写过呢? 我们没法做到,或者说判定结果的可信度并不会特别高。我们有很多方法可以检查,但是无法保证万无一失。

JavaScript 中的原生函数

在 JavaScript 中,“原生函数”(Native function) 是那些源代码被编译为原生机器码的函数。我们可以在 JavaScript 标准内置对象 中找到原生函数(诸如 eval()parseInt()) ,或者在 浏览器 Web API 找到(诸如 fetch()localStorage.getItem())。

由于 JavaScript 的动态特性,开发者可以覆盖浏览器暴露出的原生函数。这种技巧被我们称作 猴子补丁(Monkey patching )

猴子补丁

猴子补丁主要用于修改浏览器内置 API 和原生函数的默认行为。这通常是添加特定功能、polyfill 特性、hook 到 API 的唯一方法,因为我们没法直接对这些 API 进行访问。

例如,像是 Bugsnag 这样的监测工具,重写了 Fetch 和 XMLHttpRequest 的 API 来获取由 JavaScript 代码触发的网络连接相关信息。

猴子补丁是个强大而危险的技巧,因为你没法控制那些被你覆盖的代码:未来 JavaScript 引擎的更新可能会打破你在补丁中做出的一些假设,并导致严重的 Bug。

另外,对那些并非由你负责的代码打猴子补丁,可能会覆盖一些被其他开发者加入的猴子补丁,引入潜在的冲突。

由于种种原因,有时需要确定给定函数是否是原生函数,是否被打了猴子补丁,但是我们能做到吗?

toString() 来检查函数上的猴子补丁

检查一个函数是否 “干净”(没有猴子补丁) 最常用的方式那就是检查函数的 toString() 输出。

默认情况下,原生函数的 toString() 返回这么一行 "function fetch() { [native code] }"

如何检测 JavaScript 原生函数 是否被打过 猴子补丁

依照运行 JavaScript 引擎的不同,输出结果会略有不同。不过,在大多数浏览器中,还是可以很安全的假定返回的字符串中会包含 "[native code]"

打过猴子补丁的原生函数,它的 toString() 将不会返回包含 "[native code]" 的字符串,而是会返回字符串化的函数体。

所以说,想要知道函数是否仍是原生的,我们可以通过检测 toString() 输出是否包含 "[native code]" 来简单判断。

基本的检测方式如下:

function isNativeFunction(f) {
  return f.toString().includes("[native code]");
}

isNativeFunction(window.fetch); // → true

// 对 fetch API 打猴子补丁
(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Fetch call intercepted:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString(); // → "function fetch(...args) {\n console.log("Fetch...

isNativeFunction(window.fetch); // → false

这种方式在大多数场景下都能正常生效。然而,你得清楚,很多伎俩可以让函数绕过这个检测。无论是出于恶意目的(注入恶意代码)还是说你不希望自己的覆盖行为被发现,有几种方法可以让函数看起来很 “原生”。

比如,可以添加一些包含 "[native code]" 的代码(甚至是一条注释!)在函数体里:

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    // function fetch() { [native code] }
    console.log("Fetch call intercepted:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString(); // → "function fetch(...args) {\n // function fetch...

isNativeFunction(window.fetch); // → true

… 或者,可以重写 toString() 方法,返回包含 "[native code]" 的字符串:

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Fetch call intercepted:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString = function toString() {
  return `function fetch() { [native code] }`;
};

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

… 或者,可以用 bind 创建一个猴子补丁函数,这会生成一个原生函数:

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Fetch call intercepted:", ...args);
    return originalFetch(...args);
  }.bind(window.fetch); // 👈
})();

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

… 或者,可以通过 ES6 的 Proxy 来捕获 apply() 调用,这样一来,从外部来看,函数完全是原生的:

window.fetch = new Proxy(window.fetch, {
  apply: function (target, thisArg, argumentsList) {
    console.log("Fetch call intercepted:", ...argumentsList);
    Reflect.apply(...arguments);
  },
});

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

好了,我就不举例子了。

我主要想强调的是: 开发者可以轻易地绕开你的 toString() 检测

我觉得大多数情况下,不需要太在意上面那些边缘情况。但是如果你想的话,还是可以用一些额外检测来覆盖上面的用例。

例如:

  • 可以使用一个一次性的 iframe 来获取 “干净” 的 toString() 值,再做严格匹配;
  • 可以多次调用 .toString().toString() 确保 toString() 不被重写;
  • 使用元编程技巧,对 Proxy 构造函数自身来打个猴子补丁,以此来确定原生函数是否被代理过了(因为依照规范,无法察觉到什么东西是 Proxy
  • 等等 …

这完全取决于你想在 toString() 这个兔子洞里钻多深。

但是这真的值得吗?我们能够覆盖所有的边缘情况吗?

iframe 获取一个干净的函数

如果你需要调用一个 “干净” 的函数,而不是去检查一个原生函数是不是被打过猴子补丁,那么我们可以从一个同源的 iframe 中获取:

// 创建一个同源的 iframe
// 你可能需要添加一些样式先隐藏 iframe,稍后再从 DOM 中彻底删除
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
// 新的 iframe 会创建它自身的 “干净” window 对象,这样你就可以从这里拿到你想要的函数了
const cleanFetch = iframe.contentWindow.fetch;

尽管,我觉得这种方式比调用 toString() 去做验证要好,但也会有一些局限性;

  • iframe 有时会由于 强 CSP 或者 你的代码没有通过浏览器运行 而导致不可用。
  • 尽管不太现实,但第三方可以给 iframe API 上猴子补丁。所以还是不能 100% 信任生成 iframe 的 window 对象。
  • 修改或调用 DOM 的原生函数(比如 document.createElement)没法使用这种方法,因为它们会指向 iframe 的 DOM 而不是顶层的 DOM。

这个解决方案来自 lobste.rs/s/pppun8/ch…

通过判断引用是否相等来检查函数上的猴子补丁

如果安全是你首要考虑的因素,我认为你可以选择一种不同的方法:长期存储一个 “干净” 的原生函数引用,然后,用它来和可能的猴子补丁函数进行比较:

<html>
  <head>
    <script>
      // 在其他脚本修改原生函数之前,保存 “干净” 原生函数的原始引用。
      // 在这个例子中,我们保存了 fetch API 的原始引用
      // 并把它保存在闭包里。如果你无法预先决定要检查什么 API,
      // 那可以存储多个 window 对象。
      (function () {
        const { fetch: originalFetch } = window;
        window.__isFetchMonkeyPatched = function () {
          return window.fetch !== originalFetch;
        };
      })();
      // 现在开始,你可以调用 window.__isFetchMonkeyPatched()
      // 来检查 fetch API 是不是被打了猴子补丁
      //
      // 例如:
      window.fetch = new Proxy(window.fetch, {
        apply: function (target, thisArg, argumentsList) {
          console.log("Fetch call intercepted:", ...argumentsList);
          Reflect.apply(...arguments);
        },
      });
      window.__isFetchMonkeyPatched(); // → true
    </script>
  </head>
</html>

通过严格的引用检查,我们可以避免所有的 toString() 漏洞。甚至这种方式也能应用于Proxy,因为 Proxy 没法捕获相等性比较 🪤。

这种方法最大的问题在于有点不切实际。它需要在运行任何 app 中其他代码之前,保存函数的原始引用,以确保函数没有被动过手脚。但我们有时根本没法做到这一点(比如,你构建的是一个库)。

可能有些方式能绕过这项测试,但是我在撰写本文时没有想到。欢迎大家补充。

那么,如何确定一个 JavaScript 原生函数是否被重写过呢?

需要 检查函数上猴子补丁的次数,用一只手都能数得过来。

不过我对这个问题很感兴趣,我认为对于很多场景,不存在真正万无一失的判定方法。

  • 如果你能控制整个网页,可以预先在函数都还是 “干净” 的时候存储它们,之后再进行比较。
  • 不然,你可以使用 iframe,创建一次性的 iframe 并从中获取 “干净” 的函数。但你要明白你还是无法 100% 确定 iframe API 是否被动了手脚。
  • 再者,由于 JavaScript 的动态特性,你可以简单使用 toString().includes("[native code])" 来检查(但恶意代码很容易绕过这种检测)。你还可以增加大量的安全检测来覆盖大多数(没法做到全部)的边缘情况。
转载自:https://juejin.cn/post/7142856679976075271
评论
请登录