6 points par GN⁺ 2024-02-03 | 1 commentaires | Partager sur WhatsApp
  • Même un petit programme C hello devient sous Linux un exécutable ELF, dont on peut inspecter directement la structure interne avec readelf, nm et objdump
  • Les trois notions clés pour comprendre un exécutable sont les symboles, les sections et les segments : ils servent respectivement à relier les fonctions, à séparer code et données, et à organiser la mémoire à l’exécution
  • Avec objdump et readelf, on peut examiner les octets et les propriétés de sections comme .text, .rodata, .data, .bss et .interp
  • Un programme ne démarre pas directement dans main, mais entre d’abord par _start, puis appelle main après plusieurs étapes d’initialisation
  • Un exécutable n’est pas un « bloc illisible » : c’est un fichier au format défini, que des outils permettent d’explorer progressivement pour suivre le code, les chaînes et les informations de linking

Un exécutable est un format de fichier lisible

  • Au premier abord, un exécutable compilé peut ressembler à un « binaire magique » illisible, mais c’est en réalité un format de fichier que l’on peut comprendre
  • L’exemple porte sur un binaire ELF sous Linux, et comme un binaire dépend de la plateforme, l’explication est elle aussi liée à cette plateforme
  • Le programme C utilisé en exemple est le suivant
#include <stdio.h>

int main() {
    printf("Penguin!\n");
}
  • On le compile avec gcc -o hello hello.c pour produire l’exécutable hello, puis on en examine l’intérieur
  • Toute l’explication s’articule autour de trois concepts
    • symboles (symbols) : servent à retrouver l’emplacement de fonctions définies ailleurs, comme printf
    • sections (sections) : unités qui séparent le code et les données, comme .text, .data ou .rodata
    • segments (segments) : regroupent les sections selon leur disposition en mémoire au moment de l’exécution

Même ouvert en texte, on voit déjà des indices

  • Si l’on ouvre directement l’exécutable avec cat hello, la sortie ressemble en grande partie à des caractères corrompus
  • On peut quand même y repérer des chaînes comme Penguin! et ELF
  • ELF est le nom du format de fichier de ce binaire
  • Si la majeure partie de la sortie est difficile à lire, c’est parce qu’un exécutable contient des données binaires

La table des symboles permet de voir les noms de fonctions et leurs liens

  • readelf --symbols hello affiche la table des symboles de l’exécutable
  • Dans l’exemple, on y trouve notamment
    • main : l’adresse de la fonction main() écrite dans le programme
    • puts@@GLIBC_2.2.5 : une référence liée au printf appelé dans le code, probablement transformé en puts par une optimisation du compilateur
    • _start : un symbole important lié au démarrage du programme
  • Un programme ne commence pas directement dans main : il entre en réalité par _start
  • _start effectue plusieurs tâches importantes, dont l’appel à main

Les symboles rendent le linking possible

  • Si l’on écrit une fonction appelée hello dans un programme, le binaire compilé associe le symbole hello au code de cette fonction
  • Pour appeler une fonction de bibliothèque comme printf, il faut un moyen de retrouver l’emplacement de son code
  • Ce processus de résolution des emplacements s’appelle le linking
    • s’il a lieu juste après la compilation, on parle de linking statique
    • s’il a lieu au moment de l’exécution, on parle de linking dynamique
  • libc contient les fonctions de la bibliothèque standard du C
  • Même si nm affiche “no symbols” pour libc, on peut voir ses symboles avec objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so
  • Dans la table des symboles de libc, on peut trouver des fonctions comme sprintf, strlen, fork et exec
  • On peut ainsi se représenter le fonctionnement du linking dynamique : hello appelle puts, puis le système retrouve l’emplacement de puts dans la table des symboles de libc

Les sections séparent le code et les données

  • objdump -s hello affiche les octets contenus dans chaque section de l’exécutable, en hexadécimal et en ASCII
  • Les sections importantes sont les suivantes
    • .text : contient le code réel du programme, c’est-à-dire l’assembleur, avec notamment _start et main
    • .rodata : contient les données en lecture seule ; dans l’exemple, la chaîne "Penguin!"
    • .interp : contient le nom du fichier du linker dynamique
  • Sections et segments interviennent à des moments différents
    • les sections sont utilisées par ld au moment de l’édition de liens
    • les segments sont utilisés à l’exécution
  • readelf --sections hello permet d’examiner plus en détail les métadonnées des sections
  • Les indicateurs de l’exemple révèlent la nature de chaque section
    • .text : exécutable et en lecture seule
    • .rodata : lecture seule
    • .data : lecture/écriture
    • .bss : zone de données accessible en écriture

Le désassemblage montre le code machine sous forme d’assembleur

  • La section .text contient les octets que le CPU interprète et exécute comme du code
  • Dans l’exemple, les premiers octets de .text, 31 ed, ne sont pas immédiatement compréhensibles pour un humain ; il faut donc un désassembleur
  • objdump -d ./hello désassemble la section .text et l’affiche sous forme d’instructions assembleur
  • Dans la sortie d’exemple, 31 ed apparaît comme xor %ebp,%ebp
  • De cette manière, on peut vérifier à quelles instructions assembleur correspondent les octets de code présents dans le binaire

Les segments définissent l’organisation mémoire à l’exécution

  • Un exécutable est aussi structuré en segments, ou en-têtes de programme (program headers)
  • readelf --segments hello affiche les segments du programme ainsi que la correspondance entre sections et segments
  • Les segments servent à déterminer comment chaque partie du programme sera répartie en mémoire
  • L’exemple contient deux segments LOAD principaux
    • premier segment LOAD : marqué R E, donc lisible et exécutable
    • second segment LOAD : marqué RW, donc lisible et inscriptible
  • .text doit pouvoir être lu et exécuté, mais pas modifié : il appartient donc au premier segment
  • .data et .bss doivent être modifiables, mais n’ont pas besoin d’être exécutables : elles appartiennent donc au second segment

Outils et ressources pour aller plus loin

1 commentaires

 
GN⁺ 2024-02-03
Avis de Hacker News
  • Comme je l’ai dit dans un autre fil https://news.ycombinator.com/item?id=38847750#38862450, je recommande vivement d’écrire un ELF à la main au moins une fois
    C’est un bon exercice pour comprendre les composants de base d’un exécutable, et c’est aussi utile si l’on veut, à l’inverse de cet article, adopter une approche de bas en haut plutôt que de haut en bas
    Il y a aussi beaucoup de bonnes discussions dans les différents fils de cet autre post HN

    • J’ai récemment écrit un fichier ELF moi-même : https://github.com/avik-das/garlic/blob/master/recursive/elf...
      Pour expliquer le format à moi-même et aux autres, j’ai aussi créé une visualisation interactive qui montre les octets du fichier
      Cliquer sur un octet affiche une explication, et les octets associés dans le fichier sont mis en évidence pour aider à comprendre : https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
    • De la même manière, je recommande aussi d’écrire un chargeur ELF simple
      L’édition de liens dynamique implique pas mal de complexité d’implémentation, mais si l’on ne prend en charge que les ELF statiques, c’est assez direct
    • Modifier des ELF existants est aussi très formateur et amusant
      Au début, c’est frustrant parce que le débogage est pratiquement impossible quand ça ne fonctionne pas, mais quand ça finit par marcher, c’est vraiment génial
      ELF peut être patché de façons intéressantes, et le vecteur auxiliaire permet aussi l’auto-observation à l’exécution
      Comme Linux fournit l’adresse de la table des en-têtes de programme, on peut atteindre n’importe quoi à partir de là, et il suffit d’étendre le segment LOAD pour qu’il couvre tout le binaire
      Par exemple, j’ai créé un outil qui insère directement des modules Lisp et du code Lisp dans l’exécutable de mon interpréteur Lisp
      Le segment inséré est chargé automatiquement par ELF, et l’interpréteur le trouve puis l’exécute
      J’ai tellement aimé cette petite fonctionnalité que j’ai écrit un article à son sujet : https://www.matheusmoreira.com/articles/self-contained-lone-...
      J’aimerais que les langages grand public adoptent aussi ce genre d’approche
    • Quand on le fait soi-même, cela ressemble en pratique à de l’assemblage manuel
      On lit la documentation, on choisit les octets nécessaires dans la fiche technique du processeur, on les place dans l’ordre dans différentes sections, on remplit les champs ELF, et au final tout revient à saisir ces données
      Dans des environnements pré-ELF comme l’Apple II 8 bits, un moniteur en langage machine permettait de saisir directement les octets d’un programme, puis ces octets étaient exécutés
      L’enregistrement sur disque n’est qu’un peu plus complexe, et là aussi il y a des possibilités
      On peut créer un fichier avec un éditeur de secteurs disque, et ainsi de suite
    • A Magnetized Needle and a Steady Hand de Chris Wellons est un article qui crée un exécutable ELF à partir de zéro : https://nullprogram.com/blog/2016/11/17/
  • D’après ce que je comprends, le symbole main est spécifique au C
    Le symbole _start est le point d’entrée binaire indépendant du langage, et dans ce cas il appelle main
    S’il y avait eu une convention consistant à appeler le point d’entrée _start et à lui passer le argc/argv de main, le format aurait été beaucoup moins flexible

    • À proprement parler, le nom _start n’a rien de spécial non plus
      Le binaire inscrit une adresse de point d’entrée dans son en-tête, et le système d’exploitation commence l’exécution à cette adresse
      Appeler ce symbole _start n’est qu’une convention du C et d’autres langages, que l’éditeur de liens utilise pour définir le point d’entrée lorsqu’il écrit l’en-tête ELF
      Si l’on écrit son propre script de linkage, on peut nommer le point d’entrée comme on veut
    • Le symbole main n’est effectivement fourni qu’en C hosted
      En C freestanding, on peut avoir le point d’entrée que l’on veut
      _start n’est lui aussi qu’une valeur par défaut de l’éditeur de liens, et on peut indiquer un meilleur symbole avec -Wl,--entry="${symbol}" ; GCC permet également de le définir directement sans l’inesthétique -Wl
      De plus, le point d’entrée n’est pas réellement un symbole, mais un pointeur
      L’éditeur de liens prend simplement l’adresse du symbole indiqué et la définit comme point d’entrée ELF
      En plus du nombre d’arguments et du vecteur d’arguments, la pile contient aussi le vecteur d’environnement et le vecteur auxiliaire
      Le code de démarrage du processus peut se résumer à extraire ces valeurs de la pile, les placer dans les registres appropriés, puis appeler la fonction C souhaitée
      Le point d’entrée lui-même n’est pas une fonction, il n’a donc nulle part où revenir
      Le code du point d’entrée doit se terminer par l’appel système exit afin que le processus se termine proprement lorsque main renvoie un code d’état
      C’est du moins comme cela que ça fonctionne sous Linux
    • Cela dépend du runtime du langage, mais l’une des tâches courantes consiste à initialiser les valeurs statiques globales non nulles
      Dans des langages comme Rust/C/C++, on peut aussi injecter les variables à initialiser via des options de l’éditeur de liens
      Si le programme est lié dynamiquement, si je comprends bien, le runtime de l’éditeur de liens s’exécute avant _start, résout les liens, puis passe le contrôle à _start
      Au final, ce sont des hacks empilés sur des hacks, ajoutés organiquement pour offrir de l’extensibilité ; ils sont suffisamment acceptés socialement et fonctionnent suffisamment bien pour qu’on continue à les utiliser
  • En 2012, quand j’ai réorienté mon parcours académique des mathématiques vers l’informatique, j’ai lancé un blog, et ce sujet a littéralement été la première chose que j’ai étudiée : https://heinrichhartmann.com/archive/Dissecting-Hello-World....
    Je n’ai jamais regretté d’être entré dans ce terrier de lapin
    Si ma mémoire est bonne, Julia a aussi une formation en mathématiques
    Peut-être que ce qui attire les gens venus des maths vers ce genre d’expériences, c’est l’envie de raisonner depuis les fondations
    Je suis content qu’elle ait rendu ce sujet accessible à beaucoup de monde

  • Les articles de Julia sont toujours excellents
    Quand on veut montrer que le code compilé ne peut pas cacher ses secrets, une démonstration de strings fait toujours son effet

    • Il faudrait aussi l’expliquer aux juges allemands
      Un pauvre type a été condamné à une amende pour avoir trouvé un mot de passe de la même manière qu’en lançant strings sur un binaire
      Les juges ont estimé qu’il avait « contourné » les mesures de sécurité du logiciel : https://www.theregister.com/2024/01/19/germany_fine_security...
  • Ce n’est ni une critique ni du pinaillage, juste une pensée qui m’est venue
    En lisant la phrase « les binaires sont pratiquement la définition même de quelque chose de spécifique à une plateforme, donc tout cela est aussi spécifique à une plateforme », ça m’a rappelé le moment où Actually Portable Executable a montré qu’un même binaire pouvait s’exécuter sur plusieurs plateformes
    Je ne m’en suis toujours pas vraiment remis mentalement, tellement c’était surréaliste
    Pendant des décennies, on a essayé de résoudre le problème du multiplateforme par toutes sortes d’approches fractales — Java, bibliothèques cross-platform, etc. — alors que la solution était sous notre nez depuis le début

    • Personnellement, je ne suis pas certain que les binaires portables soient globalement une bonne chose
      À l’ère des ordinateurs rapides, je pense que distribuer les sources et compiler localement est préférable à la distribution de binaires
      Malheureusement, une grande partie des logiciels dont nous dépendons est trop volumineuse, et les compilateurs sont relativement lents, ce qui fait de la distribution de binaires une sorte de mal nécessaire
      J’aimerais qu’on consacre davantage d’efforts à des composants logiciels plus simples, qui se compilent naturellement vite, et à des compilateurs plus rapides, plutôt qu’aux binaires portables
    • Je me trompe peut-être, mais APE ne me semble pas être un format binaire en soi
      C’est un script qui peut s’exécuter sur n’importe quel système, et ce script peut charger un binaire
      Si je me souviens bien, la version originale devait être décodée depuis base64 avant le chargement
      C’est donc plutôt un chargeur de binaire exécutable
  • Au début des années 1990, j’étais fasciné par les formats d’exécutables ; j’ai passé quelques semaines à écrire en Modula 2 un visualiseur d’exécutables DOS et Windows, que j’ai appelé VEXE et distribué en shareware en 1991
    L’outil a acquis une petite popularité de niche chez les crackers, au point d’être mentionné dans les tutoriels de +ORC : https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
    Probablement parce qu’il pouvait détecter divers schémas de chiffrement et de compression utilisés pour empêcher la rétro-ingénierie des programmes

  • Si vous vous demandez jusqu’où on peut réduire la taille d’un fichier binaire ELF, cet article amusant pourrait vous plaire : https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...

  • Vous pouvez aussi jeter un œil à mon outil, qui permet d’explorer ELF avec SQL : https://github.com/fzakaria/sqlelf

  • Quelqu’un pourrait-il recommander des ressources ou des livres pratiques d’introduction à la programmation bas niveau pour une personne ayant surtout un bagage Python ?
    J’ai récemment commencé à apprendre Rust et je me suis rendu compte que j’avais beaucoup de retard à rattraper
    Je n’ai jamais suivi de cours sur les compilateurs, donc je passe peut-être à côté de beaucoup d’informations
    Par exemple, je ne savais même pas qu’il existait des symboles dans les binaires, ni quelle était la différence entre ELF et Mach-O

  • Faire un cat d’un binaire dans le terminal est un raccourci vers la tristesse
    J’aime bien | hd, qui est en fait hexdump -C, même si c’est tout aussi illisible à l’œil nu