Salim Bitam

Introdução aos internos de descompilação de Hex-Rays

Nesta publicação, nos aprofundamos no microcódigo Hex-Rays e exploramos técnicas para manipular o CTree gerado para desofuscar e anotar código descompilado.

25 min de leituraAnálise de malware
Introdução aos internos de descompilação do Hex-Rays

Introdução

Nesta publicação, nos aprofundamos no microcódigo Hex-Rays e exploramos técnicas para manipular o CTree gerado para desofuscar e anotar código descompilado. A seção final inclui um exemplo prático demonstrando como anotar uma tabela de importação personalizada para análise de malware.

Este guia tem como objetivo ajudar engenheiros reversos e analistas de malware a entender melhor as estruturas internas usadas durante a descompilação de funções do IDA. Recomendamos ficar de olho no Hex-Rays SDK que pode ser encontrado no diretório de plugins do IDA PRO, todas as estruturas discutidas abaixo são originadas dele.

Arquitetura

O Hex-Rays descompila uma função por meio de um processo de vários estágios, começando com o código desmontado de uma função:

  1. Código de montagem para microcódigo:
    Ele faz uma conversão das instruções de montagem que são armazenadas em uma estrutura insn_t para instruções de microcódigo representadas por uma estrutura minsn_t

  2. Geração CTree:
    A partir do microcódigo otimizado, o Hex-Rays gera a Árvore de Sintaxe Abstrata (AST), seus nós são declarações (cinsn_t) ou expressões (cexpr_t); observe que cinsn_t e cexpr_t herdam da estrutura citem_t

Microcódigo

Microcódigo é uma linguagem intermediária (IL) usada pelo Hex-Rays, gerada pela elevação do código de montagem de um binário. Isso tem várias vantagens, uma delas é que é independente do processador.

A captura de tela a seguir exibe o assembly e o código descompilado, juntamente com seu microcódigo extraído usando o Lucid, uma ferramenta que facilita a visualização do microcódigo.

Podemos acessar o MBA (microcode block array) por meio da estrutura cfunc_t de uma função descompilada com o campo MBA.

Dica: obtemos o cfunc_t de uma função descompilada com o ida_hexrays.decompile.

mba_t é uma matriz de microblocos mblock_t, o primeiro bloco representa o ponto de entrada da função e o último representa o fim. Os microblocos (mblock_t) são estruturados em uma lista duplamente encadeada, podemos acessar o bloco seguinte/anterior com os campos nextb/prevb respectivamente. Cada mblock_t inclui uma lista duplamente vinculada de instruções de microcódigo minsn_t, acessadas pelo campo head para a primeira instrução do bloco e tail para a última instrução do bloco. A estrutura mblock_t é descrita no seguinte trecho 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;

Uma instrução de microcódigo minsn_t é uma lista duplamente encadeada, cada instrução de microcódigo contém 3 operandos: esquerda, direita e destino. Podemos acessar a instrução de microcódigo seguinte/anterior do mesmo bloco com os campos next/prev ; o campo opcode é uma enumeração (mcode_t) de todos os opcodes da microinstrução, por exemplo, a enumeração m_mov representa o opcode 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 é do tipo mop_t e, dependendo do tipo (acessado com o campo t ), ele pode conter registradores, valores imediatos e até mesmo instruções de microcódigo aninhadas. Como exemplo, segue o microcódigo de uma função com múltiplas instruções aninhadas:

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)
  #...

A geração de microcódigo progride por vários níveis de maturidade, também chamados de níveis de otimização. O nível inicial, MMAT_GENERATED, envolve a tradução direta do código assembly em microcódigo. O nível de otimização final antes de gerar o CTree é 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
};

Exemplo de travessia de microcódigo

O código Python a seguir é usado como um exemplo de como percorrer e imprimir as instruções de microcódigo de uma função; ele percorre o microcódigo gerado no primeiro nível de maturidade (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()

A saída do script é apresentada abaixo: à esquerda, o microcódigo impresso no console e, à direita, o código assembly do IDA:

Árvore CTree

Nesta seção, vamos nos aprofundar nos principais elementos da estrutura do Hex-Rays CTree e, em seguida, prosseguir para um exemplo prático demonstrando como anotar uma tabela de importação personalizada de malware que carrega APIs dinamicamente.

Para uma melhor compreensão, utilizaremos o seguinte plugin (hrdevhelper) que nos permite visualizar os nós do CTree no IDA como um gráfico.

citem_t é uma classe abstrata que é a base para cinsn_t e cexpr_t, ela contém informações comuns como endereço, tipo de item e rótulo, além de apresentar constantes como is_expr, contains_expr que podem ser usadas para saber o tipo do 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()
//...

O tipo de item acessado com o campo op indica o tipo do nó, os nós de expressão são prefixados com cot_ e os nós de instrução são prefixados com cit_, por exemplo, cot_asg indica que o nó é uma expressão de atribuição, enquanto cit_if indica que o nó é uma instrução de condição (se).

Dependendo do tipo do nó de instrução, um cinsn_t pode ter um atributo diferente. Por exemplo, se o tipo de item for cit_if podemos acessar os detalhes do nó de condição por meio do campo cif , como visto no snippet abaixo, cinsn_t é implementado usando uma união. Observe que um cblock_t é uma instrução de bloco que é uma lista de instruções cinsn_t . Podemos encontrar esse tipo, por exemplo, no início de uma função ou após uma instrução 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
  };
//...

No exemplo abaixo, o nó de condição do tipo cit_if tem dois nós filhos: o da esquerda é do tipo cit_block , que representa o ramo "Verdadeiro", e o da direita é a condição a ser avaliada, que é uma chamada para uma função. Um terceiro filho está faltando, pois a condição não tem um ramo "Falso".

O seguinte é um gráfico que mostra o nó de declaração cit_if

Encontre a descompilação associada para o CTree acima:

A mesma lógica se aplica aos nós de expressão cexpr_t, dependendo do tipo de nó, diferentes atributos estão disponíveis, por exemplo, um nó do tipo cot_asg tem nós filhos acessíveis com os campos x e 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)
      };
    };
//...

Por fim, a estrutura cfunc_t contém informações relacionadas à função descompilada, ao endereço da função, à matriz de blocos de microcódigo e ao CTree acessado com os campos entry_ea, mba e 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
//...

Exemplo de travessia CTree

O código Python fornecido serve como um mini visitante recursivo de um CTree. Observe que ele não manipula todos os tipos de nós. A última seção descreverá como usar a classe visitante interna do Hex-Rays ctree_visitor_t. Para começar, obtemos o cfunc da função usando ida_hexrays.decompile e acessamos seu CTree através do campo body .

Em seguida, verificamos se o nó (item) é uma expressão ou uma declaração. Por fim, podemos analisar o tipo por meio do campo op e explorar seus nós filhos.

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()

Abaixo é exibida a saída do script de travessia executado na start função de uma amostra BLISTER :

Exemplo prático: anotando a tabela de importação personalizada de uma amostra de malware

Agora que obtivemos insights sobre a arquitetura e as estruturas do CTree gerado, vamos nos aprofundar em uma aplicação prática e explorar como automatizar a anotação de uma tabela de importação personalizada de malware.

O Hex-Rays fornece uma classe de utilitário ctree_visitor_t que pode ser usada para percorrer e modificar o CTree. Dois métodos virtuais importantes a serem conhecidos são:

  • visit_insn:para visitar uma declaração
  • visit_expr: visitar uma expressão

Para este exemplo, o mesmo exemplo BLISTER é usado; após localizar a função que obtém endereços de APIs do Windows por hash no endereço 0x7FF8CC3B0926 (no .rsrc seção), adicionando a enumeração ao IDB e aplicando o tipo enum ao seu parâmetro, criamos uma classe que herda de ctree_visitor_t, como estamos interessados em expressões, substituiremos apenas visit_expr .

A ideia é localizar um nó cot_call (1) da função que resolve APIs passando o endereço obj_ea do primeiro filho do nó para a função idc.get_name que retornará o nome da função.

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

Em seguida, recupere a enumeração do hash acessando o parâmetro correto do nó de chamada(2), no nosso caso o parâmetro 3.

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

O próximo passo é localizar a variável que recebeu o valor de endereço da função WinAPI. Para fazer isso, primeiro precisamos localizar o nó cot_asg (3), pai do nó de chamada, usando o método find_parent_of em cfunc.body da função descompilada.

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

Por fim, podemos acessar o primeiro nó filho(4) sob o nó cot_asg , que é do tipo cot_var e obter o nome da variável atual. A API Hex-Rays ida_hexrays.rename_lvar é usada para renomear a nova variável com o nome da API do Windows obtido do parâmetro enum.

Esse processo pode economizar uma quantidade significativa de tempo para um analista. Em vez de gastar tempo renomeando variáveis, eles podem direcionar sua atenção para a funcionalidade principal. Entender como os CTrees funcionam pode contribuir para o desenvolvimento de plugins mais eficazes, permitindo o tratamento de ofuscações mais complexas.

Para uma compreensão completa e contexto do exemplo, veja o código completo abaixo:

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()

Conclusão

Concluindo nossa exploração do microcódigo Hex-Rays e da geração CTree, adquirimos técnicas práticas para navegar pelas complexidades da ofuscação de malware. A capacidade de modificar o pseudocódigo Hex-Rays nos permite eliminar ofuscações como a Ofuscação de Fluxo de Controle, remover código morto e muito mais. O Hex-Rays C++ SDK surge como um recurso valioso, oferecendo orientação bem documentada para referência futura.

Esperamos que este guia seja útil para outros pesquisadores e qualquer aluno interessado. Encontre todos os scripts em nosso repositório de pesquisa.

Recursos

Compartilhe este artigo