BBLABSBBLABS
LaboratoriosAprender desde 0FuncionesPreciosTestimoniosDetrás de BBLABS
  1. Inicio
  2. Labs
  3. Blind SQL injection unauthenticated vía desincronización de un endpoint de peticiones agrupadas
Difícil1h 25min

Blind SQL injection unauthenticated vía desincronización de un endpoint de peticiones agrupadas

Por @gorka

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

57 visitas2 completadosActualizado sept 2026
Iniciar sesión para empezar

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.

650

hunters entrenando

50

labs de hackeos reales

380

completaciones

$12.000

pagados por estos bugs

40 flags capturadas esta semana
Crear cuenta
Ya tengo cuenta

Acceso a todos los labs · sin permanencia · cancela cuando quieras

Hackers que lo han resuelto· 2

w4tchw0lf1
@w4tchw0lfHace 3 días
MA2
@maxioliveraHace 5 días

Objetivos

1
Detectar que el filtro de exclusión de autores alcanza una cláusula NOT IN en la consulta SQL de posts.
2
Confirmar que la vía directa del filtro es segura porque castea cada elemento a entero.
3
Descubrir el endpoint que agrupa varias peticiones y su promesa de alinearlas 1:1 con las respuestas.
4
Provocar una desincronización de rutas con una sub-petición cuyo path no se puede interpretar.
5
Reencaminar un filtro escalar y crudo de una ruta ajena hacia el handler que construye el NOT IN.
6
Confirmar el oráculo booleano observando el conteo de posts que devuelve cada intento.
7
Enumerar el catálogo interno de la base de datos para localizar la tabla de secretos.
8
Extraer el secreto carácter a carácter mediante búsqueda binaria sobre el oráculo.
9
Descartar los cuatro decoys con pruebas acotadas antes de capturar la flag final.

Información

Plataforma
HackerOne
Dificultad
Difícil
Duración
1h 25min
Bounty
Sin recompensa
Completados
2
Creador
gorka@gorka
Actualizado
sept 2026

Descarga el entorno

Reprodúcelo y encuentra el bug tú mismo

Crear cuenta

Herramientas

curlPython (requests)Burp Suitejq

Prerequisitos

  • SQL básico (WHERE/NOT IN)
  • HTTP y APIs JSON
  • curl o Python

Tags

SQLiInformation DisclosureAPI Abuse

Logro que recibirás

Cuando resuelvas este lab desbloqueas este logro compartible

Cuando resuelvas este lab desbloqueas este logro compartible

Writeups de la comunidad

Gratis · sin cuenta

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

  1. Recon del filtro de exclusión de autores. El portal de desarrollador documenta GET /api/v2/posts con 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áusula NOT 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 un NOT IN real detrás del filtro, pero necesitas que el valor le llegue crudo por otra vía (Decoy 1, descartado).

  2. 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 un 207 con responses[] 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 los path vá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 un path que 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 — pero responses[1], que pediste como categories, vuelve con la forma de un feed de posts (data/total, con author_id en cada elemento); y responses[2] sale como 500 invalid_handler. Las respuestas se desalinearon: la petición N se ejecutó con el handler de N+1.

  3. 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 al NOT 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 del WHERE:

    /api/v2/categories?author_exclude=0) AND (1=1) -- 
    

    dentro del mismo batch [":", <esa ruta>, "/api/v2/posts"]. El responses[1].body.total con (1=1) te devuelve el número real de posts publicados; con (1=2) te devuelve 0. Ya tienes un oráculo booleano exacto por request.

  4. 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')=1 te 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 clave publishing_root_key, sin dar el valor). Enumera key con group_concat si quieres confirmar el resto de filas (smtp_relay_password, webhook_signing_key, preview_bypass_token, media_cdn_signing_key son señuelos verosímiles, no la flag).

  5. Extrae carácter a carácter. Con el oráculo confirmado, monta una búsqueda binaria sobre length(...) y luego sobre cada posición con unicode(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}.

  6. 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 con bash 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 path vá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 un path invá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=' devuelve 200 normal, 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.

La comunidad

Esto no es una plataforma con usuarios.Es gente rompiendo lo mismo que tú.

Dentro se pregunta, se enseña lo que se ha encontrado y se resuelve en grupo lo que atascaría a cualquiera solo. Se entra gratis: sin cuenta, sin plan y sin dejar el correo.

Entrar a la comunidad de BBLABS en Discord
Entrar al Discord

Es gratis y abierto. No hace falta tener cuenta en BBLABS para entrar.

BBLABS LogoBBLABS

Laboratorios de hacking basados en hackeos reales. Aprende practicando sobre entornos vulnerables, con la resolución explicada paso a paso.

Producto

  • Funcionalidades
  • Precios
  • Labs
  • Aprender desde 0
  • Blog
  • Detrás de BBLABS

Legal

  • Términos
  • Privacidad
  • Cookies
  • Entrar
  • Crear cuenta

Para cualquier duda, bug, soporte o sugerencia puedes escribir a team@bblabs.es

© 2026 BBLABS. Todos los derechos reservados.