【L3HCTF】double-joy引出的解VM类题型新思路
刚刚结束的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来讲一下
## **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的具体实现就不一一细说了,可以看代码
```python
ip = 0
asm = ""
while ip < len(opcode):
asm += "label_{}:\n".format(ip) # ip作为label
if opcode == 0: # add
ip += 1
asm += """\
pop eax;
pop ebx;
add eax, ebx;
push eax;
"""
elif opcode == 1: # sub
ip += 1
asm += """\
pop eax;
pop ebx;
sub eax, ebx;
push eax;
"""
elif opcode == 2: # imul
ip += 1
asm += """\
pop eax;
pop ebx;
imul eax, ebx;
push eax;
"""
elif opcode == 3: # idiv
ip += 1
asm += """\
xor edx, edx;
pop eax;
pop ebx;
idiv ebx;
push eax;
"""
elif opcode == 4: # mod
ip += 1
asm += """\
pop eax;
pop ebx;
idiv ebx;
push edx;
"""
elif opcode == 5: # and
ip += 1
asm += """\
pop eax;
pop ebx;
and eax, ebx;
push eax;
"""
elif opcode == 6: # or
ip += 1
asm += """\
pop eax;
pop ebx;
or eax, ebx;
push eax;
"""
elif opcode == 7: # xor
ip += 1
asm += """\
pop eax;
pop ebx;
xor eax, ebx;
push eax;
"""
elif opcode == 8: # stack=TOS
ip += 1
asm += """\
pop eax;
pop ebx;
mov , eax;
"""
elif opcode == 9: # TOS=stack
ip += 1
asm += """\
mov eax, ;
mov ebx, ;
mov , ebx;
"""
elif opcode == 10: # TOS=!TOS
ip += 1
asm += """\
xor ebx,ebx;
mov eax, ;
test eax, eax;
sete bl;
mov , ebx;
"""
elif opcode == 11: # TOS=TOS<0
ip += 1
asm += """\
mov eax, ;
shr eax, 31;
mov , eax;
"""
elif opcode == 12: # xchg TOS1, TOS
ip += 1
asm += """\
pop eax;
pop ebx;
push eax;
push ebx;
"""
elif opcode == 13: # pop
ip += 1
asm += """\
pop eax;
"""
elif opcode == 14: # push
val = struct.unpack("<i", bytes(opcode))
ip += 5
asm += """\
push {};
""".format(val)
elif opcode == 15: # jmp
val = struct.unpack("<i", bytes(opcode))
ip += 5
asm += """\
jmp label_{};
""".format(ip+val)
elif opcode == 16: # jnzTOS
val = struct.unpack("<i", bytes(opcode))
# if stack != 0:
# ip += 5 + val
# else:
# ip += 5
ip += 5
asm += """\
pop eax;
cmp eax, 0;
jnz label_{};
""".format(ip+val)
elif opcode == 17: # sp += imm
val = struct.unpack("<i", bytes(opcode))
ip += 5
asm += """\
sub esp, {};
""".format(val*4)
elif opcode == 18: # ret
val = struct.unpack("<i", bytes(opcode))
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编译成二进制文件。
- nasm shellcode.asm
然后可以对照着shellcode和动调的结果,把栈上的各个位置所表示的含义修复出来。
再看shellcode,就非常清晰了。第一段shellcode:
第二段shellcode:
可以很清晰的看出,第一段shellcode在做xTEA,而第二段shellcode在做TEA。其中delta密钥的初始化均在其中,并且在TEA和xTEA算法之前都有一次异或。
然后就是坑的地方了,动调会发现这个VM走了很多很多次,而且TEA和xTEA的opcode是交替执行的,且EIP还不一样(这两段shellcode dump出来会发现分别都有两个入口)。对于这个问题,我在 0x148B 的位置下了一个条件断点,把每次进入VM前的相关数据打印出来
```python
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的交替算法
```cpp
#include<cstdio>
#include<stdio.h>
int cipher={-1360987416, -63028991, 377269650, 1374317758, 606732544, 22092315, 1364027028, 794804203, 1188258712, 2045699056};
void decrypt(int *v)
{
int xtea_key={0x494c, 0x6f76, 0x6520, 0x4355};
int tea_key={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,v1=v;
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,v1=v;
for(int round=round_max-1;round>=0;round--) {
v1 -= ((v0 * 16) + tea_key) ^ (v0 + tea_sum) ^ ((v0 / 32) + tea_key);
v0 -= ((v1 * 16) + tea_key) ^ (v1 + tea_sum) ^ ((v1 / 32) + tea_key);
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);
}
v=v0;v=v1;
}
v -= ((16 * v + 8308) ^ (v + 1614981796) ^ (v / 32 + 25953));
v -= (16 * v + 21332) ^ (v + 1614981796) ^ (v / 32 + 20301);
for (int i=0;i<10;i++)
v^=0x01010101*(i+1);
v -= (((16 * v) ^ (v / 32)) + v) ^ 0x423A9AE6;
v -= (((16 * v) ^ (v / 32)) + v) ^ 0x3ADED827;
for (int i=0;i<10;i++)
v^=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}
``` 太厉害了,你写的每一个字我都认识。合一起我啥也看不懂{:1_907:} db 0x3E并不是出题人故意加的,你可以看到db 0x3E的地址和下一条jmp的地址是相同的,这好像是指示某个段?具体不记得了。建议学一下switch的修复:edit -> other -> specify switch idiom 精彩,庖丁解牛把VM的emulator部分弄出来,釜底抽薪的做法! 太强了,对VM类的分析提供了新的思路 学习了~谢谢分享 感谢楼主,虽然看不懂 很好的思路,学习了 学习了,非常感谢!回去试试 厉害了,又学习到了新思路,感谢楼主分享{:301_993:} aikoz88 发表于 2021-11-16 18:38
谢谢 学习一下
我也过来学习一下