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:
| |
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.
| |
Picking the signal out of the minified noise, the API surface is:
| |
And critically, the submission payload shape, pulled straight out of the bundle:
| |
So a submission is:
| |
Listing the challenges:
| |
| |
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
- Cloudflare bans non-browser clients. My first submission with Python’s
urllibgotHTTP 403, error code 1010— Cloudflare’s “banned by browser signature” response. The fix was to send a normal browserUser-AgentplusOrigin/Refererheaders. After that,curlsailed through. challengeIdmust be the Mongo_id, not the human slug. SubmittingchallengeId: "secure-the-login"returned{"error":"Failed to submit code"}. SubmittingchallengeId: "6a239b9b2d4bae0574a2148b"returned{"message":"Submission queued for verification"}. ThechallengeIdslug 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:
| |
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'='1makes theWHEREclause 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:
| |
produces:
| |
'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):
| |
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:
| |
Then poll GET /api/submissions/<id> and watch the status walk through building → testing → attacking → 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:
| |
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:
| |
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.:
| |
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:
| |
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:
- Verify the JWT is valid (
_require_auth), then - Look up
user_idstraight 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 of — any 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:
| |
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:
| |
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)
| |
Submitting
| |
Polling the result:
| |
🚩 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:
| Bug | Root cause | The one-line principle |
|---|---|---|
| SQLi | User input concatenated into a SQL query string | Send query and data separately → parameterized queries |
| SSTI | User input concatenated into a template program | Pass input as render context, never into the template source |
| IDOR | Authenticated ≠ authorized; object id taken from the URL with no ownership check | Always 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. 🛡️