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

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

By @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.

This hack was reported with no reward

58 views2 completedUpdated Sep 2026
Log in to start

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.

650

hunters training

50

labs from real hacks

380

completions

$12,000

paid out for these bugs

40 flags captured this week
Create account
I already have an account

Access to all labs · no commitment · cancel anytime

Hackers who solved it· 2

w4tchw0lf1
@w4tchw0lf3 days ago
MA2
@maxiolivera5 days ago

Objectives

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.

Information

Platform
HackerOne
Difficulty
Hard
Duration
1h 25min
Bounty
Sin recompensa
Completed
2
Creator
gorka@gorka
Updated
Sep 2026

Download the environment

Reproduce it and find the bug yourself

Create account

Tools

curlPython (requests)Burp Suitejq

Prerequisites

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

Tags

SQLiInformation DisclosureAPI Abuse

Achievement you'll earn

Solve this lab to unlock this shareable achievement

Solve this lab to unlock this shareable achievement

Community writeups

Free · no account

The checklist I run on every new target

47 checks ordered by cost: first what can get you in trouble, then the cheap stuff, and finally the expensive stuff — which is where the big bounties are. I'll send it to your inbox right now.

Unsubscribe in one click, from any email.

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.

The community

This isn't a platform with users.It's people breaking the same things you are.

Inside people ask, share what they found and solve together what would block anyone on their own. It's free to join: no account, no plan, no email required.

Join the BBLABS community on Discord
Join the Discord

Free and open. You don't need a BBLABS account to join.

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.