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 :
-
Assembly code to microcode:
It does a conversion of the assembly instructions that are stored in aninsn_t
structure to microcode instructions represented by aminsn_t
structure -
CTree generation:
From the optimized microcode, Hex-Rays generates the Abstract Syntax Tree(AST), its nodes are either statements (cinsn_t
) or expressions (cexpr_t
); note that bothcinsn_t
andcexpr_t
inherit from thecitem_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.
Tip: we get the cfunc_t
of a decompiled function with the ida_hexrays.decompile
.
mba_t
is an array of micro blocks mblock_t
, the first block represents the entry point of the function and the last one represents the end. Micro blocks (mblock_t
) are structured in a double linked list, we can access the next / previous block with nextb
/prevb
fields respectively. Each mblock_t
includes a double linked list of microcode instructions minsn_t
, accessed by the field head
for the first instruction of the block and tail
for the last instruction of the block. The mblock_t
structure is depicted in the following code snippet.
class mblock_t{//...public:mblock_t *nextb; ///< next block in the doubly linked listmblock_t *prevb; ///< previous block in the doubly linked listuint32 flags; ///< combination of \ref MBL_ bitsea_t start; ///< start addressea_t end; ///< end addressminsn_t *head; ///< pointer to the first instruction of the blockminsn_t *tail; ///< pointer to the last instruction of the blockmba_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 enumerationint iprops; ///< combination of \ref IPROP_ bitsminsn_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 addressmop_t l; ///< left operandmop_t r; ///< right operandmop_t d; ///< destination operand//...enum mcode_t{m_nop = 0x00, // nop // no operationm_stx = 0x01, // stx l, {r=sel, d=off} // store register to memorym_ldx = 0x02, // ldx {l=sel,r=off}, d // load register from memorym_ldc = 0x03, // ldc l=const, d // load constantm_mov = 0x04, // mov l, d // movem_neg = 0x05, // neg l, d // negatem_lnot = 0x06, // lnot l, d // logical not//...};
Chaque opérande est de type mop_t
Selon 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 numbermnumber_t *nnn; // mop_n immediate valueminsn_t *d; // mop_d result (destination) of another instructionstkvar_ref_t *s; // mop_S stack variableea_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 informationlvar_ref_t *l; // mop_l local variablemop_addr_t *a; // mop_a variable whose address is takenchar *helper; // mop_h helper function namechar *cstr; // mop_str utf8 string constant, user representationmcases_t *c; // mop_c casesfnumber_t *fpc; // mop_fn floating point constantmop_pair_t *pair; // mop_p operand pairscif_t *scif; // mop_sc scattered operand info};#...}/// Instruction operand typestypedef uint8 mopt_t;const mopt_tmop_z = 0, ///< nonemop_r = 1, ///< register (they exist until MMAT_LVARS)mop_n = 2, ///< immediate number constantmop_str = 3, ///< immediate string constant (user representation)#...
The microcode generation progresses through various maturity levels, also referred to as optimization levels. The initial level, MMAT_GENERATED
, involves the direct translation of assembly code into microcode. The final optimization level before generating the CTree is MMAT_LVARS
.
enum mba_maturity_t{MMAT_ZERO, ///< microcode does not existMMAT_GENERATED, ///< generated microcodeMMAT_PREOPTIMIZED, ///< preoptimized pass is completeMMAT_LOCOPT, ///< local optimization of each basic block is complete.///< control flow graph is ready too.MMAT_CALLS, ///< detected call argumentsMMAT_GLBOPT1, ///< performed the first pass of global optimizationMMAT_GLBOPT2, ///< most global optimization passes are doneMMAT_GLBOPT3, ///< completed all global optimization. microcode is fixed now.MMAT_LVARS, ///< allocated local variables};
Exemple de traversée du microcode
The following Python code is used as an example of how to traverse and print the microcode instructions of a function, it traverses the microcode generated at the first maturity level (MMAT_GENERATED
).
import idaapiimport ida_hexraysimport ida_linesMCODE = 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 namereturn Nonedef 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.eatext = hex(ea) + " " + opcodefor mop in [minsn.l, minsn.r, minsn.d]:text += ' ' + parse_mop_t(mop)print(text)def parse_mblock_t(mblock):minsn = mblock.headwhile minsn and minsn != mblock.tail:parse_minsn_t(minsn)minsn = minsn.nextdef 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 cursormaturity = ida_hexrays.MMAT_GENERATEDmbr = 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
is an abstract class that is the base for both cinsn_t
and cexpr_t
, it holds common info like the address, item type and label while also featuring constants like is_expr
, contains_expr
that can be used to know the type of the object:
struct citem_t{ea_t ea = BADADDR; ///< address that corresponds to the item. may be BADADDRctype_t op = cot_empty; ///< item typeint 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-statementcexpr_t *cexpr; ///< details of expression-statementcif_t *cif; ///< details of if-statementcfor_t *cfor; ///< details of for-statementcwhile_t *cwhile; ///< details of while-statementcdo_t *cdo; ///< details of do-statementcswitch_t *cswitch; ///< details of switch-statementcreturn_t *creturn; ///< details of return-statementcgoto_t *cgoto; ///< details of goto-statementcasm_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 :
The same logic applies to expressions nodes cexpr_t
, depending on the node type, different attributes are available, as an example, a node of type cot_asg
has children nodes accessible with the fields x
and y
.
struct cexpr_t : public citem_t{union{cnumber_t *n; ///< used for \ref cot_numfnumber_t *fpc; ///< used for \ref cot_fnumstruct{union{var_ref_t v; ///< used for \ref cot_varea_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 expressionunion{cexpr_t *y; ///< the second operand of the expressioncarglist_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 expressionint 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 addressmba_t *mba; ///< underlying microcodecinsn_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 idaapiimport ida_hexraysOP_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 namereturn Nonedef 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 sideexplore_ctree(item.y) # right sideelif item.op == ida_hexrays.cot_call:explore_ctree(item.x)for a_item in item.a: # call parametersexplore_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 nodesexplore_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.bodyexplore_ctree(ctree)if __name__ == '__main__':main()
Displayed below is the output of the traversal script executed on the start
function of a BLISTER sample:
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éclarationvisit_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 idaapiimport ida_hexraysimport idcimport ida_linesimport randomimport stringHASH_ENUM_INDEX = 2def generate_random_string(length):letters = string.ascii_lettersreturn "".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 = cfuncself.func_name = "sub_7FF8CC3B0926"# API resolution function namedef 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 nameexpr_parent = self.cfunc.body.find_parent_of(expr) # Get node parent# find asg nodewhile 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 variableida_hexrays.rename_lvar(self.cfunc.entry_ea, lvariable_old_name, api_name) # rename variablereturn 0def 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.