VoidLink se reinventa con cada actualización: cuatro generaciones en apenas unos años

VoidLink resultó no ser simplemente otro framework de Linux para el control encubierto de sistemas infectados. La filtración de los códigos fuente, binarios listos y scripts de despliegue mostró un panorama mucho más grave: dentro del proyecto desarrollaban una línea completa de rootkits para el kernel de Linux, que adaptaban a distintas generaciones del kernel y a sistemas objetivos reales. Por la composición del volcado se aprecia que no se trató de un experimento aislado. En el directorio había versiones para diferentes ramas del kernel, módulos .ko listos para compilaciones concretas, cabeceras BTF separadas y generaciones sucesivas del código. Esto significa que los desarrolladores no solo escribían un rootkit, sino que lo compilaban, probaban y perfeccionaban en condiciones de uso real.
Check Point anteriormente describió a VoidLink como un framework modular de comando y control escrito en Zig con más de 30 complementos, soporte para entornos en la nube y varias técnicas de ocultación, incluyendo LD_PRELOAD, módulos de kernel cargables y eBPF. El nuevo volcado reveló la parte más peligrosa del sistema: la subsistema de sigilo a nivel del kernel. Por el contenido de los archivos se ve que los desarrolladores no se limitaron a un solo método. Reunieron una arquitectura híbrida donde el clásico módulo de kernel cargable funcionaba junto con un programa eBPF. Tal combinación es rara en rootkits reales para Linux.
El módulo principal se disfrazaba como vl_stealth, y en algunas versiones también como amd_mem_encrypt. La elección del segundo nombre parece calculada. En Linux existe un módulo legítimo amd_mem_encrypt relacionado con el soporte de Secure Memory Encryption y Secure Encrypted Virtualization en plataformas AMD. Si el módulo malicioso copia sus metadatos, una comprobación superficial con modinfo deja de ser fiable. En el archivo se muestra directamente cómo el rootkit sustituye su información:
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Advanced Micro Devices, Inc.");
MODULE_DESCRIPTION("AMD Memory Encryption Support");
MODULE_VERSION("3.0");
Esta técnica es especialmente útil en entornos en la nube y máquinas virtuales, donde los módulos relacionados con AMD no despiertan sospechas. En la quinta versión los desarrolladores fueron más allá y ocultaron el propio nombre del módulo mediante una obfuscación XOR sencilla, para que no apareciera en una búsqueda básica de cadenas en el binario:
static char obf_modname[] = {
'a'^ICMP_KEY, 'm'^ICMP_KEY, 'd'^ICMP_KEY, '_'^ICMP_KEY,
'm'^ICMP_KEY, 'e'^ICMP_KEY, 'm'^ICMP_KEY, '_'^ICMP_KEY,
'e'^ICMP_KEY, 'n'^ICMP_KEY, 'c'^ICMP_KEY, 'r'^ICMP_KEY,
'y'^ICMP_KEY, 'p'^ICMP_KEY, 't'^ICMP_KEY, 0
};
static void decrypt_string(char *dst, const char *src, u8 key)
{
while (*src) { *dst++ = *src++ ^ key; }
*dst = 0;
}
Por sí sola esa protección es primitiva. La clave se encuentra sin dificultad. Pero para un simple strings o un análisis grosero con grep ya supone una barrera adicional. Y son ese tipo de pequeñas decisiones las que conforman la resistencia general de la herramienta maliciosa.
La arquitectura de VoidLink destaca desde el principio por la forma en que los desarrolladores dividieron responsabilidades entre dos partes. El módulo de kernel cargable se encargaba del trabajo pesado: interceptar funciones del kernel mediante ftrace, ocultar procesos, filtrar la lectura de pseudoarchivos sensibles, enmascarar módulos, procesar comandos a través de un canal oculto basado en ICMP. La parte eBPF resolvía una tarea más fina: ocultar conexiones de red ante la utilidad ss, interviniendo en las respuestas Netlink ya en el límite del espacio de usuario. Esta separación de lógica es importante: utilidades como netstat y ss obtienen datos del kernel por rutas distintas, por lo que un solo hook rara vez cubre ambas direcciones.
A partir del volcado los investigadores distinguen al menos 4 generaciones de VoidLink. La más temprana se orientaba a CentOS 7 con kernel 3.10 y utilizaba la técnica directa antigua: modificación de la tabla de llamadas al sistema. En esos kernels el símbolo kallsyms_lookup_name() todavía se exportaba públicamente, por lo que la dirección de sys_call_table se podía obtener directamente. Luego el módulo desactivaba temporalmente la protección de escritura del registro CR0, cambiaba los punteros a los manejadores y devolvía la protección:
write_cr0(read_cr0() & ~X86_CR0_WP); // Disable write protection
sys_call_table[__NR_getdents64] = (unsigned long)hooked_getdents64;
sys_call_table[__NR_getdents] = (unsigned long)hooked_getdents;
write_cr0(read_cr0() | X86_CR0_WP); // Re-enable write protection
Ese enfoque es antiguo, ruidoso y bien conocido, pero para CentOS 7 aún funciona. Lo más interesante es otra cosa: los desarrolladores claramente se enfrentaron a problemas reales de despliegue. El compilador GCC, con optimizaciones interprocedimentales, renombra funciones añadiendo sufijos como .isra.0, .constprop.5 o .part.3. Por eso la búsqueda de símbolos por nombre exacto suele fallar. En VoidLink implementaron una función separada que itera variantes automáticamente:
static unsigned long find_symbol_flexible(const char *base_name)
{
unsigned long addr;
char buf[128];
int i;
addr = kallsyms_lookup_name(base_name);
if (addr) return addr;
for (i = 0; i <= 20; i++) {
snprintf(buf, sizeof(buf), "%s.isra.%d", base_name, i);
addr = kallsyms_lookup_name(buf);
if (addr) return addr;
}
for (i = 0; i <= 20; i++) {
snprintf(buf, sizeof(buf), "%s.constprop.%d", base_name, i);
addr = kallsyms_lookup_name(buf);
if (addr) return addr;
}
return 0;
}
Ese fragmento ilustra bien el origen del código. No se escribe así en teoría, sino tras la práctica frustrante de que un módulo se cargue en un sistema y en otro falle por un símbolo renombrado. En la misma rama temprana el rootkit interceptaba tanto getdents como getdents64, porque las herramientas de usuario en CentOS 7 usan ambos formatos de entrada de directorios. Para /proc/modules aplicaban un truco separado reemplazando el puntero seq_operations.show tras abrir el archivo con filp_open(). Ya allí aparecieron un temporizador anti depuración y un comando de autodestrucción, y la salida al registro del kernel se silenciaba simplemente redefiniendo pr_info, pr_err y pr_warn como operaciones vacías.
El paso a kernels Linux 5.x requirió más que maquillaje: exigió un cambio de estrategia. Desde Linux 5.7 el símbolo kallsyms_lookup_name() dejó de exportarse, y la protección de la memoria del kernel se endureció por CONFIG_STRICT_KERNEL_RWX. Los desarrolladores de VoidLink eludieron el primer problema con un truco con kprobe. En lugar de llamar directamente registran una sonda en el símbolo deseado, el kernel resuelve la dirección al registrar la sonda, el módulo lee el valor desde kp.addr y a continuación quita la sonda:
static int init_symbols(void)
{
struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" };
if (register_kprobe(&kp) < 0)
return -EFAULT;
kln_func = (kln_t)kp.addr;
unregister_kprobe(&kp);
return kln_func ? 0 : -EFAULT;
}
Ese método es conocido en rootkits modernos para Linux, pero su presencia en VoidLink muestra que el proyecto se adaptó en serio a nuevas ramas del kernel. Tras obtener kallsyms_lookup_name el módulo puede localizar de nuevo otros símbolos internos sin depender de las interfaces antiguas abiertas.
La intercepción de llamadas al sistema en esta generación también cambió. En lugar de editar directamente la tabla de llamadas, los desarrolladores pasaron a ftrace. Este mecanismo fue creado para trazado y depuración de rendimiento, pero resulta útil como forma estándar de desviar la ejecución a un manejador propio. En VoidLink con ftrace enganchan al menos __x64_sys_getdents64 y vfs_read. Gracias a las banderas FTRACE_OPS_FL_SAVE_REGS y FTRACE_OPS_FL_IPMODIFY el módulo obtiene control completo sobre el estado guardado de los registros y puede sustituir el puntero de instrucciones antes de llamar a la función original.
El hook sobre vfs_read resultó especialmente útil. En lugar de enmascarar un archivo puntual, el rootkit intercepta la lectura de varios pseudoarchivos sensibles y elimina del búfer las líneas que contienen el nombre del módulo o rastros de registro de kprobe y kretprobe. En la publicación se mencionan /proc/kallsyms, /proc/modules y /sys/kernel/debug/kprobes/list. Ese filtro centralizado es notablemente más cómodo que el enfoque antiguo de reemplazar solo seq_operations.show para un único camino.
Los investigadores consideran que la tercera generación ya está prácticamente lista para su explotación. En ella aparecieron parámetros de carga del módulo para que el operador pudiera especificar desde el inicio procesos a ocultar, puertos y el modo de sigilo al insertar el módulo en el kernel. Un ejemplo del volcado es:
insmod vl_stealth.ko init_pids=1234 init_ports=8080 stealth=1
El parámetro init_pids establece los procesos que deben ocultarse de inmediato. init_ports enumera los puertos que hay que eliminar de la salida de netstat y ss. La bandera stealth controla la eliminación del módulo de la lista de módulos cargados tras la inicialización. Con ese esquema no hace falta primero cargar el rootkit y luego configurarlo por el canal oculto: la ventana en la que el módulo está presente pero aún no se ha ocultado ni ha activado los filtros se reduce notablemente.
También se reforzó el mecanismo de recepción de comandos por ICMP. En lugar de registrarse solo en una cadena de Netfilter, los desarrolladores se situaron tanto en NF_INET_PRE_ROUTING como en NF_INET_LOCAL_IN. Para un rootkit eso es un seguro práctico: la recepción de paquetes resulta más fiable según la configuración de red del anfitrión y las reglas de iptables. Muchos proyectos similares se limitan a un único punto de entrada; aquí se observa la intención de prever fallos con antelación.
Una de las partes más interesantes de VoidLink está en cómo oculta las conexiones de red ante ss. Para los rootkits de Linux esta es una vieja dificultad. Históricamente es más fácil ocultar procesos o un módulo que sustituir cuidadosamente la visión de red para distintos instrumentos de diagnóstico. VoidLink resuelve el problema con un componente eBPF que interviene en Netlink y limpia los datos en su camino al espacio de usuario. En el archivo se incluye un ejemplo de comprobación sencilla para detectar discrepancias entre vistas de red, donde se comparan los datos de ss con el análisis directo de /proc/net/tcp y /proc/net/tcp6:
proc_ports=$(
awk 'NR>1 && $4 == "0A" {split($2, a, ":"); print a[2]}' \
/proc/net/tcp /proc/net/tcp6 2>/dev/null \
| while read -r hex; do printf "%d\n" "0x$hex"; done \
| sort -un
)
echo "ss listening ports : $(echo "$ss_port_nums" | tr '\n' ' ')"
echo "/proc/net/tcp listening : $(echo "$proc_ports" | tr '\n' ' ')"
diff_result=$(diff <(echo "$ss_port_nums") <(echo "$proc_ports") || true)
if [ -z "$diff_result" ]; then
echo "[OK] Network views match"
else
echo "[!] MISMATCH - possible hidden connections:"
echo "$diff_result"
fi
Esa comprobación no demuestra la presencia exclusiva de VoidLink, pero ilustra bien cómo detectar la clase de manipulaciones cuando distintas interfaces del sistema empiezan a contar historias diferentes sobre la red.
En la quinta versión los desarrolladores añadieron otro mecanismo desagradable: protección de procesos frente a la terminación. Para ello interceptan do_send_sig_info mediante ftrace y el rootkit descarta selectivamente señales si están dirigidas a un PID protegido. El código del volcado es:
if (chk_protected(p->pid)) {
if (sig == SIGKILL || sig == SIGTERM || sig == SIGSTOP ||
sig == SIGINT || sig == SIGHUP || sig == SIGQUIT) {
return 0; // Pretend success but don't deliver
}
}
Quedan bloqueadas SIGKILL, SIGTERM, SIGSTOP, SIGINT, SIGHUP y SIGQUIT, es decir casi todas las formas estándar de detener o terminar un proceso. Para el llamador la función devuelve éxito, como si la señal se hubiera entregado; en realidad se descarta silenciosamente. Si la señal se envía a un proceso oculto pero no específicamente protegido, el rootkit puede devolver -ESRCH, manteniendo la ilusión de que dicho proceso no existe.
Resulta especialmente interesante el script de arranque load_lkm.sh. Muestra que VoidLink no estaba pensado como un módulo aislado, sino como parte de un conjunto mayor de herramientas. Antes de cargar el rootkit el script recorre /proc/*/exe y busca procesos ejecutados desde memfd. En Linux ese patrón a menudo indica un implante sin archivo en disco, es decir un proceso que vive solo en memoria:
for pid in $(ls /proc 2>/dev/null | grep -E "^[0-9]+$"); do
exe=$(readlink /proc/$pid/exe 2>/dev/null)
if [[ "$exe" == *"memfd"* ]]; then
IMPLANT_PIDS="$IMPLANT_PIDS $pid"
fi
done
El sentido de ese recorrido es claro: el rootkit busca primero cargas útiles ya existentes sin archivo y luego obtiene la capacidad de ocultarlas y protegerlas. Es decir, VoidLink actúa no solo como un mecanismo de sigilo independiente, sino también como una capa de servicio para otros componentes del ataque.
Los investigadores subrayan que los códigos fuente contienen no solo la lógica técnica, sino también rastros del propio proceso de desarrollo. En el código hay muchas marcas de refactorización por fases, comentarios tipo manual y una numeración secuencial de versiones. Ese patrón coincide con las conclusiones previas de Check Point sobre que VoidLink se creó casi por completo en un flujo de trabajo asistido por IA mediante el entorno TRAE. Si el informe anterior mostraba el panorama general, el volcado reveló la mecánica interna: cómo los distintos módulos del rootkit se reescribieron varias veces, se verificaron y se pulieron hasta alcanzar mayor solidez.
Para defenderse contra herramientas de este tipo los investigadores recomiendan no limitarse al control antivirus habitual. Se aconseja activar Secure Boot y la firma obligatoria de módulos del kernel, usar el modo kernel lockdown, vigilar con Auditd las llamadas al sistema init_module y finit_module, y, en la medida de lo posible, restringir las llamadas bpf() para eBPF mediante seccomp o políticas LSM. Si no se necesita depuración basada en eBPF, conviene al menos desactivar eBPF no privilegiado con kernel.unprivileged_bpf_disabled=1.
Como detector basado en firmas, los investigadores publicaron una regla YARA orientada a módulos VoidLink y artefactos relacionados:
rule Linux_Rootkit_VoidLink {
meta:
author = "Elastic Security"
creation_date = "2026-03-12"
last_modified = "2026-03-12"
os = "Linux"
arch = "x86_64"
threat_name = "Linux.Rootkit.VoidLink"
description = "Detects VoidLink LKM rootkit variants"
strings:
$mod1 = "AMD Memory Encryption Support"
$mod2 = "AMD Memory Encryption Driver"
$mod3 = "Advanced Micro Devices, Inc."
$func1 = "vl_stealth"
$func2 = "g_data"
$func3 = "icmp_cmd"
$func4 = "chk_pid"
$func5 = "chk_port"
$func6 = "mod_hide"
$func7 = "amd_mem_encrypt"
$ebpf1 = "hidden_ports"
$ebpf2 = "recvmsg_ctx"
$ebpf3 = "SOCK_DIAG_BY_FAMILY"
condition:
(2 of ($mod*) and 3 of ($func*)) or
(1 of ($mod*) and 2 of ($ebpf*)) or
(4 of ($func*))
}
La historia de VoidLink es inquietante no solo por el repertorio de técnicas. Lo más relevante es otra cosa: ante los investigadores no apareció un módulo aislado para una versión de kernel, sino una plataforma de sigilo en evolución, adaptada a distintos distribuciones de Linux y generaciones del kernel. Sabe camuflarse como un controlador legítimo, obtener direcciones de símbolos internos en kernels nuevos, filtrar pseudoarchivos, ocultar actividad de red, recibir comandos por ICMP y proteger procesos frente a la terminación. Para sistemas Linux en la nube y servidores esto ya no es una curiosidad de laboratorio, sino un conjunto práctico de medios diseñado para operar largo tiempo sin ser detectado.