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 to Hex-Rays decompilation internals

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. Assembly code to microcode:
    It does a conversion of the assembly instructions that are stored in an insn_t structure to microcode instructions represented by a minsn_t structure

  2. 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 both cinsn_t and cexpr_t inherit from the 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.

A view of the assembly code, decompiled code, and microcode
A view of the assembly code, decompiled code, and 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 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 :

Nested microcode instructions
Nested microcode instructions

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

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

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 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 :

Microcode traversal script’s output, assembly code
Microcode traversal script’s output, assembly code

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.

CTree graph of a function generated using hrdevhelper
CTree graph of a function generated using hrdevhelper

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

A graph showcasing the statement node cit_if
A graph showcasing the statement node cit_if

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

The associated decompilation for the above CTree
The associated decompilation for the above CTree

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.

A graph showcasing the expression node cot_asg
A graph showcasing the expression node cot_asg

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

Displayed below is the output of the traversal script executed on the start function of a BLISTER sample:

CTree traversal script’s output, decompiled function, CTree graph
CTree traversal script’s output, decompiled function, CTree graph

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.

Annotation of a custom import table of a malware
Annotation of a custom import table of a malware

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.

CTree graph of a function at address 0x7FF8CC3B7637 generated using hrdevhelper
CTree graph of a function at address 0x7FF8CC3B7637 generated using hrdevhelper

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