某 ICP 備案号查詢接口 jsjiami v6 分析

某 ICP 備案号查詢接口 jsjiami v6 分析

逆向目标 目标:站 Z 之家網站 ICP 備案号查詢 主頁:aHR0cDovL2ljcC5jaGluYXouY29tLw== 接口:aHR0cDovL2ljcC5jaGluYXouY29tL2hvbWUvR2V0UGVyaW1pdEJ5SG9zdA== 逆向參數:hostToken、permitToken 本次主要是 AST 解混淆實戰,本例中的 JS 混淆方式...

逆向目标

  • 目标:站 Z 之家網站 ICP 備案号查詢
  • 主頁:aHR0cDovL2ljcC5jaGluYXouY29tLw==
  • 接口:aHR0cDovL2ljcC5jaGluYXouY29tL2hvbWUvR2V0UGVyaW1pdEJ5SG9zdA==
  • 逆向參數:hostTokenpermitToken

本次主要是 AST 解混淆實戰,本例中的 JS 混淆方式是 sojson 旗下的 jsjiami v6 版本,感興趣的可以去官網體驗一下:https://www.jsjiami.com/ ,如果你還不了解 AST,可以先看看 《利用 AST 技術還原 JavaScript 混淆代碼

第三方工具

逆向領域大佬雲集,市面上已經有很多大佬寫好的解混淆工具了,除了我們自己手動去寫 AST 解析代碼以外,有時候直接使用工具會更加方便,當然并沒有十全十美的工具,不過大部分情況下都能成功解混淆的,以下工具值得去體驗一下:

  • 蔡老闆一鍵還原 OB 混淆:https://github.com/Tsaiboss/decodeObfuscator
  • 哲哥 AST 混淆還原框架:https://github.com/sml2h3/ast_tools
  • V 神 Chrome 插件,内置 AST 混淆還原:https://github.com/cilame/v_jstools
  • jsjiami v6 專用解密工具:https://github.com/NXY666/JsjiamiV6-Decryptor

抓包分析

進入主題,首先抓包看看,來到 ICP 備案查詢頁面,查詢結果中,其他信息都可以直接在相應的 html 源碼中找到,隻有這個備案号是通過接口傳過來的,對應的請求和相關加密參數如下圖所示:

加密定位

直接搜索關鍵字 hostToken 或者 permitToken 即可定位:

關鍵代碼:

  1. 'data': {
  2. 'kw': kw,
  3. 'hostToken': _0x791532['IIPmq'](generateHostKey, kw),
  4. 'permitToken': _0x791532[_0x404f('‫1df', '7Gn4')](generateWordKey, kw)
  5. }

這裏的混淆可以手動跟一下,還原後如下:

  1. 'data': {
  2. 'kw': kw,
  3. 'hostToken': generateHostKey(kw),
  4. 'permitToken': generateWordKey(kw)
  5. }

kw 是查詢的域名,有用的就是 generateHostKey() 和 generateWordKey() 兩個方法了,跟進去看,代碼經過了 jsjiami v6 混淆:

AST 脫混淆

jsjiami 混淆的特征其實和 OB 混淆是類似的:

  1. 一般由一個大數組或者含有大數組的函數、一個數組位移操作的自執行函數、一個解密函數和加密後的函數四部分組成;
  2. 函數名和變量名通常以 _0x 或者 0x 開頭,後接 1~6 位數字或字母組合;
  3. 數組位移操作的自執行函數裏,有明顯的 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')

注意這裏代碼裏面有一些特殊字符,類似于 RLERLO 之類的,如果在 VSCode 打開是一些 U+202BU+202E 的字符,實際上這是 RTLO (Right-to-Left Override) 字符,U+202B 和 U+202E 的意思分别是根據内存順序從左至右和從右至左顯示字符,感興趣的可以網上搜索了解一下。這裏并不影響我們進行還原操作。但是如果直接複制過來的話就會導緻前後文顯示的順序不對,所以本文中爲了方便描述,粘貼的部分代碼就手動去掉了這些字符。

所以第一步我們要還原一下解密函數,把所有 _0x530e 調用的地方直接替換成實際值,首先需要将大數組、自執行函數、加密函數和解密函數分割開,将代碼放到 astexplorer.net 看一下,也就是将 body 的前四部分和後面剩餘部分分割開來,如下圖所示:

分割代碼:

  1. const fs = require("fs");
  2. const parse = require("@babel/parser").parse;
  3. const generate = require("@babel/generator").default
  4. const traverse = require("@babel/traverse").default
  5. const types = require("@babel/types")
  6. // 導入混淆代碼并解析爲 AST
  7. const oldCode = fs.readFileSync("generatetoken.js", {encoding: "utf-8"});
  8. const astCode = parse(oldCode);
  9. // 獲取整個 AST 節點的長度
  10. let astCodeLength = astCode.program.body.length
  11. // 獲取解密函數的名字 也就是 _0x530e
  12. let decryptFunctionName = astCode.program.body[3].id.name
  13. // 分割加密函數和解密函數,即 body 的前四部分和後面剩餘部分
  14. let decryptFunction = astCode.program.body.slice(0, 4)
  15. let encryptFunction = astCode.program.body.slice(4, astCodeLength)
  16. // 獲取加密函數和解密函數的方法多種多樣,比如可以挨個取值并轉換成 JS 代碼
  17. // 這樣做就不需要将解密函數賦值給整個 AST 節點了
  18. // let decryptFunction = "";
  19. // for(let i=0; i<4; i++){
  20. // decryptFunction += generate(astCode.program.body[i], {compact: true}).code
  21. // }
  22. // eval(decryptFunction);

在上面的獲取加密函數和解密函數的代碼中,方法不是唯一的,多種多樣,比如直接循環取 body 并轉換成 JS 代碼,比如直接人工把大數組、自執行函數和解密函數三部分,拿出來放到一個新文件裏,然後導出解密方法,後續直接調用也可以。

在本例中,拿到解密函數後,需要将其賦值給整個 AST 節點,然後再将整個 AST 節點轉換成 JavaScript 代碼,這裏注意有可能會檢測代碼是否格式化,所以建議轉換要加一個 compact 參數,避免格式化,轉換完成後 eval 執行一下,讓數組位移操作完成,然後我們就可以直接調用解密函數,即 _0x530e()

  1. // 将解密函數賦值給整個 AST 節點
  2. astCode.program.body = decryptFunction
  3. // 将 AST 節點轉換成 JS 代碼,并 eval 執行一下
  4. decryptFunction = generate(astCode, {compact: true}).code
  5. eval(decryptFunction);
  6. // 測試一下,直接調用 _0x530e 函數可以正确拿到結果
  7. // 輸出 split
  8. // 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() 方法替換節點,遍曆代碼如下:

  1. // 将加密函數賦值給整個 AST 節點,此時整個節點就沒有大數組、自執行函數和解密函數了
  2. astCode.program.body = encryptFunction
  3. // 調用解密函數,直接計算出類似以下方法的值并替換
  4. // 混淆代碼:_0x530e('‮b', 'Zp9G')
  5. // 還原後:split
  6. const visitor1 = {
  7. CallExpression(path){
  8. if (path.node.callee.name === decryptFunctionName && path.node.arguments.length === 2){
  9. path.replaceInline(types.stringLiteral(eval(path.toString())))
  10. }
  11. }
  12. }
  13. // 遍曆節點
  14. traverse(astCode, visitor1)
  15. // 将 AST 節點轉換成 JS 代碼并寫入到新文件裏
  16. const result = generate(astCode, {concise:true}).code
  17. 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,如下圖所示:

首先我們将這四個大的對象單獨提取出來,還是保持原來的鍵值對樣式,提取完成後删除這兩個節點,遍曆代碼如下:

  1. let functionName = {
  2. "_0x3b79c6": {},
  3. "_0x278b2d": {},
  4. "_0x4115c4": {},
  5. "_0xd8ec33": {}
  6. }
  7. // 單獨提取出四個大對象
  8. const visitor2 = {
  9. VariableDeclarator(path){
  10. for (let key in functionName){
  11. if (path.node && path.node.id.name == key) {
  12. const properties = path.node.init.properties
  13. for (let i=0; i<properties.length; i++){
  14. functionName[key][properties[i].key.value] = properties[i].value
  15. }
  16. // 寫入對象後就可以删除該節點了
  17. path.remove()
  18. }
  19. }
  20. }
  21. }

這裏要注意,大的對象裏面,有 +-== 之類的二項式計算,也有直接爲字符串的,還有變成函數調用的,如下所示:

  1. var _0x3b79c6 = {
  2. 'MuRlB': function (_0x3ca134, _0x50ee94) {
  3. return _0x3ca134 + _0x50ee94;
  4. },
  5. 'Ucwyj': function (_0x32bfa3, _0x3b191b) {
  6. return _0x32bfa3(_0x3b191b);
  7. },
  8. 'YrYQW': '#IpValue'
  9. }

針對不同的情況有不同的處理方法,同時還要注意傳參和 return 返回的參數位置,不要還原後把 a - b 搞成 b - a 了,當然在本例中傳入和返回的順序是一樣的,就不需要考慮這個問題。

字符串還原

首先來看字符串,有以下幾種情況:

  • 以 _0x3b79c6['YrYQW'] 爲例,實際上其值爲字符串 '#IpValue',觀察其結構,是一個 MemberExpression,在一個列表裏;
  • 以 _0x278b2d['pjbyX'] 爲例,實際上其值爲字符串 '3|2|1|4|5|0|6',觀察其結構,是一個 MemberExpression,在一個字典裏;
  • 以 _0x278b2d['CnTaO'] 爲例,雖然也是一個 MemberExpression,也在一個字典裏。但實際上是二項式計算,所以要排除在外。

所以我們在寫遍曆代碼時,同時要注意這三種情況,滿足條件後直接取原來大對象對應的節點進行替換即可,遍曆代碼如下所示:

  1. // 函數替換,字符串替換:将類似 _0x3b79c6['YrYQW'] 變成 '#IpValue'
  2. const visitor3 = {
  3. MemberExpression(path) {
  4. for (let key in functionName){
  5. if (path.node.object && path.node.object.name == key && path.inList ) {
  6. path.replaceInline(functionName[key][path.node.property.value])
  7. }
  8. if (path.node.object && path.node.object.name == key && path.parent.property && path.parent.property.value == "split") {
  9. path.replaceInline(functionName[key][path.node.property.value])
  10. }
  11. }
  12. }
  13. }

二項式計算替換

再來看看二項式計算的情況,以 _0x278b2d['CnTaO'](_0x691267["length"], 0x1) 爲例,實際上是做減法運算,即 _0x691267["length"] - 0x1,看一下替換前後對比:

對于這種情況,我們可以直接提取兩個參數,然後提取大對象裏對應方法的操作符,然後将參數和操作符直接連接起來組成新的節點(binaryExpression)并替換即可,遍曆代碼如下:

  1. // 函數替換,二項式計算:将類似 _0x278b2d['CnTaO'](_0x691267["length"], 0x1) 變成 _0x691267["length"] - 0x1
  2. const visitor4 = {
  3. CallExpression(path){
  4. for (let key in functionName) {
  5. if (path.node.callee && path.node.callee.object && path.node.callee.object.name == key) {
  6. let func = functionName[key][path.node.callee.property.value]
  7. if (func.body.body[0].argument.type == "BinaryExpression") {
  8. let operator = func.body.body[0].argument.operator
  9. let left = path.node.arguments[0]
  10. let right = path.node.arguments[1]
  11. path.replaceInline(types.binaryExpression(operator, left, right))
  12. }
  13. }
  14. }
  15. }
  16. }

方法調用還原

以 _0x4115c4["PJbSm"](getRandom, 0x64, 0x3e7) 爲例,實際上是 getRandom(0x64, 0x3e7),看一下替換前後對比:

對于這種情況,傳入的第一個參數爲方法名稱,後面的都是參數,那麽可以直接取第一個元素爲方法名稱,使用 slice(1) 方法取後面所有的參數(因爲後面的參數個數是不一定的),然後構造新的節點(callExpression)并替換即可,這部分遍曆代碼可以和前面二項式的替換相結合,代碼如下:

  1. // 函數替換,二項式計算:将類似 _0x278b2d['CnTaO'](_0x691267["length"], 0x1) 變成 _0x691267["length"] - 0x1
  2. // 函數替換,方法調用:将類似 _0x4115c4["PJbSm"](getRandom, 0x64, 0x3e7) 變成 getRandom(0x64, 0x3e7)
  3. const visitor4 = {
  4. CallExpression(path){
  5. for (let key in functionName) {
  6. if (path.node.callee && path.node.callee.object && path.node.callee.object.name == key) {
  7. let func = functionName[key][path.node.callee.property.value]
  8. if (func.body.body[0].argument.type == "BinaryExpression") {
  9. let operator = func.body.body[0].argument.operator
  10. let left = path.node.arguments[0]
  11. let right = path.node.arguments[1]
  12. path.replaceInline(types.binaryExpression(operator, left, right))
  13. }
  14. if (func.body.body[0].argument.type == "CallExpression") {
  15. let identifier = path.node.arguments[0]
  16. let arguments = path.node.arguments.slice(1)
  17. path.replaceInline(types.callExpression(identifier, arguments))
  18. }
  19. }
  20. }
  21. }
  22. }

自此,第二步的大對象還原就完成了,可以看一下還原前後的對比,如下圖所示淺藍色标記的地方,所有調用四個大對象(_0x3b79c6_0x278b2d_0x4115c4_0xd8ec33)的地方都被還原了:

switch-case 反控制流平坦化

經過前面幾步的還原之後,我們發現 generateHostKey()generateWordKey()getRandom() 方法裏都有一個 switch-case 的控制流,關于反控制流平坦化的講解在我上期文章有很詳細的介紹,不理解的可以看看上期文章,此處也不再贅述了,直接貼代碼了:

  1. // switch-case 反控制流平坦化
  2. const visitor5 = {
  3. WhileStatement(path) {
  4. // switch 節點
  5. let switchNode = path.node.body.body[0];
  6. // switch 語句内的控制流數組名,本例中是 _0x28073a、_0x2efb35、_0x187fb8
  7. let arrayName = switchNode.discriminant.object.name;
  8. // 獲取控制流數組綁定的節點
  9. let bindingArray = path.scope.getBinding(arrayName);
  10. // 獲取節點整個表達式的參數、分割方法、分隔符
  11. let init = bindingArray.path.node.init;
  12. let object = init.callee.object.value;
  13. let property = init.callee.property.value;
  14. let argument = init.arguments[0].value;
  15. // 模拟執行 '3|2|1|4|5|0|6'['split']('|') 語句
  16. let array = object[property](argument)
  17. // 也可以直接取參數進行分割,方法不通用,比如分隔符換成 , 就不行了
  18. // let array = init.callee.object.value.split('|');
  19. // switch 語句内的控制流自增變量名,本例中是 _0x38c69e、_0x396880、_0x3b3dc7
  20. let autoIncrementName = switchNode.discriminant.property.argument.name;
  21. // 獲取控制流自增變量名綁定的節點
  22. let bindingAutoIncrement = path.scope.getBinding(autoIncrementName);
  23. // 可選擇的操作:删除控制流數組綁定的節點、自增變量名綁定的節點
  24. bindingArray.path.remove();
  25. bindingAutoIncrement.path.remove();
  26. // 儲存正确順序的控制流語句
  27. let replace = [];
  28. // 遍曆控制流數組,按正确順序取 case 内容
  29. array.forEach(index => {
  30. let consequent = switchNode.cases[index].consequent;
  31. // 如果最後一個節點是 continue 語句,則删除 ContinueStatement 節點
  32. if (types.isContinueStatement(consequent[consequent.length - 1])) {
  33. consequent.pop();
  34. }
  35. // concat 方法拼接多個數組,即正确順序的 case 内容
  36. replace = replace.concat(consequent);
  37. }
  38. );
  39. // 替換整個 while 節點,兩種方法都可以
  40. path.replaceWithMultiple(replace);
  41. // path.replaceInline(replace);
  42. }
  43. }

其他細節還原

到這裏其實大部分混淆都已經還原了,已經很容易分析其邏輯了,還剩下一些細節,我們也還原一下,主要有以下細節:

  • 十六進制、Unicode 編碼等,轉正常字符;
  • 對象屬性還原,比如 _0x3cbc20['length'] 轉換成 _0x3cbc20.length
  • 表達式還原,比如 !![] 直接計算成 true;
  • 删除未引用的變量,比如 _0xodD= "jsjiami.com.v6";
  • 删除冗餘邏輯代碼,隻保留 if 爲 true 的。

這些還原代碼在我上期文章有詳細講過,結合代碼,在 astexplorer.net 對照其結構看,也能理解,同樣也不贅述了,直接貼代碼:

  1. const visitor5 = {
  2. // 十六進制、Unicode 編碼等,轉正常字符
  3. "StringLiteral|NumericLiteral"(path){
  4. delete path.node.extra;
  5. },
  6. // _0x3cbc20["length"] 轉換成 _0x3cbc20.length
  7. MemberExpression(path){
  8. if (path.node.property.type == "StringLiteral") {
  9. path.node.computed = false
  10. path.node.property = types.identifier(path.node.property.value)
  11. }
  12. },
  13. // 表達式還原,!![] 直接計算成 true
  14. "BinaryExpression|UnaryExpression"(path) {
  15. let {confident, value} = path.evaluate()
  16. if (confident){
  17. path.replaceInline(types.valueToNode(value))
  18. }
  19. },
  20. // 删除未引用的變量,比如 _0xodD = "jsjiami.com.v6";
  21. AssignmentExpression(path){
  22. let binding = path.scope.getBinding(path.node.left.name);
  23. if (!binding) {
  24. path.remove();
  25. }
  26. }
  27. }
  28. // 删除冗餘邏輯代碼,隻保留 if 爲 true 的
  29. const visitor6 = {
  30. IfStatement(path) {
  31. if(path.node.test.type == "BooleanLiteral") {
  32. if(path.node.test.value) {
  33. path.replaceInline(path.node.consequent.body)
  34. } else {
  35. path.replaceInline(path.node.alternate.body)
  36. }
  37. }
  38. }
  39. }

自此 jajiami v6 混淆就還原完畢了,還原前後對比一下,代碼量縮短了很多,邏輯也更加清楚了,如下圖所示:

最後結合 Python 代碼,攜帶生成的 hostToken 和 permitToken,成功拿到備案号:

完整代碼

原混淆代碼 generatetoken.js、AST 脫混淆代碼 generatetokenAst.js、還原後的代碼 generatetokenNew.js,以及 Python 測試代碼均在 GitHub,均有詳細注釋,歡迎 Star。所有内容僅供學習交流,嚴禁用于商業用途、非法用途,否則由此産生的一切後果均與作者無關,在倉庫中下載的文件學習完畢之後請于 24 小時内删除!

代碼地址:https://github.com/kgepachong/crawler/tree/main/icp_chinaz_com

版權申明:
版權聲明

①:本站文章均爲原創,除非另有說明,否則本站内容依據CC BY-NC-SA 4.0許可證進行授權,轉載請附上出處鏈接,謝謝。
②:本站提供的所有資源均爲網上搜集,不保證能100%完整,如有涉及或侵害到您的版權請立即通知我們。
③:本站所有下載文件,僅用作學習研究使用,請下載後24小時内删除,支持正版,勿用作商業用途。
④:本站保證所提供資源的完整性,但不含授權許可、幫助文檔、XML文件、PSD、後續升級等。
⑤:使用該資源需要用戶有一定代碼基礎知識!由本站提供的資源對您的網站或計算機造成嚴重後果的本站概不負責。
⑥:本站資源售價隻是贊助,收取費用僅維持本站的日常運營所需。
⑦:如果喜歡本站資源,歡迎捐助本站開通會員享受優惠折扣,謝謝支持!
⑧:如果網盤地址失效,請在相應資源頁面下留言,我們會盡快修複下載地址。

0

評論0

請先

會員低至49元,開通享海量VIP資源免費下載 自助開通
顯示驗證碼
沒有賬号?注冊  忘記密碼?