Reports
ALTA[OTRAS INYECCIONES]#4038342

Node.js: dns.resolve* corta los nombres en el byte NUL y deja burlar validaciones de host (fix incompleto de CVE-2026-48930)

El parche de CVE-2026-48930 bloqueaba los bytes NUL en dns.lookup, pero no en dns.resolve*: Node consultaba "attacker.test" aunque el código hubiera validado "attacker.test\x00.trusted.corp".

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

En junio de 2026 Node.js corrigió CVE-2026-48930, un fallo por el que un byte NUL (\x00) dentro de un nombre de host hacía que la parte posterior desapareciera al resolverlo. El arreglo añadió el rechazo de esos bytes en dns.lookup, dns.promises.lookup y en options.host de net/tls, es decir, en los caminos que acaban en getaddrinfo de libuv.

El investigador comprobó que el arreglo se quedó a medias: ninguna de las funciones dns.resolve*, que usan el binding de c-ares (precisamente el que nombraba el aviso original), rechaza el NUL. En Node v26.9.0, ya parcheado, una llamada a dns.resolve4() con este nombre:

attacker.test\x00.trusted.corp

no lanzaba error, enviaba al servidor DNS una pregunta solo por attacker.test y devolvía una dirección con total normalidad. El nombre que el programa ve y valida en JavaScript y el nombre por el que Node pregunta realmente al DNS dejan de ser el mismo. El fallo tiene asignado CVE-2026-105482.

Pasos de reproducción

Todo el entorno de prueba funcionaba en local (127.0.0.1 y 127.0.0.2), sin contactar con terceros. Se compararon dos versiones: v26.9.0 (parcheada, descargada de nodejs.org y verificada por checksum) y v26.3.0 (anterior al parche) como control.

  1. Montar un servidor DNS autoritativo propio en loopback que registre la sección de pregunta de cada paquete recibido.
  2. Apuntar Node a ese servidor con dns.setServers().
  3. Prueba de control: pedir dns.resolve4() de control.bound.test. En el cable aparece exactamente control.bound.test (18 caracteres), lo que confirma que el arnés lee bien los QNAME.
  4. Pedir dns.resolve4() de attacker.test\x00.trusted.corp (27 caracteres). El QNAME que sale es attacker.test (13 caracteres); .trusted.corp no aparece en el paquete. La sección de pregunta en bruto fue:
0861747461636b657204746573740000010001

Es decir, la etiqueta attacker, la etiqueta test y el terminador. La llamada devuelve una dirección sin error ni aviso.

  1. Repetir el mismo nombre con dns.lookup(): en v26.9.0 lanza ERR_INVALID_ARG_VALUE y no sale ningún paquete. Es el camino que sí cubrió el parche.
  2. Repetir todo en v26.3.0: los resultados de dns.resolve4 son idénticos byte a byte, y en cambio dns.lookup no rechaza el nombre. Esto demuestra que el arreglo llegó a un camino y no al otro.
  3. Escenario realista: un guardia que comprueba que el host termina en un sufijo de confianza, lo resuelve con dns.promises.resolve4 y se conecta a la dirección obtenida. Con attacker.test el guardia rechaza y no se contacta nada. Con attacker.test\x00.trusted.corp el guardia aprueba, Node pregunta por attacker.test, recibe 127.0.0.2 en lugar de 127.0.0.1 y el servicio de 127.0.0.2 responde a la conexión.

El investigador entregó un único script que hace todo lo anterior sin argumentos, variables de entorno ni ficheros adicionales (localiza la versión parcheada, levanta el DNS y dos servicios TCP en loopback, ejecuta cada caso y luego lo repite con el node del sistema; termina con código 0 si se cumple el patrón esperado):

cd C:/Users/Owner/Documents/mywork/nodejs-node/CVE-2026-48930
node proof/poc.js

Impacto

Cualquier código que valide una cadena de host y después la resuelva con dns.resolve* está validando un nombre distinto del que Node consulta. Las funciones afectadas son resolveAny, resolve4, resolve6, resolveCaa, resolveCname, resolveMx, resolveNs, resolveTlsa, resolveTxt, resolveSrv, resolvePtr, resolveNaptr, resolveSoa y reverse, tanto en la API de callbacks como en la de promesas, y tanto en el resolvedor por defecto como en cualquier new dns.Resolver().

Según el investigador, esto alcanza a:

  • Listas blancas anti-SSRF que comprueban el sufijo del dominio antes de resolverlo y conectarse.
  • Protecciones contra DNS rebinding basadas en el nombre.
  • Verificaciones de dominio mediante consultas de registros, como las comprobaciones de TXT.

Como la resolución termina bien en lugar de fallar, nada delata la sustitución.

Qué aprender de este caso

  • Cuando se corrige un fallo de validación de entrada en una librería, hay que enumerar todos los puntos de entrada que llegan al mismo destino: aquí dns.lookup y dns.resolve* aceptan el mismo tipo de dato pero van por bindings distintos (libuv y c-ares), y solo se protegió uno.
  • Al revisar un parche publicado, merece la pena probar las funciones "hermanas" de la parcheada con el mismo payload; un fix incompleto puede acabar en un CVE nuevo, como en este caso.
  • Si tu código valida un host por sufijo y luego lo resuelve, rechaza explícitamente cualquier carácter de control (en especial \x00) antes de validar, o mejor aún, valida también la dirección IP resultante y no solo el nombre.
  • Cualquier cadena que cruce de JavaScript a código C es sospechosa de truncarse en el NUL: compara el nombre que pediste con lo que realmente sale al cable, como hizo el investigador leyendo el QNAME del paquete.