Enumeración de artefactos internos vía la API de listado de una media library
La API de listado de assets ecoa el filtro que le envías y el store de ficheros no autentica: enumera artefactos internos y extrae el secreto embebido en un bundle.
Learn to find this bug
A real HackerOne hack, reproduced for you to practice.
Create your account and practice real bugs that got paid. Download the environment, find it and learn the exact technique — your path to your first bounty.
hunters training
labs from real hacks
completions
paid out for these bugs
Access to all labs · no commitment · cancel anytime
Hackers who solved it· 5
Objectives
Achievement you'll earn
Solve this lab to unlock this shareable achievement
Enumeración de artefactos internos vía la API de listado de una media library
Community writeups
Contenido del lab
Escenario
Assetria es una plataforma de gestión de activos digitales (DAM) para estudios de diseño web: sus clientes suben logos, imágenes de marca y bundles de build a una biblioteca central, y publican una galería pública para que sus propios equipos y sus partners consuman los assets aprobados. Por debajo de la galería vive una API de catálogo de medios (/api/v1/media) que la misma interfaz consume y que, según la documentación interna, también usan integraciones de terceros — de ahí que sea machine-readable y acepte parámetros de filtro explícitos en vez de estar cableada a una única vista.
Eres pentester contra el sitio público de Assetria. Tu misión: demostrar que la API de catálogo expone más superficie de la que la galería deja ver, y usar esa superficie para llegar hasta un artefacto interno del estudio (un bundle de build) que nunca debería ser público — y de ahí, extraer el service_license_key embebido dentro, un secreto de licencia de la organización.
Qué tienes
- Cuenta demo:
attacker@assetria.local/password123— un colaborador de marca (contributor) de baja privilegia. Te sirve para entrar y ver tu perfil, pero no te da acceso al panel de administración (/admin/media, restringido aadmin) y no es el punto de entrada de la vulnerabilidad. - La galería pública de Assetria, con buscador, paginación y vista de detalle por asset.
- El bundle JavaScript público del front-end (galería + panel) — servido igual que cualquier SPA, sin ofuscar ni requerir sesión para descargarlo.
Guía paso a paso
Recon del surface: encuentra el XHR que alimenta la galería. Navega la galería pública y abre la pestaña de red. Verás que dispara
GET /api/v1/media?type=image&visibility=public&page=1&per_page=20. La respuesta es JSON:items[](cada uno conid,type,visibility,title,filename,source_url,size,created_at), un bloquepagination, y un bloquefilterque ecoa exactamente lo que mandaste:"filter":{"type":"image","visibility":"public","q":null}. Auto-descubrimiento puro: no adivinas ni la ruta ni los nombres de los parámetros, los lees directamente del tráfico que la propia galería genera.Prueba a cambiar o quitar el filtro de visibilidad. El bloque
filterque acabas de ver ecoando tu petición es una invitación a experimentar: repite la llamada convisibility=internal, o quítala del todo. Elfilterde la respuesta cambia exactamente con lo que mandaste — confirmación de que el servidor aplica tu valor tal cual, sin comprobar si tienes permiso para pedirlo. Aparecen de inmediato items nuevos que la galería nunca mostró:"visibility":"internal","type":"application", contitledel estilo "Studio Brand Kit — internal build 2025" yfilenamecon el patrónstudio-brandkit-build-<año>-<sufijo>.zip. Eltotaldepaginationsube respecto a la vista pública, y cada item trae su propiasource_urlya resuelta.Contrasta con las rutas de escritura del mismo catálogo. Antes de asumir que "toda la API es así de abierta", prueba a crear o borrar un asset sin sesión (
POST/DELETEsobre la ruta de administración del catálogo): responde 401 sin cookie. El listado y la lectura, en cambio, no te pidieron nada. La asimetría es la pista: la restricción existe de verdad en esta API — solo que no cubre lectura ni descarga, únicamente escritura.Descarga sin autenticarte el artefacto interno más reciente. Con la
source_urldel paso 2, pide el fichero directamente (sin cookie, sin headerAuthorization): 200,content-type: application/zip. El store de ficheros que sirve estos assets no comprueba nada antes de entregar los bytes por su clave.Descomprime y localiza el secreto — confirma que es el build vigente. Dentro del ZIP hay un fichero
LICENSE.keycon nombre inequívoco. Contiene varios campos de acompañamiento y uno decisivo:service_license_key. En el build 2025 (el más reciente porcreated_aty por el nombre deltitle) ese campo trae la flag en claro. Si grepeas un build más viejo primero, el campo sale vacío o marcadoEXPIRED— señal de que hay que quedarse con el vigente.
Canales de feedback (no vuelas a ciegas)
- El XHR de la galería responde 200 con JSON legible/
grep-able; el bloquefilterecoa exactamente lo que mandaste, invitando a experimentar con otros valores. - Cambiar o quitar el filtro de visibilidad hace aparecer de inmediato items nuevos y sube el
totalde la paginación — no hace falta adivinar si "hay algo más", la respuesta te lo muestra. - El contraste 401 (crear/borrar sin sesión) frente a 200 (listar/leer/descargar sin sesión) es observable en cada request, sin ambigüedad.
- La descarga del artefacto responde 200
application/zipsin cookie; el fichero de licencia dentro del ZIP tiene un nombre que no deja lugar a dudas sobre qué grepear. - Los builds antiguos traen el campo del secreto vacío o
EXPIRED— un confirmador explícito de "este no es, prueba el vigente", no un callejón silencioso.
Decoys (descártalos por razonamiento acotado)
- Bundles públicos de marketing. Ya visibles en la galería (
type:"application",visibility:"public"), descargan 200 igual que el resto. Pero dentro solo hay unREADME.txtde prensa y un par de imágenes — sinLICENSE.key. Refutable en una prueba:unzip+grepno encuentra el campo. - Una imagen de campaña sin publicar. Aparece con la misma etiqueta de visibilidad "escondida" que los artefactos de verdad, y el mismo fallo la deja descargar sin auth — exposición real, pero es un
.png: no hay ZIP que abrir ni secreto que extraer. Refuerza el fallo sin ser el objetivo. - El login con la cuenta demo. Entrar con
attacker@assetria.localno abre ningún camino nuevo: es una cuentacontributor, no tiene acceso al panel de administración, y la descarga del artefacto interno funciona exactamente igual con o sin sesión. Refutable en una prueba: pedir el artefacto sin cookie ya da 200. - Builds antiguos del brand kit. Descargan 200 igual que el vigente, pero su
LICENSE.keytrae el campo del secreto vacío o marcadoEXPIRED(clave rotada). Fuerza a no conformarte con el primer ZIP que encuentres, sino a comparar fechas y quedarte con el build 2025.
La falla del laboratorio
Clase: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), con CWE-862 (Missing Authorization), CWE-306 (Missing Authentication for Critical Function) y CWE-602 (Client-Side Enforcement of Server-Side Security) como CWEs relacionadas. OWASP A01:2021 (Broken Access Control) + A05:2021 (Security Misconfiguration).
El catálogo de medios de Assetria trata visibility y type como filtros que el cliente puede suministrar libremente, y los aplica al pie de la letra (WHERE visibility = <valor recibido>, o sin cláusula alguna si se omite) sin derivar, del rol o de la sesión de quien pregunta, qué visibilidad tiene derecho a pedir. La restricción "un usuario anónimo solo debería ver public" no vive en el servidor: vive en la galería, que sencillamente elige mandar visibility=public en su propia llamada. Cualquier otro consumidor de la misma API — o el mismo atacante con curl — puede pedir lo contrario. No es un if invertido ni una comprobación rota: es la ausencia de una regla de autorización sobre un parámetro que el código de listado, por lo demás, procesa correctamente.
Esa ausencia se combina con una segunda: el store de ficheros que sirve los blobs por su clave de almacenamiento no exige ninguna credencial antes de devolver el contenido. Aunque el listado hubiera estado bien protegido, cualquiera que conociera (o enumerara) una clave de almacenamiento podría descargar el fichero igualmente. Juntas, ambas ausencias convierten un catálogo pensado para distinguir "público" de "interno" en una fuga total: el atacante enumera lo que no debería ver y lo descarga sin credencial alguna.
La escalada final añade una tercera capa, más de diseño que de código: el equipo de build embebe un secreto de licencia real (service_license_key) dentro de un artefacto binario pensado para uso interno, confiando en que ese artefacto nunca sea alcanzable desde fuera. Cuando la exposición de arriba lo hace alcanzable, el secreto sale con él — sin que el atacante necesite ninguna credencial en ningún punto de la cadena.
Lo que habría prevenido el bug es enunciable en una frase: derivar la visibilidad permitida del rol del solicitante en vez de aceptar la del cliente (anon ⇒ solo public, sin excepción), y exigir autenticación/autorización en el store de ficheros igual que se exige en las rutas de escritura del mismo catálogo. Adicionalmente, ningún artefacto con secretos reales debería residir en un store alcanzable sin control de acceso, con independencia de si su URL es "adivinable" o no.
Lección: que una API "solo" acepte parámetros de filtro razonables (tipo, visibilidad, paginación) no implica que esté autorizando correctamente quién puede pedir cada valor — filtrar y autorizar son preguntas distintas, y basta con responder bien a la primera para dar una falsa sensación de control sobre la segunda. Cuando la UI restringe algo "por defecto" mandando un valor concreto, comprueba siempre si esa restricción también vive en el servidor o si solo vive en la elección del cliente.
Laboratorio basado en la reproducción anonimizada (empresa ficticia Assetria) de una técnica real de bug bounty sobre una media library cuya API de listado indexaba artefactos internos que la interfaz nunca enlazaba, servidos por un store de ficheros sin autenticación.