檢查原生 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] }"的內容。
這個字元串可能略有不同,這取決於運行的是什麼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]")檢查,或者添加大量的安全檢查來覆蓋大多數(但不是全部)邊緣情況。
擴展閱讀
- StackOverflow: Is there a way to check if a native Javascript function was monkey patched?
- StackOverflow: Detect if function is native to browser
- StackOverflow: How to determine that a JavaScript function is native (without testing 『[native code]『)
- David Walsh: Detect if a Function is Native Code with JavaScript
