CVE-2026-113337
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/scroll/programmatic_scroll_animator.cc |
modified | |
last_granularity_third_party/blink/renderer/core/scroll/scroll_animator.cc |
modified | |
ifthird_party/blink/renderer/core/scroll/scroll_animator.cc |
modified | |
whilethird_party/blink/web_tests/cssom/scroll-promise-after-gc.html |
modified | |
ifthird_party/blink/web_tests/cssom/scroll-promise-after-gc.html |
modified |
Files Changed
third_party/blink/renderer/core/scroll/programmatic_scroll_animator.ccthird_party/blink/renderer/core/scroll/scroll_animator.ccthird_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txtthird_party/blink/web_tests/cssom/scroll-promise-after-gc.html
Patch
From a4f51416619e3dd7468c5aaf6b488527dbfc38db Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Thu, 17 Sep 2026 09:14:22 -0700 Subject: [PATCH] Avoid running scroll animator callbacks during garbage collection ProgrammaticScrollAnimator::Dispose() and ScrollAnimator::~ScrollAnimator() previously executed the stored on_finish_ callback during object cleanup. Invoking this callback within a pre-finalizer or destructor is invalid because its execution can trigger allocations and enqueue events while garbage collection is active. ProgrammaticScrollAnimator::Dispose() now resets on_finish_ without running it. Releasing the callback drops the bound ActiveScrollTracker, which defers promise resolution to a posted task. ScrollAnimator's destructor is defaulted for consistent lifetime management, and scroll-promise-after-gc.html is extended to cover the boundary limit teardown path. Additionally, ensure ProgrammaticScrollAnimator::CancelAnimation() and ResetAnimationState() invoke on_finish_ with kInterruptedByScroll rather than kFinished, drain pending scrollend events from test setup resets in scroll-promise-after-gc.html, and assert that scrollend fires when an element becomes non-scrollable, updating scroll-promise-after-gc-expected.txt with the expected failure. TAG=agy CONV=201c4d07-bc47-45fe-9b5b-129bfab1be78 Bug: 562898435 Fixed: 501680084 Change-Id: Ia5327e545042f26ba69f0caa12777ecb9048e81e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8381677 Commit-Queue: Andrew Paseltiner <[email protected]> Reviewed-by: Robert Flack <[email protected]> Cr-Commit-Position: refs/heads/main@{#1699869} --- diff --git a/third_party/blink/renderer/core/scroll/programmatic_scroll_animator.cc b/third_party/blink/renderer/core/scroll/programmatic_scroll_animator.cc index 7559cd8..9a3273b1 100644 --- a/third_party/blink/renderer/core/scroll/programmatic_scroll_animator.cc +++ b/third_party/blink/renderer/core/scroll/programmatic_scroll_animator.cc @@ -22,17 +22,26 @@ ProgrammaticScrollAnimator::~ProgrammaticScrollAnimator() = default; void ProgrammaticScrollAnimator::Dispose() { - if (on_finish_) { - std::move(on_finish_).Run(ScrollableArea::ScrollCompletionMode::kFinished); - } + // Drop the callback without running it; running it here in a pre-finalizer + // would be invalid because its completion handling attempts to allocate and + // enqueue a `scrollend` event during garbage collection. Resetting the + // callback releases any bound `ScrollPromiseResolver::ActiveScrollTracker`, + // which schedules the promise resolution from a posted task instead of from + // within garbage collection. + // + // TODO(crbug.com/562898435): Because we drop the on finish callback here, we + // don't dispatch scrollend. Follow up on fixing this the right way. + on_finish_.Reset(); } void ProgrammaticScrollAnimator::ResetAnimationState() { ScrollAnimatorCompositorCoordinator::ResetAnimationState(); animation_curve_.reset(); start_time_ = base::TimeTicks(); - if (on_finish_) - std::move(on_finish_).Run(ScrollableArea::ScrollCompletionMode::kFinished); + if (on_finish_) { + std::move(on_finish_) + .Run(ScrollableArea::ScrollCompletionMode::kInterruptedByScroll); + } } mojom::blink::ScrollType ProgrammaticScrollAnimator::GetScrollType() const { @@ -92,11 +101,11 @@ void ProgrammaticScrollAnimator::CancelAnimation() { DCHECK_NE(run_state_, RunState::kRunningOnCompositorButNeedsUpdate); - ScrollAnimatorCompositorCoordinator::CancelAnimation(); if (on_finish_) { std::move(on_finish_) .Run(ScrollableArea::ScrollCompletionMode::kInterruptedByScroll); } + ScrollAnimatorCompositorCoordinator::CancelAnimation(); } void ProgrammaticScrollAnimator::TickAnimation(base::TimeTicks monotonic_time) { diff --git a/third_party/blink/renderer/core/scroll/scroll_animator.cc b/third_party/blink/renderer/core/scroll/scroll_animator.cc index af41b119..eadf92b0 100644 --- a/third_party/blink/renderer/core/scroll/scroll_animator.cc +++ b/third_party/blink/renderer/core/scroll/scroll_animator.cc @@ -59,11 +59,7 @@ tick_clock_(tick_clock), last_granularity_(ui::ScrollGranularity::kScrollByPixel) {} -ScrollAnimator::~ScrollAnimator() { - if (on_finish_) { - std::move(on_finish_).Run(ScrollableArea::ScrollCompletionMode::kFinished); - } -} +ScrollAnimator::~ScrollAnimator() = default; ScrollOffset ScrollAnimator::DesiredTargetOffset() const { if (run_state_ == RunState::kWaitingToCancelOnCompositor) diff --git a/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt b/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt index eecb4d6..28917c5 100644 --- a/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt +++ b/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt @@ -1,4 +1,6 @@ This is a testharness.js-based test. +[FAIL] Scroll promise handling after overflow change and garbage collection + assert_true: scrollend should be fired expected true got false [FAIL] Scroll promise handling after iframe removed and garbage collected assert_unreached: unresolved scroll_promise Reached unreachable code Harness: the test ran to completion. diff --git a/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html b/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html index 93cce5f9..3c18243f 100644 --- a/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html +++ b/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html @@ -17,6 +17,9 @@ <div id="scroller"> <div class="filler"></div> </div> +<div id="overflow_scroller" style="overflow:scroll;height:100px"> + <div class="filler"></div> +</div> <iframe id = "iframe1" srcdoc=' <!DOCTYPE html> <style> @@ -47,13 +50,13 @@ const scroller = document.getElementById("scroller"); - async function confirmPromiseSettles(scroll_promise) { - async function waitForFrames(num) { - while (num--) { - await new Promise(requestAnimationFrame); - } + async function waitForFrames(num) { + while (num--) { + await new Promise(requestAnimationFrame); } + } + async function confirmPromiseSettles(scroll_promise) { // The worst case wait time below is added only to avoid test harness // timeout error which would make it hard to spot the failure point. let val = await Promise.race([scroll_promise, waitForFrames(10)]); @@ -61,6 +64,7 @@ if (val === undefined) { assert_unreached("unresolved scroll_promise"); } + return val; } promise_test(async () => { @@ -75,30 +79,75 @@ assert_true(scroller.scrollTop < offset, "Smooth scroll continues after gc"); - await promise; + let result = await promise; assert_equals(scroller.scrollTop, offset, "Smooth scroll ends through await"); + assert_false(result.interrupted, "Smooth scroll should not be interrupted"); }, "Scroll promise handling after garbage collection"); promise_test(async () => { scroller.scrollTop = 0; + await waitForFrames(2); + + let scrollend_fired = false; + scroller.addEventListener("scrollend", () => { + scrollend_fired = true; + }, { once: true }); let scroll_promise = scroller.scrollBy({ top: 1000, behavior: "smooth" }); scroller.remove(); window.gc(); - await confirmPromiseSettles(scroll_promise); + let result = await confirmPromiseSettles(scroll_promise); + assert_true(result.interrupted, "Scroll should be interrupted"); + await waitForFrames(2); + assert_false( + scrollend_fired, + "scrollend should not be fired because the event target node was no " + + "longer attached"); }, "Scroll promise handling after scroller removal and garbage collection"); promise_test(async () => { + const overflow_scroller = document.getElementById("overflow_scroller"); + overflow_scroller.scrollTop = 0; + + let scrollend_fired = false; + overflow_scroller.addEventListener("scrollend", () => { + scrollend_fired = true; + }, { once: true }); + + let scroll_promise = + overflow_scroller.scrollBy({ top: 1000, behavior: "smooth" }); + overflow_scroller.style.overflow = "visible"; + overflow_scroller.offsetHeight; + window.gc(); + + let result = await confirmPromiseSettles(scroll_promise); + assert_true(result.interrupted, "Scroll should be interrupted"); + await waitForFrames(2); + assert_true(scrollend_fired, "scrollend should be fired"); + }, "Scroll promise handling after overflow change and garbage collection"); + + promise_test(async () => { const iframe = document.getElementById("iframe1"); const scroller1 = iframe.contentDocument.getElementById("scroller"); + let scrollend_fired = false; + scroller1.addEventListener("scrollend", () => { + scrollend_fired = true; + }, { once: true }); + let scroll_promise = scroller1.scrollBy({ top: 1000, behavior: "smooth" }); iframe.remove(); window.gc(); - await confirmPromiseSettles(scroll_promise); + let result = await confirmPromiseSettles(scroll_promise); + assert_true(result.interrupted, "Scroll should be interrupted"); + await waitForFrames(2); + assert_false( + scrollend_fired, + "scrollend should not be fired because the event target node was no " + + "longer attached"); }, "Scroll promise handling after iframe removed and garbage collected"); // The following test is similar to the above except that the scroller is @@ -109,11 +158,22 @@ const iframe = document.getElementById("iframe2"); iframe.contentDocument.body.appendChild(scroller); + let scrollend_fired = false; + scroller.addEventListener("scrollend", () => { + scrollend_fired = true; + }, { once: true }); + let scroll_promise = scroller.scrollBy({ top: 1000, behavior: "smooth" }); iframe.remove(); window.gc(); - await confirmPromiseSettles(scroll_promise); + let result = await confirmPromiseSettles(scroll_promise); + assert_true(result.interrupted, "Scroll should be interrupted"); + await waitForFrames(2); + assert_false( + scrollend_fired, + "scrollend should not be fired because the event target node was no " + + "longer attached"); }, "Scroll promise handling after iframe with appended child removed and " + "garbage collected"); </script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt b/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt
index eecb4d6..28917c5 100644
--- a/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt
+++ b/third_party/blink/web_tests/cssom/scroll-promise-after-gc-expected.txt
@@ -1,4 +1,6 @@
This is a testharness.js-based test.
+[FAIL] Scroll promise handling after overflow change and garbage collection
+ assert_true: scrollend should be fired expected true got false
[FAIL] Scroll promise handling after iframe removed and garbage collected
assert_unreached: unresolved scroll_promise Reached unreachable code
Harness: the test ran to completion.
diff --git a/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html b/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html
index 93cce5f9..3c18243f 100644
--- a/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html
+++ b/third_party/blink/web_tests/cssom/scroll-promise-after-gc.html
@@ -17,6 +17,9 @@
<div id="scroller">
<div class="filler"></div>
</div>
+<div id="overflow_scroller" style="overflow:scroll;height:100px">
+ <div class="filler"></div>
+</div>
<iframe id = "iframe1" srcdoc='
<!DOCTYPE html>
<style>
@@ -47,13 +50,13 @@
const scroller = document.getElementById("scroller");
- async function confirmPromiseSettles(scroll_promise) {
- async function waitForFrames(num) {
- while (num--) {
- await new Promise(requestAnimationFrame);
- }
+ async function waitForFrames(num) {
+ while (num--) {
+ await new Promise(requestAnimationFrame);
}
+ }
+ async function confirmPromiseSettles(scroll_promise) {
// The worst case wait time below is added only to avoid test harness
// timeout error which would make it hard to spot the failure point.
let val = await Promise.race([scroll_promise, waitForFrames(10)]);
@@ -61,6 +64,7 @@
if (val === undefined) {
assert_unreached("unresolved scroll_promise");
}
+ return val;
}
promise_test(async () => {
@@ -75,30 +79,75 @@
assert_true(scroller.scrollTop < offset,
"Smooth scroll continues after gc");
- await promise;
+ let result = await promise;
assert_equals(scroller.scrollTop, offset,
"Smooth scroll ends through await");
+ assert_false(result.interrupted, "Smooth scroll should not be interrupted");
}, "Scroll promise handling after garbage collection");
promise_test(async () => {
scroller.scrollTop = 0;
+ await waitForFrames(2);
+
+ let scrollend_fired = false;
+ scroller.addEventListener("scrollend", () => {
+ scrollend_fired = true;
+ }, { once: true });
let scroll_promise = scroller.scrollBy({ top: 1000, behavior: "smooth" });
scroller.remove();
window.gc();
- await confirmPromiseSettles(scroll_promise);
+ let result = await confirmPromiseSettles(scroll_promise);
+ assert_true(result.interrupted, "Scroll should be interrupted");
+ await waitForFrames(2);
+ assert_false(
+ scrollend_fired,
+ "scrollend should not be fired because the event target node was no " +
+ "longer attached");
}, "Scroll promise handling after scroller removal and garbage collection");
promise_test(async () => {
+ const overflow_scroller = document.getElementById("overflow_scroller");
+ overflow_scroller.scrollTop = 0;
+
+ let scrollend_fired = false;
+ overflow_scroller.addEventListener("scrollend", () => {
+ scrollend_fired = true;
+ }, { once: true });
+
+ let scroll_promise =
+ overflow_scroller.scrollBy({ top: 1000, behavior: "smooth" });
+ overflow_scroller.style.overflow = "visible";
+ overflow_scroller.offsetHeight;
+ window.gc();
+
+ let result = await confirmPromiseSettles(scroll_promise);
+ assert_true(result.interrupted, "Scroll should be interrupted");
+ await waitForFrames(2);
+ assert_true(scrollend_fired, "scrollend should be fired");
+ }, "Scroll promise handling after overflow change and garbage collection");
+
+ promise_test(async () => {
const iframe = document.getElementById("iframe1");
const scroller1 = iframe.contentDocument.getElementById("scroller");
+ let scrollend_fired = false;
+ scroller1.addEventListener("scrollend", () => {
+ scrollend_fired = true;
+ }, { once: true });
+
let scroll_promise = scroller1.scrollBy({ top: 1000, behavior: "smooth" });
iframe.remove();
window.gc();
- await confirmPromiseSettles(scroll_promise);
+ let result = await confirmPromiseSettles(scroll_promise);
+ assert_true(result.interrupted, "Scroll should be interrupted");
+ await waitForFrames(2);
+ assert_false(
+ scrollend_fired,
+ "scrollend should not be fired because the event target node was no " +
+ "longer attached");
}, "Scroll promise handling after iframe removed and garbage collected");
// The following test is similar to the above except that the scroller is
@@ -109,11 +158,22 @@
const iframe = document.getElementById("iframe2");
iframe.contentDocument.body.appendChild(scroller);
+ let scrollend_fired = false;
+ scroller.addEventListener("scrollend", () => {
+ scrollend_fired = true;
+ }, { once: true });
+
let scroll_promise = scroller.scrollBy({ top: 1000, behavior: "smooth" });
iframe.remove();
window.gc();
- await confirmPromiseSettles(scroll_promise);
+ let result = await confirmPromiseSettles(scroll_promise);
+ assert_true(result.interrupted, "Scroll should be interrupted");
+ await waitForFrames(2);
+ assert_false(
+ scrollend_fired,
+ "scrollend should not be fired because the event target node was no " +
+ "longer attached");
}, "Scroll promise handling after iframe with appended child removed and " +
"garbage collected");
</script>
Original Bug Report
UAF: JS execution during Oilpan GC sweep via ProgrammaticScrollAnimator
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 without the Chrome Security team.
Overview: When the experimental ProgrammaticScrollPromise feature is enabled, a detached ProgrammaticScrollAnimator can resolve a promise during an Oilpan GC sweep. This allows an attacker to synchronously execute JavaScript via an Object.prototype.then getter while the heap is in an inconsistent state. Running script during GC finalization creates a potential Use-After-Free (UAF) primitive.
Affected files:
third_party/blink/renderer/core/scroll/programmatic_scroll_animator.ccthird_party/blink/renderer/core/scroll/scroll_promise_resolver.hthird_party/blink/renderer/core/scroll/scrollable_area.ccthird_party/blink/renderer/bindings/core/v8/script_promise_resolver.cc
Estimated timestamp from git blame: 2024-02-27
Summary
A potential security vulnerability exists in the interaction between ProgrammaticScrollAnimator, ScrollPromiseResolver, and the Oilpan (cppgc) garbage collection mechanism. When the experimental ProgrammaticScrollPromise feature is enabled, it is possible to trigger synchronous JavaScript execution during the Oilpan incremental sweep phase. This violates Blink’s core invariant that destructors and GC sweeping must not run script, creating a potential Use-After-Free (UAF) vulnerability. MiraclePtr (BackupRefPtr) does not mitigate this, as the affected objects are managed by Oilpan’s Member<> pointers.
Technical Details
The issue originates in how ScrollableArea manages the ProgrammaticScrollAnimator. When an animation is initiated via ScrollableArea::InitiateScrollAnimation, a ScrollCallback is created that takes ownership of a std::unique_ptr<ScrollPromiseResolver::ActiveScrollTracker>. This callback is stored in ProgrammaticScrollAnimator::on_finish_.
If the ScrollableArea is disposed (e.g., via PaintLayerScrollableArea::DisposeImpl), ScrollableArea::ClearScrollableArea is called. This method detaches the ProgrammaticScrollAnimator and clears the Member<> reference to it, but crucially, it does not call CancelAnimation() or otherwise clear the on_finish_ callback.
When the unreferenced ProgrammaticScrollAnimator is later garbage-collected during an IncrementalSweepTask, its destructor executes the on_finish_ callback. The destruction of the callback destroys the ActiveScrollTracker, which tells the ScrollPromiseResolver to resolve the promise with a ScrollResult dictionary.
Because IncrementalSweepTask runs on a normal foreground task runner without entering a ScriptForbiddenScope, ScriptPromiseResolverBase::NotifyResolveOrReject believes it is safe to resolve the promise immediately. During V8’s internal promise resolution of the ScrollResult dictionary, the engine synchronously accesses the then property to check if the object is a “thenable”. An attacker can define a malicious getter on Object.prototype.then to gain synchronous JavaScript execution while Oilpan is actively sweeping the heap, allowing them to interact with partially destroyed objects.
Potential Steps to Reproduce
Note: These are suggested steps based on code analysis; our tooling agent does not yet have the ability to run code to provide a working proof of concept.
- Run Chrome with the experimental
ProgrammaticScrollPromisefeature enabled (e.g., via--enable-experimental-web-platform-features). - In JavaScript, define a malicious getter:
Object.defineProperty(Object.prototype, 'then', { get: function() { /* interact with heap */ } }); - Create a scrollable element and initiate a smooth scroll:
element.scrollTo({top: 100, behavior: 'smooth'}). This populatesProgrammaticScrollAnimator::on_finish_with the tracker-owning callback. - Interrupt the scrollable area synchronously (e.g., by changing
overflow: visibleand forcing a layout update). This destroys thePaintLayerScrollableAreaand drops the reference to the animator without cancelling the animation. - Trigger garbage collection. When the
IncrementalSweepTasksweeps the animator, the destructor will run the callback, resolve the promise, and synchronously trigger thethengetter.
Suggested Fix
Ensure that the animation is properly cancelled when the ProgrammaticScrollAnimator is detached.
In third_party/blink/renderer/core/scroll/scrollable_area.cc, update ClearScrollableArea():
void ScrollableArea::ClearScrollableArea() {
// ...
if (programmatic_scroll_animator_) {
programmatic_scroll_animator_->CancelAnimation(); // ADD THIS
programmatic_scroll_animator_->DetachElement();
programmatic_scroll_animator_.Clear();
}
// ...
}
Additionally, it may be worth reviewing if IncrementalSweepTask should execute within a ScriptForbiddenScope or if ProgrammaticScrollAnimator::~ProgrammaticScrollAnimator should avoid executing complex callbacks that might re-enter V8 during destruction.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.