Salim Bitam

Introduction à la décompilation interne de Hex- Rays

Dans cette publication, nous nous penchons sur le microcode Hex-Rays et explorons les techniques de manipulation du CTree généré pour désobfusquer et annoter le code décompilé.

25 minutes de lectureAnalyse des malwares
Introduction à la décompilation interne de Hex-Rays

Introduction

Dans cette publication, nous nous penchons sur le microcode Hex-Rays et explorons les techniques de manipulation du CTree généré pour désobfusquer et annoter le code décompilé. La dernière section comprend un exemple pratique montrant comment annoter un tableau d'importation personnalisé pour l'analyse des logiciels malveillants.

Ce guide a pour but d'aider les ingénieurs inverses et les analystes de logiciels malveillants à mieux comprendre les structures internes utilisées lors de la décompilation des fonctions d'IDA. Nous vous conseillons de garder un œil sur le SDK Hex-Rays qui se trouve dans le répertoire des plugins d'IDA PRO, toutes les structures discutées ci-dessous en sont issues.

Architecture

Hex-Rays décompile une fonction par un processus en plusieurs étapes, en commençant par le code désassemblé de la fonction :

  1. Code d'assemblage en microcode :
    Il convertit les instructions d'assemblage stockées dans une structure en instructions de microcode représentées par un code. insn_t en instructions de microcode représentées par une minsn_t structure

  2. Génération de CTree :
    A partir du microcode optimisé, Hex-Rays génère l'arbre syntaxique abstrait (AST), dont les nœuds sont soit des instructions (cinsn_t) ou des expressions (cexpr_t) ; notez que cinsn_t et cexpr_t héritent tous deux de la structure citem_t structure

Microcode

Le microcode est un langage intermédiaire (IL) utilisé par Hex-Rays, généré en levant le code d'assemblage d'un binaire. Cette méthode présente de nombreux avantages, dont celui d'être indépendante du processeur.

La capture d'écran suivante affiche l'assemblage et le code décompilé, ainsi que son microcode extrait à l'aide de Lucid, un outil qui facilite la visualisation du microcode.

Nous pouvons accéder au MBA (microcode block array) via la structure cfunc_t d'une fonction décompilée avec le champ MBA.

Astuce : on obtient le cfunc_t d'une fonction décompilée avec le ida_hexrays.decompile.

mba_t est un tableau de micro-blocs mblock_tle premier bloc représente le point d'entrée de la fonction et le dernier représente la fin. Les micro-blocs (mblock_t) sont structurés dans une liste doublement liée, nous pouvons accéder au bloc suivant / précédent avec les champs nextb/prevb respectivement. Chaque site mblock_t comprend une liste doublement liée d'instructions de microcodes minsn_t, accessible par le champ head pour la première instruction du bloc et tail pour la dernière instruction du bloc. La structure de mblock_t est décrite dans l'extrait de code suivant.

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;

Une instruction de microcode minsn_t est une liste doublement liée, chaque instruction de microcode contient 3 opérandes : gauche, droite et destination. Nous pouvons accéder à l'instruction microcode suivante/précédente du même bloc avec les champs next/prev; le champ opcode est une énumération (mcode_t) de tous les opcodes de microinstruction ; par exemple, l'énumération m_mov représente l'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
//...
};

Chaque opérande est de type mop_tSelon le type (auquel on accède par le champ t ), il peut contenir des registres, des valeurs immédiates et même des instructions de microcode imbriquées. A titre d'exemple, voici le microcode d'une fonction comportant plusieurs instructions imbriquées :

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 génération du microcode passe par différents niveaux de maturité, également appelés niveaux d'optimisation. Le premier niveau, MMAT_GENERATED, implique la traduction directe du code d'assemblage en microcode. Le niveau d'optimisation final avant la génération du CTree est 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
};

Exemple de traversée du microcode

Le code Python suivant est utilisé comme exemple pour parcourir et imprimer les instructions du microcode d'une fonction, il parcourt le microcode généré au premier niveau de maturité (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 sortie du script est présentée ci-dessous : à gauche, le microcode imprimé dans la console, et à droite, le code d'assemblage par IDA :

CTree

Dans cette section, nous allons nous plonger dans les éléments fondamentaux de la structure CTree de Hex-Rays, puis nous passerons à un exemple pratique démontrant comment annoter une table d'importation personnalisée d'un logiciel malveillant qui charge des API de manière dynamique.

Pour une meilleure compréhension, nous utiliserons le plugin suivant(hrdevhelper) qui nous permet de visualiser les nœuds CTree dans IDA sous la forme d'un graphe.

citem_t est une classe abstraite qui sert de base aux classes cinsn_t et cexpr_tElle contient des informations communes telles que l'adresse, le type d'objet et l'étiquette, ainsi que des constantes telles que is_expr, contains_expr qui peuvent être utilisées pour connaître le type de l'objet :

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

Le type d'élément auquel on accède avec le champ op indique le type du nœud, les nœuds d'expression sont préfixés par cot_ et les nœuds d'énoncés sont préfixés par cit_, par exemple cot_asg indique que le nœud est une expression d'affectation tandis que cit_if indique que le nœud est un énoncé de condition (if).

En fonction du type de nœud de déclaration, cinsn_t peut avoir un attribut différent. Par exemple, si le type d'élément est cit_if, nous pouvons accéder aux détails du nœud de condition par le biais du champ cif, comme le montre l'extrait ci-dessous, cinsn_t est mis en œuvre à l'aide d'une union. Notez qu'un cblock_t est un bloc d'instructions qui est une liste d'instructions cinsn_t, on peut trouver ce type d'instructions par exemple au début d'une fonction ou après une instruction conditionnelle.

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
  };
//...

Dans l'exemple ci-dessous, le nœud de condition de type cit_if a deux nœuds enfants : le nœud de gauche est de type cit_block et représente la branche "True" et le nœud de droite est la condition à évaluer, qui est un appel à une fonction. Il manque un troisième enfant car la condition n'a pas de branche "False".

Le graphique suivant illustre le nœud de l'instruction cit_if

Trouvez la décompilation associée pour le CTree ci-dessus :

La même logique s'applique aux nœuds d'expression cexpr_t, en fonction du type de nœud, différents attributs sont disponibles. Par exemple, un nœud de type cot_asg a des nœuds enfants accessibles avec les champs x et 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)
      };
    };
//...

Enfin, la structure cfunc_t contient des informations relatives à la fonction décompilée, à l'adresse de la fonction, au tableau de blocs de microcodes et à l'arbre CT auquel on accède respectivement par les champs entry_ea, mba et body.

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

Exemple de traversée d'un arbre

Le code Python fourni sert de mini-visiteur récursif d'un CTree, notez qu'il ne gère pas tous les types de noeuds, la dernière section décrira comment utiliser la classe de visiteur intégrée de Hex-Rays ctree_visitor_t. Pour commencer, nous obtenons le site cfunc de la fonction à l'aide de ida_hexrays.decompile et nous accédons à son CTree via le champ body.

Ensuite, nous vérifions si le nœud (item) est une expression ou une déclaration. Enfin, nous pouvons analyser le type à travers le champ op et explorer ses nœuds enfants.

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

Vous trouverez ci-dessous la sortie du script de recherche exécuté sur la start fonction d'un échantillon BLISTER:

Exemple pratique : annotation de la table d'importation personnalisée d'un échantillon de logiciel malveillant

Maintenant que nous avons mieux compris l'architecture et les structures de l'arbre CT généré, nous allons nous plonger dans une application pratique et explorer comment automatiser l'annotation d'une table d'importation personnalisée de logiciels malveillants.

Hex-Rays fournit une classe utilitaire ctree_visitor_t qui peut être utilisée pour parcourir et modifier l'arbre CT. Deux méthodes virtuelles importantes sont à connaître :

  • visit_insn: pour visiter une déclaration
  • visit_expr: pour visiter une expression

Pour cet exemple, le même échantillon BLISTER est utilisé ; après avoir localisé la fonction qui obtient les adresses des API Windows par hachage à l'adresse 0x7FF8CC3B0926 (dans le fichier .rsrc ), en ajoutant l'énumération à la BDI et en appliquant le type d'énumération à son paramètre, nous créons une classe qui hérite de ctree_visitor_t, puisque nous nous intéressons aux expressions, nous ne remplacerons que visit_expr.

L'idée est de localiser un nœud cot_call (1) de la fonction qui résout les API en passant l'adresse obj_ea du premier enfant du nœud à la fonction idc.get_name qui renverra le nom de la fonction.

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

Récupérez ensuite l'énumération du hachage en accédant au paramètre droit de l'appel node(2), dans notre cas le paramètre 3.

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

L'étape suivante consiste à localiser la variable à laquelle a été attribuée la valeur d'adresse de la fonction WinAPI. Pour ce faire, nous devons d'abord localiser le nœud cot_asg (3), parent du nœud d'appel, en utilisant la méthode find_parent_of sous cfunc.body de la fonction décompilée.

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

Enfin, nous pouvons accéder au premier nœud enfant (4) sous le nœud cot_asg, qui est de type cot_var et obtenir le nom de la variable actuelle, l'API Hex-Rays ida_hexrays.rename_lvar Hex-Rays est utilisée pour renommer la nouvelle variable avec le nom de l'API Windows tiré du paramètre enum.

Ce processus peut en fin de compte faire gagner beaucoup de temps à l'analyste. Au lieu de perdre du temps à renommer des variables, ils peuvent se concentrer sur la fonctionnalité principale. La compréhension du fonctionnement des CTrees peut contribuer au développement de plugins plus efficaces, permettant de traiter des obscurcissements plus complexes.

Pour une compréhension et un contexte complets de l'exemple, veuillez trouver l'intégralité du code ci-dessous :

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

Conclusion

Pour conclure notre exploration du microcode Hex-Rays et de la génération de CTree, nous avons acquis des techniques pratiques pour naviguer dans les complexités de l'obscurcissement des logiciels malveillants. La capacité de modifier le pseudo-code Hex-Rays nous permet de couper à travers l'obfuscation comme l'obfuscation du flux de contrôle, de supprimer le code mort, et bien d'autres choses encore. Le SDK C++ de Hex-Rays s'avère être une ressource précieuse, offrant des conseils bien documentés pour toute référence future.

Nous espérons que ce guide sera utile à nos collègues chercheurs et à tout apprenant avide, veuillez trouver tous les scripts dans notre référentiel de recherche.

Ressources

Partager cet article