JavaScript 反混淆的一般套路和技巧[起][承][轉][結]

https://www.blackglory.me/javascript-deobfuscate-general-routines-and-tips-chapter-1/

最近發現網上沒有什麼專門深入去講解JavaScript反混淆的文章, 能找到的, 基本都是針對於某一種加密方式的簡單解密方法, 雖然能夠解決一時的問題, 但從學習和研究的角度去看, 並沒有太多價值. 加之近日從他人手中接手了一個算是有些棘手的反混淆單子, 感覺有所收穫, 遂將自己在反混淆方面的一些理解和方案, 做一下記錄, 於是便有了此文.

這篇文章假設你是一位使用JavaScript的中級以上水平的前端工程師, 對於一些相對基礎的內容, 將不做討論和額外講解. 原先打算只寫一篇文章, 但考慮到內容較多, 沒有一次性寫完的精力, 請原諒我分成多篇文章來寫.

在開始動手之前, 你需要知道…

作爲一位熟練使用JavaScript的編程人員, 你應該已經非常明白, JavaScript作爲一個以函數式爲核心的多範式動態弱類型腳本語言, 它的靈活性太強大了, 這直接導致了源代碼在經過一些壓縮工具的蹂躪之後, 變得極難還原. 所以, 你幾乎不可能把代碼還原到跟原始代碼一致, 這是事實, 你能還原出來的, 通常只是和原本的代碼運行流程一致的另一份代碼.

不過, 也確實存在可以直接還原的代碼, 比如這個:

eval(function(p,a,c,k,e,r){e=String;if(!''.replace(/^/,String)){while(c--)r1=k1||c;k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k1)p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k1);return p}('0.1("2 3!")',4,4,'console|log|Hello|World'.split('|'),0,{}))

我想告訴你的是, 這段代碼的混淆太弱, 只做了單純的加密, 所以並不能歸類到需要認真去反混淆的代碼範疇之中. 對於類似Jspacker這樣的加密手段, 解密方法在網上一搜有一大把, 由於它過於簡單和流行, 以至於不少代碼格式化工具都自帶了Jspacker的解密功能. 這種程度的加密, 基本只在表面上做了功夫, 防君子不防小人. 當然, 你也可以把加密後的代碼再加密一次, JavaScript的強大靈活性允許你這麼做, 但和加密一次相比, 效果也差不多, 因爲加密太簡單, 而原始代碼的可讀性又太強.

還有一類混淆器, 在降低代碼可讀性上確實下了不少功夫, 比如把變量名設置成i, 1, l, I,|或者O,o,0這樣的相似字符, 把加密後的代碼拆分後再次加密等等, 以前寫過的《l1l=document.all 特徵 JS 混淆器反混淆流程詳解》就是這種混淆, 比較可惜的是, 那篇文章裏的例子在執行後會將原始代碼直接作爲script掛在document上, 去進行反混淆的唯一作用就是看看解密過程中除了把script掛到document上之外, 還有沒有做其他的事, 付出與回報的價值有很大可能性不符, 性價比太低了.

讀到這裏你大概也猜到了, 在進行反混淆時, 你需要了解和明確的是: 什麼是有價值去反混淆的代碼? 怎樣最小化你因爲反混淆在時間上的損失? 而這些, 就是我接下來要告訴你的.

什麼是有價值去反混淆的代碼?

爲什麼要反混淆這段代碼? 在面對一大堆看都不想看一眼的數字字母符號組合出來的文本之前, 你必須問自己這個問題. 一般來說, 只有利益驅使這麼一種原因, 這裏的利益並不一定是金錢, 如果你在看到”利益”二字時只能想到金錢, 或許你該慎重考慮一下是否應該繼續在這行幹下去, 這裏的利益可以是爲了滿足自己的好奇心一窺原理, 爲了使用其中的某一段代碼, 或是爲了取證, 當然, 純粹的金錢交易也是一種情況. 如果是最後一種情況, 那麼你必須權衡好付出和回報, 也就是反混淆的性價比, 因爲其他非金錢驅動的利益作爲動機時, 多半情況下你是非幹不可的, 而涉及到金錢時, 你是有權利不去幹這種髒活的, 何況, 你也知道, 任何涉及到代碼的事情, 預估需要耗費的時間, 是比較困難的. 作爲程序員, 請儘量偷懶和拒絕去做你不感興趣的事.

怎樣最小化你因爲反混淆在時間上的損失?

答案其實很簡單, 只要充分的利用工具就行了. 工具永遠是你最好的幫手, 如果有現成的工具, 請拿來用. 在存在現成的反混淆方案/工具的時候, 不拿來用而是嘗試自己去人工進行反混淆, 在學習以外的目的上這麼做絕對是自殺行爲.

而在人工進行反混淆時, 你必然會用到的工具有2個: 一個符合你習慣的代碼格式化工具和一個稱手的代碼編輯器.

在一般的反混淆流程中, 格式化代碼總是最先要做的事, 代碼格式化工具可以很快的幫你還原代碼的格式, 這樣你就可以比較輕鬆的看出代碼的結構, 不過一般用完之後就沒它什麼事了, 剩下的就看你的技術和編輯器是否強力了.

代碼編輯器則至少應該具有代碼高亮和替換查找功能, 代碼高亮應能正確的區分關鍵字和符號, 替換查找功能則必須能區分單詞和大小寫, 或是正則表達式.

除了格式化工具和編輯器, 你還可能用到類似js2coffee的代碼轉化工具以及類似CoffeeScript的JavaScript預編譯器, 是否會用到這些工具, 取決於你的知識儲備量和需要反混淆的代碼的複雜程度.

至此, 你在反混淆上的準備應該完全了, 在下一篇文章《JavaScript 反混淆的一般套路和技巧: 承》中, 將討論一些常見的混淆方式和反混淆的方法, 以及怎樣組織各個流程的順序.

https://www.blackglory.me/javascript-deobfuscate-general-routines-and-tips-chapter-2/

反混淆的本質, 是提升代碼的可讀性, 反混淆的過程中, 你所做的大多數事情, 都是在保證代碼運行結果不變的情況下, 提升代碼的可讀性, 人工進行反混淆這一行爲本身, 就是對自己閱讀代碼能力的一種鍛鍊.

本篇文章將討論一些常見的混淆方式和反混淆的方法, 以及怎樣組織各個流程的順序, 爲了方便敘述, 我們以反混淆的幾個主要步驟來展開講解:

  1. 解密被加密的代碼, 將代碼結構儘可能還原至最接近原始代碼的狀態.
  2. 去除可能存在的最外尾的IIFE的參數, 將參數轉化爲函數頂部的變量定義.
  3. 將只使用一次的變量轉化爲值或表達式.
  4. 計算所有可直接計算的表達式.
  5. 還原判斷語句的短路邏輯簡寫.
  6. 還原變量類型轉換簡寫.
  7. 去除專門用於妨礙閱讀的代碼.
  8. 基於主函數的運行順序整理.
  9. 猜解變量名和函數名.

解密被加密的代碼, 將代碼結構儘可能還原至最接近原始代碼的狀態

對於一些加密過多次的混淆代碼, 必須先將代碼解密, 由於JavaScript是腳本語言, 腳本解釋器最終執行代碼的效果必然和原始代碼的運行效果一致, 所以理論上不存在無法解密的代碼.

大部分加密工具最後都會通過eval函數執行被加密的代碼, 很多時候我們只要把eval換成輸出用的函數就可以得到加密前的代碼.

少部分加密工具會將原始代碼拆分後分別加密, 這時可能需要先對加密後的代碼進行反混淆, 找出解密函數所在的位置, 逐一得到加密前的代碼, 人工進行拼接.

去除可能存在的最外圍的IIFE的參數, 將參數轉化爲函數頂部的變量定義

IIFE的傳參位置在整段代碼的尾部, 人類的正常閱讀習慣是從上至下的閱讀, 如果保留IIFE的參數, 會影響我們對代碼的理解, 絕大多數情況下, 我們都應該去除IIFE的參數, 將參數轉變爲變量定義.

反混淆前:

(function(a){
...
})('Hello World!')

反混淆後:

(function(){
    var a = 'Hello World!'
    ...
})()

將只使用一次的變量轉化爲值或表達式

有一些變量在代碼中只被使用到了一次, 把這些變量直接用變量的值或表達式替代, 會有助於提升代碼的可讀性.

反混淆前:

var a = 'Hello World',
    b = a.split(' ')

反混淆後:

var b = 'Hello World'.split(' ')

需要注意的是, 一些變量定義被寫在代碼中的位置可能與代碼在運行時的時間有關, 這類變量不應該簡單的被去除, 在還未知曉具體用處的時候, 應當保留.

例如:

var a = new Date

計算所有可直接計算的表達式

對於可直接計算出結果的表達式, 建議只保留代碼運行的產生的結果.

反混淆前:

'Hello World!'.split(' ')

反混淆後:

['Hello', 'World']

這個例子的轉換是可能存在風險的, 只有在測試了split函數沒有被修改的情況下, 才能進行化簡, 一定要做足測試.

另外, 使用new RegExp(pattern, attributes)創建的正則表達式, 也應該被簡寫成直接量/pattern/attributes, new Array和new Object也需要視具體情況進行簡寫.

還原判斷語句的短路邏輯簡寫

JavaScript的判斷語句可以用短路邏輯簡寫, 簡寫後的代碼在一定程度上降低了代碼的可讀性, 通常我們會將其還原.

反混淆前:

a === 'Hello World!' && b()

反混淆後:

if(a === 'Hello World!'){
  d()
}

還原變量類型轉換簡寫

JavaScript的變量類型轉換也可以被簡寫, 部分簡寫在JavaScript中被視爲陷阱, 所以儘量將簡寫還原成等價代碼或不影響運行效果的近似代碼.

反混淆前:

function a(){
    return '0x5f3759df'
}
var b = +a()

反混淆後:

function a(){
    return '0x5f3759df'
}
var b = Number(a())

去除專門用於妨礙閱讀的代碼

一些混淆器會往表達式中添加一些無關緊要的表達式或變量賦值, 例如:

var a = Math.random(),
    b = Math.round(a * 9),
    c = ''.split()[0],
    d = [1, 1, 1, 1, 1, 1, 1, 1, 1 ,1][b > 9 ? c : b]

當你發現一些數量或調用方式明顯異常的代碼, 就要好好檢查一下是否是例子中的這種情況了, 類似的情況在反混淆中可能會遇到很多, 有時你甚至得重寫函數.

流程順序的安排

除”解密被加密的代碼, 將代碼結構儘可能還原至最接近原始代碼的狀態”、”猜解變量名和函數名”, 其他流程是可以交叉進行的, 但個人建議每次只處理1~2種情況, 以免發生混亂. 優先級方面, 先解決數量最多的混淆, 再解決最影響可讀性的混淆, 其他的依次處理即可.

在反混淆過程中, 務必頻繁進行測試, 以保證代碼執行效果與混淆代碼一致, 越早發現問題, 也越容易解決問題.

由於篇幅所限, “基於主函數的運行順序整理”和”猜解變量名和函數名”將放在下一章《JavaScript 反混淆的一般套路和技巧: 轉》中講解.

https://www.blackglory.me/javascript-deobfuscate-general-routines-and-tips-chapter-3/

把沒用的多餘的簡寫的代碼處理完後, 代碼就差不多有個基本的樣子了, 不過現在還沒到休息的時候. 由於函數錯綜複雜、變量名錶達不出語義等原因, 我們的代碼雖然可讀性較之前已經得到了提高, 但想要完整理解代碼的意圖, 在當前的情況下仍然是一件麻煩的事.

爲了理解代碼, 通常我們要做兩件事, 一件是“基於主函數的運行順序整理”, 另一件是” 猜解變量名和函數名 ” , 之後, 代碼在可讀性上就能更加接近原始代碼的級別了.

基於主函數的運行順序整理

除非你在反混淆的代碼是一個庫或者模塊, 否則每一段獨立的程序, 都會有一個主體的部分, 反混淆到達這一步, 你需要找出整個代碼的主函數.

主函數可能是整段代碼中最長的那段代碼, 也可能是整段代碼最尾部的那一段代碼, 或者, 整段代碼就是被安排好了順序執行的, 那麼整段代碼就是主函數.

找到主函數之後, 再繼續尋找那些代碼中只被調用過一次的函數, 如果存在這些函數, 直接把這些函數的代碼抽出來, 合併進主函數裏, 當然, 你還需要確定和管理好變量各自的作用域, 這並不是件易事, 但如果你這麼做了, 代碼可能會產生像從異步到同步那樣在可讀性方面的神奇轉變.

再接下來, 你需要調整函數和變量聲明的位置, 將它們分類, 比如把變量聲明全部放在函數頭部之類的, 而函數都應該被放在它所處層的最頂部, 如果能把代碼像ES5定義的嚴格模式那樣格式化好, 可讀性也會得到上升, 起碼這個時候, 你不需要藉助IDE之類的工具把各種語法上的定義通過列表之類的控件特別抽離出來就能找到它們所應處的位置, 不過這也是非常難做的一件事.

事實上, 在ES6和ES7裏, JavaScript被賦予了很多新的特性, 讓這門本來就顯得有些離經叛道的語言, 變得更加酷炫也更加難以琢磨, 不過我沒反混淆過ES5以上的JavaScript代碼, 所以關於ES6和之後的標準在反混淆時應當如何處理運行順序, 我是不知道的, 如果有這個機會, 我會寫一篇續篇補上關於ES6和ES7的部分.

猜解變量名和函數名

這可能是整個反混淆中最壓抑的部分, 每一個程序員都怕用a, b, c…或者apple, banana, pear…作爲變量名和函數名的程序員, 而大多數壓縮工具, 都是這樣的程序員.

在這裏, 我們先從規模最小的函數開始入手, 像那些只有幾行的函數, 是最容易搞明白具體意思的, 當你看懂它的意圖之後, 請根據你所熟悉的命名規範來爲其命名, 不過無論是哪種變量命名方式, 我都建議在變量名上標上它指向的數據的類型(如果一個變量指向過多種類型, 建議創建一個新的變量來表示這種新的類型). 如果變量名起了衝突或實在無法決定該如何命名時, 可以考慮在變量名或類型名後加上數字序號的命名方法, 因爲即便這樣命名, 也比用a, b, c來得好.

規模最小的函數搞定之後, 繼續猜解調用這些函數的其他函數, 逐層向上, 最終就能猜解完大多數的代碼.

另外, 如果函數內部出現了有關瀏覽器環境的內容, 比如XHR和Cookie之類的內容, 你完全有理由相信並大膽猜測這個函數與這些內容有關, 以此作爲假設繼續猜解其他內容, 將可能得到效率上的提升. 多利用編輯器的查找和替換功能, 也可以幫助減少疏漏.

至此, 代碼的反混淆已經大致完成, 在下一章, 也是最後一章《JavaScript 反混淆的一般套路和技巧: 結》中, 將說明一些反混淆過程中的其他注意事項.

https://www.blackglory.me/javascript-deobfuscate-general-routines-and-tips-chapter-4/

本來《JavaScript 反混淆的一般套路和技巧》是打算作爲一篇單獨的長文來寫的, 後來硬生生被我拆成了《起》、《承》、《轉》、《結》四章, 在《轉》裏面整個反混淆的事情其實已經被我們解決了, 結果就是這作爲最後一篇的《結》, 變成了多餘的一章. 這章該寫些什麼讓我想了很久, 最後決定還是想到什麼寫什麼.

下面是我在寫這篇文章時暫時能夠想到的一些反混淆中的注意事項.

避開陷阱和可能利用陷阱實現的混淆

由於JavaScript和ES在歷史上遺留的問題很多, 運行環境複雜, 所以存在不少的陷阱. 一旦遇到可能是陷阱的代碼, 請立即運行查看結果, 否則到最後發現出現了什麼問題, 由於調試困難, 半天都找不到問題在哪, 會白白浪費很多時間.

比如==在JavaScript中很不可靠, 在確定修改爲===後執行效果不變的情況下, 建議將==全部修改爲===. 發現和解決陷阱的另一個好處是, 能幫助自己養成良好的JavaScript編碼習慣.

事實上我印象中有一個絕大多數JavaScript程序員都會犯錯的陷阱, 可惜一時半會想不起來了(也忘了記錄), 不然拿出來當例子真是極好的.

混淆代碼中混有瀏覽器兼容代碼

這是反混淆中很容易遇到的情況, 個人的建議是直接刪掉與你測試用的環境無關的兼容代碼, 如有需要, 在反混淆的最後將兼容代碼自己加回來. 不過, 在反混淆結束後把兼容代碼加回來並非易事, 而且必定會造成可讀性的下降.

建議直接刪除的理由很簡單, 因爲有不少古老的瀏覽器兼容代碼採用的是旁敲側擊式的判斷方法, 在沒有註釋的情況下很難看出它究竟兼容的是哪種瀏覽器的哪個版本, 還有一些兼容代碼寫得就像巫師的巫術一樣, 你很難分清這究竟是混淆代碼還是兼容用的代碼, 所以只保留你測試環境需要的代碼即可(建議的測試環境是V8引擎和Webkit引擎, 也就是Chrome). 反混淆的過程中你應當只關注測試環境與代碼的執行效果本身, 而不應該被兼容代碼所拖累.

代碼可讀性太差的情況

在這種情況下你可以考慮用js2cofee這樣的工具, 將原JavaScript代碼轉換爲你所熟悉的其他語法乾淨的語言, 比如CoffeeScript(前提是你對這門語言足夠熟悉, 知道它存在的語法陷阱, 而且轉換後不會比原來可讀性更差, 並且這門語言可以將代碼轉換爲普通的JavaScript). 通常在去除了大量堪稱糟粕的C類語言關鍵字和符號之後, 代碼的可讀性會大幅上升.

如果你把代碼轉換成了CoffeeScript, 那麼在猜解變量名時會特別難受, 因爲CoffeeScript的變量作用域和JavaScript使用的都是詞法作用域, 而CoffeeScript裏是無法像JavaScript一樣用var關鍵字來聲明一個變量的, 倘若代碼裏存在重複的變量名, 你需要小心翼翼的把不同作用域的變量名區別命名, 以免在編譯後混用同一個作用域的變量.

各個步驟的代碼保存

你可以使用版本控制系統來保存代碼, 也可以手工複製代碼文件來保存各個版本, 不過無論你用的是哪種方法, 我都建議你備一個文本比較工具或者帶有比較功能的編輯器.

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