如何檢測 JavaScript 原生函式是否被打過 "猴子補丁"

語言: CN / TW / HK

本文譯者為奇舞團前端工程師

原文標題:Checking if a JavaScript native function is monkey patched

原文作者:Mazzarolo Matteo

原文地址:https://mmazzarolo.com/blog/2022-07-30-checking-if-a-javascript-native-function-was-monkey-patched/

簡單講:如何確定 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 引擎的不同,輸出結果會略有不同。不過,在大多數瀏覽器中,還是可以很安全的假定返回的字串中會包含 "[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); // :point_left:
})();

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。

這個解決方案來自 https://lobste.rs/s/pppun8/checking_if_javascript_native_function 。

通過判斷引用是否相等來檢查函式上的猴子補丁

如果安全是你首要考慮的因素,我認為你可以選擇一種不同的方法:長期儲存一個 “乾淨” 的原生函式引用,然後,用它來和可能的猴子補丁函式進行比較:

<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])" 來檢查(但惡意程式碼很容易繞過這種檢測)。你還可以增加大量的安全檢測來覆蓋大多數(沒法做到全部)的邊緣情況。

-   E N D   -

3 6 0 W 3 C E C M A T C 3 9 L e a d e r 注和加