关注微信公众号: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…