Ir al contenido

Herramientas web contra una aplicación HTTP controlada

Por

Madurezvalidated
Última revisión
Validación técnica29 jul 2026 · web-tooling-curl-8-7-1-gobuster-3-8-2-ffuf-2-2-1-loopback, web-tooling-feroxbuster-2-13-1-httpx-1-10-0-katana-1-6-1-loopback, web-tooling-nginx-1-29-5-node-22-17-0-loopback
Publicado
Última actualización

Este laboratorio comprueba el comportamiento de seis clientes contra el mismo sistema. La aplicación sirve recursos válidos, respuestas negativas con estado 200, una redirección, un recurso autenticado, una respuesta lenta y un 429. Nginx añade selección por Host, TLS y Server Name Indication (SNI).

Las afirmaciones comprobadas son:

  1. una respuesta de referencia permite separar recursos de un soft 404 estático
  2. un soft 404 dinámico exige filtrar contenido, no un único tamaño
  3. Gobuster 3.8.2 requiere --exclude-length al activar --exclude-hostname-length
  4. ffuf 2.2.1 evalúa -fr sobre cabeceras y cuerpo concatenados
  5. los límites de concurrencia y frecuencia no sustituyen el timeout por petición
  6. HTTPX conserva 200 y 429 como observaciones distintas
  7. Katana respeta un scope regular y descubre enlaces internos sin seguir el nombre externo
  8. SNI, Host y cookie cambian la respuesta sin cambiar la dirección de destino

El fixture vive en labs/web-tooling/. Docker Compose crea una aplicación Node.js 22.17.0 y un frontal Nginx 1.29.5. Las imágenes están fijadas por tag y digest. Los únicos puertos publicados son 127.0.0.1:18080 y 127.0.0.1:18443.

La aplicación se ejecuta como el usuario node. Ambos servicios usan un sistema de archivos raíz de solo lectura, no-new-privileges y retirada de capacidades Linux (capabilities). Nginx recupera únicamente CHOWN, SETUID y SETGID para preparar sus directorios temporales y reducir el usuario de sus procesos. La primera variante retiraba todas las capacidades del frontal. Nginx terminó antes de servir porque no podía cambiar el propietario de client_temp. Devolver el conjunto completo habría ocultado qué permiso necesitaba realmente.

start.sh genera un certificado desechable para app.example.test, arranca los contenedores y comprueba /exists. La clave queda bajo .state/, excluida de Git:

Arrancar y comprobar el laboratorio
./labs/web-tooling/start.sh
mkdir -p labs/web-tooling/evidence
docker compose \
--project-name offsec-kb-web-tooling \
--file labs/web-tooling/compose.yml \
ps

En mkdir, -p crea el directorio cuando falta y no falla si ya existe. En docker compose, --project-name fija el namespace de los recursos y --file selecciona el manifiesto. El estado esperado muestra app y edge en ejecución. Si el script no alcanza HTTPS en veinte intentos, devuelve un error en lugar de dejar el fallo como un timeout posterior de las herramientas.

Las releases de Gobuster, ffuf, HTTPX y Katana se descargaron desde sus repositorios y se verificaron contra sus manifiestos oficiales de checksums. El digest publicado por GitHub se utilizó para Feroxbuster. El laboratorio registró estas versiones:

curl 8.7.1
gobuster 3.8.2
ffuf 2.2.1
feroxbuster 2.13.1
httpx 1.10.0
katana 1.6.1
nginx 1.29.5
node 22.17.0

La arquitectura del sistema que ejecutó los clientes no modifica las afirmaciones y, por tanto, no forma parte del identificador publicado.

Comprobar SNI, Host, sesión y timeout con cURL

Sección titulada «Comprobar SNI, Host, sesión y timeout con cURL»

--resolve asocia FQDN, puerto y dirección solo para esa ejecución. También hace que cURL envíe el SNI y la autoridad HTTP correctos. El certificado es local y autofirmado, por lo que --insecure se utiliza únicamente dentro de este laboratorio:

Seleccionar el virtual host TLS
curl --silent --show-error --insecure \
--resolve app.example.test:18443:127.0.0.1 \
https://app.example.test:18443/exists

La respuesta fue known-resource. La petición directa a https://127.0.0.1:18443/exists llegó al servidor TLS predeterminado y devolvió default-tls-host.

--cookie envía una cookie concreta. Sin ella, /private responde 401. Con la sesión esperada devuelve 200 y el recurso:

Comparar estado anónimo y autenticado
curl --silent --show-error --include \
--resolve app.example.test:18080:127.0.0.1 \
http://app.example.test:18080/private
curl --silent --show-error --include \
--cookie 'session=valid' \
--resolve app.example.test:18080:127.0.0.1 \
http://app.example.test:18080/private

--max-time limita toda la operación. Con 0.5 segundos, /slow terminó con el código de cURL 28. Con dos segundos devolvió slow-resource. El timeout corto demuestra el límite del cliente, no una caída del servicio:

Provocar y resolver un timeout controlado
curl --silent --show-error --max-time 0.5 \
--resolve app.example.test:18080:127.0.0.1 \
http://app.example.test:18080/slow
curl --silent --show-error --max-time 2 \
--resolve app.example.test:18080:127.0.0.1 \
http://app.example.test:18080/slow

Soft 404 estático y virtual hosts con Gobuster

Sección titulada «Soft 404 estático y virtual hosts con Gobuster»

La rama /static/ devuelve un cuerpo de 15 bytes para cualquier nombre ausente y 22 bytes para /static/exists. --exclude-length 15 elimina la referencia negativa. --threads, --delay y --timeout controlan, respectivamente, concurrencia, espera por hilo y tiempo máximo de cada petición:

Encontrar un recurso sobre un soft 404 estático
gobuster dir \
--url http://127.0.0.1:18080/static/ \
--wordlist labs/web-tooling/content-wordlist.txt \
--headers 'Host: app.example.test' \
--threads 4 \
--delay 50ms \
--timeout 2s \
--exclude-length 15 \
--output labs/web-tooling/evidence/gobuster-static.txt

El único candidato fue exists (Status: 200) [Size: 22]. En la raíz, el cuerpo negativo incorpora la ruta y cambia de longitud. Gobuster detectó el wildcard durante el precheck y detuvo la ejecución. Forzarlo habría ocultado la razón del fallo.

Para vhosts, --domain aporta el sufijo y --append-domain construye palabra.example.test. La respuesta predeterminada tiene 18 bytes. --exclude-hostname-length compensa que el nombre probado cambie de longitud, pero Gobuster 3.8.2 exige que se declare también --exclude-length:

Separar el vhost real del servidor predeterminado
gobuster vhost \
--url http://127.0.0.1:18080/ \
--domain example.test \
--append-domain \
--wordlist labs/web-tooling/vhost-wordlist.txt \
--threads 2 \
--delay 100ms \
--timeout 2s \
--exclude-length 18 \
--exclude-hostname-length \
--output labs/web-tooling/evidence/gobuster-vhost.txt

El resultado fue app.example.test, con estado 200 y 10 bytes. Omitir --exclude-length produjo un error de argumentos antes de enviar la wordlist.

Soft 404 dinámico, estados y latencia con ffuf

Sección titulada «Soft 404 dinámico, estados y latencia con ffuf»

-fr filtra una expresión regular. En ffuf 2.2.1, el motor antepone las cabeceras al cuerpo antes de evaluarla. Por eso ^missing: no filtró los soft 404, mientras que missing: sí lo hizo. -mc all conserva todos los estados antes del filtro. -p 0.05, -rate 10, -t 4, -timeout 2 y -maxtime 30 fijan pausa, frecuencia global, tareas concurrentes, timeout por petición y duración total:

Filtrar contenido dinámico y conservar JSONL
ffuf -w labs/web-tooling/content-wordlist.txt \
-u http://127.0.0.1:18080/FUZZ \
-H 'Host: app.example.test' \
-mc all \
-fr 'missing:' \
-t 4 \
-rate 10 \
-p 0.05 \
-timeout 2 \
-maxtime 30 \
-sa \
-json > labs/web-tooling/evidence/ffuf.jsonl

Los resultados conservados fueron 200 /exists, 403 /admin, 302 /redirect, 401 /private, 429 /limited y 200 /slow. Las dos rutas ausentes quedaron filtradas. /slow tardó aproximadamente 1,5 segundos. Reducir -timeout a un segundo la convierte en error sin cambiar el endpoint.

Feroxbuster recibió el mismo soft 404 estático. --threads 4 limita peticiones por scan, --scan-limit 1 evita procesar varios directorios en paralelo, --rate-limit 10 fija la frecuencia global y --filter-size 15 elimina el cuerpo negativo:

Recorrer una raíz con límites independientes
feroxbuster \
--url http://127.0.0.1:18080/static/ \
--wordlist labs/web-tooling/content-wordlist.txt \
--headers 'Host: app.example.test' \
--depth 1 \
--threads 4 \
--scan-limit 1 \
--rate-limit 10 \
--timeout 2 \
--filter-size 15 \
--json \
--output labs/web-tooling/evidence/feroxbuster.json

Feroxbuster registró su configuración, la raíz y /static/exists. También ejecutó peticiones de autofiltrado. El contador total superó las entradas de la wordlist. Por ello, el tamaño de la lista no equivale al número final de solicitudes.

La entrada contiene /exists y /limited. Créala de forma explícita para que ninguna URL proceda del historial de otra herramienta:

Preparar la entrada de HTTPX
printf '%s\n' \
'http://127.0.0.1:18080/exists' \
'http://127.0.0.1:18080/limited' \
> labs/web-tooling/evidence/http-input.txt

El formato %s\n imprime cada argumento como una línea. Después, -threads 2, -rate-limit 5, -timeout 2 y -retries 0 fijan concurrencia, frecuencia, espera y reintentos. -sc guarda el estado, -include-chain conserva redirecciones y -json produce JSONL:

Comparar un recurso válido y un límite
httpx -l labs/web-tooling/evidence/http-input.txt \
-H 'Host: app.example.test' \
-threads 2 \
-rate-limit 5 \
-timeout 2 \
-retries 0 \
-sc \
-include-chain \
-json \
-o labs/web-tooling/evidence/httpx.jsonl

HTTPX conservó un 200 de 15 bytes y un 429 de 13 bytes. En la primera ejecución de la versión 1.10.0, el output JSON inicializó un clasificador de páginas y descargó su modelo a la caché del usuario. La caché se retiró después de la prueba. Este acceso externo debe prepararse o bloquearse explícitamente antes de usar JSON en una estación aislada.

-crawl-scope recibe una expresión regular aplicada a las URL que el crawler puede seguir. -concurrency 2 limita fetchers para la entrada y -parallelism 1 procesa una sola semilla. Los límites global y por host son cinco peticiones por segundo:

Descubrir enlaces internos sin seguir el externo
katana -u http://127.0.0.1:18080/crawl \
-H 'Host: app.example.test' \
-crawl-scope '^http://127\.0\.0\.1:18080/' \
-d 2 \
-concurrency 2 \
-parallelism 1 \
-rate-limit 5 \
-host-rate-limit 5 \
-timeout 2 \
-retry 0 \
-jsonl \
-o labs/web-tooling/evidence/katana.jsonl

El JSONL registró /crawl, /exists y /redirect. El enlace outside.example.invalid se descubrió en el HTML, pero no se solicitó. Una variante con dos barras invertidas antes del punto no coincidió con la URL y produjo un archivo vacío. El fallo pertenecía a la expresión regular, no al crawler.

stop.sh ejecuta docker compose down sobre el nombre y el manifiesto exactos, elimina volúmenes, retira contenedores huérfanos y borra el certificado generado:

Retirar el laboratorio
./labs/web-tooling/stop.sh
curl --silent --show-error --max-time 1 \
http://127.0.0.1:18080/exists

El control final debe terminar con conexión rechazada. El directorio labs/web-tooling/evidence/ está excluido de Git. Conserva solo resultados saneados o sus hashes cuando la práctica lo requiera.

La prueba se ejecutó sobre loopback y no cubre pérdida de red, HTTP/2, proxy autenticado, WAF, CDN, crawling headless, JavaScript dinámico, formularios ni reanudación tras caída. Los perfiles validados no deben copiarse como rates universales. Demuestran la relación entre cada control y la señal observada.