CVE-2026-13952
Overview
Files Changed
third_party/blink/renderer/core/frame/animation_frame_timing_monitor.ccthird_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.htmlthird_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js
Patch
From 522dca02c116c2b9bc7894e1998cc05c22a75389 Mon Sep 17 00:00:00 2001 From: Noam Rosenthal <[email protected]> Date: Wed, 20 May 2026 05:48:54 -0700 Subject: [PATCH] LoAF: don't expose function names of double-bound functions Bug: 513401808 Change-Id: I3647aa873300b5e7a40d9f5059fad6994efe3e19 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7864061 Commit-Queue: Noam Rosenthal <[email protected]> Reviewed-by: Yoav Weiss (@Shopify) <[email protected]> Cr-Commit-Position: refs/heads/main@{#1633530} --- diff --git a/third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc b/third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc index 049c918..7194371 100644 --- a/third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc +++ b/third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc @@ -738,8 +738,11 @@ return ScriptTimingInfo::ScriptSourceLocation(); } - v8::Local<v8::Value> bound = value.As<v8::Function>()->GetBoundFunction(); - if (!bound.IsEmpty() && bound->IsFunction()) { + while (value->IsFunction()) { + v8::Local<v8::Value> bound = value.As<v8::Function>()->GetBoundFunction(); + if (bound.IsEmpty() || !bound->IsFunction()) { + break; + } value = bound; } diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html new file mode 100644 index 0000000..772b0b2d --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html @@ -0,0 +1,38 @@ +<!DOCTYPE HTML> +<meta charset=utf-8> +<title>Long Animation Frame Timing: double-bound function from opaque script sanitization</title> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/common/get-host-info.sub.js"></script> +<script src="resources/utils.js"></script> + +<body> +<h1>Long Animation Frame: double-bound function from opaque script sanitization</h1> +<div id="log"></div> +<script> +const {REMOTE_ORIGIN} = get_host_info(); + +promise_test(async t => { + const scriptURL = new URL("long-animation-frame/resources/double-bound-script.js", REMOTE_ORIGIN); + const script = document.createElement("script"); + script.src = scriptURL.href; + document.body.appendChild(script); + t.add_cleanup(() => script.remove()); + + // Wait for the script to load + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + }); + + const [entry, scriptEntry] = await expect_long_frame_with_script((t, busy_wait) => { + requestAnimationFrame(window.doubleBound.bind(null, busy_wait)); + }, script => script.invoker === "FrameRequestCallback", t); + + assert_true(!!entry, "LoAF entry should be observed"); + assert_true(!!scriptEntry, "Script entry should be observed"); + assert_equals(scriptEntry.sourceFunctionName, "", "Opaque double-bound function name should be sanitized"); +}, "Opaque cross-origin double-bound function name should be sanitized"); +</script> +</body> diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js new file mode 100644 index 0000000..412ab9b --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js @@ -0,0 +1,4 @@ +function sensitiveFunction(busy_wait) { + busy_wait(); +} +window.doubleBound = sensitiveFunction.bind(null).bind(null);
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html
new file mode 100644
index 0000000..772b0b2d
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-bound-function-opaque-sanitization.html
@@ -0,0 +1,38 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: double-bound function from opaque script sanitization</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<script src="resources/utils.js"></script>
+
+<body>
+<h1>Long Animation Frame: double-bound function from opaque script sanitization</h1>
+<div id="log"></div>
+<script>
+const {REMOTE_ORIGIN} = get_host_info();
+
+promise_test(async t => {
+ const scriptURL = new URL("long-animation-frame/resources/double-bound-script.js", REMOTE_ORIGIN);
+ const script = document.createElement("script");
+ script.src = scriptURL.href;
+ document.body.appendChild(script);
+ t.add_cleanup(() => script.remove());
+
+ // Wait for the script to load
+ await new Promise((resolve, reject) => {
+ script.onload = resolve;
+ script.onerror = reject;
+ });
+
+ const [entry, scriptEntry] = await expect_long_frame_with_script((t, busy_wait) => {
+ requestAnimationFrame(window.doubleBound.bind(null, busy_wait));
+ }, script => script.invoker === "FrameRequestCallback", t);
+
+ assert_true(!!entry, "LoAF entry should be observed");
+ assert_true(!!scriptEntry, "Script entry should be observed");
+ assert_equals(scriptEntry.sourceFunctionName, "", "Opaque double-bound function name should be sanitized");
+}, "Opaque cross-origin double-bound function name should be sanitized");
+</script>
+</body>
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js
new file mode 100644
index 0000000..412ab9b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/double-bound-script.js
@@ -0,0 +1,4 @@
+function sensitiveFunction(busy_wait) {
+ busy_wait();
+}
+window.doubleBound = sensitiveFunction.bind(null).bind(null);
Original Bug Report
Cross-origin function name leak in Long Animation Frame (LoAF) API
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The Long Animation Frame (LoAF) API incorrectly identifies the script origin for callbacks that have been bound multiple times via Function.prototype.bind. This allows a page to discover sensitive function names from cross-origin scripts that should remain opaque, bypassing intended cross-origin information leak protections.
Affected files:
third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc
Estimated timestamp from git blame: 2023-12-01
Summary
A potential vulnerability in the Long Animation Frame (LoAF) API allows a page to discover the sourceFunctionName of callbacks defined in no-CORS (opaque) cross-origin scripts. The issue occurs because the code responsible for sanitizing sensitive information from LoAF entries only unwraps a single level of Function.prototype.bind when checking for script opaqueness.
Root Cause
In third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc, the function CaptureScriptSourceLocation extracts function names and locations for LoAF entries. To handle bound functions, it attempts to unwrap them using v8::Function::GetBoundFunction():
// third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc:741
v8::Local<v8::Value> bound = value.As<v8::Function>()->GetBoundFunction();
if (!bound.IsEmpty() && bound->IsFunction()) {
value = bound;
}
This implementation only unwraps exactly one level of binding. If a callback is bound two or more times (e.g., func.bind(x).bind(y)), the resulting value remains a v8::JSBoundFunction rather than the underlying v8::JSFunction defined in the script.
When CaptureScriptSourceLocation calls v8::Function::GetScriptOrigin() on a v8::JSBoundFunction, the V8 API returns a default-constructed v8::ScriptOrigin because it is not a v8::JSFunction. In V8, a default-constructed v8::ScriptOrigin has its IsOpaque() property set to false by default.
This causes the security check in Blink to be bypassed:
// third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc:758
// Opaque scripts don't report character index/function name.
if (origin.Options().IsOpaque()) {
return source_location;
}
Since IsOpaque() evaluates to false, the code proceeds to call function->GetName(). Unlike GetScriptOrigin(), v8::Function::GetName() correctly traverses the binding chain and resolves the name of the underlying function. This results in the source-level function name being leaked to the LoAF script entry.
Potential Impact
An attacker could potentially learn the source-level function names of callbacks defined in third-party, no-CORS scripts. Function names can reveal sensitive information about the application state or user status (e.g., onUserLoggedIn, handleSensitiveAction). This is a cross-origin information leak (XS-Leak) from a script that the browser is intended to keep opaque.
Suggested Reproduction Steps
- Host a script
sdk.json Origin B that defines a function with a sensitive name. - In
sdk.js, bind that function twice:const callback = targetFunction.bind(null).bind(null);. - Register the double-bound function as an event listener.
- On Origin A, embed the script without CORS:
<script src="https://origin-B.com/sdk.js"></script>. - On Origin A, observe LoAF entries using a
PerformanceObserver. - Trigger the callback on the page. The
PerformanceScriptTimingentry in the LoAF report will likely expose thesourceFunctionName, revealing the private name from the opaque script.
Suggested Fix
Modify CaptureScriptSourceLocation in third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc to recursively unwrap bound functions using a loop, similar to how it is handled in the V8 inspector:
while (value->IsFunction()) {
v8::Local<v8::Value> bound = value.As<v8::Function>()->GetBoundFunction();
if (bound.IsEmpty() || !bound->IsFunction()) {
break;
}
value = bound;
}
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.