High chrome UAF 📄 Reporter bug report

Overview

High
Severity
—
CVSS
No
Exploited ITW
Embargoed
Fix Status
ImpactUse after free in SiteIsolation
DescriptionUse after free in SiteIsolation
ComponentSiteIsolation
Sub-componentInternals>Sandbox>SiteIsolation
Bug ClassUAF
Tracker560749221
CISA KEVNot listed
CreditedQuyen Son at VinFast (@zer0qs)
Disclosed2026-10-20

Fix not yet public

No public source fix for this bug has been identified on the main branch yet — it is embargoed or not yet disclosed. Root-cause analysis is withheld until the fix commit is available.

Original Bug Report

reported by tr...@gmail.com

Browser process serves arbitrary local files to a blob:null-locked renderer via forged opaque-origin precursor

VULNERABILITY DETAILS

A renderer whose ProcessLock is blob:null/<uuid> can make the browser process open and stream any local file. That lock is obtainable by ordinary web content; turning it into file access takes one forged field in one IPC. Four defects compose.

Defect 1 — the jail check is skipped for blob:null locks. ChildProcessSecurityPolicyImpl::PerformJailAndCitadelChecks (.../cpsp/child_process_security_policy_impl.cc:2694-2702) returns true for any requested precursor when the lock is a blob:null/ URL:

// TODO(acolwell, nasko): https://crbug.com/1029092: Ensure the precursor
// of opaque origins matches the renderer's origin lock.
if (url_is_precursor_of_opaque_origin) {
  const GURL& lock_url = actual_process_lock.GetProcessLockURL();
  // SitePerProcessBrowserTest.TwoBlobURLsWithNullOriginDontShareProcess.
  if (lock_url.SchemeIsBlob() &&
      base::StartsWith(lock_url.path(), "null/")) {
    return true;
  }

The justification is one browser test name.

Defect 2 — that carve-out neutralises the check crbug.com/1029092 asked for. Item 1.2 of that bug asked that the if (!result.opaque()) guard around the GetOriginForURLLoaderFactory CHECK be dropped so precursors would be verified. It was dropped: content/browser/renderer_host/navigation_request.cc:10046-10054 applies the CHECK to opaque origins, under a comment at :10043-10045 calling it “security-critical … it should never be reverted.” I instrumented that site; it runs, and it passes on a forged precursor (block [2]):

[MEAS9] security-critical CHECK url=about:srcdoc origin=null [... derived from file://]
        precursor=file:// proc=7 lock={ blob:null/c4b2d2e4-... } CanAccessOrigin=PASS

The enforcement Chromium intends is present and reached; Defect 1 makes it answer true.

Defect 3 — effective_scheme comes from the precursor and grants file access. content/browser/renderer_host/render_frame_host_impl.cc:13207 sets effective_scheme to origin.GetTupleOrPrecursorTupleIfOpaque().scheme(), and :13288-13300 hands the document a FileURLLoaderFactory. No path restriction applies: ContentBrowserClient::IsFileAccessAllowed returns true (content/public/browser/content_browser_client.cc:460) and Chrome’s override reaches IsAccessAllowedInternal (chrome/browser/net/chrome_network_delegate.cc:142), whose desktop branch is also return true. The lift is keyed on the precursor, not on file: :13225 grants CreateWebUIURLLoaderFactory(this, effective_scheme, {}) for a chrome:// precursor, where empty allowed_hosts means every host (web_ui_url_loader_factory.cc:357).

Defect 4 — the equality check that would catch the lie is conditional. render_frame_host_impl.cc:16133-16135 rejects a commit whose params->origin differs from commit_params().origin_to_commit, but only if (navigation_request && ...). The synchronous about:blank commit of a new iframe has no NavigationRequest, so the comparison is skipped and the renderer’s value is taken verbatim: DidNavigate (:5407) stores it and AddCommittedOrigin (:16750) records it, after which HostsOrigin() accepts the forged file:// precursor for the life of the process (block [4]). That record is what the July 2026 hardening leans on: ipc_utils.cc:84-100 removed the error-page and MHTML exemptions because the origin “has been recorded by … AddCommittedOrigin and the HostsOrigin() check below will accept it”, warning against letting “the renderer claim an opaque initiator with an arbitrary precursor”. This chain writes one in.

Measured versus inferred. Measured with browser-process logging: the non-sandboxed lock, the carve-out firing, the CanAccessOrigin verdict above, the poisoned committed_origins_ entry, effective_scheme=file, the factory hand-off, and the browser serving seven named files. Measured on unpatched Chrome Stable 152: every structural step of the frame chain (block A). Not done: I built content_shell, not chrome; the grant path is shared content/ code and the one embedder-specific call is the IsFileAccessAllowed pair above, which I read rather than ran. Not tested on ChromeOS/Android, where IsAccessAllowedInternal applies an allowlist.

Why MojoJS is not sufficient. The forged field is params->origin in DidCommitProvisionalLoadParams, built in C++ by RenderFrameImpl::MakeDidCommitProvisionalLoadParams (content/renderer/render_frame_impl.cc:5203) from frame_document.GetSecurityOrigin() and sent on the associated NavigationClient endpoint held by RenderFrameImpl. Script has no handle to that endpoint and cannot substitute a commit reply, so MojoJS cannot express this lie. measurement.patch therefore simulates the compromised renderer. No browser-process check is weakened by it: every browser-side hunk is LOG(ERROR) only, plus one read-only extra CanAccessOrigin call so its verdict can be printed.

IMPACT

A compromised web renderer reads any file the user can read, including data belonging to other origins. The consequential instance is measured, not extrapolated: the browser process opened and streamed ~/Library/Application Support/Google/Chrome/Default/History — 59 015 168 bytes of SQLite format 3, whose urls table holds url, title, visit_count and last_visit_time in plaintext — to a process locked to blob:null/<uuid>, along with that profile’s Cookies, Login Data, Local State, ~/.ssh/known_hosts and /private/etc/passwd (block [6]).

One limit, stated before it is found: in Cookies and Login Data the secret column is AES-GCM encrypted under a Keychain-held key (509 and 1191 v10 records in the files above) that this bug does not reach, so those two yield the cross-origin inventory — cookie host, name, path, flags; credential site and username — not the secrets. History is not encrypted, nor is any other plaintext file in the user’s home. No interaction beyond visiting the attacker’s page is needed, and the read is immediate.

VERSION

Chrome Version: 152.0.7977.83 stable   (block A, unpatched)
                src@7716e48 content_shell, instrumented (blocks 1-8)
Operating System: macOS 26.6.2, arm64 (Apple silicon)

REPRODUCTION CASE

Part A runs on released Chrome Stable and needs no command-line flags. Part B needs a build with measurement.patch, for the reason under Why MojoJS is not sufficient.

Part A — the frame chain, on unmodified Chrome Stable.

  1. Start the file server. It serves files only, never launches or controls Chrome, and writes nothing outside its own directory.

    python3 server.py 8731
    
  2. Launch Chrome yourself and open the page:

    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
      --user-data-dir=/tmp/blobnull-poc --no-first-run \
      http://127.0.0.1:8731/
    
  3. Read the server’s stdout; observed-stable.log is my capture of it. On 152.0.7977.83 it prints A1 through A8: a plain data: iframe (no sandbox attribute) creates blob:null/<uuid>, navigates to it, then builds an about:blank child and an about:srcdoc grandchild inside it — the frame shape Part B forges an origin on.

  4. chrome://process-internals/#web-contents in a second tab shows the blob:null/<uuid> process lock for that subtree. Ctrl-C the server when done; the only state to remove is /tmp/blobnull-poc.

Part B — the forgery and the file read, on an instrumented build.

  1. git apply measurement.patch on src@7716e48, then autoninja -C out/cs content_shell. The patch touches content/ and third_party/blink/, so chrome is affected identically.

  2. Start the second server. Its frame names are the hooks the patch keys on, and it fills in your own home directory, so no editing is needed:

    python3 serve_forge.py 8742
    
  3. Open it in the build yourself:

    "out/cs/Content Shell.app/Contents/MacOS/Content Shell" \
      --site-per-process --enable-logging --log-file=/tmp/forge.log \
      http://127.0.0.1:8742/
    
  4. observed-instrumented.log is that run — the server’s stdout plus /tmp/forge.log — reduced to the decisive lines.

Both polarities are in that log. Block [7]: a non-existent path is refused, so the positive results are not a stuck probe. Block [8]: the same forgery on a navigation that does have a NavigationRequest is caught at render_frame_host_impl.cc:16135, killing the renderer with bad_message 329 — the rig incriminates itself wherever the existing check applies.

Suggested fix. Delete the blob:null/ branch at child_process_security_policy_impl.cc:2698-2702, giving SitePerProcessBrowserTest.TwoBlobURLsWithNullOriginDontShareProcess whatever narrower allowance it needs; the kCanCommitNewOrigin CHECK at navigation_request.cc:10051 then does what crbug.com/1029092 item 1.2 intended. Separately, dropping the navigation_request && condition at render_frame_host_impl.cc:16133 — comparing params->origin on synchronous about:blank commits too — removes the means of writing a forged precursor into committed_origins_.

CREDIT INFORMATION

Reporter credit: Quyền Sơn (@zer0qs)
View on issue tracker