wandres.dev
ENLAZADOR · El linker y ELF

El linker dinámico: ld.so, GOT, PLT y LD_PRELOAD

Lo que ocurre antes de main. Esta lección sigue al intérprete ELF desde el exec del núcleo hasta la primera instrucción de tu programa, deduce por qué el código independiente de posición necesita una tabla de desplazamientos globales, explica el enlace perezoso y su retirada frente a RELRO, y usa LD_PRELOAD para interceptar funciones sin tocar el código fuente.

⏱ 19 min

Entre el momento en que el núcleo atiende tu execve y el momento en que se ejecuta la primera línea de main transcurre un programa entero que no escribiste y que casi nunca has mirado: el enlazador dinámico. Abre bibliotecas, resuelve dependencias transitivas, reubica estructuras, aplica precedencias de espacio de nombres y ejecuta constructores, todo antes de cederte el control. Ese trabajo es la razón de que existan la tabla GOT y el trampolín PLT, de que exista el enlace perezoso, y de que baste una variable de entorno para sustituir malloc en un binario ajeno del que no tienes el código. Aquí se desmonta esa maquinaria pieza a pieza.

🎯 Al terminar esta lección sabrás
  • Reconstruir la secuencia completa de arranque, desde execve hasta la entrada en main.
  • Explicar por qué el código independiente de posición exige una indirección y qué papel juegan GOT y PLT.
  • Evaluar el enlace perezoso frente a la resolución anticipada y su interacción con RELRO.
  • Interceptar funciones de biblioteca con LD_PRELOAD y dlsym, conociendo sus límites.

Lo que ocurre antes de main

Un ejecutable dinámico contiene un segmento PT_INTERP con una cadena: la ruta de su intérprete, típicamente /lib64/ld-linux-x86-64.so.2. El núcleo, al procesar execve, no salta al punto de entrada de tu programa; carga además ese intérprete y le cede el control a él. El enlazador dinámico es, por tanto, un programa ordinario que se ejecuta en tu proceso y con tus permisos.

Su trabajo tiene cuatro fases. Primero localiza las dependencias declaradas en las entradas DT_NEEDED de la sección dinámica, buscándolas en el orden DT_RPATH, la variable LD_LIBRARY_PATH, DT_RUNPATH, la caché de ldconfig y los directorios por defecto. Segundo, proyecta cada objeto en memoria con mmap y repite el proceso para sus propias dependencias, construyendo un grafo. Tercero, reubica: recorre las tablas de reubicación dinámicas de cada objeto y escribe las direcciones definitivas, que solo ahora se conocen porque la carga puede haber sido aleatoria. Cuarto, inicializa: ejecuta los constructores de cada objeto en orden de dependencia, es decir, todo lo listado en DT_INIT_ARRAY, que incluye las funciones marcadas con __attribute__((constructor)).

readelf -l ./prog | grep interpreter    # que interprete pide el binario
readelf -d ./prog                       # NEEDED, RUNPATH, INIT_ARRAY, FLAGS
ldd ./prog                              # grafo de dependencias ya resuelto
LD_DEBUG=libs,reloc ./prog              # traza del enlazador dinamico paso a paso

La variable LD_DEBUG merece un lugar en tu memoria activa: con libs ves la búsqueda de cada biblioteca, con bindings cada símbolo que se enlaza y a qué objeto, y con statistics el coste en tiempo de todo el arranque. Es la única forma honesta de responder a la pregunta de por qué un proceso tarda en levantarse.

Dos detalles de esa búsqueda tienen consecuencias operativas serias. El primero es que lo que se busca no es un nombre de fichero cualquiera sino el DT_SONAME grabado en la biblioteca al construirla, y ese nombre incluye la versión mayor de la interfaz: por eso conviven libfoo.so.1 y libfoo.so.2 en el mismo sistema sin estorbarse, y por eso el enlace libfoo.so sin sufijo solo existe para el enlazado, no para la ejecución. El segundo es que DT_RPATH tiene prioridad sobre LD_LIBRARY_PATH y no se puede anular desde el entorno, mientras que DT_RUNPATH se consulta después y sí se puede; de ahí que las guías de empaquetado recomienden RUNPATH y que el enlazador moderno lo emita por defecto con --enable-new-dtags.

Conviene añadir una advertencia de seguridad que se repite poco: ldd no es un inspector pasivo. En su implementación clásica funciona ejecutando el binario con una variable de entorno que hace que el enlazador dinámico imprima el grafo en lugar de arrancar el programa, lo que significa que puede ejecutar código del objeto que estás analizando. Sobre un binario en el que no confías, la herramienta correcta es readelf -d, que se limita a leer bytes.

GOT y PLT: la indirección obligatoria

El código independiente de posición debe funcionar sin importar en qué dirección se cargue, lo que prohíbe incrustar direcciones absolutas en las instrucciones. Si el código es de solo lectura y compartido entre procesos, no puede parchearse en cada uno. La única salida es una indirección: el código lee la dirección de una tabla que sí es escribible y privada de cada proceso. Esa tabla es la GOT, la tabla de desplazamientos globales, y vive en .got y .got.plt.

Para los datos basta con eso. Para las funciones se añade una capa más, la PLT, una tabla de pequeños trampolines de código situada en .plt. Llamar a printf no salta a printf: salta a su entrada en la PLT, que hace un salto indirecto a través de la casilla correspondiente de la GOT.

flowchart LR
A[Tu codigo hace call a printf] --> B[Entrada en la PLT]
B --> C[Salto indirecto via casilla de la GOT]
C --> D[Primera llamada: salta al resolvedor]
D --> E[Busca el simbolo y escribe la casilla]
E --> F[Salta a printf dentro de libc]
C --> F
style C fill:#89b4fa,color:#11111b
style E fill:#f9e2af,color:#11111b
🗂️

La GOT

Datos escribibles. Una casilla por símbolo externo, con la dirección definitiva que el enlazador dinámico escribe al reubicar o al resolver.

🪃

La PLT

Código de solo lectura. Un trampolín por función externa que convierte una llamada directa en un salto indirecto a través de la GOT.

El coste de esta arquitectura es medible y, lo que importa más, evitable en los casos que dependen de ti. Dentro de una misma biblioteca, una llamada de una función a otra pasa igualmente por la PLT si el símbolo es exportable, porque el modelo permite que alguien lo interponga desde fuera. Marcar la función como static, o compilar con -fvisibility=hidden y exportar solo la API pública, elimina esa posibilidad y con ella la indirección: el compilador emite entonces una llamada relativa directa.

// Visible fuera de la biblioteca: toda llamada pasa por la PLT
__attribute__((visibility("default"))) int api_publica(int x);

// Interna: el compilador puede llamarla directamente, sin PLT
__attribute__((visibility("hidden"))) int auxiliar(int x);
gcc -std=c23 -O2 -fPIC -fvisibility=hidden -shared -o libfoo.so foo.c
readelf --dyn-syms libfoo.so     # solo deberia aparecer la API publica
objdump -d libfoo.so | grep '@plt'

Enlace perezoso y su retirada

Resolver miles de símbolos en el arranque cuesta tiempo, y un programa típico no llega a llamar a la mayoría. De ahí nació el enlace perezoso: las casillas de la GOT correspondientes a funciones se inicializan apuntando al propio trampolín, de modo que la primera llamada acaba en el resolvedor del enlazador dinámico, que busca el símbolo, escribe la dirección real en la casilla y salta a ella. Las llamadas siguientes ya encuentran la casilla resuelta y pagan solo un salto indirecto.

El precio es una superficie de ataque evidente: si la GOT es escribible durante toda la vida del proceso, cualquier escritura fuera de límites que la alcance permite secuestrar el flujo de control. La respuesta es RELRO. En su forma parcial, el enlazador agrupa las secciones de reubicación en un segmento GNU_RELRO que se marca como de solo lectura tras el arranque, pero deja .got.plt escribible porque el enlace perezoso lo exige. En su forma completa, activada con -Wl,-z,relro,-z,now, se resuelve todo por adelantado y la tabla entera pasa a solo lectura.

gcc -std=c23 -O2 -Wl,-z,relro,-z,now -o prog prog.c
readelf -d ./prog | grep -E 'BIND_NOW|FLAGS'
LD_BIND_NOW=1 ./prog             # forzar resolucion anticipada sin recompilar
checksec --file=./prog           # resumen de RELRO, NX, PIE y canarios

El balance ha cambiado con los años y conviene tenerlo actualizado. Cuando el enlace perezoso se diseñó, resolver miles de símbolos en el arranque era un coste perceptible en máquinas lentas; hoy, con tablas de resolución basadas en DT_GNU_HASH y con procesos que viven mucho más que su arranque, la diferencia suele ser de milisegundos. A cambio, la resolución anticipada permite proteger la tabla entera y elimina la variabilidad de latencia en la primera llamada, algo que importa en sistemas con requisitos temporales. Por eso las distribuciones orientadas a seguridad construyen todo con -z now por defecto, y por eso LD_BIND_NOW sigue siendo útil para comprobar sin recompilar si un problema de arranque proviene de un símbolo que falta.

Toda la indirección existe para que el código pueda compartirse

Detrás de la GOT, la PLT, el enlace perezoso y la aleatorización hay una sola decisión de diseño de la que todo lo demás se deduce: el texto de una biblioteca debe ser una única copia física compartida por todos los procesos que la usan. Si la biblioteca de C se cargara parcheando direcciones dentro de su código, cada proceso necesitaría su propia copia privada de esos megabytes y el ahorro desaparecería. Al mantener el código intacto y mover toda la variabilidad a una tabla de datos, las páginas de texto se proyectan en modo compartido y solo la GOT, que mide unos pocos kilobytes, se duplica por proceso. Esa misma propiedad habilita gratis otras dos cosas de primer orden. Habilita la aleatorización del espacio de direcciones, porque si nada dentro del código depende de la dirección de carga, esa dirección puede cambiar en cada ejecución. Y habilita la interposición de símbolos: como cada llamada pasa por una casilla escribible, sustituir una función es escribir un puntero distinto, no reescribir código. LD_PRELOAD, los perfiladores, los depuradores de memoria y los sanitizadores viven todos de esa misma casilla. Al mismo tiempo, es el precio que se paga: la interposición impide al compilador dar por sabida la dirección de una función exportada, y por eso -fvisibility=hidden no es solo higiene de API sino una optimización real que elimina indirecciones dentro de la propia biblioteca.

Interceptar con LD_PRELOAD

Como la resolución consulta los objetos en un orden y se queda con la primera definición que encuentra, una biblioteca cargada antes que las demás gana. LD_PRELOAD inserta objetos al principio de ese orden, de modo que sus definiciones interponen a las originales sin recompilar ni relinkar nada.

El patrón canónico consiste en definir la función con la firma exacta de la original y recuperar la implementación real con dlsym y el pseudohandle RTLD_NEXT, que significa el siguiente objeto en el orden de búsqueda a partir del actual.

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

static size_t total = 0;

void *malloc(size_t n) {
    static void *(*real)(size_t) = NULL;
    if (!real) real = dlsym(RTLD_NEXT, "malloc");
    total += n;
    fprintf(stderr, "malloc de %zu bytes, acumulado %zu\n", n, total);
    return real(n);
}
gcc -std=c23 -shared -fPIC -o trazamalloc.so trazamalloc.c -ldl
LD_PRELOAD=./trazamalloc.so ./prog

Los límites de la técnica conviene tenerlos claros. No funciona sobre binarios estáticos, porque no hay enlazador dinámico que interponer nada. El sistema ignora LD_PRELOAD en programas setuid, y con razón evidente. Las llamadas internas de una biblioteca a sus propios símbolos pueden no pasar por la PLT y escapar a la interposición, que es justo el efecto de la visibilidad oculta descrita más arriba. Y escribir sobre malloc exige cuidado con la reentrada: si tu código de traza asigna memoria, entras en recursión infinita.

El problema de la reentrada tiene además una versión sutil que sorprende la primera vez. dlsym puede necesitar asignar memoria internamente, de modo que la primera llamada a tu malloc interpuesto invoca a dlsym, que invoca a malloc, que vuelve a tu función antes de que el puntero real esté inicializado. La solución habitual es un asignador de emergencia: un búfer estático servido por un puntero de avance que atiende las primeras peticiones mientras se resuelve el símbolo verdadero.

static char emergencia[8192];
static size_t usado = 0;

static void *malloc_emergencia(size_t n) {
    n = (n + 15u) & ~(size_t)15u;          // alineacion generosa
    if (usado + n > sizeof emergencia) return NULL;
    void *p = emergencia + usado;
    usado += n;
    return p;
}

Queda una vía distinta que conviene conocer aunque no se use hoy: la carga explícita con dlopen y dlsym, que traslada el mismo mecanismo a tiempo de ejecución bajo control del programa. Es la base de los sistemas de complementos y la razón de que una aplicación pueda ampliarse sin recompilarse. El precio es que la resolución de errores pasa a ser tuya: dlopen devuelve nulo y dlerror explica el motivo, y ese camino de error hay que escribirlo.

💡
La misma palanca sirve para diagnosticar

Antes de escribir tu propia biblioteca de interposición, recuerda que ya existen varias muy pulidas que usan este mismo mecanismo: los sanitizadores de dirección y de hilos, valgrind con un enfoque distinto, y los perfiladores de asignación. Escribir la tuya tiene sentido cuando necesitas una política concreta, como inyectar fallos de asignación para probar rutas de error.

⚔️ Toma el control del arranque
  1. Ejecuta readelf -l sobre un binario dinámico y localiza la ruta del intérprete. Contrástala con la salida de ldd y con la de readelf -d.
  2. Lanza tu programa con LD_DEBUG=libs y después con LD_DEBUG=statistics, y cuantifica cuánto del arranque se va en reubicaciones.
  3. Desensambla con objdump -d una llamada a printf y sigue el camino hasta la entrada de la PLT. Con un depurador, inspecciona la casilla de la GOT antes y después de la primera llamada y comprueba que cambia.
  4. Compila el mismo programa con -Wl,-z,now y repite el punto anterior: verifica que la casilla ya está resuelta desde el principio y que readelf -d muestra BIND_NOW.
  5. Escribe la biblioteca de interposición de malloc del ejemplo, úsala sobre un programa cualquiera y añade después una política de fallo que devuelva nulo una de cada cien asignaciones para auditar el manejo de errores.