Last weekend, I played DEFCON CTF Quals with the team @mhackeroni, placing 4th place and qualifying for the finals. This is my write-up for Waybird Machine, a web/misc challenge I enjoyed solving with my teammates.
The challenge required chaining multiple bugs and quirks to turn a limited file upload and a constrained SSRF into an arbitrary TCP SSRF, allowing us to send TCP packets (and SQL queries) to an internal Babelfish instance in order to retrieve the flag:
Chaining DNS rebinding, SSRF with obs-fold header injection, and a pyftpdlib parsing quirk allows cross-protocol SSRF to the internal FTP server.
Upload a polyglot file that passes image validation but is a valid TDS stream for Babelfish.
Exploit the FTP bounce to send the uploaded TDS stream to Babelfish, which will execute the embedded SQL and reveal the flag.
Only nginx is exposed externally. Internally, the Flask application, the FTP server, and Babelfish database are reachable within the web container's network.
As we can see below, If the fetched resource is verified as a valid image format, it is saved under /app/app/static/scraped/<uuid>.<ext>, that is also the root directory for the internal FTP service: python -m pyftpdlib -D --port 21 -w -d /app/app/static/scraped &
defscrape(url, auth_user, auth_pass): _validate_url(url) r = _fetch(url, auth_user, auth_pass) tmp = tempfile.NamedTemporaryFile(delete=False,dir=app.config["UPLOAD_FOLDER"], suffix=ext)try:# <download logic> meta = verify(tmp.name)if meta isNone:raise ScrapeError("Image verification failed") ext = IMAGEMAGICK_FORMAT_TO_EXT.get(meta["format"].upper(), ext) safe_name =f"{uuid.uuid4().hex}{ext}" dest = os.path.join(app.config["UPLOAD_FOLDER"], safe_name) shutil.move(tmp.name, dest) meta["file_size"]= os.path.getsize(dest)return safe_name, meta
Submitted URLs must use the HTTP or HTTPS scheme and cannot resolve to a private/internal address:
def_validate_url(url): parsed = urlparse(url)if parsed.scheme notin ALLOWED_SCHEMES:raise ScrapeError(f"Unsupported URL scheme: {parsed.scheme}") hostname = parsed.hostname
resolved = socket.getaddrinfo(hostname,None)for family, _, _, _, sockaddr in resolved: ip = ipaddress.ip_address(sockaddr[0])ifisinstance(ip, ipaddress.IPv6Address)and ip.ipv4_mapped isnotNone: ip = ip.ipv4_mapped
for network in BLOCKED_NETWORKS:# BLOCKED_NETWORKS = [127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16]if ip in network:raise ScrapeError("Access to private/internal addresses is not allowed")return parsed
Because the hostname resolution in _validate_url is decoupled from the subsequent socket connection established by _fetch, we can use DNS rebinding.
By configuring a custom DNS server to return a public IP on the first resolution query, and resolving to 127.0.0.1 or 0.0.0.0 on subsequent requests, we bypass the validation step.
After that response, requests prepares a new request, computes the Digest auth value and adds the Authorization header to the request, as it's possible to see in requests/src/requests/auth.py:
Though CRLF injection is blocked, so the question now is how to turn the request into valid FTP commands.
The HTTP/1.1 specification supports obsolete line folding (obs-fold) in request headers; a CRLF followed by at least one space or tab (\r\n<space> or \r\n\t) can be used to fold a an header value into multiple lines:
This is a good starting point, we can inject FTP commands through the submitted username, but each injected command is preceded by a tab or space making each command line invalid.
So we need a way to get rid of the leading space.
pyftpdlib's control connection handler (pyftpdlib/pyftpdlib/handlers/ftp/control.py) inherits its line-reading behavior from asynchat (deprecated in favor of asyncio).
It buffers incoming socket data until a \r\n line terminator is encountered.
Crucially, when the buffer limit is reached, pyftpdlibclears the internal buffer, returns an error message, maintains the connection, and keeps reading data.
This behavior lets us get rid of the leading space by arranging the injected Digest username in the following way:
<padding until the FTP parser overflows and clears its current line>
USER anonymous\r\n
\t<padding>
PASS\r\n
\t<padding>
...
\t<padding> clears the internal buffer, at the next read the buffer will contain a clean command.
Padding to 2048+1 bytes is not enough by itself. handle_read() reads at most 64KiB from the socket and splits the data on \r\n:
classasync_chat(dispatcher): ac_in_buffer_size =65536defhandle_read(self): data = self.recv(self.ac_in_buffer_size)# read from socket self.ac_in_buffer = self.ac_in_buffer + data
while self.ac_in_buffer: terminator = self.get_terminator() index = self.ac_in_buffer.find(terminator)if index !=-1:if index >0: self.collect_incoming_data(self.ac_in_buffer[:index])# adds or clears internal buffer self.ac_in_buffer = self.ac_in_buffer[index +len(terminator):] self.found_terminator()# parse command from internal bufferelse: self.collect_incoming_data(self.ac_in_buffer) self.ac_in_buffer =b""# when no terminator is found, clear the buffer
That means each real FTP command has to start exactly at position 0 in the next data chunk, at offset (chunk_n * 65536) in the stream.
Chunk 0 (64KiB): GET / HTTP/1.1\r\nHost: ...\r\nAuthorization: Digest username="[PADDING]
Chunk 1 (64KiB): USER anonymous\r\n\s[PADDING]
Chunk 2 (64KiB): PASS\r\n\s[PADDING]
Note: this layout works only if handle_read() always reads exactly 64KiB from the socket. Otherwise, FTP commands no longer land at the start of a chunk, and the stream breaks.
The username/ftp request that will be injected into the authorization header can be generated as follows:
the control connection, where commands like USER, PASS, and RETR are sent.
the data connection where file contents are transferred.
FTP Bounce enters the chat: in active mode, the client sends a PORT command to tell the FTP server where it should open the data connection.
With it, we can make the FTP server open a TCP connection to a chosen (local) host and port, then send the bytes of an uploaded file over that connection:
USER anonymous
PASS
TYPE I
PORT 127,0,0,1,5,153 # h1.h2.h3.h4:(p1 * 256 + p2) = 127.0.0.1:1433
RETR <uploaded filename>
Note: TYPE I switches FTP to binary mode. Without it, ASCII mode may corrupt the file binary stream.
As previously seen, we can upload files that must be valid images to pass the ImageMagick validation. To be able to talk to Babelfish, we need to craft an image that is also a valid TDS stream.
It turns out that the same bytes that start a TDS PRELOGIN packet are also accepted by ImageMagick as the beginning of a TGA header.
TGA fields are little-endian, so the first 18 bytes are interpreted roughly like this:
raw bytes = 12 01 01 19 00 00 00 00 00 00 0b 00 06 01 00 11 01 00
id length = 0x12
color map type = 1
image type = 1
y origin = 11
width = 262
height = 4352
bits per pixel = 1
We only need to pad our payload to around 1.2 MB so the file has enough bytes for the dimensions ImageMagick thinks it saw:
After the PRELOGIN, the file has to contain a LOGIN7 packet that uses the default Babelfish credentials:
user = babelfish_user
password = 12345678
database = birdarchive
This is followed by a TDS SQL_BATCH packet containing:
UPDATE flags SET is_hidden=0;
The final file layout is:
TDS PRELOGIN packet also parsed as the TGA header by ImageMagick
TDS LOGIN7 packet authenticates to Babelfish
TDS SQL_BATCH packet runs UPDATE flags SET is_hidden=0;
PADDING until the file is large enough for ImageMagick
The exploit is not only about sending the right bytes. It also has to keep synced three independent parsers: requests throws BadStatusLine and terminates the control connection with the FTP service as soon as it reads the FTP banner as an HTTP response, so pyftpdlib and babelfish need to parse every command before this event.
After the DNS rebind, once requests opens the connection to the FTP service, pyftpdlib immediately sends the FTP banner on the control connection, but requests later tries to parse that banner as an HTTP response. Once it reaches read(), it throws BadStatusLine and closes the socket, which aborts the final FTP read before RETR is processed:
Rendering diagram...
The FTP command stream must therefore be fully parsed before requests calls read(). To fix this issue we can make the outgoing request large. The injected username can be close to the default nginx body limit (around 10 MB), so while the client is still writing the huge Digest header, pyftpdlib has enough time to keep reading and parsing the commands.
The same kind of timing problem appears later, between pyftpdlib and Babelfish.
After PORT, RETR makes pyftpdlib open a data connection to Babelfish and write the TGA/TDS polyglot. Babelfish needs that data socket to stay alive long enough to receive and parse every TDS packet. If the control connection gets abrupted by requests while the transfer is still in progress, pyftpdlib closes the data channel and Babelfish only sees a part of the stream:
Rendering diagram...
To fix this issue QUIT can be used. In pyftpdlib, a QUIT during an active transfer closes only the control channel, leaving the data channel open until the transfer completes. Once the control channel is already closed, requests can no longer reset it.
defftp_QUIT(self, line):"""Quit the current session disconnecting the client."""# ... self.respond("221 ...")# If file transfer is in progress, the connection must remain# open for result response and the server will then close it.# We also stop responding to any further command.if self.data_channel: self._quit_pending =True self.del_channel()#! deletes **only the control channel**else: self._shutdown_connecting_dtp() self.close_when_done()
Exploit with corrected timings:
Rendering diagram...
So the final FTP command stream would look like this:
USER anonymous\r\n <padding>
PASS\r\n <padding>
TYPE I\r\n <padding>
PORT 127,0,0,1,5,153\r\n <padding>
RETR <filename>\r\n <padding>
QUIT\r\n <padding until ~10 MB>