JavaScript 之「偷天換日術」

在開始填 YouDanMu 這個坑之前我就已經知道了遲早要碰上這種問題,趁著這個機會我來詳細地講一講如何偷到閉包裏面的私有變量。即 JavaScript 之「偷天換日術」。

爲了簡化問題,請考慮下面這段代碼:

(function(global){
    function GUID(secret) {
        this.secret = secret;
    }
    GUID.prototype.generate = function() {
        return Math.floor((1 + this.secret) * 0x10000000000000).toString(16).toUpperCase();
    };
    GUID.history = [];
    GUID.guid = function() {
        var id;
        do {
            id = (new GUID(Math.random())).generate();
        } while (GUID.history.indexOf(id) >= 0);
        GUID.history.push(id);
        return id;
    };
    global.guid = GUID.guid;
})(window);

這是一個簡單的 GUID 生成器(爲了簡單并沒有按照 RFC 4122 規範來寫,請不要使用)。他會向外部暴露一個 window.guid() 函數,每次調用時它會生成一個隨機數作爲種子, new 一個 GUID 對象,產生一段 GUID 碼,如果這段 GUID 碼存在于 GUID.history 中則重新生成,以保證生成 GUID 碼的唯一性(爲了簡單這個唯一性衹在這段程序的生命周期中有效)。

我們現在要寫一個瀏覽器插件,在不改動上面這段代碼的情況下,我們可以在這段代碼執行前後的全局作用域中添加我們的代碼,我們最終的目的是拿到閉包中 GUID 私有對象(Function)的讀寫權限。即我們能操縱 GUID.history,比如能夠刪掉之前生成過的 GUID 碼記錄讓唯一性失效等等。

這個簡化版的問題正是我在對 YouTube 内部 API 進行劫持時遇到的等同問題,如果我們能解決這個簡化版的問題,那麽也就能成功劫持到 YouTube 的内部 API 對象了。

「偷天換日術」

我們先來看看這個「偷天換日術」的實現代碼:

/* 「偷天換日術」的實現部分 */
Object.defineProperty(Function.prototype, 'guid', {  
    __proto__: null,
    enumerable: true,
    configurable: false,
    get: function _getter() {
        return this._guid;
    },
    set: function _setter(value) {
        value._this = this;
        this._guid = value;
    }
});
/* 這是原來的代碼 */
(function(global){
    function GUID(secret) {
        this.secret = secret;
    }
    GUID.prototype.generate = function() {
        return Math.floor((1 + this.secret) * 0x10000000000000).toString(16).toUpperCase();
    };
    GUID.history = [];
    GUID.guid = function() {
        var id;
        do {
            id = (new GUID(Math.random())).generate();
        } while (GUID.history.indexOf(id) >= 0);
        GUID.history.push(id);
        return id;
    };
    global.guid = GUID.guid;
})(window);
/* 結果演示 */
window.guid();  
console.log(window.guid._this.history);  

可以先在瀏覽器裏面跑一跑體驗一下,然後可以跟蹤調試一下并嘗試理解。在 _getter 和 _setter 的函數裏面設置斷點,在中斷的時候追溯一下 Call Stack 嘗試理解一下工作原理。

通過跟蹤我們能發現,這個「偷天換日術」最關鍵的一步發生在 GUID.guid = function() ... 這裏。我們知道,GUID 是一個 Function,不,準確來説是 Function 類的一個實例,相當於 new Function() 的結果,所以 GUID 應該繼承了 Function 這個類的 prototype,也就是說 GUID.__proto__ === Function.prototype。如果這裏不理解,請溫習一下 JavaScript 的面嚮對象編程以及封裝方法(by 阮一峰)

其次我們知道,在 JavaScript 中對一個對象 x 的屬性 y 進行賦值的流程是這樣的:

  1. 如果在當前對象的直屬屬性中存在名爲 y 的屬性,則調用 y 相應的 setter 將新的值傳進去,同時設置這個 setter 的 this 爲 x;如果該屬性存在但是沒有定義 setter 則直接賦值在它的 Property Descriptorvalue 屬性上。
  2. 如果在當前對象的直屬屬性中找不到名爲 y 的屬性,則訪問當前對象的 __proto__ 屬性,如果 __proto__ != undefined 則把當前對象替換爲該 __proto__ 屬性,然後重複步驟 1。
  3. 如果 __proto__ 訪問發生循環,或最後訪問到的 __proto__ == undefined,則終止 __proto__ 鏈訪問。這種情況下,JavaScript 才會重新回到最開始的對象 x 上面,在它的直屬屬性中定義一個新的名爲 y 的屬性,并賦值在它的 Property Descriptor 的 value 屬性上面。

我們把這種遞歸式的屬性查詢訪問稱爲 Prototype Chain。我們可以把整個流程用 JavaScript 重現一遍:

function assignProperty(object, property, value) {  
    var current = object;
    var protoChain = [current];
    while (true) {
        if (current.hasOwnProperty(property)) {
            var descriptor = Object.getOwnPropertyDescriptor(current, property);
            if (typeof descriptor.set === 'function') {
                descriptor.set.call(object, value);
            } else {
                descriptor.value = value;
                Object.defineProperty(current, property, descriptor);
            }
            break;
        } else {
            if (current.__proto__ != undefined
                && protoChain.indexOf(current.__proto__) < 0) {
                current = current.__proto__;
                protoChain.push(current);
            } else {
                Object.defineProperty(object, property, {
                    configurable: true,
                    enumerable: true,
                    value: value,
                    writable: true
                });
                break;
            }
        }
    }
}

這段代碼將讓 x.y = 'value' 等價于 assignProperty(x, 'y', 'value'),可以試試。

看到這裏,我想很多人都已經明白了這個「偷天換日術」的秘密了。最關鍵的一點,在於 Prototype Chain 賦值操作中,一個屬性擁有 setter 的時候,調用 setter 的同時會將 this 設置爲最上級的對象。也就是説,如果我們能在一個對象 x 的某一級 __proto__ 中定義一個帶有我們自己設置的 setter 的屬性 y,所有對 x.y 進行的賦值操作,除了會把新的值傳給我們的 setter 之外,還會把 setter 調用的 this 設成 x,所以我們在 setter 中訪問 this 就是在訪問 x。明白了這點,這個「偷天換日術」就不是什麽秘密了。

最後的問題就是如何在 GUID 的某層 __proto__ 中插入一個我們提前制定好了 setter 的 guid 屬性。之前我們講到了,GUID 繼承了 Function.prototype,用 JavaScript 重現一遍 function GUID() {...} 的内部實現就是:

var GUID = Function('/* function body ... */');  
GUID.__proto__ = Function.prototype;  
// 真正的内部實現還有很多別的内容,此處簡化省略

所以你看,我們衹要修改一下 Function.prototype 這個全局變量,就能在 GUID 的 Prototype Chain 上面隨意地添加「陷阱」了。好了我們再回頭看看我們的「偷天換日術」的實現:

/* 「偷天換日術」的實現部分 */
Object.defineProperty(Function.prototype, 'guid', {  
    __proto__: null,
    enumerable: true,
    configurable: false,
    get: function _getter() {
        return this._guid;
    },
    set: function _setter(value) {
        value._this = this;
        this._guid = value;
    }
});

可以看到,我在 Function.prototype 上面定義了一個同時帶有 setter 和 getter 的屬性 'guid'。於是在原來代碼執行到 GUID.guid = function() ... 這裏的時候,就會執行我們定義的 setter,把新的值(一個 Function)作爲參數傳進來,同時把 setter 的 this 設置成 GUID。於是在這個 setter 中我們就能進行「偷天」:把 this,也就是 GUID 放在 value_this 屬性上,注意這個 value 最後會被暴露到全局作用域上,我們就能通過它把 GUID 一起帶出去。其實我這麽寫衹是爲了好看,我們完全可以在這個 setter 裏面直接把 this 賦值給一個全局變量,比如 window.GUID = this,就直接偷到 GUID 了。爲了優雅,我還加上了「換日」:setter 會把傳進來的值存在一個別的屬性 _guid 上,然後我寫了一個 getter,會把 _guid 返回回去。所以在原來的代碼 global.guid = GUID.guid 這裏我們得到的 guid 實際上來自於 GUID._guid,而且上面還帶著我們「偷天」回來的 _this 屬性,一起被傳出了全局作用域。

那麽一式 JavaScript 之「偷天換日術」就是這樣了。

Rix

Read more posts by this author.