DEV Community

Cover image for Render & Plunder - dalCTF 2026
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

Render & Plunder - dalCTF 2026

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()
Enter fullscreen mode Exit fullscreen mode

User-controlled name is interpolated directly into a Jinja2 template string before rendering. An attacker can send:

{ "name": "{{ 7*7 }}" }
Enter fullscreen mode Exit fullscreen mode

And get back Hello 49! — confirming code execution. From there, full RCE is trivial:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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..."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)