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
Aprende a encontrar este bug
Este bug pagó $700 en YesWeHack.
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
- 650
- labs de reportes reales
- 50
- completaciones
- 380
- en bounties practicados
- $200.000
hunters entrenando
labs de reportes reales
completaciones
en bounties practicados
Acceso a todos los labs · sin permanencia · cancela cuando quieras
Hunters que lo han resuelto· 4
Objetivos
Logro que recibirás
Cuando resuelvas este lab desbloqueas este logro compartible
SQL Injection en identificador de tabla → RCE por PostgreSQL `COPY … FROM PROGRAM`
Writeups de la comunidad
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.