Fallo de copia y DirtyFrag: errores en la caché de la página de Linux en estado real

Esta investigación analiza las vulnerabilidades de escalada de privilegios del núcleo de Linux, Copy Fail y DirtyFrag, que explotan sutiles errores de corrupción de caché de página para crear rutas fiables de acceso root. Además, Elastic Security Labs está publicando lógica de detección para estas vulnerabilidades.

Lectura de 4 minutosIngeniería de detección

Introducción

Las vulnerabilidades recientes de escalada de privilegios del núcleo de Linux, Copy Fail (CVE-2026-31431), Copy Fail 2 y DirtyFrag, ponen de manifiesto cómo los sutiles errores de corrupción de la caché de página pueden convertir en caminos prácticos y fiables para rootear. Estos problemas son especialmente relevantes para los defensores porque la explotación implica interfaces legítimas del kernel, ejecución local y código de prueba de concepto corto. Se reportó que el fallo de copia fue explotado en estado real y se agregó al catálogo de Vulnerabilidades Explotadas Conocidas de CISA.

Para ayudar a mitigar estas amenazas, Elastic Security Labs desarrolló una lógica de detección centrada en los patrones de explotación alrededor de estas vulnerabilidades, en lugar de solo coincidir con una implementación específica de prueba de concepto.

Fallo de copia

Fallo de copia es un error lógico en la plantilla criptográfica authencesn del núcleo de Linux. Las cadenas de vulnerabilidades AF_ALG y splice() para crear una escritura controlada de 4 bytes en la caché de páginas de cualquier archivo legible. En la práctica, esto corrompe la vista en memoria de un binario setuid como /usr/bin/su y escala privilegios sin cambiar el archivo en disco. El exploit público es un script Python de 732 bytes que funciona en Ubuntu, Amazon Linux, RHEL y SUSE.

DirtyFrag

DirtyFrag expande la misma clase de error en la pila de red con dos variantes de escritura de caché de página. La ruta ESP emplea asociaciones de seguridad XFRM a través de AF_NETLINK para realizar operaciones criptográficas in situ sobre páginas empalmadas, sobrscribiendo /usr/bin/su con un ELF mínimo de root-shell. La ruta de respaldo de RxRPC emplea AF_RXRPC con pcbc(fcrypt) para corromper /etc/passwd, borrando el campo de contraseña de la raíz. Ambos caminos requieren unshare(CLONE_NEWUSER | CLONE_NEWNET) para obtener capacidades de espacio de nombres antes de activar la escritura de la caché de páginas.

DirtyFrag no depende del módulo algif_aead , lo que significa que los sistemas que solo aplicaron la mitigación de fallos de copia pueden seguir exponidos.

Detección

Para estas vulnerabilidades, nos centramos en detectar las primitivas y comportamientos subyacentes, no solo en una implementación específica de un exploit. Esa distinción importa, Copy Fail ya tiene múltiples reimplementaciones públicas (Python, Go, Rust, C, Metasploit), y DirtyFrag se incluye como prueba de concepto pública en C. Intentar detectar solo a una persona de concepto concreta deja a los defensores un paso por detrás.

Primitivas a nivel de llamada de sistema (auditadas)

Tanto Copy Fail como DirtyFrag dependen de socket(AF_ALG) para acceder al subsistema cripto del núcleo, y splice() para inyectar páginas de archivo de solo lectura en búferes de red donde las operaciones criptográficas in situ corrompen la caché de páginas. DirtyFrag además usa socket(AF_RXRPC) como respaldo cuando AF_ALG no está disponible. Estas primitivas son visibles mediante auditorías auditadas de llamadas de sistema socket con valores hexadecimales a0 de 26 (AF_ALG) o 21 (AF_RXRPC), y llamadas splice de procesos no raíz. Usamos estas como señales en fase temprana, correlacionadas mediante secuencias EQL con el paso final de escalada de privilegios de obtener 0 uid efectivo de un llamador no root:

sequence with maxspan=60s
  [any where host.os.type == "linux" and    
   (
    (event.category == "process" and auditd.data.syscall == "socket" and auditd.data.a0 in ("26", "21")) or 
    (event.category == "process" and auditd.data.syscall == "splice") or 
    (event.category == "network" and event.action == "bound-socket" and data_stream.dataset == "auditd_manager.auditd" and ?auditd.data.socket.family == "38") 
    )  
   and user.id != "0"]  by process.pid, host.id, user.id with runs=10
  [process where host.os.type == "linux"  and event.action == "executed" and 
   (
     (user.effective.id == "0" and user.id != "0") or 
     (process.name in ("bash", "sh", "zsh", "dash", "fish", "ksh", "busybox") and 
      process.args in ("-c", "--command", "-ic", "-ci", "-cl", "-lc", "-bash", "-sh", "-zsh", "-dash", "-fish", "-ksh"))
    )] by process.parent.pid, host.id, user.id

Example of matches :

Creación de espacios de nombres (específico de DirtyFrag)

La cadena de exploits de DirtyFrag también depende de unshare(CLONE_NEWUSER | CLONE_NEWNET) para obtener capacidades de espacio de nombres. Correlacionamos este evento con la ejecución de un proceso raíz o con una llamada de sistema setuid(0) poco después:

sequence by host.id, process.parent.pid with maxspan=30s
 [process where host.os.type == "linux" and 
  (
   (auditd.data.syscall == "unshare" and auditd.data.class == "namespace" and auditd.data.a0 in ("10000000", "50000000", "70000000", "10020000", "50020000", "70020000")) or 

   (process.name == "unshare" and  
    (process.args in ("--user", "--map-root-user", "--map-current-user") or process.args like ("-*U*", "-*r*")))
   ) and user.id != "0" and user.id != null]
 [process where host.os.type == "linux" and 
  user.id == "0" and user.id != null and 
  (
   process.name in ("su", "sudo", "pkexec", "passwd", "chsh", "newgrp", "doas", "run0", "sg", "dash", "sh", "bash", "zsh", "fish", 
                    "ksh", "csh", "tcsh", "ash", "mksh", "busybox", "rbash", "rzsh", "rksh", "tmux", "screen", "node") or 
   process.name like ("python*", "perl*", "ruby*", "php*", "lua*")
  )]

Abuso binario genérico de SUID (Eventos de Ejecutivos de Procesos)

También evaluamos las opciones de detección usando solo eventos de ejecutivos de proceso, ya que suelen estar habilitados en más entornos que la auditoría de llamadas de sistema auditadas. Un paso final común para ambos exploits es corromper o influir en la ejecución en memoria de un binario SUID como su, sudo, pkexec, passwd chsh newgrp, haciendo que ejecute código controlado por el atacante como raíz.

La detección busca ejecuciones sospechosas donde el proceso se ejecuta como UID 0 efectivo, el usuario real no es root, el proceso padre también no es root, el binario SUID se lanza con argumentos mínimos y el proceso padre es un tiempo de ejecución de script, una línea de shell o ejecutable desde una ruta que el usuario puede escribir:

process where event.type == "start" and event.action == "exec" and (
  (process.user.id == 0 and process.real_user.id != 0) or
  (process.group.id == 0 and process.real_group.id != 0)
) and (
  (process.name == "su" and process.args_count <= 2) or
  (process.name == "sudo" and process.args_count == 1) or
  (process.name == "pkexec" and process.args_count == 1) or
  (process.name == "passwd" and process.args_count <= 2)
) and
(
  process.parent.name like (".*", "python*", "perl*", "ruby*", "lua*", "php*", "node", "deno", "bun", "java") or
  process.parent.executable like ("./*", "/tmp/*", "/var/tmp/*", "/dev/shm/*", "/run/user/*", "/var/run/user/*", "/home/*/*") or
  (
    process.parent.name in ("bash", "dash", "sh", "tcsh", "csh", "zsh", "ksh", "fish", "mksh") and
    process.parent.args in ("-c", "-cl", "-lc", "--command", "-ic", "-ci", "-bash", "-sh", "-zsh", "-dash", "-fish", "-ksh") and
    process.parent.args_count <= 4
  )
)

Sin depender de que se genere un proceso hijo, también podemos buscar proactivamente actividad de explotación usando ES|QL. Tanto Copy Fail como DirtyFrag producen una ráfaga distintiva de llamadas de sistema intercaladas de socket(AF_ALG) y splice() del mismo proceso. Copy Fail se itera 48 veces para escribir 192 bytes, y DirtyFrag sigue un patrón similar en sus rutas ESP y RxRPC.

La siguiente consulta agrega estas llamadas de sistema por proceso y pone en superficie cualquier proceso no raíz que combine sockets AF_ALG o AF_RXRPC con llamadas splice en volumen :

FROM logs-auditd_manager.auditd-default*
| WHERE host.os.type == "linux" AND user.id != "0" AND
  (
    (event.category == "process" AND auditd.data.syscall == "socket" AND auditd.data.a0 IN ("26", "21")) OR
    (event.category == "process" AND auditd.data.syscall == "splice") OR
    (event.category == "network" AND event.action == "bound-socket" AND auditd.data.socket.family == "38")
  )
| EVAL
    is_af_alg   = CASE(auditd.data.syscall == "socket" AND auditd.data.a0 == "26", 1, 0),
    is_af_rxrpc = CASE(auditd.data.syscall == "socket" AND auditd.data.a0 == "21", 1, 0),
    is_splice   = CASE(auditd.data.syscall == "splice", 1, 0),
    is_bind_alg = CASE(event.action == "bound-socket" AND auditd.data.socket.family == "38", 1, 0)
| STATS
    socket_af_alg   = SUM(is_af_alg),
    socket_af_rxrpc = SUM(is_af_rxrpc),
    splice_count    = SUM(is_splice),
    bind_af_alg     = SUM(is_bind_alg),
    total_calls     = COUNT(*),
    first_seen      = MIN(@timestamp),
    last_seen        = MAX(@timestamp)
  BY host.name, user.name, process.executable, process.pid
| EVAL
    duration_seconds = DATE_DIFF("seconds", first_seen, last_seen),
    distinct_syscalls = CASE(
      socket_af_alg > 0 AND splice_count > 0 AND bind_af_alg > 0, "af_alg+splice+bind",
      socket_af_alg > 0 AND splice_count > 0, "af_alg+splice",
      socket_af_rxrpc > 0 AND splice_count > 0, "af_rxrpc+splice",
      socket_af_alg > 0, "af_alg_only",
      socket_af_rxrpc > 0, "af_rxrpc_only",
      splice_count > 0, "splice_only",
      "other"
    )
| WHERE total_calls >= 10 AND
  (socket_af_alg > 0 OR socket_af_rxrpc > 0) AND
  splice_count > 0
| SORT total_calls DESC
| LIMIT 50

Normas de auditoría:

Las siguientes reglas pueden agregar a tu configuración de integración con Auditd para permitir la visibilidad de estas primitivas de exploits:

-a always,exit -F arch=b64 -S socket -k socket_syscall
-a always,exit -F arch=b32 -S socketcall -k socket_syscall
-a always,exit -F arch=b64 -S splice -k splice-syscall
-a always,exit -F arch=b32 -S splice -k splice-syscall
-a always,exit -F arch=b64 -S bind -k socket_bound
-a always,exit -F arch=b32 -S bind -k socket_bound

Reglas de detección:

Mitigación

La detección debe ir acompañada de endurecimiento y parcheo. La solución principal para ambas vulnerabilidades es actualizar el núcleo de Linux una vez que haya parches de distribución disponibles.

Cuando no es posible aplicar parches inmediatos, el bloqueo dirigido de módulos puede reducir la superficie de ataque. Para fallo de copia, desactivar el módulo algif_aead impide la ruta AF_ALG AEAD empleada por el exploit:

echo "install algif_aead /bin/false" > /etc/modprobe.d/copyfail.conf
rmmod algif_aead 2>/dev/null

Para DirtyFrag, desactivar los módulos de red afectados bloquea tanto las rutas de exploit ESP como RxRPC:

printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf
rmmod esp4 esp6 rxrpc 2>/dev/null

Tras aplicar cualquiera de las mitigaciones, eliminar la caché de páginas cerciora que cualquier página en memoria previamente corrompida se descarte:

echo 3 > /proc/sys/vm/drop_caches

Estas mitigaciones deben probar en un entorno de staging antes del despliegue en producción, ya que desactivar los módulos del kernel puede afectar a VPNs IPsec, aplicaciones criptográficas u otros servicios dependiendo de los subsistemas afectados. Perder la caché de la página provoca un breve pico de E/S y debe evitar durante la carga pico.

Restringir la creación de espacios de nombres de usuario sin privilegio también refuerza contra DirtyFrag y exploits similares:

sysctl -w kernel.unprivileged_userns_clone=0

En RHEL/Fedora, usa user.max_user_namespaces=0 en su lugar. Esta configuración puede afectar a aplicaciones que dependen de espacios de nombres no privilegiados, como ciertos entornos de ejecución de contenedores y sandboxes de navegador. Evalúa la compatibilidad antes de aplicar.

Referencias: