This page looks best with JavaScript enabled

L3HCTF double-joy引出的解VM类题型新思路

 ·  ☕ 6 min read · 👀... views

师傅们太强了,这波直接夺冠保送Final!
result

这次比赛做这个double-joy有些新的想法,好吧其实也 。可这次的VM实在太复杂了,之前的跟调、爆破、dump根本看不懂,神tm VM跑了202次opcode还不一样,跟完一段都已经傻了。

大概就是,VM其实做的是一个opcode模拟shellcode的运行过程,opcode进入VM译码执行对应的汇编操作,如果能把shellcode dump出来,通过静态分析工具建数据流和控制流,甚至生成伪代码,那相当于剥离VM了,程序的分析难度也大大降低了。而实现这个过程,需要手动的分析出VM里每条opcode对应的汇编,并dump出opcode将所有opcode译码成汇编输出并编译。如果前面的过程都没有错,就可以在IDA等静态分析工具上清晰的看到VM做了什么事情。

听着还是有点抽象,直接以L3HCTF的double-joy来讲一下

double-joy

main函数里首先是要求输入39个字符,然后做了一堆vm的初始化,这里调过去看和输入没关系就暂时没有管,然后会进入一个嵌套的while

double-joy_1

然后可以分析出vm_instance结构体的结构,为了方便分析,可以在IDA里面创建一个结构体。在Structures窗口里,Add struct type(Insert键)一个名为VM的结构

double-joy_2

然后按Data(D键),创建VM结构体成员并定义对应的数据类型

double-joy_3

然后开始分析VM(sub_1D90)

这题可以说用了IDA静态分析的漏洞,VM函数是一个switch结构但是这个结构没有被识别出来,导致F5什么都看不到。这是因为出题人在switch的jmp前加了一条 db 0x3E,他干扰了IDA对switch的识别。不过这个问题大家都知道,直接把这个位置patch成nop就可以正常F5了。

F5后,如果已经建好了VM实例的结构,那就很简单了,是一个很清晰的VM。18个opcode,分析发现虚拟机的操作和Python虚拟机很像,都是基于栈从栈顶做操作的,没有通用寄存器

double-joy_4

18个opcode都非常简单,然后就是重点了,要对这18种opcode写出对应的汇编。每条opcode的具体实现就不一一细说了,可以看代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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编译成二进制文件。

  • nasm shellcode.asm

然后可以对照着shellcode和动调的结果,把栈上的各个位置所表示的含义修复出来。

double-joy_5

再看shellcode,就非常清晰了。第一段shellcode:

double-joy_6

第二段shellcode:

double-joy_7

可以很清晰的看出,第一段shellcode在做xTEA,而第二段shellcode在做TEA。其中delta密钥的初始化均在其中,并且在TEA和xTEA算法之前都有一次异或。

然后就是坑的地方了,动调会发现这个VM走了很多很多次,而且TEA和xTEA的opcode是交替执行的,且EIP还不一样(这两段shellcode dump出来会发现分别都有两个入口)。对于这个问题,我在 0x148B 的位置下了一个条件断点,把每次进入VM前的相关数据打印出来

1
2
3
4
5
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)

double-joy_8

可以看到,总共进入VM了202次,只有第一次和第二次进入时EIP为0,其他时候走的都是另一个入口,并且两段opcode是交替着跑的。分析可以知道第一次和第二次走入口为0时,做的是TEA和xTEA的初始化操作,而后面只是跑for循环,而且跑一轮就出来,外面控制参数再跑。输入共10个DWORD数,两个一组分为5组,每组跑20轮TEA和20轮xTEA,再加两次初始化操作,正好202次VM。

然后就可以写脚本出了,就是一个xTEA和TEA的交替算法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#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));
}
Share on

Qfrost
WRITTEN BY
Qfrost
CTFer, Anti-Cheater, LLVM Committer