Challenge: Chakravyuh Defense (Secure the Login · Render & Plunder) · CTF: DalCTF 2026 · Category: Web / Defense · Difficulty: Easy–Medium

Category: Defense (Blue-team / Code-patching) Challenges: Secure the Login (easy, 50 pts) · Render & Plunder (medium, 50 pts) Author: ASmilyBun Flags:

  • dalctf{3asy_P3asy_SQL_1nj3ction}
  • dalctf{W1ll_Y0u_ID0R_Moi_SSTI?}

Intro: a CTF where you fix the bug

Most CTF web challenges hand you a vulnerable app and say “go pop it.” The Chakravyuh platform at https://defense.dalctf2026.com/ flips that script. It’s a defensive CTF: you’re given the vulnerable source code, and your job is to patch the vulnerabilities while keeping the application fully functional. The grader then launches a fixed set of attacks against your patched build. Only when every attack is blocked and the functionality/health tests still pass does the platform cough up the flag.

The name is a nice touch — Chakravyuh is the multi-layered military formation from the Mahabharata (“Breach the formation,” says the site tagline). Each challenge is a layer you have to seal.

This writeup walks through how I mapped the platform, then solved both challenges. I did everything from the command line against the platform’s API, so you’ll see the actual recon and submission mechanics too — not just the one-line fixes.


Part 0 — Recon: understanding the platform

The landing page is a Vite/React single-page app. curl -i on the root shows a Cloudflare-fronted server and a bundled JS file:

1
2
3
4
5
6
$ curl -s -i https://defense.dalctf2026.com/
HTTP/2 200
server: cloudflare
...
<title>Chakravyuh - CTF Defense Platform</title>
<script type="module" crossorigin src="/assets/index-C_MvlBPG.js"></script>

Every “interesting” path (robots.txt, flag.txt, .git/HEAD, config.json) returned the same 933-byte response — that’s the SPA’s index.html fallback, not real content. So the interesting surface isn’t files on disk; it’s the API the SPA talks to. Time to read the JavaScript bundle.

1
2
curl -s https://defense.dalctf2026.com/assets/index-C_MvlBPG.js -o app.js
grep -oE '/api/[a-zA-Z0-9_/-]+|"/[a-zA-Z0-9_./-]+"' app.js | sort -u

Picking the signal out of the minified noise, the API surface is:

1
2
3
4
5
6
GET  /api/challenges                  list challenges
GET  /api/challenges/:id              full challenge (includes source code!)
POST /api/challenges/load             spin up an instance
POST /api/submissions                 submit patched files
GET  /api/submissions/:id             poll a submission's result
GET  /api/analytics/platform          stats

And critically, the submission payload shape, pulled straight out of the bundle:

1
Z1.create({ challengeId: e, modifiedFiles: A.map(M => ({ path: M.path, content: M.content })), attackId: I })

So a submission is:

1
2
3
4
5
{
  "challengeId": "<mongo _id>",
  "modifiedFiles": [{ "path": "server.js", "content": "<patched source>" }],
  "attackId": null
}

Listing the challenges:

1
$ curl -s https://defense.dalctf2026.com/api/challenges | python3 -m json.tool
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[
  { "_id": "6a239b9b2d4bae0574a2148b", "challengeId": "secure-the-login",
    "title": "Secure the Login", "difficulty": "easy",
    "flagPolicy": { "mode": "all_attacks" },
    "attacks": [ {"id":"attack_1","buttonLabel":"Attack 1: Basic SQLi"},
                 {"id":"attack_2","buttonLabel":"Attack 2: Advanced SQLi"} ] },
  { "_id": "6a239b902d4bae0574a21483", "challengeId": "render-and-plunder",
    "title": "Render & Plunder", "difficulty": "medium",
    "attacks": [ {"id":"attack_1","buttonLabel":"Attack 1: SSTI"},
                 {"id":"attack_2","buttonLabel":"Attack 2: IDOR"} ] }
]

flagPolicy.mode = "all_attacks" confirms the rule: all attacks must be patched. Fetching a challenge by its _id returns the full record including the editable source files — that’s where the real work is.

Two gotchas worth knowing before you submit

  1. Cloudflare bans non-browser clients. My first submission with Python’s urllib got HTTP 403, error code 1010 — Cloudflare’s “banned by browser signature” response. The fix was to send a normal browser User-Agent plus Origin/Referer headers. After that, curl sailed through.
  2. challengeId must be the Mongo _id, not the human slug. Submitting challengeId: "secure-the-login" returned {"error":"Failed to submit code"}. Submitting challengeId: "6a239b9b2d4bae0574a2148b" returned {"message":"Submission queued for verification"}. The challengeId slug is for display; the API keys on _id.

With the platform understood, let’s break — er, fix — some formations.


Part 1 — Secure the Login (SQL Injection)

NovaCorp’s internal employee portal uses a simple username and password login. The development team shipped it fast — maybe too fast. Lock it down without breaking access for legitimate users.

The vulnerable code

The editable file is server.js, a tiny Express + PostgreSQL app:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  try {
    const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
    const result = await pool.query(query);

    if (result.rows.length > 0) {
      res.json({ success: true, message: 'Login successful' });
    } else {
      res.status(401).json({ success: false, message: 'Invalid credentials' });
    }
  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ success: false, message: 'Server error' });
  }
});

The vulnerability

This is the textbook case. The username and password values come straight from the request body and are concatenated into the SQL string with template literals. There is no separation between code and data, so an attacker controls the query structure.

  • Attack 1 — Basic SQLi: username = admin' -- comments out the password check. Or the classic ' OR '1'='1 makes the WHERE clause always true, returning rows and logging in.
  • Attack 2 — Advanced SQLi: Anything more exotic the grader throws — UNION-based extraction, boolean conditions, stacked logic — all ride on the same root cause.

Concretely, sending:

1
{ "username": "x' OR '1'='1", "password": "x' OR '1'='1" }

produces:

1
SELECT * FROM users WHERE username = 'x' OR '1'='1' AND password = 'x' OR '1'='1'

'1'='1' is always true → result.rows.length > 0 → “Login successful”. Bypassed.

The fix: parameterized queries

The cure for SQL injection is never to build queries by string-joining untrusted input. Use a parameterized (prepared) query, where the driver sends the SQL template and the values separately. The database treats the parameters strictly as data — they can never alter the query’s structure, no matter what characters they contain. node-postgres (pg) supports this natively with $1, $2 placeholders and a values array (the app already uses this style in initDB, so it stays idiomatic):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  try {
    const query = 'SELECT * FROM users WHERE username = $1 AND password = $2';
    const result = await pool.query(query, [username, password]);

    if (result.rows.length > 0) {
      res.json({ success: true, message: 'Login successful' });
    } else {
      res.status(401).json({ success: false, message: 'Invalid credentials' });
    }
  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ success: false, message: 'Server error' });
  }
});

Now ' OR '1'='1 is just a (wrong) username string. No row matches → 401. Legitimate users with their real credentials still match exactly → 200. Functionality preserved, injection killed. That last part matters: the grader also runs a positive test (a valid login must still succeed), so over-aggressive “fixes” like rejecting all quotes would break the functionality tests.

Submitting

I patched only the two vulnerable lines, kept everything else byte-for-byte, and POSTed it:

1
2
3
4
5
6
7
8
9
UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"

curl -s -X POST "https://defense.dalctf2026.com/api/submissions" \
  -H "Content-Type: application/json" \
  -H "Origin: https://defense.dalctf2026.com" \
  -H "Referer: https://defense.dalctf2026.com/" \
  -H "User-Agent: $UA" \
  --data @sub1.json
# {"message":"Submission queued for verification","submissionId":"6a253a9b...","filesAccepted":1}

Then poll GET /api/submissions/<id> and watch the status walk through building → testing → attacking → completed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "result": {
    "success": true,
    "allPatched": true,
    "testsPassed": true,
    "testsTotal": 4,
    "attacks": [
      { "attackId": "attack_1", "patched": true },
      { "attackId": "attack_2", "patched": true }
    ],
    "finalFlag": "dalctf{3asy_P3asy_SQL_1nj3ction}"
  },
  "status": "completed"
}

🚩 dalctf{3asy_P3asy_SQL_1nj3ction}


Part 2 — Render & Plunder (SSTI + IDOR)

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.

This one is a step up: a Flask app with two independent vulnerabilities, and both attacks must be patched to get the flag.

The vulnerable code

The editable file is server.py. The relevant parts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
from flask import Flask, request, jsonify
from jinja2 import Template
import jwt

JWT_SECRET = os.environ["JWT_SECRET"]
USERS = json.loads(os.environ["USERS_JSON"])
ID_TO_USER = {v["id"]: k for k, v in USERS.items()}

def _decode_token(token: str) -> dict:
    return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])

def _require_auth():
    token = request.headers.get("Authorization", "").replace("Bearer ", "")
    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

    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

    username = ID_TO_USER.get(user_id)
    if not username:
        return jsonify({"error": "User not found"}), 404

    data = request.get_json(silent=True) or {}
    USERS[username]["profile"]["bio"] = data.get("bio", "")
    return jsonify({"message": "Profile updated", "bio": data.get("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")

    template_source = f"Hello {name}! Here is your report for user {payload.get('username', '?')}."
    result = Template(template_source).render()
    return jsonify({"report": result}), 200

The authentication itself is fine — it’s HS256 with a server-side secret, and it correctly pins algorithms=["HS256"] (so no alg:none confusion). The bugs are in what the code does after you’re authenticated.

Vulnerability 1 — Server-Side Template Injection (Attack 1)

Look at /render:

1
2
3
name = data.get("name", "User")
template_source = f"Hello {name}! Here is your report for user {...}."
result = Template(template_source).render()

The user-controlled name isn’t passed as data into a template — it’s baked into the template source string itself, which is then compiled and rendered by Jinja2. That’s the cardinal sin of templating. Jinja2 will happily evaluate any {{ ... }} expression the attacker smuggles in via name.

A probe like {{7*7}} comes back as 49, confirming SSTI. From there it’s a short hop to RCE in Jinja2 via the object/MRO-walking payloads, e.g.:

1
{ "name": "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}" }

The fundamental error: mixing untrusted input into the template program rather than into the template’s data context.

The fix

Define a static template with placeholders and pass the dynamic values as render context variables. Jinja2 then treats name purely as data to substitute — even {{7*7}} is printed literally as the seven characters {{7*7}}, never evaluated:

1
2
template_source = "Hello {{ name }}! Here is your report for user {{ user }}."
result = Template(template_source).render(name=name, user=payload.get('username', '?'))

The output for a normal name is identical to before, so functionality is preserved; the difference is that the user’s bytes can no longer become template code.

Note: because the template source is now a fixed constant, autoescaping isn’t even needed to stop code execution — the attacker simply has no way to inject into the program text anymore. (For raw-HTML sinks you’d still want autoescape on, but here the grader checks for template execution, which is fully shut.)

Vulnerability 2 — Insecure Direct Object Reference / IDOR (Attack 2)

Now the /api/users/<user_id>/data endpoints (both GET and PUT). They:

  1. Verify the JWT is valid (_require_auth), then
  2. Look up user_id straight from the URL path and return/modify that user’s data.

There is no check that the authenticated user actually owns user_id. Any logged-in user can read — or, via PUT, overwrite the bio ofany other user simply by changing the number in the URL. The token even carries the caller’s identity (payload["user_id"]), but the code never compares it to the requested user_id. Classic IDOR: authentication present, authorization missing.

Attack: log in as a low-privilege test user, grab the token, then:

1
2
GET /api/users/<someone_elses_id>/data   →  leaks their profile
PUT /api/users/<someone_elses_id>/data   →  tampers with their profile

The fix

Enforce object-level authorization: the caller may only touch their own record. Compare the user_id from the verified token against the user_id in the path, and reject mismatches with 403 Forbidden. I added the same guard to both GET and PUT, right after authentication:

1
2
if str(payload.get("user_id")) != str(user_id):
    return jsonify({"error": "Forbidden"}), 403

I wrapped both sides in str(...) because the path parameter is always a string while the token’s user_id may be numeric (it comes from the JSON user store) — comparing without normalizing could let a mismatch slip through or wrongly block the legit user. With the guard in place, a user fetching/updating their own id still gets 200 (positive test passes), but reaching for anyone else’s id now gets 403. Authorization restored.

The full patched server.py (changed regions)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@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

    if str(payload.get("user_id")) != str(user_id):          # <-- IDOR guard
        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

    if str(payload.get("user_id")) != str(user_id):          # <-- IDOR guard
        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")

    template_source = "Hello {{ name }}! Here is your report for user {{ user }}."   # <-- SSTI fix
    result = Template(template_source).render(name=name, user=payload.get('username', '?'))
    return jsonify({"report": result}), 200

Submitting

1
2
3
4
5
6
7
8
9
python3 -c "import json;print(json.dumps({
  'challengeId':'6a239b902d4bae0574a21483',
  'modifiedFiles':[{'path':'server.py','content':open('server_patched.py').read()}],
  'attackId':None}))" > sub2.json

curl -s -X POST "https://defense.dalctf2026.com/api/submissions" \
  -H "Content-Type: application/json" -H "Origin: https://defense.dalctf2026.com" \
  -H "Referer: https://defense.dalctf2026.com/" -H "User-Agent: $UA" \
  --data @sub2.json

Polling the result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "result": {
    "success": true,
    "allPatched": true,
    "testsPassed": true,
    "testsTotal": 4,
    "attacks": [
      { "attackId": "attack_1", "patched": true },
      { "attackId": "attack_2", "patched": true }
    ],
    "finalFlag": "dalctf{W1ll_Y0u_ID0R_Moi_SSTI?}"
  },
  "status": "completed"
}

🚩 dalctf{W1ll_Y0u_ID0R_Moi_SSTI?}


Takeaways

These two challenges are a neat tour of “data vs. code/identity confusion,” the theme underlying a huge share of web bugs:

BugRoot causeThe one-line principle
SQLiUser input concatenated into a SQL query stringSend query and data separately → parameterized queries
SSTIUser input concatenated into a template programPass input as render context, never into the template source
IDORAuthenticated ≠ authorized; object id taken from the URL with no ownership checkAlways check the caller owns the object before read/write

A few things the Defense format hammers home that attack-only CTFs don’t:

  • Fix the root cause, not the symptom. Blacklisting ' or {{ would be brittle and would likely fail the grader’s “advanced” variant. Parameterization and proper context binding kill the class of bug.
  • Don’t break the app. Every challenge ran positive functionality tests (testsTotal: 4) alongside the attacks. A fix that 401s/403s everyone defeats the purpose. The winning patches preserve the exact behavior for legitimate users.
  • Patch every layer. With flagPolicy: "all_attacks", Render & Plunder gave nothing until both SSTI and IDOR were sealed — true to the Chakravyuh theme.

Two layers of the formation breached. GG to ASmilyBun for a clean, instructive defense set. 🛡️