Skip to content

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

ConceptPE (Windows)ELF (Linux, BSD, la plupart de l’IoT)
MagicMZ … PE\0\0\x7fELF
Description du chargementTable des sectionsProgram headers (segments)
Description pour l’édition de liens et l’analyseTable des sections (la même)Section headers (facultatifs à l’exécution)
ImportsRépertoire d’imports + IAT.dynamic, symboles dynamiques, relocations, GOT
Stubs d’appelAppel indirect via l’IATStubs PLT via la GOT
ChargeurChargeur de ntdllNoyau + ld-linux (l’interpréteur)
Code précoceCallbacks TLSConstructeurs .init_array
Architectures vues dans les malwaresx86, x64, un peu d’ARM64x86-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 :

OffsetChampSignification
0x00EI_MAG0–37f 45 4c 46 — \x7fELF
0x04EI_CLASS1 = 32 bits, 2 = 64 bits
0x05EI_DATA1 = little-endian, 2 = big-endian
0x06EI_VERSIONToujours 1
0x07EI_OSABIGé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, pas main).
  • 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 :

text
                 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.
text
  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 libc

Avec 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. file indique statically linked, stripped ; il n’y a ni PT_INTERP ni .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 segments PT_LOAD sans section headers, et une entropie élevée. Les en-têtes sont parfois altérés pour faire échouer upx -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.

  1. 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;
    }
  2. 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
  3. 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 Type du build PIE (DYN), l’absence d’interpréteur et de section dynamique dans le build statique, et à quel point ce dernier est plus gros.

  4. 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-noshdr

    Le programme s’exécute exactement comme avant, et pourtant readelf -S ne trouve aucune section et objdump -d n’affiche aucun code. Les program headers sont intacts.

  5. 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 puts dans 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_type DYN désigne généralement un exécutable PIE, pas une bibliothèque ; e_machine vous indique laquelle des nombreuses architectures cible un échantillon IoT.
  • Les imports passent par .dynamic, la PLT et la GOT ; readelf -d vous tient lieu de table des imports, et une GOT modifiée après le démarrage mérite l’attention.
  • Les constructeurs de .init_array et les bibliothèques préchargées exécutent du code avant main — 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.