前端黑魔法 —— 如何讓自己的函數變成原生函數

前言

熟悉 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]。不過重寫後的結果少了函數名,比原生更短。算是一個小破綻。

此外,除了字符串結果不同,函數的 namelength 屬性也有差異:

// 原生值
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,但少了函數名。

下面完整對比下,包括 namelength 屬性:

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 代碼不可斷點》又可以新增一個黑魔法了。

總結

想要完美僞造一個原生函數還是非常困難的,多多少少總有一些破綻。

如果有更好的方案,歡迎補充~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章