刚刚结束的L3HCTF,可以说题目质量很高,题目《很有意思》。这次比赛中印象比较深的是double-joy这题,也是对VM类题目有了一些新的解题思路。之前大大小小比赛,出现过很多VM类题目,楼主一直用的都是最传统的 “跟调、爆破、dump” 等方法,但是这次比赛,double-joy这个VM调用关系太复杂了,用之前的方法根本看不懂,(VM跑了202次,人傻了),所以采用了一种新的方法来解VM。
VM其实做的是一个opcode模拟shellcode的运行过程,opcode进入VM译码执行对应的汇编操作,如果能把shellcode dump出来,通过静态分析工具建数据流和控制流,甚至生成伪代码,那相当于剥离VM了,程序的分析难度也大大降低了。而实现这个过程,需要手动的分析出VM里每条opcode对应的汇编,并dump出opcode将所有opcode译码成汇编输出并编译。如果前面的过程都没有错,就可以在IDA等静态分析工具上清晰的看到VM做了什么事情。其实这种方法之前就看有师傅用过,但因为嫌麻烦一直没尝试,这次实在没办法用了一下,“真香”!
听着还是有点抽象,直接以L3HCTF的double-joy来讲一下
1e179bbe47324b02b8b5534b17b42e97.zip
(6.06 KB, 下载次数: 19)
double-joy
IDA打开程序,main函数里首先是要求输入39个字符,然后做了一堆vm的初始化,这里调过去看和输入没关系就暂时没有管,然后会进入一个嵌套的while
然后可以分析出vm_instance结构体的结构,为了方便分析,可以在IDA里面创建一个结构体。在Structures窗口里,Add struct type(Insert键)一个名为VM的结构
然后按Data(D键),创建VM结构体成员并定义对应的数据类型
然后开始分析VM(sub_1D90)
这题可以说用了IDA静态分析的bug,VM函数是一个switch结构但是这个结构没有被识别出来,导致F5什么都看不到。这是因为出题人在switch的jmp前加了一条 db 0x3E,他干扰了IDA对switch的识别。不过这个问题大家都知道,直接把这个位置patch成nop就可以正常F5了。
F5后,如果已经建好了VM实例的结构,那就很简单了,是一个很清晰的VM。18个opcode,分析发现虚拟机的操作和Python虚拟机很像,都是基于栈从栈顶做操作的,没有通用寄存器
18个opcode都非常简单,然后就是重点了,要对这18种opcode写出对应的汇编。每条opcode的具体实现就不一一细说了,可以看代码
ip = 0
asm = ""
while ip < len(opcode):
asm += "label_{}:\n".format(ip) # ip作为label
if opcode[ip] == 0: # add
ip += 1
asm += """ \
pop eax;
pop ebx;
add eax, ebx;
push eax;
"""
elif opcode[ip] == 1: # sub
ip += 1
asm += """\
pop eax;
pop ebx;
sub eax, ebx;
push eax;
"""
elif opcode[ip] == 2: # imul
ip += 1
asm += """\
pop eax;
pop ebx;
imul eax, ebx;
push eax;
"""
elif opcode[ip] == 3: # idiv
ip += 1
asm += """\
xor edx, edx;
pop eax;
pop ebx;
idiv ebx;
push eax;
"""
elif opcode[ip] == 4: # mod
ip += 1
asm += """\
pop eax;
pop ebx;
idiv ebx;
push edx;
"""
elif opcode[ip] == 5: # and
ip += 1
asm += """\
pop eax;
pop ebx;
and eax, ebx;
push eax;
"""
elif opcode[ip] == 6: # or
ip += 1
asm += """\
pop eax;
pop ebx;
or eax, ebx;
push eax;
"""
elif opcode[ip] == 7: # xor
ip += 1
asm += """\
pop eax;
pop ebx;
xor eax, ebx;
push eax;
"""
elif opcode[ip] == 8: # stack[TOS1]=TOS
ip += 1
asm += """\
pop eax;
pop ebx;
mov [ebp+4*ebx], eax;
"""
elif opcode[ip] == 9: # TOS=stack[TOS]
ip += 1
asm += """\
mov eax, [esp];
mov ebx, [ebp+4*eax];
mov [esp], ebx;
"""
elif opcode[ip] == 10: # TOS=!TOS
ip += 1
asm += """\
xor ebx,ebx;
mov eax, [esp];
test eax, eax;
sete bl;
mov [esp], ebx;
"""
elif opcode[ip] == 11: # TOS=TOS<0
ip += 1
asm += """\
mov eax, [esp];
shr eax, 31;
mov [esp], eax;
"""
elif opcode[ip] == 12: # xchg TOS1, TOS
ip += 1
asm += """\
pop eax;
pop ebx;
push eax;
push ebx;
"""
elif opcode[ip] == 13: # pop
ip += 1
asm += """\
pop eax;
"""
elif opcode[ip] == 14: # push
val = struct.unpack("<i", bytes(opcode)[ip+1:ip+5])[0]
ip += 5
asm += """\
push {};
""".format(val)
elif opcode[ip] == 15: # jmp
val = struct.unpack("<i", bytes(opcode)[ip+1:ip+5])[0]
ip += 5
asm += """\
jmp label_{};
""".format(ip+val)
elif opcode[ip] == 16: # jnz TOS
val = struct.unpack("<i", bytes(opcode)[ip+1:ip+5])[0]
# if stack[sp] != 0:
# ip += 5 + val
# else:
# ip += 5
ip += 5
asm += """\
pop eax;
cmp eax, 0;
jnz label_{};
""".format(ip+val)
elif opcode[ip] == 17: # sp += imm
val = struct.unpack("<i", bytes(opcode)[ip+1:ip+5])[0]
ip += 5
asm += """\
sub esp, {};
""".format(val*4)
elif opcode[ip] == 18: # ret
val = struct.unpack("<i", bytes(opcode)[ip+1:ip+5])[0]
ip += 5
asm += """\
mov eax, {};
retn;
""".format(val)
else:
print("default")
ip += 1
pass
# time.sleep(0.1)
print(asm)
fp = open("shellcode.asm", 'w')
fp.write("section .text\n")
fp.write("bits 32\n")
fp.write(asm)
fp.close()
我对每条opcode都插入了一个label,因为VM里涉及跳转,而且跳转都是相对于opcode进行的跳转,但在汇编里的跳转是相对于汇编的,所以以opcode为单位设置label,在跳转汇编里直接写对应的label就可以了,非常方便。
然后把opcode dump出来,用上面的脚本可以输出asm汇编文件。然后用nasm编译成二进制文件。
然后可以对照着shellcode和动调的结果,把栈上的各个位置所表示的含义修复出来。
再看shellcode,就非常清晰了。第一段shellcode:
第二段shellcode:
可以很清晰的看出,第一段shellcode在做xTEA,而第二段shellcode在做TEA。其中delta密钥的初始化均在其中,并且在TEA和xTEA算法之前都有一次异或。
然后就是坑的地方了,动调会发现这个VM走了很多很多次,而且TEA和xTEA的opcode是交替执行的,且EIP还不一样(这两段shellcode dump出来会发现分别都有两个入口)。对于这个问题,我在 0x148B 的位置下了一个条件断点,把每次进入VM前的相关数据打印出来
stack_base = get_qword(cpu.rdi+8)
stack = []
for i in range(22):
stack.append(hex(get_wide_dword(stack_base+i*4)))
print("EIP:%s \t" % hex(get_wide_word(cpu.rdi+0x10)), stack)
可以看到,总共进入VM了202次,只有第一次和第二次进入时EIP为0,其他时候走的都是另一个入口,并且两段opcode是交替着跑的。分析可以知道第一次和第二次走入口为0时,做的是TEA和xTEA的初始化操作,而后面只是跑for循环,而且跑一轮就出来,外面控制参数再跑。输入共10个DWORD数,两个一组分为5组,每组跑20轮TEA和20轮xTEA,再加两次初始化操作,正好202次VM。
然后就可以写脚本出了,就是一个xTEA和TEA的交替算法
#include<cstdio>
#include<stdio.h>
int cipher[10]={-1360987416, -63028991, 377269650, 1374317758, 606732544, 22092315, 1364027028, 794804203, 1188258712, 2045699056};
void decrypt(int *v)
{
int xtea_key[4]={0x494c, 0x6f76, 0x6520, 0x4355};
int tea_key[4]={0x5354, 0x4f4d, 0x2074, 0x6561};
int round_max;
int xtea_sum=0x423a35c6;
int tea_sum=0x6042aaa4;
for(int i=0;i<10;i+=2) {
if(i==0) round_max = 19;
else round_max = 20;
int v0=v[i],v1=v[i+1];
for(int round=0;round<round_max;round++) {
xtea_sum += 0x75bcd15;
tea_sum += 0x154cbf7;
}
}
for (int i=8;i>=0;i-=2) {
int round_max=(i==0)?19:20;
int v0=v[i],v1=v[i+1];
for(int round=round_max-1;round>=0;round--) {
v1 -= ((v0 * 16) + tea_key[2]) ^ (v0 + tea_sum) ^ ((v0 / 32) + tea_key[3]);
v0 -= ((v1 * 16) + tea_key[0]) ^ (v1 + tea_sum) ^ ((v1 / 32) + tea_key[1]);
tea_sum -= 0x154cbf7;
v1 -= (((v0 * 16) ^ (v0 / 32)) + v0) ^ (xtea_sum + xtea_key[(xtea_sum / 2048) & 3]);
xtea_sum -= 0x75bcd15;
v0 -= (((v1 * 16) ^ (v1 / 32)) + v1) ^ (xtea_sum + xtea_key[xtea_sum & 3]);
}
v[i]=v0;v[i+1]=v1;
}
v[1] -= ((16 * v[0] + 8308) ^ (v[0] + 1614981796) ^ (v[0] / 32 + 25953));
v[0] -= (16 * v[1] + 21332) ^ (v[1] + 1614981796) ^ (v[1] / 32 + 20301);
for (int i=0;i<10;i++)
v[i]^=0x01010101*(i+1);
v[1] -= (((16 * v[0]) ^ (v[0] / 32)) + v[0]) ^ 0x423A9AE6;
v[0] -= (((16 * v[1]) ^ (v[1] / 32)) + v[1]) ^ 0x3ADED827;
for (int i=0;i<10;i++)
v[i]^=0x01010101*(i+1);
}
int main() {
decrypt(cipher);
for(int i=0;i<40;i++)
printf("%c",*((char*)cipher+i));
}
// L3HCTF{D0uBle_vM_W1th_dOubIe_TEA}