作者论坛账号:T4DNA
免责声明
1、本贴仅作为技术讨论,本人不会利用以下技术盈利、或从事任何侵害该网站的行为。
2、如觉得此贴不妥,请联系本人将第一时间删除。
背景
使用的xhtml文件和img均在附件中。
某网站下载的漫画epub使用calibre等常规阅读器阅读不便,出现白屏,无法下拉等情况。
epub文件本质是压缩包,文字为xhtml文件,图片放在Image文件夹下。
解压发现该epub每一章节的xhtml是乱七八糟一堆,而Image则为分割的图片,不仅乱序,还有反过来的。
通过浏览器打开这个网页,即可正常显示图片。
xhtml改为html
代码有混淆,不是一目了然的,所以得动态调试。
浏览器虽然可以打开xhtml,但是js代码被CDATA区域包裹,只是Xhtml代码用于忽略语法错误的。
尽管浏览器可以正常解析,但是不方便下断点,故删除CDATA包裹,并且将xhtml改为html后缀。
AST解混淆
html结构包含两个javascript块,第一个块仅仅定义了一个__animations__变量。animations是动画的意思,猜测应该是包含了图片的打乱/还原信息。
它看起来是一个base64字符串,猜测是对称加密的产物,大概率是AES。
第二块是主要的程序,用于解析__animations__并且完成图片的还原操作。
第二块首先定义了一个lnT9,然后是一个自执行函数,后面的代码中则使用了大量的lnT9.XXX(NNN),显然是一种字符串替换的混淆,而中间一大坨的自执行函数则是对lnT9的定义。
那么自执行函数是如何定义lnT9的呢?打开发现首先是一个eval,将字符串转为代码,那么我们把return断住,步进进入解析出的vm代码中,发现也是混淆过的。
不过我们无需进行解混淆,因为两个混淆的函数都被用于解析那一坨字符串,最后的结果z8Ke仍然是字符串,还要再通过一次eval生效,所以我们只需断住z8ke即可获取最终运行的代码。
结果这又是一段混淆的代码,不过我们之前已经分析过了,所有这段代码的根本目的应该只是定义一堆函数用于后面的主逻辑混淆,所以实际上我们无需关心这段代码的实现(后面打脸)。只需要对后面主逻辑的函数进行解混淆。
①字符串加密函数
lnT9.函数(数字)显然是一种的字符串加密,虽然函数名字各不相同,但是实际上并没有出现不同偏移的情况,本质上都是对同一个数组的取值。
因此我们只需要关心这个数组就可以将所有相关函数解混淆。
我们将第二块js代码保存为comp.js,并编写函数用于解混保存为decomp_.js。对于所有的MemberExpression,如果Object的Identifier是lnT9且参数为Literal一律替换成_0x00031C的X位。
复制代码 隐藏代码traverse(ast, {
CallExpression(path) {
const { callee, arguments: args } = path.node;
if (
types.isMemberExpression(callee) &&
types.isIdentifier(callee.object, { name: "lnT9" }) &&
args.length === 1 &&
types.isNumericLiteral(args[0])
) {
const index = args[0].value;
const replacementValue = _0x00031C[index];
if (replacementValue !== undefined) {
path.replaceWith(types.stringLiteral(replacementValue));
}
}
}
});
运行后成功替换了所有的字符串加密。
②常量还原
注意到很多case使用了-, ^ 表达,实际上两边都是常量,结果也是一个常量,我们可以直接替换成计算结果。
复制代码 隐藏代码BinaryExpression(path) {
const { operator, left, right } = path.node;
if (types.isNumericLiteral(left) && types.isNumericLiteral(right)) {
const result = eval(`${left.value} ${operator} ${right.value}`);
path.replaceWith(types.numericLiteral(result));
}
},
完成后:
准备对while switch case的控制流进行处理时,发现还有一些lnT9的尾巴作比较的一个值,应当为一个数字常量。
为了处理这些lnT9函数,先将XXX["YYY"](NNN) 替换为 XXX.YYY(NNN)。
复制代码 隐藏代码traverse(ast, {
MemberExpression(path) {
const { node } = path;
console.log(node.property);
if (types.isStringLiteral(node.property)) {
node.property = types.identifier(node.property.value);
node.computed = false;
}
}
});
然后,我们还是回到了之前的lnT9的构造方法,因此,我们先把前面的eval替换成最终的vm代码,并全部放入解密的js中。让lnT9方法直接存在于解密js文件的环境中。
这样我们就可以在ast中直接调用替换,最终成功替换了所有的lnT9相关的函数,所以前面的一大段lnT9相关代码可以删除了。
复制代码 隐藏代码traverse(ast, {
CallExpression(path) {
const { callee } = path.node;
if (
types.isMemberExpression(callee) &&
types.isIdentifier(callee.object, { name: "lnT9" }) &&
types.isIdentifier(callee.property)
) {
const methodName = callee.property.name;
if (lnT9 && typeof lnT9[methodName] === "function") {
const returnValue = lnT9[methodName]();
if (typeof returnValue === "number") {
path.replaceWith(types.numericLiteral(returnValue));
}
}
}
},
})
//既然lnT9全部放入了环境中,也可以把前面的字符串加密部分写到这个AST处理。
③反流程平坦化
代码中将很多的if...else...和循环语句都使用while switch和三元表达式平坦化了,比如
复制代码 隐藏代码while (_0x0003B1 < lnT9[lnT9.h445(5)]())
switch (_0x0003B1) {
case (0x75bcd15 - 0O726746425):
_0x0003B1 = _0x0003B0 == null ? lnT9[lnT9.xmS5(6)]() : lnT9[lnT9.h445(5)]();
break;
case (0O57060516 - 0xbc614d):
_0x0003B1 = lnT9[lnT9.h445(5)]();
return;
}
经过前面基本的ast处理后为
复制代码 隐藏代码var _0x0003B1 = 0;
while (_0x0003B1 < 65535) switch (_0x0003B1) {
case 0:
_0x0003B1 = _0x0003B0 == null ? 1 : 65535;
break;
case 1:
_0x0003B1 = 65535;
return;
}
它实际则应该为
复制代码 隐藏代码if (_0x0003B0 == null) {
return;
}
统一的地方是,都是初始将判断字符置为0,跳出判断均为65535。
因此,处理时应该从case 0开始处理,对每一个WhileStatement,如果SwitchStatement,则从0开始拼接case的代码,因为不断跳转,所以对每个case的处理可以写一个getCodeOf(caseIndex)函数。
函数里,创建一个tcode变量用于存放修改后的代码,case中存在多个语句(在switchCase.consequent中),检查每个语句,有以下几种情况:
[b]①如果是BreakStatement即跳出这个case,所以此时return tcode。
复制代码 隐藏代码function getCodeOf(indexofcase) {
var tcode = "";
switchCase = global.switchCases[indexofcase];
const consequents = switchCase.consequent;
consequents.forEach((consequent) => {
if (types.isBreakStatement(consequent)) {
return tcode
}
});
return tcode
}
[b]②如果是对判断词直接赋值,则在处理完本case后跳转到赋值的case,如果直接赋值65535则直接结束,这放到最后处理。
复制代码 隐藏代码else if (types.isExpressionStatement(consequent) &&
types.isAssignmentExpression(consequent.expression) &&
consequent.expression.left.name === controlStat &&
types.isLiteral(consequent.expression.right)
) {
// 对控制字进行赋值,如果是65535表示结束,如果是其他则表示这个语句完成后要跳转case
jumpvalue = consequent.expression.right.value
}
//...
if (jumpvalue != 65535) {
//如果是65535则直接结束
tcode += getCodeOf(jumpvalue)
}
[b]③如果是对判断词进行三元表达式的赋值,则要还原成一个if condition {};如果false时不是65535还需要获取false时case的代码作为else。
复制代码 隐藏代码else if (types.isExpressionStatement(consequent) &&
types.isAssignmentExpression(consequent.expression) &&
consequent.expression.left.name === controlStat &&
types.isConditionalExpression(consequent.expression.right)
){
// 三元表达式表示else if 语句
const condition = generate(consequent.expression.right.test).code;
const trueCode = getCodeOf(consequent.expression.right.consequent.value); // 获取case x 的代码
const falseTo = consequent.expression.right.alternate.value; // 如果False跳转到哪里?
tcode += `if (${condition}) { ${trueCode}}`;
if (falseTo != 65535) {
//如果是65535则不需要else
falseCode = getCodeOf(falseTo)
tcode += ` else {${falseCode}}`
}
}
[b]④其他的操作,return等语句通通按照原样放入代码。
复制代码 隐藏代码else {
// 其他statement 全部变为代码加上去
tcode += generate(consequent).code;
}
由于从case 0开始,只要使用getCodeOf(0),就可以获取整个还原好的code string,再使用template即可转为ast,将其替换原来的path即可。
需要注意的是,由于一些whilestatement有return,在父节点中,所以不能使用parser.parse解析,使用path.replaceWithSourceString也会报错。
复制代码 隐藏代码global.switchCases = body.cases;
global.controlStat = body.discriminant.name;
let transformedCode = getCodeOf(0);
transformedAst = template.ast(transformedCode)
path.replaceWith(transformedAst);
别忘了把判断词赋值为0的无用语句删除:
复制代码 隐藏代码traverse(ast, {
VariableDeclaration(path) {
if (path.node.kind === "var") {
path.node.declarations.filter((declarator) => {
if (
types.isIdentifier(declarator.id) &&
types.isLiteral(declarator.init, { value: 0 })
) {
path.remove();
}
});
}
},
});
这样即可把大部分的if else还原
至此已经完成了解混淆工作,接下来看代码。
算法还原
①原位置的生成
首先把解混淆完成的代码替换到html中,搜索、animations、。由于字符全部都解混淆完成了,很容易发现这是一个AES CBC,固定Key IV的解密代码。
使用AES解密后,发现是一个json字段,包含grid(整体)、imgSrc(图片为位置)、分块piece,和slicemap:
js中首先取出了这些参数,并且计算了三种width
不知道这是在做什么,那就全部搬到python中去。接下来定义了canvas对象和Image。
然后使用_0x000441函数通过column,row和slicemap以及上面三个不知道啥的字典,获取到该排该列的图片在原图中的位置。
_0x000441计算挺复杂的,不过由于switch都改成了if else,稍微把{}改成:就是python代码了。
_0x000441内主要通过不同row,colum的条件对sliceMap进行了一些计算。
_0x00046D.rect即为原图位置,degree则表示图片是否要反转180°。
②新位置的生成
新位置的生成主要是直接通过row,colum进行计算,_0x00046F是degree,degree不同则新位置的计算函数不相同。
复制代码 隐藏代码_0x00046F === 1 ? _0x000461(_0x00046B, _0x00046C) : _0x000458(_0x00046B, _0x00046C)
同样全部写成python代码即可。
然后对每个行、列获取其新、旧位置,即可得到两个字典。
Python图片还原
js中,是这样从原图中获取图片块然后存入canvas的,degree为1时会将画布倒转过来再存放,然后再倒转回去。
复制代码 隐藏代码if (_0x00046F === 1) {
{
canvasObj.save();
canvasObj.translate(canvas.width / 2, canvas.height / 2);
canvasObj.rotate(Math.PI);
canvasObj.drawImage(_0x000472, _0x00046E.left, _0x00046E.top, _0x00046E.width, _0x00046E.height, _0x000470.left - canvas.width / 2, _0x000470.top - canvas.height / 2, _0x000470.width, _0x000470.height);
canvasObj.rotate(-Math.PI);
canvasObj.translate(-canvas.width / 2, -canvas.height / 2);
canvasObj.restore();
return;
}
}
canvasObj.drawImage(_0x000472, _0x00046E.left, _0x00046E.top, _0x00046E.width, _0x00046E.height, _0x000470.left, _0x000470.top, _0x000470.width, _0x000470.height);
Python使用PIL进行图片处理。我们将所有的slicemap全部放入一个list——slicedata中。
然后把图片地址,slicedata 作为处理的参数输入一个processImg函数。
如果直接将旧的图片位置放到新位置,processImg函数应该这样写:
复制代码 隐藏代码original_image = Image.open(image_path)
new_image = Image.new("RGB", (canvas_width, canvas_height), "white")
for data in slicedata:
origin = data[0]
new = data[1]
flag = data[2]
origin_box = (
int(origin["left"]),
int(origin["top"]),
int(origin["left"] + new["width"]),
int(origin["top"] + new["height"])
)
cropped_region = original_image.crop(origin_box)
if flag == 1.0:
cropped_region = cropped_region.rotate(180)
new_box = (
int(new["left"]),
int(new["top"]),
int(new["left"] + new["width"]),
int(new["top"] + new["height"])
)
new_image.paste(cropped_region, new_box)
new_image.save(output_path)
但是这样你会得到:
显然这不对,但不是完全不对。
由于图片上存在空白,首先定位问题出在坐标上,应当是需要反转的部分存在问题,我们将degree为1的跳过,可以发现正向的都正确地贴上了。
观察slicedata发现0排0列的新位置顶点居然并非0,0。
这是因为canvas在图片操作时是将整个画布进行了扭转canvasObj.rotate(Math.PI),为了正确扭转所以先将顶点设为了画布中央,canvasObj.translate(canvas.width / 2, canvas.height / 2),所以此时使用的第一块图的left,top并非期望的0,0,实际上是图片左下角的一块的右上顶点,即left + width = canvas.width。
复制代码 隐藏代码//0,0 {'left': 322.0, 'top': 1058.0, 'width': 300, 'height': 342} {'left': 824, 'top': 1258, 'width': 300, 'height': 342}
canvasObj.save();
canvasObj.translate(canvas.width / 2, canvas.height / 2);
canvasObj.rotate(Math.PI);
canvasObj.drawImage(_0x000472, _0x00046E.left, _0x00046E.top, _0x00046E.width, _0x00046E.height, _0x000470.left - canvas.width / 2, _0x000470.top - canvas.height / 2, _0x000470.width, _0x000470.height);
canvasObj.rotate(-Math.PI);
canvasObj.translate(-canvas.width / 2, -canvas.height / 2);
canvasObj.restore();
而在Python中并不需要转画布,直接转图片即可,JS中两种的新位置改变的主要是生成函数的 不同,因此只需要在Python中把照抄的if代码,改成全用正向函数生成新位置即可。
复制代码 隐藏代码for row in range(grid["rows"]):
for column in range(grid["columns"]):
originData = _0x000442(row, column)
originRect = originData["rect"]
degree = originData["degree"]
newRect = _0x000458(row, column)
sliceData.append((originRect, newRect, degree))
这样就可以正常获取完整图片了。
实例文件(xhtml+image源文件)见左下角论坛原文。
-官方论坛
www.52pojie.cn
👆👆👆