Lua程序逆向之为Luac编写IDA Pro处理器模块

上一篇讲解了如何加载一个Luac文件到IDA Pro当中,加载进入idb数据库的内容犹如切好洗净的食材,并不能粗暴的直接展示给用户,还需要IDA Pro中的处理器模块对内容进行下一步的反汇编渲染与指令功能注释,才能最终装盘食用。

处理器模块的工作就是:解析不同段的内容,确定代码段后,通过指定的指令格式解析与构造指令;确定指令使用的数据类型、寄存器与助记符;执行代码段的线性式代码反汇编;为指令标记注释与交叉引用等。

 

处理器模块架构

IDA Pro没有详细的文档描述如何开发处理器模块,最有效的学习途径是阅读IDA Pro程序中自带的开源的处理器模块代码。IDA Pro的处理器模块比文件加载器在架构上要更加晦涩难懂,实现起来也要复杂得多。

本篇写作时,对应的IDA Pro版本为国内众所周知的IDA Pro版本7.0,实验环境为macOS 10.12平台,处理器模块的开发选择使用Python。在IDA Pro软件的加载器目录(macOS平台):/Applications/IDAPro7.0/ida.app/Contents/MacOS/procs中,有着3个Python编写的处理器模块代码,分别是spu.py、ebc.py、msp430.py,如果安装了IDA Pro的开发SDK,在其中的module/script目录下也会找到这些模块,另外,还会包含一个proctemplate.py模板。

理论上,本节编写的Luac处理器模块,放到Windows等其他平台上,不需要进行任何的修改,也可以很好的工作。 本次参考使用到的代码是ebc.py模块,因为它的实现代码量不算最少,但在指令的解码处理上,代码更加直观。

处理器模块要求py中有一个定义为PROCESSOR_ENTRY()的方法,它的返回值是一个processor_t类型的类结构,IDA Pro通过检查这个类的字段,与回调它的方法,来完成指令的处理。一个精简的代码架构如下:

class ebc_processor_t(processor_t):
    ...
    # ----------------------------------------------------------------------
    def __init__(self):
        processor_t.__init__(self)
        self.PTRSZ = 4 # Assume PTRSZ = 4 by default
        self.init_instructions()
        self.init_registers()
        ...

...

def PROCESSOR_ENTRY():
    return ebc_processor_t()

ebc_processor_t类中有很多的回调函数,它们都会在特定的场景下触发执行,所有的回调方法,可以在当前版本的IDA Pro的ida_idp.py文件中,查看processor_t的类型声明得知,不过可以发现,processor_t的声明是由swig自动生成的桥接到C的代码,看不出任何有价值的地方,在实际编写代码时,可能需要查看Python编写的处理器模块的回调函数注释,来理解回调的参数与使用场景,也可以直接查看processor_t类型在C语言中的声明,它的定义可以在SDK的include目录下的idp.hpp头文件中找到,在实现上,SDK中也包含了很多C语言编写的处理器模块,代码也很有参考价值。

这里的ebc_processor_t的__init__()方法中,会调用init_instructions()初始化处理器模块用到的指令,以及调用init_registers()初始化处理器模块用到的寄存器信息,这是一种通用的设置流程,我们在下面的代码中也采用这种方式完成Luac的相关初始化。

 

Luac处理器模块的实现

下面来动手实现Luac的处理器模块,同样的,它只支持基于Lua 5.2生成的Luac文件。 将ebc.py模块复制一份改名为loacproc.py。并修改ebc_processor_t为lua_processor_t,它的`_init()`代码不需要进行修改,代码如下:

def __init__(self):
    processor_t.__init__(self)
    self.PTRSZ = 4 # Assume PTRSZ = 4 by default
    self.init_instructions()
    self.init_registers()

self.PTRSZ描述了使用到的指针类型所占的字节大小,对于32位的Luac来说,它的值为4,通常只有在编写64位的程序处理器模块时,它的值才是8。init_instructions()与init_registers()分别用来初始化指令与寄存器列表,我们需要修改它的方法的实现部分。

在开始讲解指令与寄存器的修改前,我们先看看processor_t中需要修改的一些字段,它们的片断如下:

PLFM_LUAC = 99

class lua_processor_t(processor_t):
    # IDP id ( Numbers above 0x8000 are reserved for the third-party modules)
    id = PLFM_LUAC

    # Processor features
    flag = PR_DEFSEG32 | PR_USE64 | PRN_HEX | PR_RNAMESOK | PR_NO_SEGMOVE | PR_TYPEINFO

    # Number of bits in a byte for code segments (usually 8)
    # IDA supports values up to 32 bits
    cnbits = 8

    # Number of bits in a byte for non-code segments (usually 8)
    # IDA supports values up to 32 bits
    dnbits = 8

    # short processor names
    # Each name should be shorter than 9 characters
    psnames = ['Luac']

    # long processor names
    # No restriction on name lengthes.
    plnames = ['Lua Byte code']

    # size of a segment register in bytes
    segreg_size = 0

    # Array of typical code start sequences (optional)
    # codestart = ['\x60\x00']  # 60 00 xx xx: MOVqw         SP, SP-delta

    # Array of 'return' instruction opcodes (optional)
    # retcodes = ['\x04\x00']   # 04 00: RET

    # You should define 2 virtual segment registers for CS and DS.
    # Let's call them rVcs and rVds.

    # icode of the first instruction
    instruc_start = 0

    #
    #      Size of long double (tbyte) for this processor
    #      (meaningful only if ash.a_tbyte != NULL)
    #
    tbyte_size = 0

    segstarts = {}
    segends = {}

    ...

id字段是一个数值的ID值,用来标识处理器模块,IDA Pro中定义了一些已经存在的id,它们的定义在ida_idp.py中可以找到,如下所示:

# processor_t.id
PLFM_386        = 0       # Intel 80x86
PLFM_Z80        = 1       # 8085, Z80
PLFM_I860       = 2       # Intel 860
...
PLFM_EBC        = 57      # EFI Bytecode
PLFM_MSP430     = 58      # Texas Instruments MSP430
PLFM_SPU        = 59      # Cell Broadband Engine Synergistic Processor Unit

我们这里将其设置为PLFM_LUAC,只要定义它为一个与系统上不冲突的数值即可。

flag字段描述了处理器用到的一些特性,用样可以在ida_idp.py中可以查看processor_t.flag小节中的可选值,例如PR_USE64表示支持64位的寻址方式,PR_NO_SEGMOVE表示不支持段移动,即不允许调用move_segm()接口,PR_TYPEINFO表示支持类型信息,即支持在IDA Pro中载入til中的类型。

cnbits字段与dnbits字段表示对于代码段与非代码段,一个字节占用多少位,通常取值8。

psnames字段用来设置处理器模块的短名称,这里设置为”Luac”。还记得上一节如下的代码么:

idaapi.set_processor_type("Luac", SETPROC_ALL|SETPROC_FATAL)

当注册了该名称后,文件加载器就可以通过idaapi.set_processor_type()来设置该处理器模块了。

plnames字段是长名称,起到描述性的作用。

segreg_size字段描述段寄存器的大小,当前面的flag字段包含了PR_SEGS标志,则需要设置它的值,这里取值为0。

codestart与retcodes用于描述函数的开始与结束的指令特征,用于IDA Pro线性扫描时,自动生成函数信息。

instruc_start为指令列表的起始索引。

tbyte_size字段描述long double类型的字节大小,这里没有用到,设置为0即可。

segstarts与segends用来记录段的开始与结束地址,这两个字段在其他的代码回调处很有用。

接下来还需要设置一个assembler字段,描述了反汇编的一些信息。包括设置反汇编器的名称,各种数据类型的助记符,比如字节、字、双字通常设置为db、dw、dd,这在其他的处理器模块中常见。然后是各种保留关键字与逻辑操作的助记符,这些内容在luac_proc中,可以选择保留或者删除。

在init_instructions()的内部实现中,被要求设置一个class idef,该类型用于描述指令的具体信息,包括:指令的名称、解码回调程序、规犯标志、注释等。当然,也可以选择不实现它。在本例中,选择了使用idef辅助进行指令处理,它的定义如下:

class idef:
    """
    Internal class that describes an instruction by:
    - instruction name
    - instruction decoding routine
    - canonical flags used by IDA
    """
    def __init__(self, name, cf, d, cmt = None):
        self.name = name
        self.cf  = cf
        self.d   = d
        self.cmt = cmt

为了方便解码指令,这里定义了一张指令表self.itable,它列出了Luac中涉及到的所有指令,如下所示:

self.itable = {
    0x00: idef(name='MOVE', d=self.decode_MOVE, cf=CF_USE1 | CF_USE2, cmt=''),
    0x01: idef(name='LOADK', d=self.decode_LOADK, cf=CF_USE1 | CF_USE2, cmt=self.cmt_LOADK),
    0x02: idef(name='LOADKX', d=self.decode_LOADKX, cf=CF_USE1 | CF_USE2, cmt=''),
    0x03: idef(name='LOADBOOL', d=self.decode_LOADBOOL, cf=CF_USE1 | CF_USE2 | CF_USE3, cmt=''),
    0x04: idef(name='LOADNIL', d=self.decode_LOADNIL, cf=CF_USE1 | CF_USE2, cmt=''),
    0x05: idef(name='GETUPVAL', d=self.decode_GETUPVAL, cf=CF_USE1 | CF_USE2, cmt=''),
    ...
    0x26: idef(name='VARARG', d=self.decode_VARARG, cf=CF_USE1 | CF_USE2, cmt=''),
    0x27: idef(name='EXTRAARG', d=self.decode_EXTRAARG, cf=CF_USE1, cmt=''),
}

CF_USE1与CF_USE2标志表示使用了第一个操作数与第二个操作数,与之类似的还有CF_JUMP,表示这是一条跳转类型的指令,CF_CALL表示这是一条call类型的指令,所有支持的标志可以在ida_idp.py的instruc_t.feature小节查看。

完成这张表后,需要使用它来填充处理器模块的instruc字段,代码如下:

# Now create an instruction table compatible with IDA processor module requirements
Instructions = []
i = 0
for x in self.itable.values():
    d = dict(name=x.name, feature=x.cf)
    if x.cmt != None:
        d['cmt'] = x.cmt
    Instructions.append(d)
    setattr(self, 'itype_' + x.name, i)
    i += 1

# icode of the last instruction + 1
self.instruc_end = len(Instructions) + 1

# Array of instructions
self.instruc = Instructions

# Icode of return instruction. It is ok to give any of possible return
# instructions
self.icode_return = self.itype_RETURN

instruc_end字段为指令列表的结束索引,它对应着前面的instruc_start字段。

instruc字段通过Instructions进行设置,它只取了指令的名称与标志两个字段。

icode_return字段指明可能的返回指令,itype_RETURN是前面通过setattr()设置的RETURN指令。

下面看看指令的解码部分,即前面self.itable中定义的如self.decode_MOVE与self.decode_LOADK部分。

self.decode_MOVE的实现如下:

def decode_MOVE(self, insn, a, b, c, ax, bx, sbx):
    """
    OP_MOVE,/*    A B    R(A) := R(B)                    */
    """
    insn.Op1.type = o_reg
    insn.Op1.reg = a
    insn.Op1.dtype = dt_dword

    insn.Op2.type = o_reg
    insn.Op2.reg = b
    insn.Op2.dtype = dt_dword

    return True

可以看到,实现方法上,主要是填充inst指令的两个操作数,因为它的最终展示形式形如:

MOVE      R(A), R(B)

在填充时,除了指定它是否为寄存器类型o_reg外,还需要设置它的具体值a与b,当a为2,b为1时,它生成的反汇编代码为:

MOVE       R2, R1

然后以LOADK指令为例,它的解码回调为self.decode_LOADK,代码如下:

def decode_LOADK(self, insn, a, b, c, ax, bx, sbx):
    """
    OP_LOADK,/*    A Bx    R(A) := Kst(Bx)                    */
    """
    insn.Op1.type = o_reg
    insn.Op1.reg = a
    insn.Op1.dtype = dt_dword

    insn.Op2.type = o_displ
    insn.Op2.reg = bx
    insn.Op2.dtype = dt_dword

    return True

这一次,Op2的类型不为o_reg,而是o_displ,这是指针类型的数据,这里在最终解码时,我们会判断它操作的是否为Upvalue,来进一步确定它是UpValue,还是Constant常量,如果是前者,我们输出时会指定U开头,如果是后者,输出时会指定K开头。至于具体的判断方法,则是将指令的Op的specval值设置为1。如decode_SETUPVAL()的实现:

def decode_SETUPVAL(self, insn, a, b, c, ax, bx, sbx):
    """
    OP_SETUPVAL,/*    A B    UpValue[B] := R(A)                */
    """
    insn.Op1.type = o_reg
    insn.Op1.reg = a
    insn.Op1.dtype = dt_dword

    insn.Op2.type = o_displ
    insn.Op2.reg = b
    insn.Op2.dtype = dt_dword
    insn.Op2.specval = 1

    return True

接下来就是一条条的实现每一条指令的解码回调,这就是一个体力活。

接着是初始化寄存器的部分,代码如下:

def init_registers(self):
    """This function parses the register table and creates corresponding ireg_XXX constants"""
    # Registers definition
    self.reg_names = [
        # General purpose registers
        # #define MAXSTACK    250
        # >>> for i in xrange(250):
        # ...     print("\"R%d\"," % i)
        "R0",
        "R1",
        "R2",
        "R3",
        "R4",
        "R5",
        "R6",
        "R7",
        "R8",
        ...
        # Fake segment registers
        "CS",
        "DS"
    ]

    # Constants definition
    self.constant_names = [
        # #define MAXSTACK    250
        # >>> for i in xrange(250):
        # ...     print("\"K%d\"," % i)
        "K0",
        "K1",
        "K2",
        "K3",
        "K4",
        "K5",
        "K6",
        "K7",
        "K8",
        ...
    ]

    # Upvalues definition
    self.upvalue_names = [
        # #define MAXSTACK    250
        # >>> for i in xrange(250):
        # ...     print("\"U%d\"," % i)
        "U0",
        "U1",
        "U2",
        "U3",
        "U4",
        "U5",
        "U6",
        "U7",
        "U8",
        ...
    ]

    # Create the ireg_XXXX constants
    for i in xrange(len(self.reg_names)):
        setattr(self, 'ireg_' + self.reg_names[i], i)

    # Create the iconst_XXXX constants
    for i in xrange(len(self.constant_names)):
        setattr(self, 'iconst_' + self.constant_names[i], i)

    # Create the iupval_XXXX constants
    for i in xrange(len(self.upvalue_names)):
        setattr(self, 'iupval_' + self.upvalue_names[i], i)

    # Segment register information (use virtual CS and DS registers if your
    # processor doesn't have segment registers):
    self.reg_first_sreg = self.ireg_CS
    self.reg_last_sreg  = self.ireg_DS

    # number of CS register
    self.reg_code_sreg = self.ireg_CS

    # number of DS register
    self.reg_data_sreg = self.ireg_DS

主要是定义了寄存器名称表reg_names,常量名称表constant_names,UpValue名称表upvalue_names。以及为这些表各自设置名称属性。

完成了这两卡的初始化,接着就是实现处理器模块的回调了。重要的有,notify_ana(),作用是解码每一条指令,它的实现代码如下:

def notify_ana(self, insn):
    """
    Decodes an instruction into insn
    """
    # take opcode byte
    b = insn.get_next_dword()

    # the 6bit opcode
    opcode = b & 0x3F
    arg_a = GET_BITS(b, 6, 13)
    arg_b = GET_BITS(b, 23, 31)
    arg_c = GET_BITS(b, 14, 22)
    arg_ax = GET_BITS(b, 6, 31)
    arg_bx = GET_BITS(b, 14, 31)
    arg_sbx = GET_BITS(b, 14, 31) - 131071

    print("opcode:%x, a:%x, b:%x, c:%x, ax:%x, bx:%x, sbx:%d" % (opcode, arg_a, arg_b, arg_c, arg_ax, arg_bx, arg_sbx))

    # opcode supported?
    try:
        ins = self.itable[opcode]
        # set default itype
        insn.itype = getattr(self, 'itype_' + ins.name)
    except:
        return 4

    # call the decoder
    return insn.size if ins.d(insn, arg_a, arg_b, arg_c, arg_ax, arg_bx, arg_sbx) else 0

解析32位的指令,取它的opcode、arg_a、arg_b、arg_c、arg_ax、arg_bx、arg_cx等值,然后根据不同的opcode索引查表,设置指令的itype字段,最后返回指令的长度即可。

notify_out_insn()用于输出完整指令,out_mnem()用于输出助记符,notify_out_operand()用于输出操作数,后两个回调是前一个回调的两个拆分,这几个回调加在一起,可以处理指令输出的全部细节。notify_out_insn()与out_mnem()的实现对于多数的反汇编引擎部分是一样的,这里不去细究,主要看看notify_out_operand(),它的实现如下:

def notify_out_operand(self, ctx, op):
    """
    Generate text representation of an instructon operand.
    This function shouldn't change the database, flags or anything else.
    All these actions should be performed only by u_emu() function.
    The output text is placed in the output buffer initialized with init_output_buffer()
    This function uses out_...() functions from ua.hpp to generate the operand text
    Returns: 1-ok, 0-operand is hidden.
    """
    #print("notify_out_operand called. op:%x" % op.type)
    optype = op.type
    fl     = op.specval
    def_arg = is_defarg(get_flags(ctx.insn.ea), op.n)

    if optype == o_reg:
        ctx.out_register(self.reg_names[op.reg])

    elif optype == o_imm:
        # for immediate loads, use the transfer width (type of first operand)
        if op.n == 1:
            width = self.dt_to_width(ctx.insn.Op1.dtype)
        else:
            width = OOFW_32 if self.PTRSZ == 4 else OOFW_64
        ctx.out_value(op, OOFW_IMM | width)

    elif optype in [o_near, o_mem]:
        r = ctx.out_name_expr(op, op.addr, idc.BADADDR)
        if not r:
            ctx.out_tagon(COLOR_ERROR)
            ctx.out_btoa(op.addr, 16)
            ctx.out_tagoff(COLOR_ERROR)
            remember_problem(PR_NONAME, ctx.insn.ea)

    elif optype == o_displ:
        is_upval = fl
        if is_upval:
            ctx.out_register(self.upvalue_names[op.reg])    #Upvalues
        else:
            ctx.out_register(self.constant_names[op.reg])   #Constants

        #if op.addr != 0 or def_arg:
        #    ctx.out_value(op, OOF_ADDR | (OOFW_32 if self.PTRSZ == 4 else OOFW_64) | signed | OOFS_NEEDSIGN)

    else:
        return False

    return True

根据不同的操作数类型,调用不同的方法进行输出。ctx.out_register负责输出寄存器;ctx.out_value负责输出立即数;而对于UpValue与Constant的输出,这里借用了ctx.out_register来输出,只是使用了不同的名称组。

取这里,指令的基本的反汇编就算完成了,IDA Pro在应用该处理器模块时,会线性的扫描所有的CODE类型的代码段,进行反汇编处理。由于篇幅的原因,这篇就到此这止了,有兴趣的读者,可以在此基础上,实现函数的创建、代码与数据的交叉引用、自动添加注释等功能。最终实现的效果如图所示:

完整的luac_proc.py文件可以在这里找到:https://github.com/feicong/lua_re

(完)