Post

Avant main() : PLongée dans le C runtime.

Avant main() : PLongée dans le C runtime.

Avant main() : Plongée dans le C Runtime

La fonction main() est communément présentée comme le point d’entrée des programmes C. Pourtant, lors de l’exécution de ./prog, le système d’exploitation ne transfère pas directement le contrôle à main(). Une infrastructure fournie par le compilateur et le runtime C s’exécute en amont.

Cet article examine la chaîne d’appels réelle menant à main(), explique l’origine de cette convention de nommage, et démontre comment définir un point d’entrée personnalisé.

Le rôle du compilateur

Prenons ce code source très simple :

1
2
3
4
5
6
7
#include <stdio.h>

int main()   
{         
  printf("Hello, World!\n");
  return 0;
}

Lorsque nous compilons ce programme via gcc main.c -o prog, Le compilateur traduit notre code source C en code machine. En plus de cela, il ajoute d’autres chose que nous allons décortiquer par la suite.

Une fois compilé et exécuté, on a bien ceci :

1
2
./prog
Hello, World!

Maintenant imaginons que nous changions int main() par int new_entry()

1
2
3
4
gcc main.c -o prog
/usr/bin/ld: /usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/../../../../lib/crt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status

On apprend donc qu’à la compilation, une fonction _start est lié à notre programme et cherche la fonction main().

Du coup, Pourquoi il cherche main()? C’est quoi cette fonction _start? Est-il possible de changer cela ?

Enquête dans le debugger

Regardons le resultat de la compilation par GDB.

Voici les différents symlboles :

1
2
3
4
5
6
7
8
pwndbg> info functions

Non-debugging symbols:
0x0000000000401000  _init
0x0000000000401030  puts@plt
0x0000000000401040  _start
0x0000000000401126  main
0x0000000000401140  _fini

Nous pouvons voir qu’il n’y a pas que main() point de vu machine. Ce qui nous intéresse se trouve dans _start.

Voici les partie importantes de _start:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> disass _start
Dump of assembler code for function _start:
   •••
   # Placement des arguments de commande en paramètre d'un prochain appel de fonction
   0x0000000000401046 <+6>:     mov    r9,rdx
   0x0000000000401049 <+9>:     pop    rsi
   0x000000000040104a <+10>:    mov    rdx,rsp
   •••
   •••
   # Placement de l'addresse de main en paramètre du prochain apel de fonction
   0x0000000000401058 <+24>:    mov    rdi,0x401126
   # Appel de la fonction à rip+0x2f63
   0x000000000040105f <+31>:    call   QWORD PTR [rip+0x2f63]        # 0x403fc8
   •••

Si nous placons un breakpoint à 0x000000000040105f puis que nous lançons le programme, nous allons voir ce qui est appelé.

1
2
3
4
5
0x40105f <_start+31>    call   qword ptr [rip + 0x2f63]    <__libc_start_main>
    rdi: 0x401126 (main) ◂— push rbp
    rsi: 1
    rdx: 0x7fffffffdbb8 —▸ 0x7fffffffdf55 ◂— '/tmp/test/prog'
    •••

_start() appel donc __libc_start_main avec pour argument :

  • L’adresse de main()

  • Le nombre d’argument (ici 1, le nom du programme)

  • le pointeur vers la liste des arguments (argv[])

S’il y avait deux arguments, ce serait comme ceci :

appel ./prog test

1
2
rsi: 2
rdx: 0x7fffffffdba8 —▸ 0x7fffffffdf50 ◂— '/tmp/test/prog'

l’argument 1 est donc le nom du programme. Pour être sure que le second soit bien “test” nous pouvons l’afficher comme ceci :

1
2
3
pwndbg> x/2s 0x7fffffffdf50
0x7fffffffdf50: "/tmp/test/prog"
0x7fffffffdf5f: "test"

Ensuite, qu’est-ce qu’il se passe dans __libc_start_main ?

Nous pouvons voir que c’est ce programme qui va appeler _init & _fini :

1
call_init(argc, argv, __environ);

Ce sont des fonctions qui permettent d’exécuter les constructeur et deconstructeur respectivement avant et après l’exécution de main().

Après avoir fait _init(), il appele donc main():

1
__libc_start_call_main(main, argc, argv MAIN_AUXVEC_PARAM);

Donc pour résumé : _start -> __libc_start_call_main -> main

CRT0

Aussi, au moment du lien entre notre programme et _start, l’erreur nous à montré ce fichier : /usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/../../../../lib/crt1.o.

Il s’agit en fait du programme _start. Voici une version simplifié que l’on peut faire :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SECTION .text
  global _start

_start:
  ; Obtenir argc, argv, envp de la pile
  mov		rdi, [rsp]
  lea		rsi, [rsp + 8]
  lea 	rdx, [rsi + rdi * 8 + 8]

  ; Appeler main(argc, argv, envp)
  extern main
  call	main

  ; Appel systeme pour quitter le programme
  mov		rdi, rax
  mov		rax, 60
  syscall

Nous compilons notre CRT1.o et le lions à notre programme.

1
2
3
4
5
6
nasm -f elf64 crt1.s -o crt1.o

gcc -nostartfiles crt1.o main.c -o prog

./prog
Hello, World!

Maintenant vérifions que c’est bien notre _start.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pwndbg> info functions
All defined functions:

Non-debugging symbols:
    0x0000000000001010  puts@plt
    0x0000000000001020  _start
    0x000000000000103d  main

pwndbg> disass _start
Dump of assembler code for function _start:
    0x0000000000001020 <+0>:     mov    rdi,QWORD PTR [rsp]
    0x0000000000001024 <+4>:     lea    rsi,[rsp+0x8]
    0x0000000000001029 <+9>:     lea    rdx,[rsi+rdi*8+0x8]
    0x000000000000102e <+14>:    call   0x103d <main>
    0x0000000000001033 <+19>:    mov    rdi,rax
    0x0000000000001036 <+22>:    mov    eax,0x3c
    0x000000000000103b <+27>:    syscall

C’est bien notre _start. Il nous reste plus qu’a vérifier que c’est bien ce fichier qui est responsable de l’appel strict à main().

1
2
3
  ; Appler main(argc, argv, envp)
  extern new_entry
  call	new_entry

On remplace dans notre CRT1.o main par new_entry.

1
2
3
4
5
6
nasm -f elf64 crt1.s -o crt1.o

gcc -nostartfiles crt1.o main.c -o prog

./prog
Hello, World!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> info functions

Non-debugging symbols:
    0x0000000000001010  puts@plt
    0x0000000000001020  _start
    0x000000000000103d  new_entry

pwndbg> disass _start
Dump of assembler code for function _start:
    0x0000000000001020 <+0>:     mov    rdi,QWORD PTR [rsp]
    0x0000000000001024 <+4>:     lea    rsi,[rsp+0x8]
    0x0000000000001029 <+9>:     lea    rdx,[rsi+rdi*8+0x8]
    0x000000000000102e <+14>:    call   0x103d <new_entry>
    0x0000000000001033 <+19>:    mov    rdi,rax
    0x0000000000001036 <+22>:    mov    eax,0x3c
    0x000000000000103b <+27>:    syscall

Nous avons donc compris pourquoi main() est le point d’entré d’un code C et comment faire en sorte que ce soit autre chose !

Conclusion

_start(), fournie par le fichier crt1.o et automatiquement liée par le compilateur, récupère les arguments du programme depuis la pile (argc, argv, envp) et les transmet à __libc_start_main(). Cette dernière initialise l’environnement d’exécution, appelle les constructeurs globaux via _init(), puis invoque enfin notre main(). Au retour, elle appelle _fini() pour nettoyer avant de terminer le programme.

Le nom main() n’est donc pas magique : c’est une simple convention du runtime C. En créant notre propre _start(), nous pouvons utiliser n’importe quel nom pour notre point d’entrée !

This post is licensed under CC BY 4.0 by the author.