JavaScript 的 Anti-Debugging 技術

JavaScript 運行在客戶端,多數 Browser 亦有很強的 debugger,有時為了保護程式碼的邏輯不被破解或想要藏惡意程式之類的,會想辦法讓分析者沒辦法輕易分析原始碼。通常又可以分為阻撓靜態分析(例如 obfuscation)和動態分析(例如 anti-debugging)。這篇文章會介紹 JavaScript 的一些 anti-debugging 的方法,討論可以如何讓分析者沒辦法在 browser 上用 debugger 或甚至偵測自己是否正在被 debug,並分析他們的優劣與可能破解方法。

這篇文章是 U Can’t Debug This: Detecting JavaScript Anti-Debugging Techniques in the Wild 的部份重點整理,該文發表於 USENIX Security 2021。該文其實遠不只 anti-debugging 技術,不過因為他們分析做得很好,所以想針對這部份做重點整理。

背景

雖然我猜會看這篇文章的人應該都有概念 debugging 長怎樣,不過還是簡單講一下。一般來說,如果要在 browser 上做 debugging,通常會使用 DevTool 給的 Debugger。召喚方法大概是這樣的:對著網頁(恩,你現在就正在看的)點右鍵,按「檢視」 / “Inspect”(至少在 Firefox 上長這樣),就會看到 DOM 被解析的樣子,然後點 Debugger,便可以看到 JavaScript code,也可以下 breakpoint。除了人工上 breakpoint,也可以在 JavaScript code 裡面用 debugger 直接下 breakpoint。

為了避免自己的 code 分析,有些人會希望阻撓 manual analysis,或更好的狀況是,知道自己正在被 debug,也許可以有不同的行為(例如很多 malware 可能發現自己正在被分析,就開始裝死以隱藏其原先的 behavior)。我們大致可以把策略分成三種:阻礙 debug、改變 debugger 行為、偵測是否在 debug。

阻礙 Debugger 開啟

一、攔截 DevTools 開啟的快捷鍵

一種雖然無腦但偶而會有效的方法是,直接禁止使用者嘗試打開 DevTools,自然也就沒辦法使用 debugger 了。一般來說打開 DevTools 有三種方法:按 F12、點右鍵選「檢視」(如前述)、從瀏覽器選單找到 DevTools。前兩種方法在使用時都會在網頁上觸發一些 event,例如按鍵盤會有 keydown,滑鼠點右鍵會有 oncontextmenu,只要去聽特定的 event 並阻撓其行為,就可以避免 DevTools 開啟。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
window.addEventListener("keydown", function(event){
    if (event.key == "F12") {
        event.preventDefault();
        return false;
    }
});
window.oncontextmenu = (event) => {
    event.preventDefault();
    return false;
};

然而破解方法也很簡單,一來是如果要看 source code,大可直接用 view-source: ...,二來是就算真的想用 DevTools,從瀏覽器選單選 DevTools 就可以了。

二、塞入大量 breakpoint

另一種惱人的方法是,在 code 裡面塞一堆 debugger 來預設 breakpoint,一般來說執行時 runtime 會直接忽略這些 breakpoint,但只要分析者一打開 debugger,把上就會被一堆 breakpoint 阻撓,完全沒辦法順利 debug。

不過要阻止的方法很簡單。首先,有些 browser 可以設定忽略特定 breakpoint,不過可以透過不斷建立新的 anonymous function 並在其中塞 debugger 來解決(因為對 browser 來說,anonymous function 的 breakpoint 是全新、未見過的)。另一個方法則是直接忽略特定區段裡面的所有 debugger,有些 browser extension 可以作到這件事。

三、不斷清除 console

有些人 debug 的習慣是把訊息 output 到 console,或是可以透過在 console 執行一些 code 來改變變數數值,藉此分析程式行為。因此,如果不斷地用 console.clear 把 console 清空,會對分析者造成不小的困擾。

1
2
3
setInterval(function() {
    console.clear();
}, 100);

改變 Debugger 行為

四、覆蓋掉預設 function 的行為

JavaScript 有個令人又愛又恨的特性是,幾乎所有的內建 function 都可以被覆蓋。許多人 debug 時會仰賴 console.logalert,只要覆蓋掉某些 function,就有機會阻止分析者 debug,甚至適時地回報某個分析者可能正在嘗試 debug。

舉例來說,Spotify 之前有一段 code 就覆蓋掉 alert 預設的行為,因為不少 bug hunter 會用 alert來檢驗 XSS 有沒有成功,如此則就算 XSS 成功呼叫 alert,也只會偷偷地回傳參數,而不會真的噴出彈跳視窗。

1
2
3
4
5
6
7
8
// Wrapping funcs in a naive attempt to catch externally found XSS vulns
(function(fn) {
	window.alert = function() {
		var args = Array.prototype.slice.call(arguments);
		_doLog('alert', args);
		return fn.apply(window, args);
	};
}(window.alert));

除此之外,也有人會透過把 console.log 覆蓋成空函數來讓分析者沒辦法順利使用 console 來 debug。

偵測是否在 Debug

五、比較視窗寬度高度

DevTools 開啟會佔用螢幕空間,如果能偵測到高度差異,便有可能知道使用者是否有打開 DevTools。在 JavaScript,可以透過 innerHeight / innerWidthouterHeight / outerWidth 來取得網頁本身大小(inner)以及整個視窗的大小(outer),如果兩者相異過大,也許就代表 DevTools 正在開啟導致 innerHeight / innerWidth 縮小了。

1
2
3
4
5
6
setInterval(() => {
    if (outerWidth - innerWidth > threshold ||
    	outerHeight - innerHeight > threshold) {
    	// DevTools are open.
    }
}, 500);

這個方法的缺點有二:首先,並非每個會改變 innerHeight / innerWidth 的都是 DevTools,其他如書籤列之類的也都會改變之,這樣會導致 false positive;此外,如果在另一個視窗開啟 DevTools 便可以不改變 innerHeight / innerWidth,造成 false negative。

六、Log Custom Getter

這個方法比較像是一系列 anti-debugging 方法的分類而非特定的方法。當 DevTools 開啟時,可能會因為各種原因而讓 DevTools 去存取一些 object 的特定 method 或 attribute,透過在這些 method 或 attribute 上設 getter,就可以知道有沒有任何 code 去嘗試存取之。

舉例來說,在過去三年,只要 DevTools 開啟時,基於某些原因,DevTools 會去存取 object 的 id,所以可以在 id 上設定一個 getter, 如果發現有程式嘗試存取之,就知道 DevTools 被打開了。不過這方法目前失效了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// https://stackoverflow.com/a/30638226
let checkStatus;
let element = document.createElement('any');
element.__defineGetter__('id', function() {
    // DevTools are open.
});

setInterval(function() {
    // This function call would access `id` if the DevTools are open.
    console.log(element);
}, 1000);

另一個方法則是,console.log 的字串會一直 delay 到 console 被打開的那一刻才會被 render,所以如果去監視一個物件的 toString 有沒有被呼叫,就可以知道 DevTools 有沒有打開。不過這方法目前一樣是失效了。

1
2
3
4
5
let logme = function(){ };
logme.toString = function() {
  // DevTools are open.
}
console.log('\%c', logme);

可惜這些技巧所用的 bug 幾乎都已經被 patch 了。

上述兩個方法都屬於使用 DevTools 的基本性質來做偵測,這些方法通常很容易被繞過或是被 patch。下面三者則會嘗試使用時間的特性來偵測 Debugger 是否有被開啟。

七、監視已知 Breakpoint 執行時間

如同在第二點所說,如果 DevTools 有開啟,則遇到 debugger 時會進入 breakpoint,讓分析者使用 debugger 時感到很困擾。但除了造成困擾以外,我們也可以透過檢查 debugger 指令的執行時間來知道現在 DevTools 是否有被開啟。簡單來說,假設 DevTools 有開啟,執行時 breakpoint 會把 runtime 暫停,則重新開始後會發現過了一段時間,可是假設 DevTools 沒有開啟,則 breakpoint 被忽略,如此 debugger 將非常快地被略過。透過這個性質就可以知道 DevTools 有沒有開啟。

1
2
3
4
5
6
7
8
9
function measure() {
    const start = performance.now();
    debugger;
    const time = performance.now() - start;
    if (time > 100) {
        // DevTools are open.
    }
}
setInterval(measure, 1000);

這個方法雖然就跟第二點一樣,可以很輕易地透過關掉所有或特定區段的 breakpoint 來解決,然而我們在此的目的是偵測 DevTools 是否有開啟,換言之,就算分析者之後關掉 breakpoint,只要第一次有成功卡住分析者,我們就知道這個使用者會使用 debugger 了。

然而這個方法有個缺點是,它非常明顯。一個有經驗的分析者如果發現自己被預設的 breakpoint 卡住,可能就會懷疑他是否已經被發現了。

八、監視 setInterval 兩次執行時間間距

另一個方法則是監測 setInterval 所設定的 function 的執行間距,理想上兩次緊鄰的執行應該時間差距要非常接近於一開始設定的 interval,只要出現顯著變化,就可能是分析者在任何一個地方下了 breakpoint 以致 function 沒有準時執行。然而缺點就是,如果分析者完全沒用到 breakpoint,則這個方法將不會奏效。此外,如果使用者切換到其他 tab,JS 的計時器可能會變不準確,所以還要額外檢查目前是否在這個 tab 上(document.focus)。

1
2
3
4
5
6
7
8
9
let threshold = 350;
function measure() {
    const diff = performance.now() - timeSinceLast;
    if (document.hasFocus() && diff > threshold) {
        // DevTools are open.
    }
    timeSinceLast = performance.now();
}
setInterval(measure, 300);

九、灌爆 console

另一個有趣的 side-channel 是透過大量寫入各種資訊並衡量所需時間。

以前有一種方法是,不斷地建立新的 DOM element,並計算所需時間,如果所需時間太長,可能就是 DevTools 有開啟,因為當 DevTools 開啟時若 DOM tree 有改變則會需要在 DevTools 中標示出改變,這個動作很吃效能,會導致新增 DOM element 的速度變慢。然而這方法已經不能用了。

現在有另一個方法是,不斷寫資料到 console 並計算所需時間,如果所需時間過長,可能就是瀏覽器忙著把資料輸出到 DevTools,代表 DevTools 有開啟。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function measure() {
    const start = performance.now();
    for (let i = 0; i < 100; i++) {
        console.log(i);
        console.clear();
    }
    const time = performance.now() - start;
    if (time > threshold) {
        // DevTools are open.
    }
}
setInterval(measure, 1000);

這個方法的一個缺陷是,如果使用者電腦太慢,可能會導致 false positive 或是拖垮網頁效能。

後記

以上總共討論了九種 anti-debugging 的方法。方法與 code 主要出自 U Can’t Debug This: Detecting JavaScript Anti-Debugging Techniques in the Wild,除了少數幾個範例為了呈現品質而有調整。另外我也參考了這篇 Stackoverflow 問題。如果想對這議題有更多了解,例如該怎麼偵測 anti-debugging techniques、哪些方法被廣泛使用之類的,可以看看原始論文。