Leçon 2.5 · Formats binaires· 35 min
ELF pour les analystes de malwares
L’essentiel d’ELF pour les malwares Linux : en-têtes, sections et segments, liens dynamiques, et pourquoi binaires strippés ou sans en-têtes tournent.
Objectifs
- Lire un en-tête ELF et identifier la classe, le boutisme, le type, l’architecture et le point d’entrée
- Expliquer la différence entre sections et segments, et lesquels le noyau utilise
- Suivre un appel à travers la PLT et la GOT et décrire la liaison paresseuse et la liaison immédiate
- Reconnaître les binaires statiques, strippés et sans sections, et adapter vos outils
Windows domine en volume de malwares (logiciels malveillants), mais c’est sous Linux que tourne une grande partie de l’infrastructure : serveurs web, conteneurs, charges de travail cloud, routeurs, caméras et tous les autres équipements embarqués. Les botnets IoT de la lignée de Mirai, les cryptomineurs déposés via des services exposés et les implants de longue durée sur les équipements réseau sont tous des binaires Linux — et ils utilisent tous l’Executable and Linkable Format (ELF).
Vous connaissez déjà le format PE ; cette leçon se concentre donc sur ce qui diffère et sur les propriétés d’ELF qu’exploitent les malwares Linux.
ELF et PE en un coup d’œil
| Concept | PE (Windows) | ELF (Linux, BSD, la plupart de l’IoT) |
|---|---|---|
| Magic | MZ … PE\0\0 | \x7fELF |
| Description du chargement | Table des sections | Program headers (segments) |
| Description pour l’édition de liens et l’analyse | Table des sections (la même) | Section headers (facultatifs à l’exécution) |
| Imports | Répertoire d’imports + IAT | .dynamic, symboles dynamiques, relocations, GOT |
| Stubs d’appel | Appel indirect via l’IAT | Stubs PLT via la GOT |
| Chargeur | Chargeur de ntdll | Noyau + ld-linux (l’interpréteur) |
| Code précoce | Callbacks TLS | Constructeurs .init_array |
| Architectures vues dans les malwares | x86, x64, un peu d’ARM64 | x86-64, ARM, MIPS, PowerPC, SPARC, SuperH… |
La dernière ligne compte. Une seule campagne de type Mirai peut livrer le même bot compilé pour une dizaine d’architectures ; la première chose à vérifier est donc le processeur ciblé par un échantillon.
L’en-tête ELF
Le fichier commence par un en-tête fixe (Elf64_Ehdr ou Elf32_Ehdr). Ses 16
premiers octets, e_ident, sont indépendants de l’architecture afin que
n’importe quel outil puisse décoder la suite :
| Offset | Champ | Signification |
|---|---|---|
| 0x00 | EI_MAG0–3 | 7f 45 4c 46 — \x7fELF |
| 0x04 | EI_CLASS | 1 = 32 bits, 2 = 64 bits |
| 0x05 | EI_DATA | 1 = little-endian, 2 = big-endian |
| 0x06 | EI_VERSION | Toujours 1 |
| 0x07 | EI_OSABI | Généralement 0 (System V), quel que soit l’OS |
Après e_ident viennent les champs que vous lisez pendant le triage :
e_type—ET_EXEC(2) pour un exécutable à adresse fixe,ET_DYN(3) pour les objets partagés et les exécutables indépendants de la position (PIE). Les distributions modernes compilent en PIE par défaut, donc « DYN » ne signifie pas « bibliothèque ».e_machine— le processeur cible : 3 = x86, 62 = x86-64, 40 = ARM, 183 = AArch64, 8 = MIPS.e_entry— l’adresse virtuelle de la première instruction (_start, pasmain).e_phoff,e_phnum— l’emplacement de la table des program headers et son nombre d’entrées.e_shoff,e_shnum,e_shstrndx— la même chose pour la table des section headers, et l’index de la section qui contient les noms de sections.
file décode tout cela en une ligne ; readelf -h affiche chaque champ.
Sections et segments
C’est l’idée la plus importante de cette leçon. ELF décrit les mêmes octets de deux façons :
File on disk
┌──────────────────────────────────────────┐
│ ELF header │
│ Program header table ──► segments │ used by the kernel & loader
├──────────────────────────────────────────┤
│ .interp .dynsym .dynstr .rela.plt ... │ ┐
│ .init .plt .text .fini │ │ PT_LOAD (R-X)
│ .rodata .eh_frame │ ┘ PT_LOAD (R--)
│ .init_array .dynamic .got .data .bss │ ── PT_LOAD (RW-)
│ .symtab .strtab .comment ... │ (not loaded)
├──────────────────────────────────────────┤
│ Section header table ──► sections │ used by linkers & analysis tools
└──────────────────────────────────────────┘- Les segments (program headers) indiquent au noyau et au chargeur dynamique
quoi projeter, où, et avec quelles permissions. Types clés :
PT_LOAD(projeter cette plage),PT_INTERP(chemin du chargeur dynamique),PT_DYNAMIC(emplacement de la table d’édition de liens dynamique),PT_GNU_STACK(exécutabilité de la pile). - Les sections (section headers) sont une vue nommée plus fine, destinée à
l’éditeur de liens et aux outils :
.text,.rodata,.data,.bss,.symtab, etc.
Le noyau ne lit jamais les section headers. Ils sont facultatifs pour
l’exécution. Les auteurs de malwares en profitent en supprimant, en mettant à
zéro ou en corrompant la table des section headers : le binaire s’exécute
normalement, tandis que objdump, nm et les désassembleurs plus anciens qui
s’appuient sur les sections ne voient rien. Les outils robustes (Ghidra, IDA,
readelf -l) se rabattent sur les segments.
Astuce : Quand un échantillon perturbe vos outils, lancez d’abord
readelf -l. Les program headers sont la vérité de terrain sur ce qui est réellement chargé.
Édition de liens dynamique : .dynamic, PLT et GOT
Un exécutable lié dynamiquement possède un segment PT_INTERP qui nomme le
chargeur (/lib64/ld-linux-x86-64.so.2) et une section .dynamic qui liste ce
dont il a besoin : des entrées DT_NEEDED pour chaque bibliothèque, des
pointeurs vers les tables de symboles et de chaînes dynamiques, et des tables de
relocations. readelf -d l’affiche — c’est l’équivalent ELF de la lecture du
répertoire d’imports d’un PE.
Les appels aux fonctions de bibliothèque passent par deux tables :
- PLT (Procedure Linkage Table) — de petits stubs de code dans une section exécutable, un par fonction importée.
- GOT (Global Offset Table) — une table d’adresses inscriptible. Chaque stub PLT saute indirectement via son emplacement dans la GOT.
call puts@plt ──► puts@plt: jmp *[GOT slot for puts]
│
first call (lazy): └──► resolver stub ──► ld-linux finds puts,
writes its address
into the GOT slot
later calls: ──► straight to puts in libcAvec la liaison paresseuse (lazy binding), chaque emplacement de la GOT
pointe initialement vers la PLT, de sorte que le premier appel déclenche le
résolveur. Avec la liaison immédiate (-z now, souvent combinée au RELRO
complet), tout est résolu au démarrage et la GOT est ensuite rendue accessible
en lecture seule. De nombreuses distributions actuelles utilisent par défaut la
liaison immédiate pour les binaires durcis. La page de référence
GOT et PLT couvre les détails au niveau des
instructions.
Pour l’analyste, cela signifie que les fonctions importées apparaissent sous des
noms du type puts@plt dans le désassemblage, et qu’une GOT modifiée après le
démarrage est suspecte : l’écrasement de la GOT est une technique classique de
hooking et d’exploitation.
Code précoce : .init_array
Comme les callbacks TLS des PE, ELF offre des moyens d’exécuter du code avant
main : des pointeurs de fonctions dans .init_array (ce que produit
__attribute__((constructor))), l’ancienne section .init et le
DT_PREINIT_ARRAY des exécutables. Les bibliothèques partagées exécutent leurs
constructeurs au chargement — c’est pourquoi LD_PRELOAD et
/etc/ld.so.preload sont des mécanismes de persistance et de hooking prisés des
malwares Linux. Vérifiez toujours les entrées de .init_array quand vous
cherchez la première instruction malveillante.
Les formes que prennent les malwares Linux
Les vrais échantillons Linux se rangent généralement dans quelques formes reconnaissables :
- Liés statiquement et strippés. Courant pour les bots IoT : aucune
dépendance à la version de la libc de la cible, et aucun symbole.
fileindiquestatically linked, stripped; il n’y a niPT_INTERPni.dynamic. Des milliers de fonctions de la libc sont embarquées et anonymes — utilisez la reconnaissance de signatures (Function ID de Ghidra, FLIRT d’IDA) pour les nommer. - Packés avec UPX. Très courant. Cherchez les marqueurs
UPX!, deux ou trois segmentsPT_LOADsans section headers, et une entropie élevée. Les en-têtes sont parfois altérés pour faire échouerupx -d; voir le packing UPX. - Sans sections ou avec des en-têtes corrompus. Comme vu plus haut, le programme s’exécute quand même.
- Appels système directs. Les bots statiques encapsulent souvent eux-mêmes
syscall/int 0x80/svc; voir la référence syscall. - Anti-débogage via
ptrace(PTRACE_TRACEME)— un processus ne peut être tracé qu’une seule fois. Voir l’anti-débogage par ptrace.
Lab : les sections sont facultatives
Utilisez une VM ou un conteneur Linux (x86-64). Il vous faut gcc, binutils,
gdb et Python 3.
-
Créez
beacon.c:c #include <stdio.h> #include <unistd.h> __attribute__((constructor)) static void early(void) { puts("[init_array] runs before main"); } int main(void) { puts("checking in with update.example.com"); printf("pid=%d\n", getpid()); return 0; } -
Compilez trois variantes :
bash gcc -o beacon-pie beacon.c # default: PIE, dynamic gcc -no-pie -Wl,-z,lazy -o beacon-dyn beacon.c # fixed address, lazy binding gcc -static -s -o beacon-static-stripped beacon.c # like many IoT bots -
Comparez-les :
bash file beacon-* readelf -h beacon-pie | grep -E 'Type|Machine|Entry' readelf -l beacon-dyn | grep -E 'LOAD|INTERP|DYNAMIC' readelf -d beacon-dyn | grep NEEDED readelf -d beacon-static-stripped ls -l beacon-*Notez le
Typedu build PIE (DYN), l’absence d’interpréteur et de section dynamique dans le build statique, et à quel point ce dernier est plus gros. -
Supprimez les section headers d’une copie avec
noshdr.py:python import struct, sys src, dst = sys.argv[1], sys.argv[2] data = bytearray(open(src, "rb").read()) assert data[:4] == b"\x7fELF" and data[4] == 2, "expected 64-bit ELF" end = "<" if data[5] == 1 else ">" (e_shoff,) = struct.unpack_from(end + "Q", data, 0x28) e_shentsize, e_shnum = struct.unpack_from(end + "HH", data, 0x3A) # The section header table normally sits at the end of the file: cut it off, # then zero e_shoff, e_shnum and e_shstrndx. del data[e_shoff:e_shoff + e_shentsize * e_shnum] struct.pack_into(end + "Q", data, 0x28, 0) struct.pack_into(end + "HH", data, 0x3C, 0, 0) open(dst, "wb").write(data)bash python3 noshdr.py beacon-dyn beacon-noshdr && chmod +x beacon-noshdr ./beacon-noshdr readelf -S beacon-noshdr objdump -d beacon-noshdr | head readelf -l beacon-noshdrLe programme s’exécute exactement comme avant, et pourtant
readelf -Sne trouve aucune section etobjdump -dn’affiche aucun code. Les program headers sont intacts. -
Observez la liaison paresseuse dans
beacon-dyn:bash gdb -q ./beacon-dyn (gdb) break main (gdb) run (gdb) disassemble 'puts@plt' (gdb) x/gx <address used by the first jmp> (gdb) next (gdb) next (gdb) x/gx <same address>Avant le premier appel, l’emplacement de la GOT pointe vers la PLT ; ensuite, il contient l’adresse de
putsdans la libc.
Questions : Quelle ligne de la sortie montre que le constructeur s’est
exécuté avant main ? Où le chercheriez-vous dans un binaire strippé ? Pourquoi
le noyau se moque-t-il de la disparition des section headers ? Dans quelle
variante strings trouve-t-il encore update.example.com ?
À retenir
- ELF décrit un fichier deux fois : les segments pour le chargement, les sections pour les outils. Seuls les segments sont nécessaires à l’exécution ; les section headers peuvent donc être absents ou faux.
- Un
e_typeDYN désigne généralement un exécutable PIE, pas une bibliothèque ;e_machinevous indique laquelle des nombreuses architectures cible un échantillon IoT. - Les imports passent par
.dynamic, la PLT et la GOT ;readelf -dvous tient lieu de table des imports, et une GOT modifiée après le démarrage mérite l’attention. - Les constructeurs de
.init_arrayet les bibliothèques préchargées exécutent du code avantmain— vérifiez-les quand vous cherchez la première instruction malveillante. - Attendez-vous à des échantillons liés statiquement, strippés, packés avec UPX ou aux en-têtes endommagés, et choisissez des outils qui font confiance aux program headers.