quickjs reverse ,刨根问底探索编译过程,dump出字节码,并解决2022YCB-easyre
quickjs reverse
quickjs是一个体积小,速度快的js编译/解释引擎,可以通过qjsc将js文件编译为.c文件,其中嵌入了字节码供虚拟机执行。
quickjs官网下载选定版本进行编译。
刨根问底
对于quickjs的一些结构和具体函数调用的实现可以参考 文章链接,本文部分图片引自网络如有侵权请联系撤回。
quickjs
中最上层的两个程序分别为qjs
和qjsc
,而qjscalc
则是一个js的计算器。
其函数调用链
main->eval_file->eval_buf->JS_EvalFunction->JS_EvalFunctionInternal->JS_CallFree->JS_CallInternal
name | function |
---|---|
qjs | js解释器,可以直接执行js代码 |
qjsc | 编译器,可以将js文件编译成c文件,通过内部虚拟机和字节码执行。 |
qjsc的虚拟机是基于栈的,并且其opcode定义在quickjs-opcode.h中。
编译好后,目录下会有一个hello.c的测试用例,其编译过程可以在makefile中找到,修改后如下。
HELLO_SRCS=hello.js
HELLO_OPTS=-fno-string-normalize -fno-map -fno-promise -fno-typedarray \
-fno-typedarray -fno-regexp -fno-json -fno-eval -fno-proxy \
-fno-date -fno-module-loader
ifdef CONFIG_BIGNUM
HELLO_OPTS+=-fno-bigint
endif
Lu1u.c: $(HELLO_SRCS)
./qjsc -e $(HELLO_OPTS) -o $@ $(HELLO_SRCS)
通过向hello.js
中写入js代码,之后运行make -f mkf1
,同目录下生成Lu1u.c
文件,内容如下。
-e 是输出 .c文件,否则直接编译成可执行文件。
/* File generated automatically by the QuickJS compiler. */
#include "quickjs-libc.h"
const uint32_t qjsc_hello_size = 78;
const uint8_t qjsc_hello[78] = {
0x02, 0x04, 0x0e, 0x63, 0x6f, 0x6e, 0x73, 0x6f,
0x6c, 0x65, 0x06, 0x6c, 0x6f, 0x67, 0x16, 0x4c,
0x75, 0x31, 0x75, 0x31, 0x75, 0x31, 0x75, 0x31,
0x75, 0x21, 0x10, 0x68, 0x65, 0x6c, 0x6c, 0x6f,
0x2e, 0x6a, 0x73, 0x0e, 0x00, 0x06, 0x00, 0xa0,
0x01, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x14,
0x01, 0xa2, 0x01, 0x00, 0x00, 0x00, 0x38, 0xe1,
0x00, 0x00, 0x00, 0x42, 0xe2, 0x00, 0x00, 0x00,
0x04, 0xe3, 0x00, 0x00, 0x00, 0x24, 0x01, 0x00,
0xcd, 0x28, 0xc8, 0x03, 0x01, 0x00
};
static JSContext *JS_NewCustomContext(JSRuntime *rt)
{
JSContext *ctx = JS_NewContextRaw(rt);
if (!ctx)
return NULL;
JS_AddIntrinsicBaseObjects(ctx);
JS_AddIntrinsicBigInt(ctx);
return ctx;
}
int main(int argc, char **argv)
{
JSRuntime *rt;//js运行时
JSContext *ctx; //上下文
rt = JS_NewRuntime();
js_std_set_worker_new_context_func(JS_NewCustomContext);
js_std_init_handlers(rt);
ctx = JS_NewCustomContext(rt);
js_std_add_helpers(ctx, argc, argv);
js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
js_std_loop(ctx);
JS_FreeContext(ctx);
JS_FreeRuntime(rt);
return 0;
}
qjsc_hello
中存放的是对应字节码的机器码,通过内部的虚拟机执行,首先程序会创建上下文和运行时,并且通过js_std_eval_binary
来执行字节码,对应传入的参数为上下文、字节码和字节码长度等。
拿到了qjsc
编译出的.c
文件,我们可以通过gcc
完成后续的编译,需要外部链接一些库,可以直接写到一个makefile文档里。
gcc Lu1u.c libquickjs.a -lm -ldl -lpthread -o test
此时我们已知qjsc的字节码会写入到c程序中,这为我们逆向指明了思路,即还原字节码甚至做到反编译。
在quickjs.c
的源码中可以发现,有关dump bytecode的相关宏定义已经被注释掉,并且内部有js_dump_function_bytecode
函数实现。
所以我们需要在函数加载的合适位置,插入dump
函数以便得到可读的字节码,并且需要将相关宏定义注释掉(取消code的注释即可),可知他在js_create_function
时调用过dump函数。
修改后重新编译quickjs项目,再次编译发现在第一步将js转成c代码的时候输出了字节码,而常规逆向中我们只能拿到字节码,所以需要让其在执行.c
文件中的某一个函数中插入js_dump_function_bytecode
函数。
patchquickjs.c
文件,找到他读取函数bytecode
的函数。
在break前插入dump
调用,但是缺少JSFunctionBytecode
的变量,所以可以跟进JS_ReadFunctionTag
函数,在其return前调用即可。
//patch1
#define DUMP_BYTECODE (1)
//patch2
#if DUMP_BYTECODE
js_dump_function_bytecode(ctx, b);
#endif
out
patch后重新编译出可执行文件并运行,可见输出了易读的字节码。
利刃出鞘
Read and Reverse
get cf
通过IDA字符串中的quickjs.c
和一些js_xxx
函数可以确定是quickjs
编译出的可执行。
提取出4513长度的机器码,替换掉自己编译出的.c
文件中的qjsc_hello
,并修改其长度。
gcc Lu1u.c libquickjs.a -lm -ldl -lpthread -o test
编译并运行test,并将结果输出到out.txt文件,test > out.txt
。
一共有12个函数,不过其中有些解密的函数是不必分析的,因为patchquickjs.c
时只dump了bytecode,所以字节码会更整齐一些。
Next, let's we reverse it.
根据函数调用链main->eval_file->eval_buf->JS_EvalFunction->JS_EvalFunctionInternal->JS_CallFree->JS_CallInternal,故入口为function\<eval>。
该虚拟机是基于栈的,参数是v命名法,所以要了解调用栈的机制,详细opcode参考quickjs-opcode.h
。
function<eval>
首先开辟了一些变量,array用于生成数组,顺序即从左向右入的栈。
data3= [5911837666743816200 ,
5133585975960501272 ,
9082418069800623372 ,
9154480062992383756 ,
6848599583376686600 ,
147787617043219975 ,
6140429622497212985 ,
2526269866358605591 ,
4552892036882908959 ,
4543304157965338119 ,
4620825451554930944 ,
8808899373961281307 ,
5924901230665995531 ,
8808899373961281307 ,
8662527953563041043 ]
data4=[2101524238053948931,9154814254531429383,8941618984500083987]
data5=[0]*12
之后开始调用函数处理数据,从初始化可知a是我们的输入,而b则是加密用的key。
根据keystr的字符串也可以让我们提前猜测encode64是换表的base64加密。
call指令后面跟参数个数,call method则是调用对象中的方法,get是获取参数入栈,push是直接压入栈,而put则是将栈中的数据存放至某个变量。
对于js的一条指令,从左向右将需要的元素入栈,如上则是依次压入myenc、encode64、enc1和参数a b。
之后将数据copy到data2中,该块为一个循环语句,to_prokey2
和put_array_el
实现了data2[i]=的功能, lt
和goto
组合实现条件跳转。
之后function<eval>
的逻辑解读同上,python实现如下。
data3= [5911837666743816200 ,
5133585975960501272 ,
9082418069800623372 ,
9154480062992383756 ,
6848599583376686600 ,
147787617043219975 ,
6140429622497212985 ,
2526269866358605591 ,
4552892036882908959 ,
4543304157965338119 ,
4620825451554930944 ,
8808899373961281307 ,
5924901230665995531 ,
8808899373961281307 ,
8662527953563041043 ]
data4=[2101524238053948931,9154814254531429383,8941618984500083987]
data5=[0]*12
index = 0
index1 = 0
i=0
def func5(s):
pass
def func6(x):
pass
keystr="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="
k="welcometoycb2022"
data2=myenc.encode64(enc1(input,k)) # 魔改xxtea + 换表base64
for i in range(len(data2)): # 异或
data2[i]^=data2[(i+2)%len(data2)]
for i in range(0,len(data2),8): #转换
if i%32 !=8:
arg=[]
for j in range(8):
arg.append(data2[i+j])
data5[index]=func5(arg)
else:
data5[index]=data4[index1]
index1+=1
index+=1
z=0
for i in range(3):
if func6(i):
z+=1
if(z==3):
print('suc')
else:
print('nono')
解下来主要分析重要的加密函数。
encode64
在初始化阶段可知,encode64函数对应第一个function\<null>。
re.js:143: function: <null>
args: input
locals:
0: var output
1: var chr1
2: var chr2
3: var chr3
4: var enc1
5: var enc2
6: var enc3
7: var enc4
8: var i
可知是经典base64的位运算,并且之后的输出位置没有变,单纯是换表。
enc1
通过参数的命名大致便确定为魔改的tea
加密。
xxtea
的数据初始化
xxtea
可见魔改了delta和位运算的参数。
delta
为289739796,位运算参数为6、3、4、5。
func6
func6为check函数,其将输入的参数4个8byte一组x、y、z、w进行逻辑运算并于data5中的数据比对,并且根据main函数中的逻辑没组第二个数据即y需要等于data4中的数据,一共3组。
因为约束求解可能会出现多解的问题,故出题人添加data4进行约束,保证解为正确的flag。
注意入栈是元素从左向右入,例如get x再get y not and 则是对y取~ 并且与x与 ,即x&(~y) 。
更多细节不再展开,把握规律后阅读十分简单。
get flag
首先我们需要通过z3约束求解方程,之后需要将得到的结果转为func5之前的存放方式,并依次进行异或、换表base64以及魔改的xxtea。
step1
from z3 import *
"""
a=((~x)&z)
b = w ^ (((~y)&x) | ((~y)&z) | (x&y)| ((~x)&z))
c = (((~(x|y)) | ((~x & y) | (x & y))) & z) | ((~y & z) & x)
d = (~y&x) | (x&y) | (~x&z) | (~y&z)
e = (y&z)|(x&y)|(~x&z)
"""
ans=[]
data3= [5911837666743816200 ,
5133585975960501272 ,
9082418069800623372 ,
9154480062992383756 ,
6848599583376686600 ,
147787617043219975 ,
6140429622497212985 ,
2526269866358605591 ,
4552892036882908959 ,
4543304157965338119 ,
4620825451554930944 ,
8808899373961281307 ,
5924901230665995531 ,
8808899373961281307 ,
8662527953563041043 ]
data4=[2101524238053948931,9154814254531429383,8941618984500083987]
index=0
for i in range(3):
x=BitVec('x',65)
y=BitVec('y',65)
z=BitVec('z',65)
w=BitVec('w',65)
s=Solver()
s.add((~x)&z==data3[5*i])
s.add(w ^ (((~y)&x) | ((~y)&z) | (x&y)| ((~x)&z))==data3[5*i+1])
s.add((((~(x|y)) | ((~x & y) | (x & y))) & z) | ((~y & z) & x)==data3[5*i+2])
s.add((~y&x) | (x&y) | (~x&z) | (~y&z)==data3[5*i+3])
s.add((y&z)|(x&y)|(~x&z) ==data3[5*i+4])
s.add(y==data4[index])
index+=1
print(s.check())
m=s.model()
ans.append(m[x].as_long())
ans.append(m[y].as_long())
ans.append(m[z].as_long())
ans.append(m[w].as_long())
#get ans
print(ans)
def tobyte1(x):
h=hex(x)[2:].zfill(16)
hi=int('0x'+h[:8],16)
lo=int('0x'+h[8:],16)
b2=int.to_bytes(hi,4,'little')
b1=int.to_bytes(lo,4,'little')
return b1 + b2
res=b''
for i in ans:
if i!=0:
res+=tobyte1(i)
res = list(res)
l=len(res)
ms=[BitVec('ms%d'%i,8) for i in range(l)]
sk=[]
for i in range(len(ms)):
sk.append(ms[i])
for i in range(len(ms)): # 异或
ms[i]^=ms[(i+2)%len(ms)]
s=Solver()
for i in range(len(ms)):
s.add(ms[i]==res[i])
print(s.check())
ss=''
mm=s.model()
for i in range(len(ms)):
ss+=chr(mm[sk[i]].as_long())
print(ss)
#mZi5mwyYytzJnJGWmJaWowrLy2m1nZLLyta5mZLInti0nZa5mJzMnJaWntHIowuXm2vLmJfMywjJn2y1nMeXzG==
step2
变表base64
step3
魔改xxtea
from ctypes import c_uint32
DELTA = 289739796
from Crypto.Util.number import *
def decrypt(v, n, k):
rounds = 6 + int(52 / n)
sum = c_uint32(rounds * DELTA)
y = v[0].value
while rounds > 0:
e = (sum.value >> 2) & 3
p = n - 1
while p > 0:
z = v[p - 1].value
v[p].value -= (((z >> 6 ^ y << 3) + (y >> 4 ^ z << 5)) ^ ((sum.value ^ y) + (k[(p & 3) ^ e] ^ z)))
y = v[p].value
p -= 1
z = v[n - 1].value
v[0].value -= (((z >> 6 ^ y << 3) + (y >> 4 ^ z << 5)) ^ ((sum.value ^ y) + (k[(p & 3) ^ e] ^ z)))
y = v[0].value
sum.value -= DELTA
rounds -= 1
s=bytes.fromhex('3291f2a6c6802009decc579ea0939b52470926f60058b9e13ee21fabc7f56a1f')
vv=[]
for i in range(0,len(s)//4):
num=0
for j in range(4):
num|=s[4*i+j]<<(8*j)
vv.append(c_uint32(num))
k=[]
kk=b'welcometoycb2022'
for i in range(0,len(kk)//4):
num=0
for j in range(4):
num|=kk[4*i+j]<<(8*j)
k.append((num))
decrypt(vv,len(vv),k)
ans=b''
for i in range(len(vv)):
ans+=int.to_bytes(vv[i].value,4,'little')
print(ans)
#9JIOSTywl3n3VzpanY93l9sZGtK1YrVr
总结
本题通过泄露的字符串确定位quickjs逆向,但逆向过程仍然具有一定的困难,版本为最新版,网上资料指出的patch并不明确且会编译出错,只能思考新的patch点。即便是拿到字节码后逆向仍是一件痛苦的事情,例如func6中冗余的逻辑运算,令人头晕目眩的端序转换等。对于字节码逆向的题目,只要把握其指令格式,花费一定的时间一般都会得到解决,虽然过程很枯燥,但是去拼一血、抢时间的过程却十分的令人兴奋。
遗憾的不该是我们,致敬每一位死磕字节码,与各种算法做斗争的逆向战士们!。
参考:
- 本文作者: Lu1u
- 本文来源: 奇安信攻防社区
- 原文链接: https://forum.butian.net/share/1887
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!