Qfrost 发表于 2021-11-16 10:35

【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}
```

d1ves 发表于 2021-11-16 13:35

太厉害了,你写的每一个字我都认识。合一起我啥也看不懂{:1_907:}

Ichild 发表于 2021-11-17 09:30

db 0x3E并不是出题人故意加的,你可以看到db 0x3E的地址和下一条jmp的地址是相同的,这好像是指示某个段?具体不记得了。建议学一下switch的修复:edit -> other -> specify switch idiom

minibeetuaman 发表于 2021-11-16 12:52

精彩,庖丁解牛把VM的emulator部分弄出来,釜底抽薪的做法!

yudream 发表于 2021-11-16 17:06

太强了,对VM类的分析提供了新的思路

jiemo365 发表于 2021-11-16 17:11

学习了~谢谢分享

93244 发表于 2021-11-16 17:27

感谢楼主,虽然看不懂

搜索曾经的回忆 发表于 2021-11-16 17:36

很好的思路,学习了

raycerlane 发表于 2021-11-16 19:01

学习了,非常感谢!回去试试

dragonjelly 发表于 2021-11-16 19:37

厉害了,又学习到了新思路,感谢楼主分享{:301_993:}

Xiaomi0814 发表于 2021-11-16 20:14

aikoz88 发表于 2021-11-16 18:38
谢谢 学习一下

我也过来学习一下
页: [1] 2 3 4
查看完整版本: 【L3HCTF】double-joy引出的解VM类题型新思路