1 points par GN⁺ 5 시간 전 | 1 commentaires | Partager sur WhatsApp
  • 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
Publicité

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

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

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

 
GN⁺ 5 시간 전
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 que gcc l’assemble à nouveau en fichier « brut » de 45 octets.
      Ce serait un ELF par accident, mais gcc n’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 de libc, comme le mécanisme atexit(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érique syscall() pour _Exit() a été incluse.