關注微信公眾號:K哥爬蟲,持續分享爬蟲進階、JS/安卓逆向等技術乾貨!
宣告
本文章中所有內容僅供學習交流,抓包內容、敏感網址、資料介面均已做脫敏處理,嚴禁用於商業用途和非法用途,否則由此產生的一切後果均與作者無關,若有侵權,請在公眾號聯絡我立即刪除!
逆向目標
- 目標:站 Z 之家網站 ICP 備案號查詢
- 主頁:
aHR0cDovL2ljcC5jaGluYXouY29tLw==
- 介面:
aHR0cDovL2ljcC5jaGluYXouY29tL2hvbWUvR2V0UGVyaW1pdEJ5SG9zdA==
- 逆向引數:
hostToken
、permitToken
本次主要是 AST 解混淆實戰,本例中的 JS 混淆方式是 sojson 旗下的 jsjiami v6 版本,感興趣的可以去官網體驗一下:https://www.jsjiami.com/ ,如果你還不瞭解 AST,可以先看看 K 哥上期的文章(非常詳細):《逆向進階,利用 AST 技術還原 JavaScript 混淆程式碼》,本文部分 AST 還原始碼直接使用了上期文章中的程式碼,所以細節方面不再贅述,有疑問的地方可以參考參考上期文章。
第三方工具
逆向領域大佬雲集,市面上已經有很多大佬寫好的解混淆工具了,除了我們自己手動去寫 AST 解析程式碼以外,有時候直接使用工具會更加方便,當然並沒有十全十美的工具,不過大部分情況下都能成功解混淆的,以下工具值得去體驗一下:
- 蔡老闆一鍵還原 OB 混淆:https://github.com/Tsaiboss/d...
- 哲哥 AST 混淆還原框架:https://github.com/sml2h3/ast...
- V 神 Chrome 外掛,內建 AST 混淆還原:https://github.com/cilame/v_j...
- jsjiami v6 專用解密工具:https://github.com/NXY666/Jsj...
抓包分析
進入主題,首先抓包看看,來到 ICP 備案查詢頁面,查詢結果中,其他資訊都可以直接在相應的 html 原始碼中找到,只有這個備案號是通過介面傳過來的,對應的請求和相關加密引數如下圖所示:
加密定位
直接搜尋關鍵字 hostToken
或者 permitToken
即可定位:
關鍵程式碼:
'data': {
'kw': kw,
'hostToken': _0x791532['IIPmq'](generateHostKey, kw),
'permitToken': _0x791532[_0x404f('1df', '7Gn4')](generateWordKey, kw)
}
這裡的混淆可以手動跟一下,還原後如下:
'data': {
'kw': kw,
'hostToken': generateHostKey(kw),
'permitToken': generateWordKey(kw)
}
kw
是查詢的域名,有用的就是 generateHostKey()
和 generateWordKey()
兩個方法了,跟進去看,程式碼經過了 jsjiami v6 混淆:
AST 脫混淆
jsjiami 混淆的特徵其實和 OB 混淆是類似的:
- 一般由一個大陣列或者含有大陣列的函式、一個陣列位移操作的自執行函式、一個解密函式和加密後的函式四部分組成;
- 函式名和變數名通常以 _0x 或者 0x 開頭,後接 1~6 位數字或字母組合;
- 陣列位移操作的自執行函式裡,有明顯的 push、shift 關鍵字。
本例中,generateHostKey()
方法在 commo.js
裡,generateWordKey()
方法在 generatetoken.js
裡,結構如下圖所示:
觀察 generatetoken.js
檔案,可以發現這裡面也有 commo.js
裡面的 generateHostKey()
和 getRandom()
方法,從方法名來看貌似是重複了,實際上混淆還原後方法是一樣的,所以這裡我們只需要還原 generatetoken.js
就可以了。
檔案結構
- 混淆 JS 檔案:
generatetoken.js
- AST 還原始碼:
generatetokenAst.js
- 還原後的程式碼:
generatetokenNew.js
解密函式還原
在原來混淆後的 JS 裡,解密函式是 _0x530e
,首先觀察整個 JS,呼叫了很多次解密函式,類似於:_0x530e('1', '7XEq')
。
注意這裡程式碼裡面有一些特殊字元,類似於 RLE
、RLO
之類的,如果在 VSCode 開啟是一些 U+202B
、U+202E
的字元,實際上這是 RTLO (Right-to-Left Override) 字元,U+202B
和 U+202E
的意思分別是根據記憶體順序從左至右和從右至左顯示字元,感興趣的可以網上搜尋瞭解一下。這裡並不影響我們進行還原操作。但是如果直接複製過來的話就會導致前後文顯示的順序不對,所以本文中為了方便描述,貼上的部分程式碼就手動去掉了這些字元。
所以第一步我們要還原一下解密函式,把所有 _0x530e
呼叫的地方直接替換成實際值,首先需要將大陣列、自執行函式、加密函式和解密函式分割開,將程式碼放到 astexplorer.net 看一下,也就是將 body 的前四部分和後面剩餘部分分割開來,如下圖所示:
分割程式碼:
const fs = require("fs");
const parse = require("@babel/parser").parse;
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const types = require("@babel/types")
// 匯入混淆程式碼並解析為 AST
const oldCode = fs.readFileSync("generatetoken.js", {encoding: "utf-8"});
const astCode = parse(oldCode);
// 獲取整個 AST 節點的長度
let astCodeLength = astCode.program.body.length
// 獲取解密函式的名字 也就是 _0x530e
let decryptFunctionName = astCode.program.body[3].id.name
// 分割加密函式和解密函式,即 body 的前四部分和後面剩餘部分
let decryptFunction = astCode.program.body.slice(0, 4)
let encryptFunction = astCode.program.body.slice(4, astCodeLength)
// 獲取加密函式和解密函式的方法多種多樣,比如可以挨個取值並轉換成 JS 程式碼
// 這樣做就不需要將解密函式賦值給整個 AST 節點了
// let decryptFunction = "";
// for(let i=0; i<4; i++){
// decryptFunction += generate(astCode.program.body[i], {compact: true}).code
// }
// eval(decryptFunction);
在上面的獲取加密函式和解密函式的程式碼中,方法不是唯一的,多種多樣,比如直接迴圈取 body 並轉換成 JS 程式碼,比如直接人工把大陣列、自執行函式和解密函式三部分,拿出來放到一個新檔案裡,然後匯出解密方法,後續直接呼叫也可以。
在本例中,拿到解密函式後,需要將其賦值給整個 AST 節點,然後再將整個 AST 節點轉換成 JavaScript 程式碼,這裡注意有可能會檢測程式碼是否格式化,所以建議轉換要加一個 compact
引數,避免格式化,轉換完成後 eval
執行一下,讓陣列位移操作完成,然後我們就可以直接呼叫解密函式,即 _0x530e()
。
// 將解密函式賦值給整個 AST 節點
astCode.program.body = decryptFunction
// 將 AST 節點轉換成 JS 程式碼,並 eval 執行一下
decryptFunction = generate(astCode, {compact: true}).code
eval(decryptFunction);
// 測試一下,直接呼叫 _0x530e 函式可以正確拿到結果
// 輸出 split
// console.log(_0x530e('b', 'Zp9G'))
現在我們能直接呼叫解密函式 _0x530e()
了,接下來要做的就是怎麼把混淆程式碼中所有呼叫 _0x530e()
的地方替換成真實值,在此之前,我們要把加密函式(generateKey()
、generateHostKey()
、generateWordKey()
和 getRandom()
)賦值給整個 AST 節點,此時整個節點就沒有大陣列、自執行函式和解密函式了,解密函式 _0x530e()
已經被寫入記憶體,所以後面不影響我們呼叫。
老樣子,還是先在 astexplorer.net 看一下呼叫 _0x530e()
的地方,以 _0x530e('b', 'Zp9G')
為例,其真實值應該是 split
,對比一下替換前後的結構,如下圖所示:
可以看到節點由原來的 CallExpression
變成了 StringLiteral
,所以我們可以遍歷 CallExpression
,如果函式名為解密函式名,那就通過 path.toString()
方法獲取節點原始碼,也就類似 _0x530e('b', 'Zp9G')
的原始碼,然後 eval
執行一下獲取其真實值,再使用 types.stringLiteral()
構建 StringLiteral
節點,最後通過 path.replaceInline()
方法替換節點,遍歷程式碼如下:
// 將加密函式賦值給整個 AST 節點,此時整個節點就沒有大陣列、自執行函式和解密函式了
astCode.program.body = encryptFunction
// 呼叫解密函式,直接計算出類似以下方法的值並替換
// 混淆程式碼:_0x530e('b', 'Zp9G')
// 還原後:split
const visitor1 = {
CallExpression(path){
if (path.node.callee.name === decryptFunctionName && path.node.arguments.length === 2){
path.replaceInline(types.stringLiteral(eval(path.toString())))
}
}
}
// 遍歷節點
traverse(astCode, visitor1)
// 將 AST 節點轉換成 JS 程式碼並寫入到新檔案裡
const result = generate(astCode, {concise:true}).code
fs.writeFile("./generatetokenNew.js", result, (err => {console.log(err)}))
自此,第一步的解密函式還原就完成了,可以看一下還原前後的對比,如下圖所示淺藍色標記的地方,所有呼叫 _0x530e()
的地方都被還原了:
大物件還原
初步還原後我們的程式碼裡就只剩下以下四個方法:
generateKey()
generateHostKey()
generateWordKey()
getRandom()
再觀察程式碼,發現每個方法一開始都有個大的物件,他們分別是:
_0x3b79c6
_0x278b2d
_0x4115c4
_0xd8ec33
後續的程式碼也在不斷呼叫這個物件的方法,比如 _0x3b79c6["esdtg"](_0x2e5848["length"], 0x4)
實際上就是 _0x2e5848["length"] != 0x4
,如下圖所示:
首先我們將這四個大的物件單獨提取出來,還是保持原來的鍵值對樣式,提取完成後刪除這兩個節點,遍歷程式碼如下:
let functionName = {
"_0x3b79c6": {},
"_0x278b2d": {},
"_0x4115c4": {},
"_0xd8ec33": {}
}
// 單獨提取出四個大物件
const visitor2 = {
VariableDeclarator(path){
for (let key in functionName){
if (path.node && path.node.id.name == key) {
const properties = path.node.init.properties
for (let i=0; i<properties.length; i++){
functionName[key][properties[i].key.value] = properties[i].value
}
// 寫入物件後就可以刪除該節點了
path.remove()
}
}
}
}
這裡要注意,大的物件裡面,有 +
、-
、==
之類的二項式計算,也有直接為字串的,還有變成函式呼叫的,如下所示:
var _0x3b79c6 = {
'MuRlB': function (_0x3ca134, _0x50ee94) {
return _0x3ca134 + _0x50ee94;
},
'Ucwyj': function (_0x32bfa3, _0x3b191b) {
return _0x32bfa3(_0x3b191b);
},
'YrYQW': '#IpValue'
}
針對不同的情況有不同的處理方法,同時還要注意傳參和 return 返回的引數位置,不要還原後把 a - b
搞成 b - a
了,當然在本例中傳入和返回的順序是一樣的,就不需要考慮這個問題。
字串還原
首先來看字串,有以下幾種情況:
- 以
_0x3b79c6['YrYQW']
為例,實際上其值為字串'#IpValue'
,觀察其結構,是一個MemberExpression
,在一個列表裡; - 以
_0x278b2d['pjbyX']
為例,實際上其值為字串'3|2|1|4|5|0|6'
,觀察其結構,是一個MemberExpression
,在一個字典裡; - 以
_0x278b2d['CnTaO']
為例,雖然也是一個MemberExpression
,也在一個字典裡。但實際上是二項式計算,所以要排除在外。
所以我們在寫遍歷程式碼時,同時要注意這三種情況,滿足條件後直接取原來大物件對應的節點進行替換即可,遍歷程式碼如下所示:
// 函式替換,字串替換:將類似 _0x3b79c6['YrYQW'] 變成 '#IpValue'
const visitor3 = {
MemberExpression(path) {
for (let key in functionName){
if (path.node.object && path.node.object.name == key && path.inList ) {
path.replaceInline(functionName[key][path.node.property.value])
}
if (path.node.object && path.node.object.name == key && path.parent.property && path.parent.property.value == "split") {
path.replaceInline(functionName[key][path.node.property.value])
}
}
}
}
二項式計算替換
再來看看二項式計算的情況,以 _0x278b2d['CnTaO'](_0x691267["length"], 0x1)
為例,實際上是做減法運算,即 _0x691267["length"] - 0x1
,看一下替換前後對比:
對於這種情況,我們可以直接提取兩個引數,然後提取大物件裡對應方法的操作符,然後將引數和操作符直接連線起來組成新的節點(binaryExpression
)並替換即可,遍歷程式碼如下:
// 函式替換,二項式計算:將類似 _0x278b2d['CnTaO'](_0x691267["length"], 0x1) 變成 _0x691267["length"] - 0x1
const visitor4 = {
CallExpression(path){
for (let key in functionName) {
if (path.node.callee && path.node.callee.object && path.node.callee.object.name == key) {
let func = functionName[key][path.node.callee.property.value]
if (func.body.body[0].argument.type == "BinaryExpression") {
let operator = func.body.body[0].argument.operator
let left = path.node.arguments[0]
let right = path.node.arguments[1]
path.replaceInline(types.binaryExpression(operator, left, right))
}
}
}
}
}
方法呼叫還原
以 _0x4115c4["PJbSm"](getRandom, 0x64, 0x3e7)
為例,實際上是 getRandom(0x64, 0x3e7)
,看一下替換前後對比:
對於這種情況,傳入的第一個引數為方法名稱,後面的都是引數,那麼可以直接取第一個元素為方法名稱,使用 slice(1)
方法取後面所有的引數(因為後面的引數個數是不一定的),然後構造新的節點(callExpression
)並替換即可,這部分遍歷程式碼可以和前面二項式的替換相結合,程式碼如下:
// 函式替換,二項式計算:將類似 _0x278b2d['CnTaO'](_0x691267["length"], 0x1) 變成 _0x691267["length"] - 0x1
// 函式替換,方法呼叫:將類似 _0x4115c4["PJbSm"](getRandom, 0x64, 0x3e7) 變成 getRandom(0x64, 0x3e7)
const visitor4 = {
CallExpression(path){
for (let key in functionName) {
if (path.node.callee && path.node.callee.object && path.node.callee.object.name == key) {
let func = functionName[key][path.node.callee.property.value]
if (func.body.body[0].argument.type == "BinaryExpression") {
let operator = func.body.body[0].argument.operator
let left = path.node.arguments[0]
let right = path.node.arguments[1]
path.replaceInline(types.binaryExpression(operator, left, right))
}
if (func.body.body[0].argument.type == "CallExpression") {
let identifier = path.node.arguments[0]
let arguments = path.node.arguments.slice(1)
path.replaceInline(types.callExpression(identifier, arguments))
}
}
}
}
}
自此,第二步的大物件還原就完成了,可以看一下還原前後的對比,如下圖所示淺藍色標記的地方,所有呼叫四個大物件(_0x3b79c6
、_0x278b2d
、_0x4115c4
、_0xd8ec33
)的地方都被還原了:
switch-case 反控制流平坦化
經過前面幾步的還原之後,我們發現 generateHostKey()
、generateWordKey()
、getRandom()
方法裡都有一個 switch-case
的控制流,關於反控制流平坦化的講解在我上期文章有很詳細的介紹,不理解的可以看看上期文章,此處也不再贅述了,直接貼程式碼了:
// switch-case 反控制流平坦化
const visitor5 = {
WhileStatement(path) {
// switch 節點
let switchNode = path.node.body.body[0];
// switch 語句內的控制流陣列名,本例中是 _0x28073a、_0x2efb35、_0x187fb8
let arrayName = switchNode.discriminant.object.name;
// 獲取控制流陣列繫結的節點
let bindingArray = path.scope.getBinding(arrayName);
// 獲取節點整個表示式的引數、分割方法、分隔符
let init = bindingArray.path.node.init;
let object = init.callee.object.value;
let property = init.callee.property.value;
let argument = init.arguments[0].value;
// 模擬執行 '3|2|1|4|5|0|6'['split']('|') 語句
let array = object[property](argument)
// 也可以直接取引數進行分割,方法不通用,比如分隔符換成 , 就不行了
// let array = init.callee.object.value.split('|');
// switch 語句內的控制流自增變數名,本例中是 _0x38c69e、_0x396880、_0x3b3dc7
let autoIncrementName = switchNode.discriminant.property.argument.name;
// 獲取控制流自增變數名繫結的節點
let bindingAutoIncrement = path.scope.getBinding(autoIncrementName);
// 可選擇的操作:刪除控制流陣列繫結的節點、自增變數名繫結的節點
bindingArray.path.remove();
bindingAutoIncrement.path.remove();
// 儲存正確順序的控制流語句
let replace = [];
// 遍歷控制流陣列,按正確順序取 case 內容
array.forEach(index => {
let consequent = switchNode.cases[index].consequent;
// 如果最後一個節點是 continue 語句,則刪除 ContinueStatement 節點
if (types.isContinueStatement(consequent[consequent.length - 1])) {
consequent.pop();
}
// concat 方法拼接多個陣列,即正確順序的 case 內容
replace = replace.concat(consequent);
}
);
// 替換整個 while 節點,兩種方法都可以
path.replaceWithMultiple(replace);
// path.replaceInline(replace);
}
}
其他細節還原
到這裡其實大部分混淆都已經還原了,已經很容易分析其邏輯了,還剩下一些細節,我們也還原一下,主要有以下細節:
- 十六進位制、Unicode 編碼等,轉正常字元;
- 物件屬性還原,比如
_0x3cbc20['length']
轉換成_0x3cbc20.length
; - 表示式還原,比如
!![]
直接計算成 true; - 刪除未引用的變數,比如
_0xodD= "jsjiami.com.v6";
; - 刪除冗餘邏輯程式碼,只保留 if 為 true 的。
這些還原始碼在我上期文章有詳細講過,結合程式碼,在 astexplorer.net 對照其結構看,也能理解,同樣也不贅述了,直接貼程式碼:
const visitor5 = {
// 十六進位制、Unicode 編碼等,轉正常字元
"StringLiteral|NumericLiteral"(path){
delete path.node.extra;
},
// _0x3cbc20["length"] 轉換成 _0x3cbc20.length
MemberExpression(path){
if (path.node.property.type == "StringLiteral") {
path.node.computed = false
path.node.property = types.identifier(path.node.property.value)
}
},
// 表示式還原,!![] 直接計算成 true
"BinaryExpression|UnaryExpression"(path) {
let {confident, value} = path.evaluate()
if (confident){
path.replaceInline(types.valueToNode(value))
}
},
// 刪除未引用的變數,比如 _0xodD = "jsjiami.com.v6";
AssignmentExpression(path){
let binding = path.scope.getBinding(path.node.left.name);
if (!binding) {
path.remove();
}
}
}
// 刪除冗餘邏輯程式碼,只保留 if 為 true 的
const visitor6 = {
IfStatement(path) {
if(path.node.test.type == "BooleanLiteral") {
if(path.node.test.value) {
path.replaceInline(path.node.consequent.body)
} else {
path.replaceInline(path.node.alternate.body)
}
}
}
}
自此 jajiami v6 混淆就還原完畢了,還原前後對比一下,程式碼量縮短了很多,邏輯也更加清楚了,如下圖所示:
最後結合 Python 程式碼,攜帶生成的 hostToken
和 permitToken
,成功拿到備案號:
完整程式碼
原混淆程式碼 generatetoken.js
、AST 脫混淆程式碼 generatetokenAst.js
、還原後的程式碼 generatetokenNew.js
,以及 Python 測試程式碼均在 GitHub,均有詳細註釋,歡迎 Star。所有內容僅供學習交流,嚴禁用於商業用途、非法用途,否則由此產生的一切後果均與作者無關,在倉庫中下載的檔案學習完畢之後請於 24 小時內刪除!
程式碼地址:https://github.com/kgepachong...