- Une expérience visant à réduire la taille du binaire
./a.out généré uniquement avec GCC part de trois contraintes : exécution réussie, code de sortie 0 et aucun post-traitement autorisé
- Le programme de base
int main(){ return 0; } pesait 15 816 octets, puis est descendu à 14 352 octets après suppression des informations de débogage avec -s
- Avec
-nostartfiles, on saute le code de démarrage avant main, puis -nostdlib -static -no-pie et un appel direct au syscall SYS_exit éliminent la structure fondée sur la liaison dynamique
- Les sections
.comment, .eh_frame et .note.gnu.property sont supprimées respectivement via -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables et -Wa,-mx86-used-note=no, ce qui réduit le surcoût des sections
- Le binaire final, avec
-Wl,--nmagic pour réduire le padding d’alignement à 0x1000, atteint 400 octets ; un post-traitement comme objcopy est hors périmètre
Objectif et conditions de base
- L’objectif est de générer le plus petit binaire
./a.out possible
- Le programme doit respecter trois conditions
./a.out doit s’exécuter correctement
$? doit être déterministiquement égal à 0
- Le binaire doit être généré uniquement avec GCC, sans post-traitement comme
objcopy, un éditeur hexadécimal ou un patch manuel
- Le point de départ est le programme le plus simple possible
// compiled with gcc empty.c
int main() {
return 0;
}
- La taille de ce programme de base est de 15 816 octets d’après
stat, avec la comparaison qu’il faut l’équivalent de quatre banques de RAM de l’Apollo guidance computer pour contenir un binaire qui ne fait rien
- La sortie de
file a.out indique ELF 64-bit LSB pie executable, dynamically linked, le chemin de l’interpréteur et l’état not stripped
- Pour réduire l’état
not stripped, on peut utiliser le drapeau -s de GCC, qui compile sans conserver les informations de débogage ; la taille descend alors à 14 352 octets
Contourner le code de démarrage et supprimer la liaison dynamique
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
- Après ce changement, la taille est de 13 632 octets ; le gain reste modeste
- La sortie de
objdump -x a.out montre, avec la section dynamique, NEEDED libc.so.6, le chemin de l’interpréteur, la table des symboles dynamiques, les métadonnées de relocalisation, la structure PLT/GOT et les références aux bibliothèques partagées
- Comme le seul objectif du programme est de se terminer immédiatement, trois drapeaux permettent de retirer de gros composants
-nostdlib : ne pas lier la bibliothèque standard
-static : éviter la structure de liaison dynamique
-no-pie : générer un exécutable à adresse fixe plutôt qu’un exécutable indépendant de la position
// compiled with gcc -static -nostdlib -no-pie -s empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
- Après le passage à un appel direct du syscall
SYS_exit, la taille tombe à 8 704 octets
Supprimer les sections restantes
- La sortie de
objdump -D a.out montre qu’il reste des sections comme .note.gnu.property, .text, .eh_frame et .comment
- La section
.comment stocke des informations sur le compilateur ayant produit le binaire ; dans ce cas, elle contient la chaîne GCC: (GNU) 15.2.0
objdump interprète ces données comme de l’assembleur et les affiche donc comme d’étranges instructions
- En ajoutant
-fno-ident, la section .comment est supprimée et la taille descend à 8 616 octets
- La section
.eh_frame sert au déroulage de pile ; pour un programme qui ne fait rien, elle n’est pas nécessaire pour la gestion d’erreurs
- Avec
-fno-exceptions -fno-asynchronous-unwind-tables, la taille chute dans la plage des 4 Ko
- Le dernier élément à supprimer est la section
.note.gnu.property
readelf -n a.out affiche les propriétés x86 feature used: x86 et x86 ISA used: x86-64-baseline
- GNU laisse des notes dans cette section pour que d’autres outils puissent les lire ; ici, c’est l’assembleur qui ajoute cette note
- En ajoutant
-Wa,-mx86-used-note=no, la taille passe à 4 320 octets
- À ce stade,
objdump -D a.out n’affiche plus que les instructions de la section .text
401000: 55 push %rbp
401001: 48 89 e5 mov %rsp,%rbp
401004: b8 3c 00 00 00 mov $0x3c,%eax
401009: 31 ff xor %edi,%edi
40100b: 0f 05 syscall
Padding d’alignement et structure en 400 octets
- La sortie de
readelf -a a.out à 4 320 octets montre l’en-tête ELF, 3 en-têtes de programme, 3 en-têtes de section, ainsi que la structure .text et .shstrtab
- Les en-têtes de programme constituent la table qui indique au chargeur de l’OS comment mapper le fichier en segments mémoire au démarrage du programme
- Dans cette sortie, les 232 octets de
LOAD correspondent à l’en-tête ELF de 64 octets et aux 3 en-têtes de programme de 56 octets
- L’exigence d’alignement de l’entrée
LOAD est 0x1000, donc l’éditeur de liens place .text après un padding
- En passant
-Wl,--nmagic à l’éditeur de liens pour qu’il n’applique pas cette hypothèse, il devient possible de mapper ensemble les métadonnées ELF et la section .text ; il ne reste alors plus qu’un seul LOAD et la taille descend à 400 octets
- La structure du binaire de 400 octets est la suivante
| Composition |
Taille |
| En-tête ELF |
64 B |
En-tête de programme : PT_LOAD |
56 B |
En-tête de programme : PT_GNU_STACK |
56 B |
Contenu de la section .text |
11 B |
Contenu de la section .shstrtab, "\0.shstrtab\0.text\0" |
17 B |
| Padding pour les en-têtes de section |
4 B |
En-tête de section [0] : NULL |
64 B |
En-tête de section [1] : .text |
64 B |
En-tête de section [2] : .shstrtab |
64 B |
PT_LOAD est nécessaire pour charger les instructions, et PT_GNU_STACK est toujours généré par GCC
.shstrtab ne peut pas être supprimée avec GCC seul
- La première entrée d’en-tête de section doit, selon la spécification ELF System V ABI, être réservée à l’index de section non défini
SHN_UNDEF, de valeur 0
- En pratique, cette entrée a le type
SHT_NULL, donc les outils l’affichent comme une section NULL
- Des outils comme
objcopy peuvent encore retirer certains éléments, mais cette méthode est hors périmètre
Tailles par étape et code final
| Étape |
Drapeau / modification |
Taille |
main classique |
gcc empty.c |
15 816 octets |
| Suppression des symboles |
-s |
14 352 octets |
| Freestanding |
-nostartfiles |
13 632 octets |
| Suppression de libc / liaison statique / no PIE |
-nostdlib -static -no-pie |
8 704 octets |
Suppression de la section .comment |
-fno-ident |
8 616 octets |
| Suppression des informations de déroulage |
-fno-asynchronous-unwind-tables -fno-exceptions |
4 400 octets |
| Suppression de la note de propriété GNU |
-Wa,-mx86-used-note=no |
4 320 octets |
| Réduction de l’alignement |
-Wl,--nmagic / -Wl,-n |
400 octets |
- La commande de compilation finale et le code sont les suivants
// gcc -Wl,--nmagic -Wa,-mx86-used-note=no -static -nostdlib -no-pie -s -fno-ident -fno-exceptions -fno-asynchronous-unwind-tables empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
- C’était un premier exercice avec
objdump et ld, et -fno-asynchronous-unwind-tables -fno-exceptions indique à GCC qu’aucun traitement de déroulage de pile n’est nécessaire en cas d’erreur
ld propose aussi le drapeau --no-eh-frame-hdr
- Sur reddit, il existe un exemple réduit jusqu’à 124 octets
1 commentaires
Avis sur Lobste.rs
Si c’est pour n’utiliser que de l’assembleur de toute façon, je ne vois pas pourquoi utiliser un compilateur C
C’est juste une expérience pour le plaisir :)
L’assembleur est un excellent point de départ. J’ai à partir de là un binaire hello world compilé de 231 octets :
https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp
Je suis parti d’un tutoriel similaire il y a quelques années, puis j’ai progressivement empilé les techniques autour tout en séparant mieux le code et en gardant l’overhead aussi bas que possible pour les cas simples. Préserver les 231 octets est important, au point que j’ai même mis en place des tests CI pour le garantir.
Édition : je viens de voir que j’avais laissé un include inutile. Il faut que je corrige ça.
D’accord. Cela dit, il y a pas mal d’astuces spécifiques au C, et sans un peu d’assembleur, j’ai l’impression que le tableau d’ensemble n’aurait pas été complet.
Lien connexe : https://www.muppetlabs.com/~breadbox/software/tiny/
Il y a effectivement ici un binaire de 45 octets. À l’extrême, on pourrait probablement l’encoder en assembleur uniquement avec une suite de
db, puis faire en sorte quegccl’assemble à nouveau en fichier « brut » de 45 octets.Ce serait un ELF par accident, mais
gccn’a pas besoin de le savoir. Ça satisferait peut-être les règles du texte original.Cela dit, avec la plupart des définitions raisonnables, il devient difficile d’appeler ça un binaire C.
J’imagine que la réponse dépend du compilateur. Mais je ne sais pas trop s’il est acceptable de s’appuyer sur du code non-C juste parce que certains compilateurs C l’acceptent 😉
Entre un programme C++ qui appelle
exit(3)et un appel assembleur àSYS_exit, il existe une étape intermédiaire. Comme l’indique le numéro de section du manuel,exit(3)est une fonction de bibliothèque, donc cela embarque beaucoup delibc, comme le mécanismeatexit(3)La manière standard d’appeler l’appel système exit brut est
_exit(2), et en le mettant dans_start()puis en liant statiquement, on devrait obtenir un résultat assez petit. En l’écrivant en C plutôt qu’en C++, on peut aussi réduire la ligne de compilation et la taille du code source.C’est exactement ce que j’ai fait.
#include <stdlib.h>void _start(void){_Exit(0); /* C99 function to call SYS_exit() */}Compilé avec
gcc -Os -nostdlib -static -o x x.c -lc, la taille de l’exécutable stripé était de 8912 octets, mais le code réellement généré ne faisait que 96 octets. C’est parce que la fonction génériquesyscall()pour_Exit()a été incluse.