wandres.dev
MAGO · Freestanding y no-libc

Un binario sin libc

Desmontar el andamiaje con `-nostdlib`, escribir tu propio `_start` respetando el contrato de la pila que deja el kernel, invocar syscalls a mano y terminar el proceso sin que exista `exit`.

⏱ 18 min

Entre el execve del kernel y la primera línea de tu main hay unas cuantas miles de instrucciones que alguien escribió por ti. Vamos a borrarlas todas y reconstruir el mínimo imprescindible: un punto de entrada, un contrato de pila, un puñado de syscalls y una terminación limpia. Al final tendrás un ELF que no depende de nada.

🎯 Al terminar esta lección sabrás
  • Separar lo que quitan -nostdlib, -nostartfiles y -nodefaultlibs.
  • Escribir un _start correcto según el contrato de entrada de proceso.
  • Invocar syscalls de Linux a mano y decodificar sus errores.
  • Terminar el proceso con exit_group y entender por qué no se puede retornar.

Desmontar el andamiaje

Cuando enlazas un programa normal, el compilador añade en silencio una lista de objetos que nunca escribiste. Conviene verlos antes de quitarlos:

gcc -std=c23 hola.c -o hola -v 2>&1 | grep -o 'crt[^ ]*\.o'
# crt1.o  crti.o  crtbegin.o  crtend.o  crtn.o

crt1.o contiene el símbolo _start de la libc: recoge los argumentos, inicializa el TLS y el estado global, invoca los constructores de las secciones .init_array, llama a main y pasa su valor de retorno a exit. Los demás son los prólogos y epílogos de las secciones de inicialización.

Las tres banderas relevantes son ortogonales y merece la pena no confundirlas:

🚫

`-nostartfiles`

Elimina los objetos crt*.o. Desaparece el _start de la libc, así que el enlazador se queda sin punto de entrada salvo que lo definas tú.

📚

`-nodefaultlibs`

Elimina las bibliotecas implícitas: -lc y también -lgcc. Sigue habiendo arranque, pero no hay implementaciones.

💣

`-nostdlib`

Las dos anteriores a la vez. Ni arranque ni bibliotecas: el enlazador solo verá tus objetos.

flowchart LR
E[execve del kernel] --> P[Carga los segmentos PT_LOAD del ELF]
P --> Q[Prepara la pila inicial con argc argv envp y auxv]
Q --> R[Salta a la direccion e_entry de la cabecera ELF]
R --> S[Tu simbolo _start]
S --> M[Funcion en C con argc y argv]
M --> X[syscall exit_group]
style S fill:#f9e2af,color:#11111b
style X fill:#f38ba8,color:#11111b

El punto de entrada no es un nombre mágico del kernel: el kernel salta a la dirección que lee del campo e_entry de la cabecera ELF. Que ese campo apunte a un símbolo llamado _start es una convención del enlazador, y se puede cambiar con ld -e otro_nombre.

El contrato de entrada: la pila que deja el kernel

Aquí está el error clásico, y lo comete todo el mundo la primera vez: escribir el punto de entrada como una función de C normal.

void _start(void) { ... }   /* INCORRECTO: dos suposiciones rotas */

El ABI System V de AMD64 especifica que, en el instante en que el kernel transfiere el control, el registro de pila apunta directamente a argc, y a continuación están el vector de punteros argv terminado en nulo, el vector envp también terminado en nulo, y el vector auxiliar. El registro de pila está alineado a 16 bytes, y —esto es lo decisivo— no hay dirección de retorno. Una función de C compilada asume lo contrario: cree que hay 8 bytes de retorno encima, así que calcula mal la alineación, y si el flujo llega al final emitirá un ret que saltará al valor de argc interpretado como dirección.

La forma correcta es escribir el punto de entrada en ensamblador y hacer que sea él quien construya una llamada legítima:

    .section .text
    .global _start
    .type   _start, @function
_start:
    xor    %ebp, %ebp                  /* corta la cadena de marcos de pila */
    mov    (%rsp), %rdi                /* argc */
    lea    8(%rsp), %rsi               /* argv */
    lea    16(%rsp,%rdi,8), %rdx       /* envp = argv + argc + 1 */
    and    $-16, %rsp                  /* realinea a 16 antes del call */
    call   arranque                    /* funcion escrita en C */
    mov    %eax, %edi                  /* codigo de salida */
    mov    $231, %eax                  /* exit_group */
    syscall
    hlt                                /* inalcanzable: red de seguridad */
    .size  _start, . - _start

Poner a cero el registro de marco no es superstición: es lo que indica a los desenrolladores de pila y a los depuradores dónde termina la cadena de llamadas. Sin eso, gdb seguirá inventando marcos hacia la basura de la pila inicial.

Syscalls a mano

La frontera real con el kernel en x86-64 es la instrucción syscall, y su convención no es la misma que la de las llamadas a función. El número va en rax y los argumentos en rdi, rsi, rdx, r10, r8, r9. Fíjate en el cuarto: es r10, no rcx, porque la propia instrucción syscall deposita la dirección de retorno en rcx y las banderas en r11, destruyendo lo que hubiera.

#include <stdint.h>
#include <stddef.h>

static inline long syscall3(long n, long a, long b, long c)
{
    long ret;
    __asm__ volatile ("syscall"
                      : "=a"(ret)
                      : "a"(n), "D"(a), "S"(b), "d"(c)
                      : "rcx", "r11", "memory");
    return ret;
}

#define SYS_write      1
#define SYS_exit_group 231

static long escribir(int fd, const void *buf, size_t n)
{
    return syscall3(SYS_write, fd, (long)buf, (long)n);
}

El listado de clobbers no es decorativo. Omitir "rcx" y "r11" deja al optimizador creyendo que sobreviven valores que la instrucción ya destruyó; omitir "memory" le permite reordenar escrituras a tu búfer más allá de la llamada. Ambos producen fallos que solo aparecen con -O2 y que son un infierno de depurar.

El convenio de errores también es propio: el kernel devuelve el resultado en rax, y señala el fallo devolviendo el negativo del código de error. Un valor en el rango de -4095 a -1 es un error, no un resultado.

static int es_error(long r) { return r < 0 && r >= -4095; }
/* errno no existe aqui: lo que la libc hace es traducir este rango */

Salir sin exit

No existe exit. No hay atexit, ni vaciado de búferes, ni destructores globales, porque nada de eso se registró. Lo único que termina el proceso es una syscall, y hay dos candidatas que no significan lo mismo:

  • exit, número 60. Termina el hilo que la invoca. En un programa monohilo coincide con terminar el proceso; en cuanto haya más hilos, deja de coincidir.
  • exit_group, número 231. Termina todos los hilos del grupo. Es lo que usa la libc por debajo de exit y de _exit, y es la que quieres por defecto.
_Noreturn static void terminar(int codigo)
{
    syscall3(SYS_exit_group, codigo, 0, 0);
    __builtin_unreachable();
}

Compilar y comprobar que de verdad no depende de nadie:

gcc -std=c23 -ffreestanding -nostdlib -static -no-pie -O2 \
    -o hola inicio.S hola.c
ldd hola                      # not a dynamic executable
readelf -h hola | grep Entry  # la direccion de tu _start
strace ./hola                 # write y exit_group; nada mas

El -no-pie no es opcional por comodidad. Un ejecutable independiente de posición necesita que alguien procese sus relocalizaciones antes de la primera instrucción útil, y ese alguien es el intérprete dinámico o, en el caso de -static-pie, un fragmento de código de autorreubicación que la libc aporta. Sin libc, o renuncias a PIE o escribes ese código tú.

Un programa es un punto de entrada y un contrato

Este es el momento en que el concepto de «programa» deja de ser una abstracción heredada y se convierte en algo que puedes sostener entero en la cabeza. No hay magia entre el execve y tu código: hay un formato de fichero con un campo que dice dónde empezar, una pila que el kernel deja preparada con una disposición documentada, y una instrucción que cruza al modo privilegiado. Eso es todo. printf son decenas de miles de líneas construidas sobre write; malloc son miles construidas sobre mmap; el arranque de la libc son cientos construidas sobre este mismo _start que acabas de escribir. Cuando has hecho este ejercicio una vez, cambia tu forma de leer un sistema: ya no ves capas opacas, ves capas que alguien decidió poner y que podrías quitar. Es exactamente el conocimiento que necesitas para el arranque de un kernel, donde la primera instrucción que se ejecuta es tuya y no hay ninguna capa debajo, y el que hace que no_std en Rust o un runtime propio dejen de parecer brujería.

⚔️ Un ELF que no debe nada a nadie
  1. Escribe inicio.S y una función en C que reciba argc y argv, imprima cada argumento con write y devuelva el número de argumentos como código de salida. Verifica con echo $?.
  2. Comprueba con ldd y con readelf -d que el binario no tiene sección dinámica.
  3. Quita deliberadamente "memory" de la lista de clobbers, compila con -O2 y busca la diferencia en el desensamblado.
  4. Provoca un error: llama a write sobre el descriptor 999 e imprime el valor devuelto. Comprueba que es el negativo de EBADF.
  5. Sustituye exit_group por exit y razona qué cambiaría si el proceso tuviera varios hilos.
  6. Compara el tamaño del binario con el de un hola mundo estático enlazado contra glibc.