Guilgo Blog

Notas de mi quehacer diario con las técnologias.

La detección de DNS tunneling (a partir del tráfico DNS) es un caso clásico de evasión, porque el “ruido” se disfraza como consultas DNS aparentemente legítimas. Este post adapta el enfoque a vuestro entorno real: AdGuard Home exporta las consultas a JSON y Wazuh-lite aplica reglas heurísticas sobre el FQDN para estimar si hay payload codificado y/o patrones de subdominios profundos, sin necesidad de tcpdump en el host.

Resumen

Este post adapta la idea del detector “DNS Tunneling” (basado en tráfico DNS) a vuestro entorno real: en vez de capturar paquetes con tcpdump, usamos el pipeline que ya tenéis con AdGuard Home:

  1. AdGuard registra las consultas DNS en querylog
  2. adguard-to-wazuh.sh convierte esos eventos a JSON y los escribe en adguard-dns.json
  3. Wazuh-lite lee ese JSON con localfile
  4. Se disparan reglas nuevas de heurísticas de DNS tunneling basadas en patrones del FQDN (domain)

Con esto, el detector es viable y “plug-and-play” dentro de Wazuh-lite (4.9.2), y además evita hacer sniffing a nivel de host.

Referencia del enfoque original: https://wazuh.com/blog/detecting-dns-tunneling-attacks-with-wazuh/

¿Qué detecta?

No replicamos el enfoque basado en qtype (TXT/NULL) porque el pipeline de AdGuard que exporta a Wazuh-lite no expone ese campo; en su lugar, trasladamos la detección al FQDN, manteniendo la misma lógica de análisis de “payload” que suele aparecer codificado en los nombres.

A cambio, detectamos patrones típicos en el nombre de dominio:

  1. Etiqueta larga alfanumérica (probable payload codificado)
  2. Profundidad anómala de subdominios (muchos labels)
  3. Variante con keywords de túnel (tunnel, dns-tunnel, googlezip)

Arquitectura

Niños/clients -> (DNS) -> AdGuard Home
               -> querylog.json
               -> adguard-to-wazuh.sh (JSON)
               -> Wazuh-lite (localfile: adguard-dns.json)
               -> reglas de dns_tunneling
               -> Telegram (solo niñ@s, level >= 7)
               -> Grafana (alertas recientes y categorías)

Esquema mínimo del JSON (replicable)

Para que el localfile del pipeline funcione “tal cual”, el log que lee debe ser JSON line-delimited: una línea por evento, donde las claves se convierten en campos accesibles para las reglas.

Las reglas se anclan en:

  • domain (FQDN sobre el que aplican las heurísticas)
  • is_kid (para decidir si el evento es accionable vía Telegram)

Ejemplo de evento mínimo (anonimizado):

{
  "timestamp": "2026-03-19T10:15:23Z",
  "source": "adguard",
  "client_ip": "X.X.X.X",
  "client_name": "KidName",
  "is_kid": true,
  "domain": "ab12cd34ef56gh78.a.b.c.example.com",
  "is_blocked": true,
  "reason": "dns_tunnel_test"
}

Reglas añadidas en Wazuh-lite

Se han creado reglas en: zz-adguard-dns-tunneling-rules.xml (directorio de reglas de Wazuh).

Las reglas usan como base el if_sid del “evento AdGuard DNS” existente en adguard-rules.xml (rule id=111001).

Heurísticas (para todos, sin Telegram)

  • 111070 (level 4): etiqueta larga alfanumérica en domain (min 16 chars)
  • 111071 (level 4): FQDN con profundidad >= 4 labels (>= 3 puntos)
  • 111072 (level 6): encoded + deep (disparo conjunto)
  • 111073 (level 5): keyword de túnel + deep

Estas reglas no disparan Telegram porque el integration de Telegram (custom-telegram.py) exige level >= 7.

Alertas (solo niñ@s, con Telegram)

  • 111074 (level 10): encoded + deep y is_kid=true
  • 111075 (level 9): keyword + deep y is_kid=true

Se disparan para is_kid=true, y por tanto aparecen en:

  1. Telegram (por el custom-telegram.py, MIN_LEVEL=7)
  2. Grafana en “Alertas Recientes (nivel >= 7)” (panel ya existente)

Regex reales que estáis usando ahora

En estas heurísticas se aplican regex (tipo PCRE2) sobre el campo domain que llega desde adguard-dns.json.

El core de las señales que componen las reglas (y que luego se combinan para 111072/111073 y se elevan para 111074/111075) queda así:

<rule id="111070" level="4">
  <if_sid>111001</if_sid>
  <field name="domain" type="pcre2">(?:^|\.)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{16,}(?:$|\.)</field>
</rule>

<rule id="111071" level="4">
  <if_sid>111001</if_sid>
  <field name="domain" type="pcre2">^(?:[^.]+\.){3,}[^.]+$</field>
  <field name="domain" type="pcre2">(?:^|\.)[A-Za-z0-9]{12,}(?:$|\.)</field>
</rule>

<rule id="111072" level="6">
  <if_sid>111001</if_sid>
  <field name="domain" type="pcre2">(?:^|\.)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{16,}(?:$|\.)</field>
  <field name="domain" type="pcre2">^(?:[^.]+\.){3,}[^.]+$</field>
</rule>

<rule id="111073" level="5">
  <if_sid>111001</if_sid>
  <field name="domain" type="pcre2">^(?:[^.]+\.){3,}[^.]+$</field>
  <field name="domain" type="pcre2">(?i)(tunnel|dns-tunnel|googlezip)</field>
</rule>

Qué tenéis que mirar (detalle que evita confusiones):

  • En 111070 (etiqueta “larga/encoded”), el match exige una etiqueta con 16+ caracteres alfanuméricos contiguos y además presencia de al menos una letra y al menos un dígito.
  • En 111071 (deep), se exige primero “profundidad” (>= 4 labels / >= 3 puntos) y luego que exista al menos una etiqueta >= 12 caracteres.
  • En 111072 (encoded + deep) combinamos el patrón de 111070 con la profundidad de 111071, pero sin exigir el filtro extra de “>=12 chars” (solo el “>=16” de la etiqueta encoded).
  • En 111073 (deep + keyword) la profundidad va acompañada por keywords típicas del tunneling.
  • Si las etiquetas que os llegan contienen guiones (-) dentro, esas etiquetas pueden dejar de matchear porque los patrones usan A-Za-z0-9 (no contempla -).

Cómo se ve en Grafana (qué paneles usar)

En wazuh-agentes-updated.json ya tenéis:

  1. “Alertas Recientes (nivel >= 7)”

    • Aquí veréis las reglas para niñ@s (111074, 111075) al tener level >= 7.
  2. “Top Categorías”

    • Este panel usa agregado por groups y cuenta también niveles bajos.
    • Por eso, aunque “heurísticas para todos” (level 4/5/6) no disparen Telegram, sí se reflejarán en las categorías incluyendo dns_tunneling.

Ajuste de falsos positivos

Ajustar falsos positivos no es “si queda tiempo”: es la parte que convierte heurísticas en un detector útil.

Ejemplo práctico de “ignore” por dominios conocidos

Por ejemplo, CDNs o dominios grandes pueden generar labels largas y parecerse a payload codificado:

<rule id="111080" level="0">
  <if_sid>111070,111071,111072,111073</if_sid>
  <match>google.com|gstatic.com|microsoft.com</match>
  <description>Ignore known legit domains</description>
</rule>

Qué tenéis que asumir:

  • Los CDNs generan patrones que pueden activar “deep” y “labels largas” sin ser tunneling.
  • Los tracking domains a veces se parecen a “payload” porque usan IDs en el subdominio.
  • SIEMPRE hay ruido: hay que filtrar con “ignore” y con correlación, no solo con thresholds por evento.

Correlación opcional por cliente (replicable)

Para evitar falsos positivos, es habitual correlacionar cuando el mismo cliente repite patrones en un intervalo corto en lugar de alertar por “una única query”.

En vuestro pipeline, estas heurísticas se anclan en domain, pero la correlación suele hacerse con un campo estable del evento. El campo estable recomendado es client_ip (si en tu stack usas otro, sustituye el nombre en el ejemplo).

Ejemplo (pseudocriterio) de correlación por client_ip:

<rule id="111090" level="12" frequency="30" timeframe="60">
  <if_sid>111072</if_sid>
  <same_field>client_ip</same_field>
  <description>Possible DNS tunneling (client behavior)</description>
</rule>

Esta regla requiere que los eventos compartan exactamente el mismo valor de client_ip; si el campo cambia (por ejemplo, hostname vs IP) la correlación no funcionará.

Verificación end-to-end (replicable, sin depender de Grafana)

La forma “pro” de validar es comprobar el flujo completo con una señal sintética, sin asumir que el dashboard de Grafana refleja lo correcto.

  1. wazuh-logtest: pasa un evento sintético que contenga domain y is_kid=true y confirma que el match es rule id='111074'.
  2. localfile: inyecta el mismo evento (JSON line-delimited) en el fichero que tu localfile está leyendo y espera a que Wazuh lo procese.
  3. UI de Wazuh: filtra por rule.id (111074/111075) o por el grupo dns_tunneling.

Si además quieres validar Telegram (opcional):

  • revisa que custom-telegram.py esté donde Wazuh lo monta (normalmente /var/ossec/integrations/) y que exista dentro del contenedor.
  • asegúrate de que la dependencia requests está instalada para el contenedor/integración.
  • evita parse_mode="Markdown" si el texto tiene caracteres que puedan romper el parseo (típico error 400 por entidades).

Checklist de validación

  1. Confirmar que el pipeline ya funciona:
    • AdGuard exporta al log adguard-dns.json que ya consume Wazuh-lite
    • Wazuh-lite carga reglas custom desde etc/rules/
  2. Generar consultas DNS que cumplan al menos:
    • etiqueta alfanumérica >= 16 chars
    • y/o profundidad >= 4 labels
  3. Ejecutar pruebas desde los equipos marcados como is_kid=true (para ver Telegram)
  4. Esperar el refresco del pipeline (normalmente hasta 5-10 min por el cron de extracción)
  5. Ver:
    • Grafana “Alertas Recientes (nivel >= 7)”
    • Telegram (solo si se cumple is_kid=true)

Pruebas recomendadas (ejemplos)

Ejecutar desde un cliente detrás de AdGuard (sin forzar resolver a 8.8.8.8, para que el query pase por AdGuard).

Instala asn (para forzar resoluciones DNS)

asn es un tool de lookup/OSINT que, cuando le pasas un hostname, lo resuelve y así genera consultas DNS que vuelven a pasar por tu resolver (y por tanto por AdGuard si el cliente está configurado para usarlo).

Requisitos y dependencias (para Debian/Ubuntu; adapta si usas otra distro):

sudo apt -y install curl whois bind9-host mtr-tiny jq ipcalc grepcidr nmap ncat aha

Instalación rápida del script (método manual):

curl "https://raw.githubusercontent.com/nitefood/asn/master/asn" > /usr/bin/asn && chmod 0755 /usr/bin/asn

encoded + deep

  • Usa un label largo alfanumérico y 3+ subdominios (ASN resuelve el hostname):
    • asn -n a1b2c3d4e5f6g7h8.a.b.c.example.com

keyword + deep

  • Incluye la keyword tunnel (ASN resuelve el hostname):
    • asn -n tunnelabc1234567890.a.b.c.example.com

Nota para pruebas: el detector se dispara por el domain (FQDN) que llega al pipeline. asn puede obtener NXDOMAIN/NOERROR según la red/recursión, pero lo importante es que la resolución pase por AdGuard y el evento llegue al querylog con domain/client_ip/client_name correctos (no el status).

Si la petición viene de un cliente con is_kid=true, deberíais ver:

  • 111074 o 111075

Limitaciones

  • No se detecta qtype TXT/NULL porque el JSON exportado desde AdGuard no incluye esa información.
  • La detección se basa en heurísticas del FQDN, lo que puede causar falsos positivos si el tráfico usa patrones similares (por ejemplo, labels largas de tracking). Por eso:
    • se usan niveles bajos para “todos” (visibles en Grafana/categorías)
    • y se eleva a level >= 7 solo para niñ@s (acción vía Telegram)

Qué se gana (y por qué merece la pena)

  • Cubre un caso realista en vuestro setup (AdGuard -> Wazuh-lite).
  • Reduce complejidad: no hace falta montar tcpdump/capturas en el host.
  • Opera con los datos ya existentes y se integra en vuestro panel/Telegram.

Conclusión

Con estos cambios, el post deja de ser un resumen “bonito” y pasa a ser implementación real:

  • Dato real (ejemplo de adguard-dns.json)
  • Precisión (regex explícitos y decisiones técnicas)
  • Operación (falsos positivos + correlación por cliente)

Eso es lo que normalmente convierte un “hobby” en algo que puedes usar como base de charla interna o incluso para tu portfolio.