前言
熟悉 JS 的都知道,原生函數轉成字符串,顯示的是 native code:
alert + '' // "function alert() { [native code] }"
如果用自己的函數對其重寫,顯示的則是自己的代碼:
alert = function(s) { console.log(s) }
alert + '' // "function(s) { console.log(s) }"
有沒有什麼黑魔法,讓自己的函數也變成 native code,從而掩蓋這個破綻?
toString
最容易想到的辦法,就是重寫函數的 toString 方法:
alert = function(s) { console.log(s) }
alert.toString = () => "function alert() { [native code] }"
alert.toString() // "function alert() { [native code] }"
不過這種辦法破綻極多,例如調用原生 toString
即可將其原形畢露:
Function.prototype.toString.call(alert) // "function(s) { console.log(s) }"
即便原生 toString
也被重寫,還可創建 iframe 頁面獲取未被污染的原生 toString
。
事實上這個辦法並沒有改變函數本質,只是改變觀察結果而已。
bind
在《如何讓 JS 代碼不可斷點》文中提到,通過 bind 方法創建的新函數,其實是原生的。
我們來驗證對比下:
// 原生結果
console.log('before:', alert + '') // "function alert() { [native code] }"
alert = (function() {
function alert(s) {
console.log('test:', s)
}
return alert.bind(window)
})()
// 重寫結果
console.log('after:', alert + '') // "function () { [native code] }"
alert('hello bind')
兩者主體部分都爲 [native code]
。不過重寫後的結果少了函數名,比原生更短。算是一個小破綻。
此外,除了字符串結果不同,函數的 name
和 length
屬性也有差異:
// 原生值
console.log('before:', alert.name) // "alert"
console.log('before:', alert.length) // 0
alert = (function() {
function alert(s) {
console.log('test:', s)
}
return alert.bind(window)
})()
// 重寫值
console.log('after:', alert.name) // "bound alert"
console.log('after:', alert.length) // 1
運行前刷新頁面
可見通過 bind 創建的新函數,其 name
屬性會多一個 bound
前綴(中間有個空格),也算是個小破綻。
由於我們的 alert
函數聲明瞭一個 s 參數,導致其 length
屬性變成了 1,而原生的則爲 0。
當然函數的 length
屬性可隨意僞造,只需聲明相應個數的形參即可。使用 ...rest
這種剩餘參數聲明的形參,不會增加 length
值。
function A(...args) {}
A.length // 0
function B(i, ...args) {}
B.length // 1
Proxy
提到重寫、切面等概念時,不得不聯想到一個功能強大的 API —— Proxy
,不少黑魔法都藉助它實現。
先來試下,被代理的函數轉成字符串是什麼結果:
new Proxy(function F() {}, {}) + '' // "function () { [native code] }'
和上述 bind 結果類似,雖有 native code
,但少了函數名。
下面完整對比下,包括 name
和 length
屬性:
console.log('before:', alert + '') // "function alert() { [native code] }"
console.log('before:', alert.name) // "alert"
console.log('before:', alert.length) // 0
alert = (function() {
function alert(...args) {
console.log('test:', ...args)
}
return new Proxy(alert, {})
})()
console.log('after:', alert + '') // "function () { [native code] }"
console.log('after:', alert.name) // "alert"
console.log('after:', alert.length) // 0
alert('hello proxy')
相比 bind 方案,Proxy 不會修改 name
屬性,因此破綻更少。
不過在 Safari 瀏覽器上有明顯的破綻,函數轉成字符串後變成:
function ProxyObject() {
[native code]
}
WebAssembly
由於 JS 程序是文本格式的,因此函數 toString 的結果自然能包含相應的代碼。如果是二進制程序,那麼 toString 的結果又會是什麼?這個問題,可以用 WebAssembly 的導出函數來驗證。
MDN 文檔 中提到,WebAssembly 導出的函數會顯示成 native code
。
我們構造一個精簡的 WebAssembly 程序,將導入的 x.y 函數導出成 z 函數:
(module
(func (export "z") (import "x" "y") (param externref))
)
使用 wat2wasm 將其轉成二進制數據,並進行封裝:
function genNativeFunction(callback) {
const buf = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 1, 111, 0, 2, 7, 1, 1, 120, 1, 121, 0, 0, 7, 5, 1, 1, 122, 0, 0, 0, 10, 4, 110, 97, 109, 101, 2, 3, 1, 0, 0
])
const mod = new WebAssembly.Module(buf)
const obj = new WebAssembly.Instance(mod, {x: {y: callback}})
return obj.exports.z
}
alert = (function() {
function alert(...args) {
console.log('test:', ...args)
}
return genNativeFunction(alert)
})()
console.log(alert + '') // "function 0() { [native code] }"
console.log(alert.name) // "0"
console.log(alert.length) // 1
alert('hello wasm')
WebAssembly 導出函數轉成字符串後確實包含 native code,不過破綻也非常明顯,其中的函數名居然是一個數字!正常的 JS 函數顯然不可能以數字命名。破綻 +1。
同樣 name
屬性也變成了數字。破綻 +2。
並且 WebAssembly 函數的形參數量是固定的,因此 length
屬性也難以僞造。破綻 +3。
因此這個方案雖然有趣,但並不隱蔽。
當然在奇葩的 Safari 上,返回結果並不包含函數名,並且 name
屬性是個空字符串。不過這依然是個大破綻。
此外在 Chrome 上,調試器單步斷點(F11)無法進入 WebAssembly 的導出函數:
debugger
alert(123)
看來之前的《如何讓 JS 代碼不可斷點》又可以新增一個黑魔法了。
總結
想要完美僞造一個原生函數還是非常困難的,多多少少總有一些破綻。
如果有更好的方案,歡迎補充~