Salim Bitam

Introducción a los aspectos internos de la descompilación de Hex-Rays

En esta publicación, profundizamos en el microcódigo de Hex-Rays y exploramos técnicas para manipular el CTree generado para desofuscar y anotar el código descompilado.

25 min de lecturaAnálisis de malware
Introducción a los aspectos internos de la descompilación de Hex-Rays

Introducción

En esta publicación, profundizamos en el microcódigo de Hex-Rays y exploramos técnicas para manipular el CTree generado para desofuscar y anotar el código descompilado. La sección final incluye un ejemplo práctico que demuestra cómo anotar una tabla de importación personalizada para el análisis de malware.

Esta guía está destinada a ayudar a los ingenieros inversos y a los analistas de malware a comprender mejor las estructuras internas empleadas durante la descompilación de funciones de IDA. Aconsejamos estar atento al SDK de Hex-Rays que se puede encontrar en el directorio de plugins de IDA PRO, todas las estructuras que se analizan a continuación se obtienen de él.

Arquitectura

Hex-Rays descompila una función a través de un proceso de varias etapas que comienza con el código desensamblado de una función:

  1. Código de ensamblado a microcódigo:
    Realiza una conversión de las instrucciones de ensamblaje que se almacenan en una estructura insn_t en instrucciones de microcódigo representadas por una estructura minsn_t

  2. Generación de CTree:
    A partir del microcódigo optimizado, Hex-Rays genera el Árbol de Sintaxis Abstracta (AST), sus nodos son declaraciones (cinsn_t) o expresiones (cexpr_t); tenga en cuenta que tanto cinsn_t como cexpr_t heredan de la estructura citem_t

Microcódigo

El microcódigo es un lenguaje intermedio (IL) empleado por los rayos hexadecimales, generado al levantar el código ensamblador de un binario. Esto tiene múltiples beneficios, una de las cuales es que es independiente del procesador.

En la siguiente captura de pantalla se muestra el ensamblado y el código descompilado, junto con su microcódigo extraído con Lucid, una herramienta que facilita la visualización del microcódigo.

Podemos acceder al MBA (microcode block array) a través de la estructura cfunc_t de una función descompilada con el campo MBA.

Consejo: obtenemos el cfunc_t de una función descompilada con el ida_hexrays.decompile.

mba_t es una matriz de micro bloques mblock_t, el primer bloque representa el punto de entrada de la función y el último representa el final. Los micro bloques (mblock_t) están estructurados en una lista de doble enlace, podemos acceder al bloque siguiente / anterior con campos nextb/prevb respectivamente. Cada mblock_t incluye una lista de instrucciones de microcódigo de doble enlace minsn_t, a la que se accede mediante el campo head para la primera instrucción del bloque y tail para la última instrucción del bloque. La estructura mblock_t se muestra en el siguiente fragmento de código.

class mblock_t
{
//...
public:
  mblock_t *nextb;              ///< next block in the doubly linked list
  mblock_t *prevb;              ///< previous block in the doubly linked list
  uint32 flags;                 ///< combination of \ref MBL_ bits
  ea_t start;                   ///< start address
  ea_t end;                     ///< end address
  minsn_t *head;                ///< pointer to the first instruction of the block
  minsn_t *tail;                ///< pointer to the last instruction of the block
  mba_t *mba;

Una instrucción de microcódigo minsn_t es una lista de doble enlace, cada instrucción de microcódigo contiene 3 operandos: izquierda, derecha y destino. Podemos acceder a la instrucción de microcódigo siguiente/anterior del mismo bloque con next/prev campos; El campo Opcode es una enumeración (mcode_t) de todos los códigos de operación de microinstrucciones, por ejemplo, la enumeración m_mov representa el código de operación mov.

class minsn_t
{
//...
public:
  mcode_t opcode;       ///< instruction opcode enumeration
  int iprops;           ///< combination of \ref IPROP_ bits
  minsn_t *next;        ///< next insn in doubly linked list. check also nexti()
  minsn_t *prev;        ///< prev insn in doubly linked list. check also previ()
  ea_t ea;              ///< instruction address
  mop_t l;              ///< left operand
  mop_t r;              ///< right operand
  mop_t d;              ///< destination operand
 //...

enum mcode_t
{
  m_nop    = 0x00, // nop                       // no operation
  m_stx    = 0x01, // stx  l,    {r=sel, d=off} // store register to memory     
  m_ldx    = 0x02, // ldx  {l=sel,r=off}, d     // load register from memory    
  m_ldc    = 0x03, // ldc  l=const,     d       // load constant
  m_mov    = 0x04, // mov  l,           d       // move                        
  m_neg    = 0x05, // neg  l,           d       // negate
  m_lnot   = 0x06, // lnot l,           d       // logical not
//...
};

Cada operando es de tipo mop_t, dependiendo del tipo (al que se accede con el campo t) puede contener registros, valores inmediatos e incluso instrucciones de microcódigo anidadas. A modo de ejemplo, a continuación se muestra el microcódigo de una función con varias instrucciones anidadas:

class mop_t
{
	public:
	  /// Operand type.
	  mopt_t t;
	union
	  {
	    mreg_t r;           // mop_r   register number
	    mnumber_t *nnn;     // mop_n   immediate value
	    minsn_t *d;         // mop_d   result (destination) of another instruction
	    stkvar_ref_t *s;    // mop_S   stack variable
	    ea_t g;             // mop_v   global variable (its linear address)
	    int b;              // mop_b   block number (used in jmp,call instructions)
	    mcallinfo_t *f;     // mop_f   function call information
	    lvar_ref_t *l;      // mop_l   local variable
	    mop_addr_t *a;      // mop_a   variable whose address is taken
	    char *helper;       // mop_h   helper function name
	    char *cstr;         // mop_str utf8 string constant, user representation
	    mcases_t *c;        // mop_c   cases
	    fnumber_t *fpc;     // mop_fn  floating point constant
	    mop_pair_t *pair;   // mop_p   operand pair
	    scif_t *scif;       // mop_sc  scattered operand info
	  };
	#...
}

/// Instruction operand types
typedef uint8 mopt_t;
const mopt_t
  mop_z   = 0,  ///< none
  mop_r   = 1,  ///< register (they exist until MMAT_LVARS)
  mop_n   = 2,  ///< immediate number constant
  mop_str = 3,  ///< immediate string constant (user representation)
  #...

La generación de microcódigo avanza a través de varios niveles de madurez, también conocidos como niveles de optimización. El nivel inicial, MMAT_GENERATED, implica la traducción directa del código ensamblador al microcódigo. El nivel de optimización final antes de generar el CTree es MMAT_LVARS.

enum mba_maturity_t
{
  MMAT_ZERO,         ///< microcode does not exist
  MMAT_GENERATED,    ///< generated microcode
  MMAT_PREOPTIMIZED, ///< preoptimized pass is complete
  MMAT_LOCOPT,       ///< local optimization of each basic block is complete.
                     ///< control flow graph is ready too.
  MMAT_CALLS,        ///< detected call arguments
  MMAT_GLBOPT1,      ///< performed the first pass of global optimization
  MMAT_GLBOPT2,      ///< most global optimization passes are done
  MMAT_GLBOPT3,      ///< completed all global optimization. microcode is fixed now.
  MMAT_LVARS,        ///< allocated local variables
};

Ejemplo de recorrido de microcódigo

El siguiente código Python se emplea como ejemplo de cómo recorrer e imprimir las instrucciones de microcódigo de una función, atraviesa el microcódigo generado en el primer nivel de madurez (MMAT_GENERATED).

import idaapi
import ida_hexrays
import ida_lines


MCODE = sorted([(getattr(ida_hexrays, x), x) for x in filter(lambda y: y.startswith('m_'), dir(ida_hexrays))])

def get_mcode_name(mcode):
    """
    Return the name of the given mcode_t.
    """
    for value, name in MCODE:
        if mcode == value:
            return name
    return None


def parse_mop_t(mop):
    if mop.t != ida_hexrays.mop_z:
        return ida_lines.tag_remove(mop._print())
    return ''


def parse_minsn_t(minsn):
    opcode = get_mcode_name(minsn.opcode)
    ea = minsn.ea
    
    text = hex(ea) + " " + opcode
    for mop in [minsn.l, minsn.r, minsn.d]:
        text += ' ' + parse_mop_t(mop)
    print(text)
    
    
def parse_mblock_t(mblock):
    minsn = mblock.head
    while minsn and minsn != mblock.tail:
        parse_minsn_t(minsn)
        minsn = minsn.next
    

def parse_mba_t(mba):
    for i in range(0, mba.qty):
        mblock_n = mba.get_mblock(i)
        parse_mblock_t(mblock_n)


def main():
    func = idaapi.get_func(here()) # Gets the function at the current cursor
    maturity = ida_hexrays.MMAT_GENERATED
    mbr = ida_hexrays.mba_ranges_t(func)
    hf = ida_hexrays.hexrays_failure_t()
    ida_hexrays.mark_cfunc_dirty(func.start_ea)
    mba = ida_hexrays.gen_microcode(mbr, hf, None, ida_hexrays.DECOMP_NO_WAIT, maturity)
    parse_mba_t(mba)


if __name__ == '__main__':
    main()

La salida del script se presenta a continuación: a la izquierda, el microcódigo impreso en la consola, y a la derecha, el código ensamblador de IDA:

CTree

En esta sección, profundizaremos en los elementos principales de la estructura CTree de Hex-Rays, y luego pasaremos a un ejemplo práctico que demuestra cómo anotar una tabla de importación personalizada de malware que carga las API de forma dinámica.

Para una mejor comprensión, aprovecharemos el siguiente complemento (hrdevhelper) que nos permite ver los nodos CTree en IDA como un gráfico.

citem_t es una clase abstracta que es la base tanto para cinsn_t como para cexpr_t, contiene información común como la dirección, el tipo de elemento y la etiqueta, mientras que también presenta constantes como is_expr, contains_expr que se pueden usar para conocer el tipo de objeto:

struct citem_t
{
  ea_t ea = BADADDR;      ///< address that corresponds to the item. may be BADADDR
  ctype_t op = cot_empty; ///< item type
  int label_num = -1;     ///< label number. -1 means no label. items of the expression
                          ///< types (cot_...) should not have labels at the final maturity
                          ///< level, but at the intermediate levels any ctree item
                          ///< may have a label. Labels must be unique. Usually
                          ///< they correspond to the basic block numbers.
  mutable int index = -1; ///< an index in cfunc_t::treeitems.
                          ///< meaningful only after print_func()
//...

El tipo de elemento al que se accede con el campo op indica el tipo de nodo, los nodos de expresión tienen el prefijo cot_ y los nodos de instrucciones tienen el prefijo cit_, el ejemplo cot_asg indica que el nodo es una expresión de asignación, mientras que cit_if indica que el nodo es una instrucción de condición (if).

Dependiendo del tipo de nodo de declaración, un cinsn_t puede tener un atributo diferente, por ejemplo, si el tipo de elemento es cit_if podemos acceder al detalle del nodo de condición a través del campo cif , como se ve en el siguiente fragmento, cinsn_t se implementa mediante una unión. Tenga en cuenta que una cblock_t es una declaración en bloque que es una lista de cinsn_t declaraciones, podemos encontrar este tipo por ejemplo al comienzo de una función o luego de una declaración condicional.

struct cinsn_t : public citem_t
{
  union
  {
    cblock_t *cblock;   ///< details of block-statement
    cexpr_t *cexpr;     ///< details of expression-statement
    cif_t *cif;         ///< details of if-statement
    cfor_t *cfor;       ///< details of for-statement
    cwhile_t *cwhile;   ///< details of while-statement
    cdo_t *cdo;         ///< details of do-statement
    cswitch_t *cswitch; ///< details of switch-statement
    creturn_t *creturn; ///< details of return-statement
    cgoto_t *cgoto;     ///< details of goto-statement
    casm_t *casm;       ///< details of asm-statement
  };
//...

En el siguiente ejemplo, el nodo de condición de tipo cit_if tiene dos nodos secundarios: el de la izquierda es de tipo cit_block que representa la rama "True" y el de la derecha es la condición a evaluar, que es una llamada a una función, falta un tercer hijo ya que la condición no tiene una rama "False".

A continuación se muestra un gráfico que muestra el nodo de instrucción cit_if

Encuentre la descompilación asociada para el CTree anterior:

La misma lógica se aplica a las expresiones de los nodos cexpr_t, dependiendo del tipo de nodo, están disponibles diferentes atributos, por ejemplo, un nodo de tipo cot_asg tiene nodos hijos accesibles con los campos x y y.

struct cexpr_t : public citem_t
{
  union
  {
    cnumber_t *n;     ///< used for \ref cot_num
    fnumber_t *fpc;   ///< used for \ref cot_fnum
    struct
    {
      union
      {
        var_ref_t v;  ///< used for \ref cot_var
        ea_t obj_ea;  ///< used for \ref cot_obj
      };
      int refwidth;   ///< how many bytes are accessed? (-1: none)
    };
    struct
    {
      cexpr_t *x;     ///< the first operand of the expression
      union
      {
        cexpr_t *y;   ///< the second operand of the expression
        carglist_t *a;///< argument list (used for \ref cot_call)
        uint32 m;     ///< member offset (used for \ref cot_memptr, \ref cot_memref)
                      ///< for unions, the member number
      };
      union
      {
        cexpr_t *z;   ///< the third operand of the expression
        int ptrsize;  ///< memory access size (used for \ref cot_ptr, \ref cot_memptr)
      };
    };
//...

Finalmente, la estructura cfunc_t contiene información relacionada con la función descompilada, la dirección de la función, la matriz de bloques de microcódigo y el CTree al que se accede con los campos entry_ea, mba y body respectivamente.

struct cfunc_t
{
  ea_t entry_ea;             ///< function entry address
  mba_t *mba;                   ///< underlying microcode
  cinsn_t body;              ///< function body, must be a block
//...

Ejemplo de recorrido CTree

El código Python proporcionado sirve como un mini visitante recursivo de un CTree, tenga en cuenta que no maneja todos los tipos de nodos, la última sección describirá cómo usar la clase de visitante incorporada Hex-Rays ctree_visitor_t. Para empezar, obtenemos el cfunc de la función mediante ida_hexrays.decompile y accedemos a su CTree a través del campo body .

A continuación, comprobamos si el nodo(item) es una expresión o una declaración. Por último, podemos analizar el tipo a través del campo op y explorar sus nodos secundarios.

import idaapi
import ida_hexrays

OP_TYPE = sorted([(getattr(ida_hexrays, x), x) for x in filter(lambda y: y.startswith('cit_') or y.startswith('cot_'), dir(ida_hexrays))])


def get_op_name(op):
    """
    Return the name of the given mcode_t.
    """
    for value, name in OP_TYPE:
        if op == value:
            return name
    return None


def explore_ctree(item):
        print(f"item address: {hex(item.ea)}, item opname: {item.opname}, item op: {get_op_name(item.op)}")
        if item.is_expr():
            if item.op == ida_hexrays.cot_asg:
                explore_ctree(item.x) # left side
                explore_ctree(item.y) # right side

            elif item.op == ida_hexrays.cot_call:
                explore_ctree(item.x)
                for a_item in item.a: # call parameters
                    explore_ctree(a_item)

            elif item.op == ida_hexrays.cot_memptr:
                explore_ctree(item.x)
        else:
            if item.op == ida_hexrays.cit_block:
                for i_item in item.cblock: # list of statement nodes
                    explore_ctree(i_item)

            elif item.op == ida_hexrays.cit_expr:
                explore_ctree(item.cexpr)
                
            elif item.op == ida_hexrays.cit_return:
                explore_ctree(item.creturn.expr)
            

def main():
    cfunc = ida_hexrays.decompile(here())
    ctree = cfunc.body
    explore_ctree(ctree)


if __name__ == '__main__':
    main()

A continuación se muestra la salida del script transversal ejecutado en la start función de una muestra de BLISTER:

Ejemplo práctico: anotar la tabla de importación personalizada de una muestra de malware

Ahora que obtuvimos información sobre la arquitectura y las estructuras del CTree generado, profundicemos en una aplicación práctica y exploremos cómo automatizar la anotación de una tabla de importación personalizada de malware.

Hex-Rays proporciona una clase de utilidad ctree_visitor_t que se puede emplear para atravesar y modificar el CTree, dos métodos virtuales importantes que hay que conocer son:

  • visit_insn: para visitar un estado de cuenta
  • visit_expr: para visitar una expresión

Para este ejemplo, se emplea la misma muestra de blíster; luego de ubicar la función que obtiene las direcciones API de Windows por hash en la dirección 0x7FF8CC3B0926 (en el .rsrc sección), agregando la enumeración al IDB y aplicando el tipo de enumeración a su parámetro, creamos una clase que hereda de ctree_visitor_t, como nos interesan las expresiones, solo visit_expr anularemos.

La idea es localizar un cot_call node(1) de la función que resuelve las APIs pasando la dirección obj_ea del primer nodo hijo a la función idc.get_name que devolverá el nombre de la función.

   if expr.op == idaapi.cot_call:
            if idc.get_name(expr.x.obj_ea) == self.func_name:
		#...

A continuación, recupere la enumeración del hash accediendo al parámetro derecho del nodo de llamada (2), en nuestro caso el parámetro 3.

    carg_1 = expr.a[HASH_ENUM_INDEX]
    api_name = ida_lines.tag_remove(carg_1.cexpr.print1(None))  # Get API name

El siguiente paso es localizar la variable a la que se le asignó el valor de dirección de la función WinAPI. Para hacer eso, primero necesitamos ubicar el cot_asg node(3), padre del nodo de llamada usando el método find_parent_of bajo cfunc.body de la función descompilada.

    asg_expr = self.cfunc.body.find_parent_of(expr)  # Get node parent

Finalmente, podemos acceder al primer nodo hijo (4) bajo el nodo cot_asg, que es de tipo cot_var y obtener el nombre de la variable actual, la API de Hex-Rays ida_hexrays.rename_lvar se emplea para renombrar la nueva variable con el nombre de la API de Windows tomado del parámetro enum.

En última instancia, este proceso puede ahorrar una cantidad significativa de tiempo a un analista. En lugar de dedicar tiempo a volver a etiquetar las variables, pueden dirigir su atención a la funcionalidad principal. La comprensión de cómo funcionan los CTrees puede contribuir al desarrollo de plugins más efectivos, permitiendo el manejo de ofuscaciones más complejas.

Para una comprensión completa y el contexto del ejemplo, encuentre el código completo a continuación:

import idaapi
import ida_hexrays
import idc
import ida_lines
import random
import string

HASH_ENUM_INDEX = 2


def generate_random_string(length):
    letters = string.ascii_letters
    return "".join(random.choice(letters) for _ in range(length))


class ctree_visitor(ida_hexrays.ctree_visitor_t):
    def __init__(self, cfunc):
        ida_hexrays.ctree_visitor_t.__init__(self, ida_hexrays.CV_FAST)
        self.cfunc = cfunc
        self.func_name = "sub_7FF8CC3B0926"# API resolution function name

    def visit_expr(self, expr):
        if expr.op == idaapi.cot_call:
            if idc.get_name(expr.x.obj_ea) == self.func_name:
                carg_1 = expr.a[HASH_ENUM_INDEX]
                api_name = ida_lines.tag_remove(
                    carg_1.cexpr.print1(None)
                )  # Get API name
                expr_parent = self.cfunc.body.find_parent_of(expr)  # Get node parent

                # find asg node
                while expr_parent.op != idaapi.cot_asg:
                    expr_parent = self.cfunc.body.find_parent_of(expr_parent)

                if expr_parent.cexpr.x.op == idaapi.cot_var:
                    lvariable_old_name = (
                        expr_parent.cexpr.x.v.getv().name
                    )  # get name of variable
                    ida_hexrays.rename_lvar(
                        self.cfunc.entry_ea, lvariable_old_name, api_name
                    ) # rename variable
        return 0


def main():
    cfunc = idaapi.decompile(idc.here())
    v = ctree_visitor(cfunc)
    v.apply_to(cfunc.body, None)


if __name__ == "__main__":
    main()

Conclusión

Al concluir nuestra exploración del microcódigo Hex-Rays y la generación de CTree, obtuvimos técnicas prácticas para navegar por las complejidades de la ofuscación de malware. La capacidad de modificar el pseudocódigo de Hex-Rays nos permite eliminar la ofuscación como la ofuscación de flujo de control, eliminar el código muerto y muchos más. El SDK de C++ de Hex-Rays surge como un recurso valioso, ya que ofrece una guía bien documentada para futuras referencias.

Esperamos que esta guía sea útil para los colegas investigadores y cualquier ávido estudiante, encuentre todos los guiones en nuestro repositorio de investigación.

Recursos