檢查原生 JavaScript 函數是否被覆蓋

  • 2022 年 8 月 27 日
  • 筆記

你如何確定一個JavaScript原生函數是否被覆蓋? 你不能--或者至少無法可靠地確定。有一些檢測方法很接近,但你不能完全相信它們。

JavaScript原生函數

在JavaScript中,原生函數指的是其源程式碼已經被編譯進原生機器碼的函數。原生函數可以在JavaScript 標準內置對象(比如說eval(), parseInt()等等),以及瀏覽器Web API(比如說fetch(), localStorage.getItem()等等)中找到。

由於JavaScript的動態特性,開發者可以覆蓋瀏覽器暴露的原生函數。這種技術被稱為"猴子修補程式"。

猴子修補程式

猴子修補程式主要用於修改瀏覽器內置API和原生函數的默認行為。這通常是添加特定功能、墊片功能或連接你無法訪問的API的唯一途徑。

比如說,諸如Bugsnag等監控工具覆蓋了Fetch和XMLHttpRequest APIs,以獲得對由JavaScript程式碼觸發的網路連接的可見性。

猴子修補程式是非常強大,但也是非常危險的技術。因為你所覆蓋的程式碼不受你的控制:未來對JavaScript引擎的更新可能會打破你的修補程式中的一些假設,從而導致嚴重的bug。

此外,通過對不屬於你的程式碼進行猴子修補程式,你可能會覆蓋一些已經被其他開發者猴子修補程式過的程式碼,從而引入潛在的衝突。

基於此,有時你可能需要測試一個給定的函數是否為原生函數,或者它是否被猴子修補程式過...但你能做到嗎?

使用toString()檢查

檢查一個函數是否仍然是 "乾淨的"(如未被猴子修補程式)的最常用方法是檢查其toString()的輸出。

默認情況下,原生函數的toString()會返回類似於 "function fetch() { [native code] }"的內容。

fetch-native-code.png

這個字元串可能略有不同,這取決於運行的是什麼JavaScript引擎。不過,在大多數瀏覽器中,你可以安全地認為這個字元串將包括"[native code]"子串。

通過對原生函數進行猴子修補程式,它的toString()將停止返回"[native code]"字元串,而是返回字元串化的函數體。

因此,檢查一個函數是否仍然是原生的一個簡單方法是,檢查其toString()輸出是否包含"[native code]"字元串。

初步檢查可能是這樣的:

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

isNativeFunction(window.fetch); // → true

// Monkey patch the 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代理來捕獲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
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
// 新的iframe將創建自己的"乾淨"window對象,
// 所以你可以從那裡抓取你感興趣的函數。
const cleanFetch = iframe.contentWindow.fetch;

雖然我認為這種方法仍然比使用toString()驗證一個函數好,但它仍然有一些明顯的局限性:

  • 無論是因為強大的Content Security Policy (CSP),還是因為你的程式碼沒有在瀏覽器中運行,有時iframes可能無法使用。
  • 雖然有點不切實際,但第三方可以對iframe的API進行猴子修補程式。因此,你仍然不能100%地信任生成的iframe的window對象。
  • 改變或使用DOM的原生函數(如document.createElement)將無法使用這種方法,因為它們的目標是iframe的DOM,而不是頂層的。

使用全等檢查

如果安全是你首要考慮的因素,我認為你應該採用不同的方法:持有一個"乾淨"原生函數的引用,稍後用潛在的猴子修補程式函數與它進行比較。

<html>
  <head>
    <script>
    // 在任何其他腳本有機會修改原始的原生函數之前,存儲一個引用。
    // 在這種情況下,我們只是持有一個原始fetchAPI的引用,並將其隱藏在一個閉包後面。
    // 如果你事先不知道你要檢查什麼API,你可能需要存儲多個window對象的引用。
      (function () {
        const { fetch: originalFetch } = window;
        window.__isFetchMonkeyPatched = function () {
          return window.fetch !== originalFetch;
        };
      })();
      // 從現在開始,你可以通過調用window.__isFetchMonkeyPatched()
      // 來檢查fetch API是否已經被猴子修補程式過。
      //
      // Example:
      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()的漏洞。它甚至適用於代理,因為它們不能捕獲相等比較。

這種方法的主要缺點是,它可能不切實際。它要求在運行應用程式中的任何其他程式碼之前存儲原始函數引用(以確保它仍然未被觸及),有時你將無法做到這一點(例如,你正在構建一個庫)。

可能有一些方法可以打破這種方法,但在寫這篇文章的時候,我還不知道這種方法。如果我遺漏了什麼,請讓我知曉。

如何確定是否被覆蓋

我對這個問題的看法(或者更好的說法是 "猜測")是,根據不同的使用情況,可能沒有一種失敗的證明方法來確定它。

  • 如果你能控制整個網頁,當它們仍然是"乾淨的"時候,你可以通過存儲你想檢查的函數的引用,來提前設置你的程式碼,然後再進行比較。
  • 否則,如果你能使用iframe,你可以創建一個隱藏的一次性iframe,並從那裡抓取一個"乾淨 "的函數--要知道你仍然不能100%確定iframe的API沒有被猴子修補程式過。
  • 否則,考慮到JavaScript的動態性質,你可以使用簡單的toString().includes("[native code]")檢查,或者添加大量的安全檢查來覆蓋大多數(但不是全部)邊緣情況。

擴展閱讀

Tags: