# Pwn2Own Whitepaper: SpanMeNot Submitted by Seunghyun Lee (@0x10n) of KAIST Hacking Lab ## Target Google Chrome Renderer Only RCE > Note that all Chromium-based browsers are vulnerable, but only one Double Tap Addon is allowed for a single participant in Pwn2Own. ## Hashes ```text $ sha256sum exp.html poc.html poc_no_transfer.html 64c1e9938cedc5d81401251f0cabfe52c3a965256ed19e9d9564687fe9523171 exp.html 7625cb021fc1cbe511cde17207370d3286aaf9eddb7a5a83ce40caaedcfda390 poc.html de5cc6dc7d5a3490d12e7101d84a0cfdc12bbf3b4407ee00c97c9454ae0e86c5 poc_no_transfer.html ``` ## Repro (Minimal PoC) 1. Run a webserver to serve the given `poc.html` file (e.g. `python3 -m http.server -b 127.0.0.1 8000`) 2. Start Chrome or Edge 3. Assert that hardware accelerated compositing is enabled. 1. Visit `chrome://gpu` 2. Check that `Compositing: Hardware accelerated` 3. If not, kill all `chrome.exe` or `msedge.exe` process and retry from step 2 with `--ignore-gpu-blocklist` flag added (if this still fails, hardware is likely ancient or is lacking proper graphics driver support) 4. Browse to `http://127.0.0.1:8000/poc.html` Result should be an immediate crash with `STATUS_ACCESS_VIOLATION`. ## Repro (RCE) 1. Run a webserver to serve the given `exp.html` file (e.g. `python3 -m http.server -b 127.0.0.1 8000`) 2. Start Chrome or Edge with `--no-sandbox` flag 3. Assert that hardware accelerated compositing is enabled. 1. Visit `chrome://gpu` 2. Check that `Compositing: Hardware accelerated` 3. If not, kill all `chrome.exe` or `msedge.exe` process and retry from step 2 with `--ignore-gpu-blocklist` flag added (if this still fails, hardware is likely ancient or is lacking proper graphics driver support) 4. Browse to `http://127.0.0.1:8000/exp.html` Result should be a command prompt opening with arbitrary commands executed (`echo`ing of some ASCII art). > Note that with all default options (Windows 11 VM in VMware Workstation), "Accelerate 3D graphics" option will be enabled by default. This uses VMware SVGA 3D display adapter, which by default supports hardware accelerated compositing for Google Chrome. Microsoft Edge for some unknown reason still blocklists this, so `--ignore-gpu-blocklist` is required. > > Without "Accelerate 3D graphics", both Chrome and Edge requires `--ignore-gpu-blocklist`. ## Bug / Root Cause Analysis The `copyTo()` method of the `VideoFrame` object supports asynchronous copying of the contents of the VideoFrame to a user-supplied `ArrayBuffer`, `DataView` or `TypedArray`. This is implemented in `VideoFrame::copyTo()`: ```cpp ScriptPromise VideoFrame::copyTo(ScriptState* script_state, const AllowSharedBufferSource* destination, VideoFrameCopyToOptions* options, ExceptionState& exception_state) { // ... // Validate destination buffer. auto buffer = AsSpan(destination); if (!buffer.data()) { exception_state.ThrowTypeError("destination is detached."); return ScriptPromise(); } if (buffer.size() < dest_layout.Size()) { exception_state.ThrowTypeError("destination is not large enough."); return ScriptPromise(); } if (RuntimeEnabledFeatures::WebCodecsCopyToRGBEnabled() && dest_layout.Format() != local_frame->format() && media::IsRGB(dest_layout.Format())) { ConvertAndCopyToRGB(local_frame, src_rect, dest_layout, buffer); } else if (local_frame->IsMappable()) { CopyMappablePlanes(*local_frame, src_rect, dest_layout, buffer); } else if (local_frame->HasGpuMemoryBuffer()) { auto mapped_frame = media::ConvertToMemoryMappedFrame(local_frame); if (!mapped_frame) { exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError, "Failed to read VideoFrame data."); return ScriptPromise(); } CopyMappablePlanes(*mapped_frame, src_rect, dest_layout, buffer); } else { DCHECK(local_frame->HasTextures()); if (auto* resolver = CopyToAsync(script_state, local_frame, src_rect, // [!] target code destination, dest_layout)) { return resolver->Promise(); } if (!CopyTexturablePlanes(*local_frame, src_rect, dest_layout, buffer)) { exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError, "Failed to read VideoFrame data."); return ScriptPromise(); } } auto result = ConvertLayout(dest_layout); return ScriptPromise::Cast( script_state, ToV8Traits>::ToV8(script_state, result)); } ``` After passing a lot of if statements, we reach `CopyToAsync()`: ```cpp ScriptPromiseResolver* VideoFrame::CopyToAsync( ScriptState* script_state, scoped_refptr frame, gfx::Rect src_rect, const AllowSharedBufferSource* destination, const VideoFrameLayout& dest_layout) { auto* background_readback = BackgroundReadback::From(*ExecutionContext::From(script_state)); if (!background_readback) return nullptr; ArrayBufferContents contents = PinArrayBufferContent(destination); // [!] (improper) ownership acquisition if (!contents.DataLength()) return nullptr; auto* resolver = MakeGarbageCollected(script_state); auto readback_done_handler = [](ArrayBufferContents contents, ScriptPromiseResolver* resolver, VideoFrameLayout dest_layout, bool success) { auto* script_state = resolver->GetScriptState(); if (success && script_state->ContextIsValid()) { resolver->Resolve(ConvertLayout(dest_layout)); } else { resolver->Reject(); } }; auto done_cb = WTF::BindOnce(readback_done_handler, std::move(contents), WrapPersistent(resolver), dest_layout); auto buffer = AsSpan(destination); // [!] acquires a base::span of the buffer background_readback->ReadbackTextureBackedFrameToBuffer( // [!] dispatched into another thread std::move(frame), src_rect, dest_layout, buffer, std::move(done_cb)); return resolver; } ``` The copy is dispatched to another thread, with the promise resolved asynchronously. To keep the buffer alive, the underlying `ArrayBufferContents` is pinned via `PinArrayBufferContent()` and is bound to the resolver callback function `done_cb`: ```cpp ArrayBufferContents PinArrayBufferContent( const AllowSharedBufferSource* buffer_union) { ArrayBufferContents result; switch (buffer_union->GetContentType()) { case AllowSharedBufferSource::ContentType::kArrayBufferAllowShared: { // ... } case AllowSharedBufferSource::ContentType::kArrayBufferViewAllowShared: { auto* view = buffer_union->GetAsArrayBufferViewAllowShared().Get(); if (view && !view->IsDetached()) { if (view->IsShared()) { view->BufferShared()->Content()->ShareWith(result); } else { view->buffer()->ShareNonSharedForInternalUse(result); // [!] non-shared, non-detached DataView } } return result; } } } bool DOMArrayBuffer::ShareNonSharedForInternalUse(ArrayBufferContents& result) { if (!Content()->BackingStore()) { result.Detach(); return false; } Content()->ShareNonSharedForInternalUse(result); return true; } void ArrayBufferContents::ShareNonSharedForInternalUse( ArrayBufferContents& other) { DCHECK(!IsShared()); DCHECK(!other.Data()); DCHECK(Data()); other.backing_store_ = backing_store_; // [!] std::shared_ptr ref to backing store } ``` However, this only pins the reference to `v8::BackingStore`. The backing store structure is kept alive, but this is insufficient to keep the underlying buffer alive. Thus the extracted `base::span` may be stale at the moment copy operation is carried out. The actual copy to buffer is done in the following function: ```cpp void GLHelper::CopyTextureToImpl::ReadbackDone(Request* finished_request) { TRACE_EVENT0("gpu.capture", "GLHelper::CopyTextureToImpl::CheckReadbackFramebufferComplete"); finished_request->done = true; FinishRequestHelper finish_request_helper; // We process transfer requests in the order they were received, regardless // of the order we get the callbacks in. while (!request_queue_.empty()) { Request* request = request_queue_.front(); if (!request->done) { break; } bool result = false; if (request->buffer != 0) { gl_->BindBuffer(GL_PIXEL_PACK_TRANSFER_BUFFER_CHROMIUM, request->buffer); unsigned char* src = static_cast(gl_->MapBufferCHROMIUM( GL_PIXEL_PACK_TRANSFER_BUFFER_CHROMIUM, GL_READ_ONLY)); if (src) { result = true; int dst_stride = base::saturated_cast(request->row_stride_bytes); int src_stride = base::saturated_cast(request->bytes_per_pixel * request->size.width()); size_t bytes_to_copy = std::min(request->row_stride_bytes, request->bytes_per_row); unsigned char* dst = request->pixels; if (request->flip_y && request->size.height() > 1) { dst += dst_stride * (request->size.height() - 1); dst_stride = -dst_stride; } for (int y = 0; y < request->size.height(); y++) { memcpy(dst, src, bytes_to_copy); // [!] copying to buffer dst += dst_stride; src += src_stride; } gl_->UnmapBufferCHROMIUM(GL_PIXEL_PACK_TRANSFER_BUFFER_CHROMIUM); } gl_->BindBuffer(GL_PIXEL_PACK_TRANSFER_BUFFER_CHROMIUM, 0); } FinishRequest(request, result, &finish_request_helper); } } ``` To sum up, the main JS thread may race against the background readback thread to reallocate the underlying buffer via `ArrayBuffer.prototype.transfer()` before the actual copy is made, resulting in a use-after-free write on the underlying buffer pointed by the `ArrayBuffer`, `DataView` or `TypedArray` passed to `VideoFrame.copyTo()`. > Note that UAF is not the only problem with modifications on the underlying buffer from another thread. The main JS thread may attempt to sort a TypedArray on this buffer at the same time, where optimizations on non-shared buffers will [result in `std::sort()` to be used](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/runtime/runtime-typedarray.cc;l=100). This function is well known to be unsafe on data races similar to the [qsort() issue reported by Qualys](https://blog.qualys.com/vulnerabilities-threat-research/2024/01/30/qualys-tru-discovers-important-vulnerabilities-in-gnu-c-librarys-syslog). > > However, also note that for the PoC given, debugging shows that the above code (`ReadbackDone()`) is in fact called in the main JS thread. It may be the case that the background readback thread dispatches the actual copy operation back to the original context (main JS thread). This is not a blocker in exploiting the data race - see the Affected Version section for more information. Back to the starting point, to trigger the bug we must pass quite a lot of if statements to reach `copyToAsync()` inside `copyTo()`. Analysis shows that `media::VideoFrame::StorageType::STORAGE_OPAQUE` is the only valid VideoFrame storage type that can reach the asynchronous copy. Although there are a lot of platform-specific codes that can create such VideoFrame, `VideoFrame::WrapNativeTextures` seems the most promising: ```cpp scoped_refptr VideoFrame::WrapNativeTextures( VideoPixelFormat format, const gpu::MailboxHolder (&mailbox_holders)[kMaxPlanes], ReleaseMailboxCB mailbox_holder_release_cb, const gfx::Size& coded_size, const gfx::Rect& visible_rect, const gfx::Size& natural_size, base::TimeDelta timestamp) { // ... const StorageType storage = STORAGE_OPAQUE; // [!] desired StorageType // ... scoped_refptr frame = new VideoFrame(*layout, storage, visible_rect, natural_size, timestamp); // [!] VideoFrame with desired StorageType // ... return frame; } ``` From a lot of call sites, `VideoFrame::Create()` seems the most promising: ```cpp VideoFrame* VideoFrame::Create(ScriptState* script_state, const V8CanvasImageSource* source, const VideoFrameInit* init, ExceptionState& exception_state) { // ... // Special case