Challenge: Render & Plunder
Category: Web Security / Defense
Challenge Description
I wrote a user-profile service with a nice renderer for myself. Surely it's secure, right? Take a look and patch any vulnerabilities while keeping the app fully functional.
"You know how to enter. Find the way out."
A Python Flask app with login, user profile GET/PUT, and a /render endpoint. We must defend it — patch all vulnerabilities without breaking functionality.
Vulnerability Analysis
The original code had three vulnerabilities:
1. Server-Side Template Injection (SSTI) — Critical
# VULNERABLE
name = data.get("name", "User")
template_source = f"Hello {name}! Here is your report for user {payload.get('username', '?')}."
result = Template(template_source).render()
User-controlled name is interpolated directly into a Jinja2 template string before rendering. An attacker can send:
{ "name": "{{ 7*7 }}" }
And get back Hello 49! — confirming code execution. From there, full RCE is trivial:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
This gives the attacker a shell on the server.
2. Broken Object Level Authorization (BOLA / IDOR) on GET
# VULNERABLE
@app.route("/api/users/<user_id>/data", methods=["GET"])
def get_user_data(user_id: str):
payload = _require_auth()
username = ID_TO_USER.get(user_id)
return jsonify({"profile": USERS[username]["profile"]}), 200
Any authenticated user can read any other user's profile by changing the user_id in the URL. The JWT is verified, but its user_id claim is never compared against the requested resource.
3. Broken Object Level Authorization (BOLA / IDOR) on PUT
# VULNERABLE
@app.route("/api/users/<user_id>/data", methods=["PUT"])
def update_user_data(user_id: str):
payload = _require_auth()
username = ID_TO_USER.get(user_id)
bio = data.get("bio", "")
USERS[username]["profile"]["bio"] = bio
Same issue on write — any authenticated user can overwrite any other user's bio.
Fixes
Fix 1 — SSTI: Never render user input as a template
Use a static template string with Jinja2 variables, and pass user data as context — never interpolate into the template source itself. Or skip Jinja2 entirely and use an f-string (since no template logic is needed here):
# FIXED — no Jinja2, no injection risk
name = data.get("name", "User")
result = f"Hello {name}! Here is your report for user {payload.get('username', '?')}."
return jsonify({"report": result}), 200
The key insight: name appears in the output, not as part of the template syntax. An f-string achieves this safely without invoking Jinja2 at all.
Fix 2 & 3 — BOLA: Enforce ownership on GET and PUT
Compare the user_id from the JWT payload against the user_id in the URL path. Reject if they don't match:
# FIXED
if str(payload.get("user_id")) != str(user_id):
return jsonify({"error": "Forbidden"}), 403
Applied to both the GET and PUT routes before any data is accessed or modified.
Final Patched Code
import os
import json
import datetime
from flask import Flask, request, jsonify
import jwt
app = Flask(__name__)
JWT_SECRET = os.environ.get("JWT_SECRET", "dev_secret_change_me")
_raw = os.environ.get("USERS_JSON", "{}")
USERS = json.loads(_raw)
ID_TO_USER = {v["id"]: k for k, v in USERS.items()}
@app.route("/health")
def health():
return jsonify({"status": "ok"}), 200
@app.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
username = data.get("username", "")
password = data.get("password", "")
user = USERS.get(username)
if user and user.get("password") == password:
token = jwt.encode(
{
"username": username,
"user_id": user["id"],
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
},
JWT_SECRET,
algorithm="HS256",
)
return jsonify({"token": token, "user_id": user["id"]})
return jsonify({"error": "Invalid credentials"}), 401
def _decode_token(token: str) -> dict:
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
def _require_auth():
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
raise Exception("Missing token")
return _decode_token(token)
@app.route("/api/users/<user_id>/data", methods=["GET"])
def get_user_data(user_id: str):
try:
payload = _require_auth()
except Exception:
return jsonify({"error": "Unauthorized"}), 401
# FIX: enforce user can only access their own data
if str(payload.get("user_id")) != str(user_id):
return jsonify({"error": "Forbidden"}), 403
username = ID_TO_USER.get(user_id)
if not username:
return jsonify({"error": "User not found"}), 404
return jsonify({"profile": USERS[username]["profile"]}), 200
@app.route("/api/users/<user_id>/data", methods=["PUT"])
def update_user_data(user_id: str):
try:
payload = _require_auth()
except Exception:
return jsonify({"error": "Unauthorized"}), 401
# FIX: enforce ownership
if str(payload.get("user_id")) != str(user_id):
return jsonify({"error": "Forbidden"}), 403
username = ID_TO_USER.get(user_id)
if not username:
return jsonify({"error": "User not found"}), 404
data = request.get_json(silent=True) or {}
bio = data.get("bio", "")
USERS[username]["profile"]["bio"] = bio
return jsonify({"message": "Profile updated", "bio": bio}), 200
@app.route("/render", methods=["POST"])
def render_report():
try:
payload = _require_auth()
except Exception:
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or {}
name = data.get("name", "User")
# FIX: plain f-string, user input never touches template engine
result = f"Hello {name}! Here is your report for user {payload.get('username', '?')}."
return jsonify({"report": result}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Summary of Changes
| # | Vulnerability | Location | Fix |
|---|---|---|---|
| 1 | SSTI (Remote Code Execution) | /render |
Removed Jinja2 Template — use plain f-string |
| 2 | BOLA / IDOR (unauthorized read) | GET /api/users/<user_id>/data |
Compare JWT user_id to URL param, return 403 if mismatch |
| 3 | BOLA / IDOR (unauthorized write) | PUT /api/users/<user_id>/data |
Same ownership check before allowing update |
Attack Demos (Original Vulnerable Code)
SSTI RCE:
curl -X POST /render \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "{{ self.__init__.__globals__.__builtins__.__import__(\"os\").popen(\"id\").read() }}"}'
# Response: "Hello uid=0(root) gid=0(root)! Here is your report..."
IDOR — read another user's profile:
# Logged in as user_id=2, accessing user_id=1
curl /api/users/1/data -H "Authorization: Bearer <user2_token>"
# Returns user 1's private profile data — no error
Key Takeaways
- Never render user input as a template. Pass it as a variable into a static template, or avoid the template engine entirely if no logic is needed.
- Authentication ≠ Authorization. Verifying a JWT proves who you are — you must still check what you're allowed to access. Always compare the token's identity claims against the requested resource.
- BOLA (Broken Object Level Authorization) is the #1 API vulnerability class (OWASP API Top 10). It's easy to miss because the endpoint works correctly for the legitimate user — it just doesn't restrict which user.
Top comments (0)