CVE-2026-113101
Overview
Fix not yet public
Original Bug Report
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.
-
Start the file server. It serves files only, never launches or controls Chrome, and writes nothing outside its own directory.
python3 server.py 8731 -
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/ -
Read the server’s stdout;
observed-stable.logis my capture of it. On 152.0.7977.83 it printsA1throughA8: a plaindata:iframe (nosandboxattribute) createsblob:null/<uuid>, navigates to it, then builds anabout:blankchild and anabout:srcdocgrandchild inside it — the frame shape Part B forges an origin on. -
chrome://process-internals/#web-contentsin a second tab shows theblob: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.
-
git apply measurement.patchonsrc@7716e48, thenautoninja -C out/cs content_shell. The patch touchescontent/andthird_party/blink/, sochromeis affected identically. -
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 -
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/ -
observed-instrumented.logis 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)