这个题目是鹏程杯预赛里的一道Reverse,是一个比较隐蔽的vm类型题目,赛后战队review被安排到了这个题目,网上的WP都解释的都很简单,正好就以该题目为例,自己做一次vm类型题目的详细记录。

vm类型题目一般是自己写程序来解释执行一些基本的汇编程序指令,这种类型题目的解题方法基本是一样的,就是程序复杂程度的区别,简单的话直接读指令就能猜出来程序逻辑,复杂点的就得自己写一个解释器把程序里指令翻译成自己理解的汇编指令或者其他指令,再去分析,这道题目我是写了一个简单的解释器,具体代码贴在后面,先来看题目。

题目分析

拿到题目,拖进IDA里,找到main函数F5看一下反汇编结果:

发现看不出什么逻辑,只能看出来是c++的代码,有一句输出,点开每个函数简单看看,发本看不出来什么明显的逻辑,但是发现在cout语句前面的几个函数中有两个反调试函数,使得我们gdb调试的时候会直接报错退出,如果想用gdb来动态调试的话,可以把两个反汇编函数用nop给patch掉,操作不难,具体做法这里就不说了,因为我patch掉以后并没有用到动态调试。

接下来就得想办法找到题目的一些关键函数,怎么找呢,一个一个翻太慢了,先运行一下程序看看:

需要我们输入一串字符,然后输出错误提示,看来关键逻辑应该是在我们输入之后,所以要先找到cin函数,直接去ida的import视图下找没找到,就从‘err...’这个字符串入手,根据调用关系向上寻找,找到了一个functiontable:

猜想可能是根据偏移量来调用函数的,再寻找off_205A10的调用关系,终于发现了cin:

那么主要逻辑应该就是在cin之后的几个函数。

一个一个分析,首先是sub_2BE0函数:

分析一下,应该是自己实现的一个str_cpy功能函数,把输入的字符串拷贝到另一个地址中。
然后是sub_2D50函数:

分析一下大概的逻辑是对字符串做了4轮的异或加密,加密逻辑也比较简单,然后将结果赋给另一个变量,分析发现,传入的参数并不是一个简单的字符串,应该是一个结构体,为了方便后面代码的分析,自己在IDA中标一下结构体,这是我标记的字符串结构体:

对于不清楚具体含义的域可以先空着,之后分析用到了再去补充,之后再去看这个函数,就会清晰很多:

接下来是sub_2DD0函数,就是在这个函数中,调用了之前发现的function table:

分析函数里的操作,指针操作有些多,看着是一些赋值操作,猜测应该也是一些结构体变量的初始化,我们先跳过直接看function call调用的function table里的函数:

主要部分是一个while循环,找到了!!VM的主要逻辑就在这里,简单分析一下,循环中的分支路径应该就是不同的操作函数,根据不同的opcode调用function table里的函数作为对应的Handler,直接分析while循环,会发现指针操作,十分混乱,很难理清楚逻辑,需要把相关的变量和结构体分析标注清楚来帮助分析。

VM代码分析

想要分析清楚VM代码的主要逻辑,必须要搞清楚程序中和汇编指令相关的几种变量,首先是程序流,通常是用数组或字符串表示。然后是pc指针,一般在程序中会是一个程序流的index索引,指向要执行的命令,然后opcode,这个比较容易发现,一般会根据opcode来决定调用不同的Handler函数。然后是寄存器regs,涉及到程序中的运算部分,通常会是一个数组表示,程序流中通常直接用数组的索引值来使用寄存器。举个例子,add 1,2 可以理解为regs[1]+=regs[2]。然后是mem和flag,程序会从一段已知的内存中取值到寄存器中,这个已知的数组一般就是mem,和regs使用的方法一样,只不过regs在程序开始前值是未知的,mem的值在程序开始时是已知的,flag通常用来记录比较或者运算的结果,根据flag来决定程序的跳转。

分析出程序的pc,regs,mem,flag等变量,然后对相关的结构体进行标注,程序就会变得十分容易理解,这是我标记的相关结构体,不同题目会有差异,但本质上寻找的变量是一样的:

再回头分析相关函数,就会清晰很多:


之后就是对每一个Handler函数进行分析,这里以只举一个例子,function table里的第一个函数:

这是一个汇编的add命令,操作数是两个寄存器在寄存器数组中的索引,vmcode是程序流,一个word类型的数组,pc指针在每次乘3(其他Handler中也是),说明指令是定长的:opcode、op1、op2。regs从当前程序中去的寄存器状态,计算后返回结果。所以这个函数可以理解为add reg1,reg2。

用同样的方法对function table中的所有函数分析,得到如下结果:

值得注意的是,分析发现程序中opcode的种类只有有限的几种,但是程序流vmcode中出现了一些无法匹配的opcode,仔细分析程序发现,vmcode是word类型的数组,每条命令取3个元素,第一个word元素会被分为两部分,前8个字节是flag,后8个字节才是opcode,具体如下:

终于做完了所有的准备工作,进入正题,这些vm命令到底做了什么事情呢,我自己写了一个脚本来将程序流解释为比较直观的类汇编命令,代码如下:

#-*-coding:utf-8-*-
code=[
0x0008, 0x0000, 0x0014,
0x0008, 0x0001, 0x0000,
0x0008, 0x0002, 0x0001, 
0x0008, 0x0007, 0x0009,
0x0008, 0x0008, 0x0000,
0x0008, 0x0009, 0x0000,
0x0001, 0x0009, 0x0008,
0x0001, 0x0008, 0x0002,
0x0003, 0x0007, 0x0008,
0x0204, 0xFFFC, 0x0000,
0x0005, 0x0003, 0x0009,
0x0003, 0x0001, 0x0000,
0x0104, 0x000A, 0x0000,
0x0005, 0x0004, 0x0001,
0x0001, 0x0004, 0x0003,
0x0001, 0x0004, 0x0004,
0x000A, 0x0005, 0x0001,
0x000C, 0x0005, 0x0004,
0x000B, 0x0006, 0x0001,
0x0001, 0x0001, 0x0002,
0x0003, 0x0006, 0x0005,
0x0104, 0xFFF5, 0x0000,
0x0204, 0x0001, 0x0000,
0x00FF, 0x0000, 0x0000,
0x0009, 0x0000, 0x0000,
0x00FF, 0x0000, 0x0000,
0x0000, 0x0000]

def interpreter():
    inst = {
    1: "add",
    2: "sub",
    3: "cmp_regs(== ->1,!= ->2)",
    4: "jmpoffset_newpc",
    5: "mov_reg2reg",
    6: "mov_pc_reg",
    7: "jmpreg_newpc",
    8: "mov_value2reg",
    9: "err_res0",
    10: "load_mem2reg",
    11: "load_encflag2reg",
    12: "xor_reg^reg",
    13: "check_res_no0",
    255: "exit"
    }

    flag=0
    for i in range(0,len(code),3):
        if(i+2>=len(code)):
            break
        opcode=code[i]
        op1=code[i+1]
        op2=code[i+2]
        if not inst.has_key(opcode):
            print hex(opcode)
            flag=opcode>>8
            opcode&=0xff
        else:
            flag=0

        print ("%02d: %04x %04x %04x -- %s %d %d" % (i/3,opcode,op1,op2,inst[opcode],op1,op2))

interpreter()

得到结果如下:

对汇编命令分析就简单了,这个程序也不复杂,首先是通过一个循环,累加0-9,得到一个常数36,之后用36对字符串中的每一位异或(36+i)×2。然后与mem中固定的字符串进行比较。

解题

总结一下程序逻辑,首先读输入的字符串做了4轮的异或加密,之后对加密后的字符串每一位都异或(36+i)×2,然后与固定的字符串进行比较,如果一致,则通过检查。我们现在知道加密后的字符串,也知道字符处理分方法,下一个逆向处理的脚本就能得到结果,程序如下:

def solve():
    data=[0x002E, 0x0026, 0x002D, 0x0029, 0x004D, 0x0067, 0x0005, 0x0044, 0x001A, 0x000E, 0x007F, 0x007F, 0x007D, 0x0065, 0x0077, 0x0024, 0x001A, 0x005D, 0x0033, 0x0051]
    res=[]
    for i in range(len(data)):
        res.append(data[i]^(36+i)*2)
    print res
    for i in range(4):
        for j in range(19,0,-1):
            res[j]^=res[j-1]
    print ''.join(map(chr,res))

solve()
#flag{Y0u_ar3_S0co0L}

总结

分析完程序之后发现,程序逻辑实际上很简单,其实大多VM类型题目的逻辑都不会特别复杂,很多都是异或一个常数、异或字符串中的前一个字符等比较直观的处理。当然也有一些特别难的vm题目,遇到了以后再来分析吧。

这类题目十分考验基本的逆向能力,没有多少技术上的要求,只要能读懂汇编,有耐心,抓住重点,分析出vm指令的pc、regs等相关变量,就能分析出程序的大概逻辑,除此之外,清楚的标注结构体对理解整个程序是有巨大的帮助。

有什么问题欢迎留言讨论~~

源链接

Hacking more

...