Blind SQL injection unauthenticated vía desincronización de un endpoint de peticiones agrupadas
Un filtro de exclusión de autores es seguro por la vía directa, pero un fallo de enrutamiento del endpoint que agrupa peticiones lo cuela crudo en el SQL: blind SQL injection ciega.
Este hackeo se reportó sin recompensa
Aprende a encontrar este bug
Un hackeo real de HackerOne, reproducido para que lo practiques.
Crea tu cuenta y practica bugs reales que se pagaron. Descarga el entorno, encuéntralo y aprende la técnica exacta — tu camino a tu primer bounty.
hunters entrenando
labs de hackeos reales
completaciones
pagados por estos bugs
Acceso a todos los labs · sin permanencia · cancela cuando quieras
Hackers que lo han resuelto· 2
Objetivos
Logro que recibirás
Cuando resuelvas este lab desbloqueas este logro compartible

Writeups de la comunidad
La checklist que repaso en cada objetivo nuevo
47 comprobaciones ordenadas por coste: primero lo que te puede meter en un lío, luego lo barato, y al final lo caro — que es donde están los bounties grandes. Te la mando al correo ahora mismo.
Te das de baja en un clic, desde cualquier correo.
Contenido del lab
Escenario
Storia es una plataforma headless de contenido: los equipos editoriales modelan sus posts, categorías y autores en Storia, y cualquier frontend los consume a través de una API de entrega de contenido pública y de solo lectura. Para ahorrar round-trips a los integradores, Storia expone además un endpoint que agrupa varias lecturas en una sola llamada y garantiza, según su propia documentación, que el orden de las respuestas coincide siempre con el de las peticiones enviadas.
Te contratan como pentester externo, sin ninguna credencial, para auditar esa API pública. No hay sesión que romper ni víctima que engañar: toda la superficie que te importa responde sin autenticación, exactamente igual para ti que para cualquier integrador legítimo.
Tu misión: demostrar que puedes alcanzar un secreto interno de configuración que ningún endpoint documentado sirve, y traerte su valor como prueba — la flag.
Qué tienes
- Cuenta demo:
attacker@storia.local/password123— te deja entrar al panel de autoría y ver el dashboard de editor, pero la cadena completa es unauthenticated: no la necesitas para explotarla. - Un portal de desarrollador público (referencia de la API, con peticiones de ejemplo ya rellenas) y un changelog público con notas de versión — ambos navegables sin sesión.
Guía paso a paso
Recon del filtro de exclusión de autores. El portal de desarrollador documenta
GET /api/v2/postscon un filtro de exclusión de autores que acepta una lista de IDs (author_exclude[]=3&author_exclude[]=5) y la mapea, internamente, a una cláusulaNOT IN (…)de la consulta SQL. Prueba primero el comportamiento legítimo (la lista de posts cambia de conteo al excluir un autor real) y luego intenta colar sintaxis SQL en ese mismo campo, tanto en forma de lista como de valor suelto:?author_exclude=0) OR 1=1--. El resultado es la lista normal, sin ningún efecto de inyección — la ruta directa castea cada valor a entero antes de tocar la base de datos. Conclusión: existe unNOT INreal detrás del filtro, pero necesitas que el valor le llegue crudo por otra vía (Decoy 1, descartado).Descubre el endpoint de peticiones agrupadas y provoca la desincronización. El mismo portal documenta
POST /api/batch/v1: envías{ "requests": [{ "path", "method" }, …] }y recibes un207conresponses[]alineado 1:1 con tus peticiones — el changelog lo confirma explícitamente ("las respuestas se corresponden posición a posición con las peticiones"). Prueba primero un batch limpio (todos lospathválidos): las respuestas alinean perfectamente, cada una con su propio handler (Decoy 2, descartado — el agrupador en sí no es el fallo). Ahora mete, antes de tus peticiones de interés, una entrada con unpathque no se pueda interpretar como ruta (":", sin la barra inicial):{ "requests": [ { "path": ":", "method": "GET" }, { "path": "/api/v2/categories?author_exclude=1", "method": "GET" }, { "path": "/api/v2/posts", "method": "GET" } ]}responses[0]sale como error (400 path_parse_error), tal como esperabas — peroresponses[1], que pediste como categories, vuelve con la forma de un feed de posts (data/total, conauthor_iden cada elemento); yresponses[2]sale como500 invalid_handler. Las respuestas se desalinearon: la petición N se ejecutó con el handler de N+1.Weaponiza el desync: cuela el filtro crudo por un portador ajeno. El filtro de exclusión de autores no está declarado en el esquema de
categories— por eso, cuando el desync hace que la petición de categories (índice 1) se ejecute con el handler de posts, los parámetros que llegan son los que se sanearon bajo el esquema de categories (que no toca el filtro) en vez de bajo el esquema de posts (que lo castearía). El escalar crudo llega intacto alNOT IN. Comprueba primero que usar posts como su propio portador no funciona (posts?author_exclude=…desincronizado se sigue saneando bajo su propio esquema — Decoy 4, descartado): el portador tiene que ser una ruta que no declare el filtro. Con categories como portador, rompe el paréntesis y controla la cola delWHERE:/api/v2/categories?author_exclude=0) AND (1=1) --dentro del mismo batch
[":", <esa ruta>, "/api/v2/posts"]. Elresponses[1].body.totalcon(1=1)te devuelve el número real de posts publicados; con(1=2)te devuelve0. Ya tienes un oráculo booleano exacto por request.Localiza la tabla de secretos. El catálogo estándar de SQLite (
sqlite_master) es navegable por cualquiera con el mismo oráculo:(SELECT count(*) FROM sqlite_master WHERE type='table' AND name='site_secrets')=1te confirma que la tabla existe — y el changelog ya te había dado la pista (una nota de versión menciona un "config store" interno y su clavepublishing_root_key, sin dar el valor). Enumerakeycongroup_concatsi quieres confirmar el resto de filas (smtp_relay_password,webhook_signing_key,preview_bypass_token,media_cdn_signing_keyson señuelos verosímiles, no la flag).Extrae carácter a carácter. Con el oráculo confirmado, monta una búsqueda binaria sobre
length(...)y luego sobre cada posición conunicode(substr(...)):0) AND (length((SELECT value FROM site_secrets WHERE key='publishing_root_key'))>N) -- 0) AND (unicode(substr((SELECT value FROM site_secrets WHERE key='publishing_root_key'),i,1))>M) --Cada comparación devuelve TRUE (N posts) o FALSE (0 posts); en torno a 7-8 peticiones por byte reconstruyes el valor completo:
FLAG{8c10d84829d06ca12513bc5b6169d07a}.Confirma el punto ciego / verifica. Comprueba que ningún endpoint público (
openapi.json,changelog,posts,authors,categories, ni la utilidad de depuración) devuelve el secreto directamente — solo la cadena completa lo alcanza. Valida tu flag conbash flag/verify.sh(md5 de la preimagen).
Canales de feedback (no vuelas a ciegas)
- El conteo de posts (
responses[N].body.total/data.length) en cada sub-respuesta del batch — el oráculo booleano exacto: TRUE→N, FALSE→0. - Los códigos del envelope:
path_parse_error(400) confirma que el path se rompió como esperabas;invalid_handler(500) confirma el shift de índices;db_error(500) confirma que un payload roto sí está tocando SQL. - El portal de desarrollador y el changelog, ambos públicos, documentan el filtro, la forma del agrupador de peticiones y el nombre del almacén de secretos — nada que adivinar de memoria.
Decoys (descártalos por razonamiento acotado)
- Decoy 1 — filtro directo casteado.
GET /api/v2/posts?author_exclude[]=<id>(o como valor suelto) valida y castea cada elemento a entero antes de construir el SQL. Un payload de inyección se reduce a un número → lista normal, sin efecto. Refutado con 1 prueba. - Decoy 2 — el agrupador en sí no es el fallo. Un batch con todos los
pathválidos alinea perfectamente sus respuestas: cada sub-petición corre con su propio handler. El fallo no es "usar el agrupador", es el off-by-one que solo aparece con unpathinválido de por medio. - Decoy 3 — la búsqueda es parametrizada.
GET /api/v2/posts?search=<q>usa el valor como parámetro ligado (LIKE ?).search='devuelve200normal, sin error ni diferencial — no es el vector. - Decoy 4 — posts como su propio portador se sanea. Usar
posts?author_exclude=<payload>como la entrada desincronizada no funciona: sus parámetros se sanean bajo el esquema de posts antes de ejecutarse. Solo un portador cuyo esquema no declare el filtro (categories, authors) sobrevive crudo al desync.
La falla del laboratorio
Clase: CWE-89 (SQL Injection) como sink final, entregado por CWE-444 (Inconsistent Interpretation of Requests — desync de enrutado) con CWE-193 (Off-by-one Error) como el defecto literal, y habilitado por CWE-1287 (Improper Validation of Specified Type of Input, bajo el paraguas CWE-20). Consecuencia: CWE-200 (Exposure of Sensitive Information). Condición habilitante: CWE-306 (Missing Authentication for Critical Function — toda la API de entrega y el agrupador son públicos). OWASP A03:2021 (Injection) + A04:2021 (Insecure Design).
El multiplexor de peticiones agrupadas resuelve sus sub-peticiones en dos fases separadas. En la fase de resolución recorre cada entrada por su posición i: si el path no se puede interpretar, registra el error en responses[i] y no empuja ningún handler al array de handlers resueltos — simplemente continúa con la siguiente entrada. En la fase de ejecución, vuelve a recorrer por posición i y toma handler = matches[i] para correrlo con los parámetros de la petición i. El problema es que matches no tiene la misma longitud que requests en cuanto una entrada fue inválida: sus índices ya no corresponden a la posición original de la petición, sino a la posición dentro de la sublista de peticiones que sí resolvieron un handler. El resultado es que la petición N acaba corriendo con el handler que en realidad pertenecía a la petición N+1 — pero con los parámetros de la petición N, que fueron saneados bajo el esquema de su propia ruta, no bajo el esquema de la ruta cuyo handler termina ejecutándose.
Ese defecto por sí solo no bastaría si el handler de posts validara su entrada de forma robusta. Pero buildAuthorExcludeClause() decide cómo tratar el filtro de exclusión de autores por truthiness (!= null && !== ''), sin comprobar Array.isArray(): si recibe un array, castea cada elemento a entero (seguro); si recibe cualquier otra cosa —un string escalar, por ejemplo—, lo concatena tal cual dentro de NOT IN ( <valor> ). La ruta directa nunca deja pasar un escalar sin castear porque su propio esquema declara el filtro como lista de enteros; pero un handler compartido, alcanzado por una vía que no declara ese filtro en su esquema, nunca pasa por ese casteo — y el handler de posts, que no vuelve a comprobar el tipo de lo que recibe, confía ciegamente en que quien lo invocó ya saneó la entrada.
Ninguno de los dos defectos es, por separado, explotable: el off-by-one del enrutado no sirve de nada si el handler final valida su entrada; el if sin Array.isArray() no sirve de nada si el único camino hacia el handler pasa siempre por el esquema que castea. La vulnerabilidad emerge exactamente en la intersección: un multiplexor que ejecuta un handler con parámetros saneados bajo el esquema de otra ruta, y un handler que asume que "si me llegan parámetros, ya vinieron validados".
Lección: la seguridad de un handler compartido no puede depender de qué ruta lo invocó. Si un mismo código de negocio es alcanzable por más de un camino, debe validar su propia entrada en el punto de uso — no confiar en que "siempre" llega ya saneada por otra capa. Y cualquier dispatcher que multiplexe peticiones debe mantener la correspondencia 1:1 entre sus fases de resolución y ejecución incluso para las entradas que fallan pronto, en vez de simplemente saltarlas del array y dejar que el índice se corra.
Laboratorio basado en la reproducción anonimizada (empresa ficticia Storia) de una técnica real de bug bounty sobre inyección SQL ciega alcanzada mediante una desincronización de enrutado en un endpoint de agrupación de peticiones.
