Challenge: Bouncer · CTF: DalCTF 2026 · Category: Web · Difficulty: Hard
“Does a kangaroo bounce fast if I attach a rocket engine to it?”
Flag: dalctf{b0unced_4nd_smug9led}
That throwaway flavor text about a bouncing kangaroo is the whole challenge in one sentence. The “bounce” is an FTP bounce attack, and the “rocket engine” is the custom gateway we abuse to smuggle Redis commands across the data channel. The flag — b0unced_4nd_smug9led — confirms it.
This is a long one, because the interesting part of Bouncer isn’t the final exploit (three Redis commands) — it’s the wall of dead ends you have to climb over to earn those three commands. I’m going to walk the whole path, including the things that didn’t work, because that’s where the actual learning is.
1. Recon: a tidy little intranet portal
We’re handed a single URL:
| |
It’s an Express app (“DAL-NET Internal Services”) with a 120 req/min rate limit. The landing page is mostly flavor, but it’s generous flavor — the system notices read like a checklist of the intended attack chain:
- Legacy FTP Gateway: “Anonymous access remains enabled for inbound transfers… Access controls are under review.”
- Storage Panel: “The internal storage management panel has been moved. Authorised staff should locate the new path via the admin index.”
- Redis Cache: “Data layer is now proxied through the internal gateway. Not externally accessible.”
And tucked in the page source, a very on-the-nose comment:
| |
So we hit it:
| |
There it is: a live FTP endpoint on port 29599, and a Redis-shaped cache behind nginx:6379.
robots.txt hands us the “moved” panel:
| |
/storage-panel is the money page. It stops hinting and starts documenting the vulnerability:
FTP Gateway: The file transfer service is currently running without egress restrictions on the PORT command. Ticket #4821 open. Until resolved, the gateway can be instructed to initiate outbound connections to arbitrary internal hosts.
Cache Proxy: The nginx instance proxies the internal Redis cache on port 6379. Authentication is disabled.
It even prints an internal services table:
| Service | Host | Port | Note |
|---|---|---|---|
| FTP Gateway | ftp | 2121 | Anonymous access, PORT unrestricted |
| Cache Proxy | nginx | 6379 | Proxies Redis, no auth |
| Redis | redis | 6379 | Internal only |
The plan writes itself: FTP bounce (SSRF via the PORT command) → reach internal Redis → read the flag. Classic.
The classic plan is also a trap, as we’ll see.
2. The FTP server: anonymous, talkative, and locked down
| |
Anonymous login works. The command set (FEAT, HELP, SYST → 215 UNIX Type: L8) is a dead ringer for pyftpdlib, and PORT/EPRT are both present — so FTP bounce should be on the table.
I confirm bounce is enabled by pointing PORT at my own public IP:
| |
200 on a foreign address = permit_foreign_addresses=True. Bounce confirmed. The 425 timed out is just my home NAT eating the inbound connection — important later.
The classic attack, and why every variant fails
The textbook FTP-bounce-to-Redis goes: upload a file full of Redis commands, then PORT at Redis and RETR the file so the server relays your bytes into Redis. So I check whether I can write anything:
| |
Fully read-only. (pyftpdlib checks the write permission before opening a data channel, so these 550s are instant.) I try a pile of credentials (ftp:ftp, admin:admin, partner:partner, …) — only anonymous works, and only read-only.
Next, maybe there’s a flag file I can just RETR. pyftpdlib’s STAT <path> returns a listing over the control channel (no data connection needed), which is perfect since my data channel is dead:
| |
Empty chroot. No files to read, nothing to write, no way out.
So I take inventory of what an attacker actually has here:
- ✅ Make the FTP server open a TCP connection to any host:port (
PORT+ a transfer command). - ❌ Send controlled bytes (no writable files →
RETR/LISTonly ever emit an empty directory listing). - ❌ Read the bounce target’s response (data channel back to me is firewalled both ways).
That is… a blind port scanner. Not a Redis shell. The “classic plan” is dead.
Ruling out the other channels
Before going deeper I make sure I’m not missing an easier path:
- Web SSRF?
/api/storage/statusignores?url=,?host=,?target=. No/api/storage/cache,/fetch,/proxy,/get. Gobuster gets429-throttled into uselessness. The web app is purely a recon hint dispenser. - Direct exposure?
nmapof the instance host shows only80,443, and29599. Redis is genuinely internal. - Data channel back to me? I test active-mode bounces to my public IP on
9000,443,80— all time out. I’m behind CGNAT; nothing inbound survives.
Everything funnels back to the read-only FTP bounce. So either I’m wrong about its capabilities, or I’m missing something about this specific server. Time to actually look at what it does on the wire.
3. Getting a real data channel (a reverse tunnel)
To understand the bounce I need to see the bytes the server emits on the data connection. I can’t receive inbound at home — so I borrow a reachable endpoint with a free reverse TCP tunnel (pinggy):
| |
The public host resolves to 172.105.85.143:45049. PORT needs IPv4 octets, and 45049 = 175*256 + 249, so my bounce target becomes:
| |
I park a logger on 12345 and tell the FTP server to bounce a LIST at it.
4. The plot twist: it’s not an FTP server, it’s a Redis proxy
The directory is empty, so a normal FTP LIST should send… nothing. Instead my listener captured this:
| |
That is not a directory listing. That is the Redis RESP encoding of the command PING.
The banner’s “Gateway routing active” was literal. This “FTP server” is a custom Redis health-check gateway wearing an FTP costume: when you trigger a transfer, it connects to the PORT target and speaks the Redis protocol at it.
So I swap my dumb logger for a fake Redis that answers, and bounce again:
| |
| |
This changes everything. The gateway is bidirectional: it forwards a command to the target and relays the target’s reply back to me over the FTP control connection. That’s a full request/response primitive — I just need to control the command.
Mapping FTP verbs → Redis commands
I point every FTP verb at my fake Redis and watch what it forwards:
| FTP command | Bytes sent to target |
|---|---|
LIST | *1\r\n$4\r\nPING\r\n (hard-coded PING) |
RETR flag | flag |
RETR test | test |
RETR GET flag | GET flag |
RETR <arg> forwards <arg> raw. Arbitrary bytes — exactly what I need. There’s one catch: Redis’s inline protocol executes a command only when it sees a newline, and RETR flag sends flag with no terminator. Can I smuggle a \n into the argument?
| |
| |
It does. The gateway strips the RETR prefix and the trailing \r\n, but keeps an embedded \n. So the full primitive is:
RETR <inline redis command>\n\r\n→ gateway runs the command against thePORTtarget and relays the reply over the FTP control channel.
A blind read-only FTP server just became an authenticated-by-nobody Redis client. Now I just need Redis’s address.
5. Finding Redis on the internal network
PORT needs a numeric IP, and EPRT |1|redis|6379| is rejected (501 Invalid EPRT format) — no hostnames allowed. So I have to locate Redis by IP.
The PASV-advertised address 172.16.61.4 is a masquerade (connecting to it from the bounce just times out — it’s not the real container network), but it’s a strong hint that the gateway’s real subnet is 172.16.61.0/24. With the bidirectional relay I now have a perfect oracle: bounce RETR PING\n at each host and watch for a relayed +PONG.
| |
| |
172.16.61.3:6379 answers +PONG. Got it.
(The three response classes are themselves a tidy little nmap: +PONG = Redis, Connection refused = host up / port closed, timed out = no host. The FTP bounce + relay turned into a credentialed internal port scanner.)
6. Looting Redis
From here it’s just Redis-over-FTP. Enumerate the keyspace:
| |
A flag key, sitting right there. Read it:
| |
🚩 dalctf{b0unced_4nd_smug9led}
7. Final exploit
The whole chain, distilled:
| |
8. Lessons & takeaways
- The “classic” path was a deliberate trap. Everyone arrives expecting upload-payload → RETR-to-Redis. Read-only + empty chroot kills that stone dead. The challenge wants you to realize the server isn’t a real FTP server at all.
- When recon dead-ends, instrument the target. I went in circles for a long time reasoning about what should be possible. The breakthrough came the moment I gave myself a real data channel (a reverse tunnel) and looked at the actual bytes —
*1\r\n$4\r\nPING\r\ninstantly reframed the entire challenge. - Protocol smuggling via a permissive newline. The single most important detail was that
RETR <arg>\n\r\npreserves an embedded\n, satisfying Redis’s inline-command parser. Without it,GET flagjust sits in Redis’s buffer forever. - A relay turns a blind SSRF into a read primitive. A normal FTP bounce is write-only/blind. Because this gateway pipes the target’s response back over the control channel, it’s a full request/response oracle — good enough to port-scan the subnet and exfiltrate data.
b0unced_4nd_smug9led— bounced (FTPPORTSSRF) and smuggled (Redis inline command through the gateway’s data channel). The flag is the summary.
Appendix: tools
curl/nmap— recon and exposure check- raw Python sockets — full control over FTP control-channel bytes (essential;
ftplibhides too much) ssh -Rreverse TCP tunnel (pinggy) — a reachable endpoint to observe the bounce from behind NAT- a ~30-line fake-Redis — to fingerprint the gateway’s protocol before pointing it at the real thing