0x00 前言
Javascript 作為一種運行在客戶端的腳本語言,其源代碼對用戶來說是完全可見的。但不是每一個 js 開發者都希望自己的代碼能被直接閱讀,比如惡意軟件的制造者們。為了增加代碼分析的難度,混淆(obfuscate)工具被應用到了許多惡意軟件(如 0day 掛馬、跨站攻擊等)當中。分析人員為了掀開惡意軟件的面紗,首先就得對腳本進行反混淆(deobfuscate)處理。
本文將介紹一些常見的混淆手段和 estools 進行靜態代碼分析的入門。
0x01 常見混淆手段
加密
這類混淆的關鍵思想在於將需要執行的代碼進行一次編碼,在執行的時候還原出瀏覽器可執行的合法的腳本,然后執行之。看上去和可執行文件的加殼有那么點類似。Javascript 提供了將字符串當做代碼執行(evaluate)的能力,可以通過Function 構造器、eval、setTimeout、setInterval將字符串傳遞給 js 引擎進行解析執行。最常見的是base62 編碼——其最明顯的特征是生成的代碼以eval(function(p,a,c,k,e,r))
開頭。
無論代碼如何進行變形,其最終都要調用一次 eval 等函數。解密的方法不需要對其算法做任何分析,只需要簡單地找到這個最終的調用,改為 console.log
或者其他方式,將程序解碼后的結果按照字符串輸出即可。自動化的實現方式已經有許多文章介紹過,此處就不再贅述。
隱寫術
嚴格說這不能稱之為混淆,只是將 js 代碼隱藏到了特定的介質當中。如通過最低有效位(LSB)算法嵌入到圖片的 RGB 通道、隱藏在圖片 EXIF 元數據、隱藏在 HTML 空白字符等。
比如這個聳人聽聞的議題:[一張圖片黑掉你:在圖片中嵌入惡意程序],PPT放出來一看,正是使用了最低有效位平面算法。結合 HTML5 的 canvas 或者處理二進制數據的 TypeArray,腳本可以抽取出載體中隱藏的數據(如代碼)。
隱寫的方式同樣需要解碼程序和動態執行,所以破解的方式和前者相同,在瀏覽器上下文中劫持替換關鍵函數調用的行為,改為文本輸出即可得到載體中隱藏的代碼。
復雜化表達式
代碼混淆不一定會調用 eval,也可以通過在代碼中填充無效的指令來增加代碼復雜度,極大地降低可讀性。Javascript 中存在許多稱得上喪心病狂的特性,這些特性組合起來,可以把原本簡單的字面量(Literal)、成員訪問(MemberExpression)、函數調用(CallExpression)等代碼片段變得難以閱讀。
Js 中的字面量有字符串、數字、正則表達式
下面簡單舉一個例子。
-
訪問一個對象的成員有兩種方法——點運算符和下標運算符。調用 window 的 eval 方法,既可以寫成
window.eval()
,也可以window['eval']
; -
為了讓代碼更變態一些,混淆器選用第二種寫法,然后再在字符串字面量上做文章。先把字符串拆成幾個部分:
'e' + 'v' + 'al'
; -
這樣看上去還是很明顯,再利用一個數字進制轉換的技巧:
14..toString(15) + 31..toString(32) + 0xf1.toString(22)
; -
一不做二不休,把數字也展開:
(0b1110).toString(4<<2) + (' '.charCodeAt() - 1).toString(Math.log(0x100000000) / Math.log(2)) + 0xf1.toString(11 << 1)
; -
最后的效果:
window[(2*7).toString(4<<2) + (' '.charCodeAt() - 1).toString(Math.log(0x100000000) / Math.log(2)) + 0xf1.toString(11 << 1)]('alert(1)')
在 js 中可以找到許多這樣互逆的運算,通過使用隨機生成的方式將其組合使用,可以把簡單的表達式無限復雜化。
0x02 靜態分析實現
解析和變換代碼
本文對 Javascript 實現反混淆的思路是模擬執行代碼中可預測結果的部分,編寫一個簡單的腳本執行引擎,只執行符合某些預定規則的代碼塊,最后將計算結果替換掉原本冗長的代碼,實現表達式的簡化。
如果對腳本引擎解釋器的原理有初步了解的話,可以知道解釋器在為了“讀懂”代碼,會對源代碼進行詞法分析、語法分析,將代碼的字符串轉換為抽象語法樹(Abstract Syntax Tree, AST)的數據形式。
如這段代碼:
var a = 42; var b = 5; function addA(d) { return a + d; } var c = addA(2) + b;
對應的語法樹如圖:
(由 JointJS的在線工具生成)
不考慮 JIT 技術,解釋器可以從語法樹的根節點開始,使用深度優先遍歷整棵樹的所有節點,根據節點上分析出來的指令逐個執行,直到腳本結束返回結果。
通過 js 代碼生成抽象語法樹的工具很多,如壓縮器 UglifyJS 帶的 parser,還有本文使用的esprima。
esprima 提供的接口很簡單:
var ast = require('esprima').parse(code)
另外 Esprima 提供了一個在線工具,可以把任意(合法的)Javascript 代碼解析成為 AST 並輸出: http://esprima.org/demo/parse.html
再結合 estools 的幾個輔助庫即可對 js 進行靜態代碼分析:
-
escope Javascript 作用域分析工具
-
esutil 輔助函數庫,檢查語法樹節點是否滿足某些條件
-
estraverse語法樹遍歷輔助庫,接口有一點類似 SAX 方式解析 XML
-
esrecurse 另一個語法樹遍歷工具,使用遞歸
-
esquery 使用 css 選擇器的語法從語法樹中提取符合條件的節點
-
escodegen與 esprima 功能互逆,將語法樹還原為代碼
項目中使用的遍歷工具是 estraverse。其提供了兩個靜態方法,estraverse.traverse
和estraverse.replace
。前者單純遍歷 AST 的節點,通過返回值控制是否繼續遍歷到葉子節點;而 replace 方法則可以在遍歷的過程中直接修改 AST,實現代碼重構功能。具體的用法可以參考其官方文檔,或者本文附帶的示例代碼。
規則設計
從實際遇到的代碼入手。最近在研究一些 XSS 蠕蟲的時候遇到了類似如下代碼混淆:
觀察其代碼風格,發現這個混淆器做了這幾件事:
-
字符串字面量混淆:首先提取全部的字符串,在全局作用域創建一個字符串數組,同時轉義字符增大閱讀難度,然后將字符串出現的地方替換成為數組元素的引用
-
變量名混淆:不同於壓縮器的縮短命名,此處使用了下划線加數字的格式,變量之間區分度很低,相比單個字母更難以閱讀
-
成員運算符混淆:將點運算符替換為字符串下標形式,然后對字符串進行混淆
-
刪除多余的空白字符:減小文件體積,這是所有壓縮器都會做的事
經過搜索,這樣的代碼很有可能是通過 javascriptobfuscator.com的免費版生成的。其中免費版可以使用的三個選項(Encode Strings / Strings / Replace Names
)也印證了前面觀察到的現象。
這些變換中,變量名混淆是不可逆的。要是可以智能給變量命名的工具也不錯,比如這個 jsnice網站提供了一個在線工具,可以分析變量具體作用自動重命名。就算不能做到十全十美,實在不行就用人工的方式,使用 IDE(如 WebStorm)的代碼重構功能,結合代碼行為分析進行手工重命名還原。
再看字符串的處理。由於字符串將會被提取到一個全局的數組,在語法樹中可以觀察到這樣的特征: 在全局作用域下,出現一個 VariableDeclarator,其 init 屬性為 ArrayExpression,而且所有元素都是 Literal ——這說明這個數組所有元素都是常量。簡單地將其求值,與變量名(標識符)關聯起來。注意,此處為了簡化處理,並沒有考慮變量名作用域鏈的問題。在 js 中,作用域鏈上存在變量名的優先級,比如全局上的變量名是可以被局部變量重新定義的。如果混淆器再變態一點,在不同的作用域上使用相同的變量名,反混淆器又沒有處理作用域的情況,將會導致解出來的代碼出錯。
在測試程序中我設置了如下的替換規則:
-
全局變量聲明的字符串數組,在代碼中直接使用數字下標引用其值
-
結果確定的一連串二元運算,如
1 * 2 + 3 / 4 - 6 % 5
-
正則表達式字面量的 source,字符串字面量的 length
-
完全由字符串常量組成的數組,其
join / reverse / slice
等方法的返回值 -
字符串常量的
substr / charAt
等方法的返回值 -
decodeURIComponent 等全局函數,其所有參數為常量的,替換為其返回值
-
結果為常數的數學函數調用,如
Math.sin(3.14)
至於縮進的還原,這是 escodegen 自帶的功能。在調用 escodegen.generate
方法生成代碼的時候使用默認的配置(忽略第二個參數)即可。
DEMO 程序
這個反混淆器的原型放在 GitHub 上:https://github.com/ChiChou/etacsufbo
運行環境和使用方法參考倉庫的 README。
從 YOU MIGHT NOT NEED JQUERY上摘抄了一段代碼,放入 javascriptobfuscator.com 測試混淆:
將混淆結果https://github.com/ChiChou/etacsufbo/blob/master/tests/cases/jsobfuscator.com.js進行解開,結果如下:
雖然變量名可讀性依舊很差,但已經可以大體看出代碼的行為了。
演示程序目前存在大量局限性,只能算一個半自動的輔助工具,還有許多沒有實現的功能。
一些混淆器會對字符串字面量進行更復雜的保護,將字符串轉換為 f(x) 的形式,其中 f 函數為一個解密函數,參數 x 為密文的字符串。也有原地生成一個匿名函數,返回值為字符串的。這種方式通常使用的函數表達式具有上下文無關的特性——其返回值只與函數的輸入有關,與當前代碼所處的上下文(比如類的成員、DOM 中取到的值)無關。如以下代碼片段中的 xor 函數:
var xor = function(str, a, b) {
return String.fromCharCode.apply(null, str.split('').map(function(c, i) { var ascii = c.charCodeAt(0); return ascii ^ (i % 2 ? a : b); })); };
如何判斷某個函數是否具有這樣的特性呢?首先一些庫函數可以確定符合,如 btoa,escape,String.fromCharCode
等,只要輸入值是常量,返回值就是固定的。建立一個這樣的內置函數白名單,接着遍歷函數表達式的 AST,若該函數參與計算的參數均沒有來自外部上下文,且其所有 CallExpression 的 callee 在函數白名單內,那么通過遞歸的方式可以確認一個函數是否滿足條件。
還有的混淆器會給變量創建大量的引用實例,也就是給同一個對象使用了多個別名,閱讀起來非常具有干擾性。可以派出 escope 工具對變量標識符進行數據流分析,替換為所指向的正確值。還有利用數學的恆等式進行混淆的。如聲明一個變量 a,若 a 為 Number,則表達式 a-a
、a * 0
均恆為 0。但如果 a 滿足 isNaN(a)
,則表達式返回 NaN
。要清理這類代碼,同樣需要借助數據流分析的方法。
目前還沒有見到使用扁平化流程跳轉實現的 js 混淆樣本,筆者認為可能跟 js 語言本身的使用場景和特點有關。一般 js 的代都是偏業務型的,不會有太復雜的流程控制或者算法,混淆起來效果不一定理想。
0x03 結束語
Javascript 的確是一門神奇的語言,經常可以遇到一些讓人驚訝的奇技淫巧。解密保護過的代碼也是有趣的事情。據說幾大科技巨頭在醞釀給瀏覽器應用設計一款通用的字節碼標准——WebAssembly。一旦這個設想得以實現,代碼保護將可以引入真正意義上的“加殼”或者虛擬機保護,對抗技術又將提升到一個新的台階。
演示項目代碼托管在 GitHub:https://github.com/ChiChou/etacsufbo
文章轉載自 http://drops.wooyun.org/tips/7713?utm_source=tuicool