← Chrome — 2026-W02

2522215e62789539ac2fa557d99eb2c55ebb4c95  Fix Float-cast-overflow in IO rootMargin

severity low class IntegerOverflow CVSS 3.7 confidence 0.8 signal score 5.8 third_party/blink/renderer AI review CONFIRMED
Primitive: rootMargin percentage strings (e.g. '3e9%') were passed through Length::IntValue(), triggering an undefined float-to-int cast overflow.
Triage note: Fix uses Length::Percent() (float) for percentage values. Web-reachable via IntersectionObserver but the primitive is UBSan-detected UB, not memory corruption; not exploitable.

The bug at a glance

A rootMargin percentage larger than INT_MAX (e.g. ‘3e9%’) was serialized by calling Length::IntValue(), which casts the stored float to a 32-bit int; when the float exceeds int range the cast is undefined behavior, caught by UBSan (float-cast-overflow). This is web-reachable through the standard IntersectionObserver JS API with no special privilege, which is why it is a real security-relevant bug (isSecurity=true). It stays at low/watch because float-cast-overflow is defined-ish on common targets (it produces an unspecified/saturated integer, not memory corruption), the value flows only into a string builder for serialization, and there is no demonstrated path from the UB to a control-flow or memory primitive. It is a correctness/UBSan-hygiene fix, not an exploitable overflow.

The defender’s angle is UBSan/hardening surface reachable from ordinary script. Any page can construct new IntersectionObserver({rootMargin:‘3e9%’}) and then read observer.rootMargin, exercising the buggy serialization. On a stock release build the practical impact is a garbage or truncated integer being emitted (before the fix, rootMargin would round-trip incorrectly for huge percentages); on a UBSan/sanitizer build it is a hard error. The value being fixed is telling: the previous code always called IntValue() and only appended ‘%’ afterward, meaning percentage lengths were being integer-truncated even in normal ranges — so this also quietly corrects percent serialization fidelity, which the new test pins to full float precision.

Root cause

IntersectionObserver serializes its rootMargin back to a string via a lambda append_length(const Length&). The original code unconditionally called string_builder.AppendNumber(length.IntValue()) and then appended either ‘%’ or ‘px’ depending on length.IsPercent(). Length::IntValue() returns the length value cast to a 32-bit int. rootMargin accepts CSS values, and a percentage such as ‘3e9%’ is stored as a float ~3,000,000,000, which is larger than INT_MAX (~2.147e9). Casting that float to int is undefined behavior in C++ and is exactly what UBSan’s float-cast-overflow check flags (bug 441524792).

The fix moves the AppendNumber inside the IsPercent branch: for percentages it now calls length.Percent(), which returns the value as a float and is appended with full precision, avoiding any float-to-int narrowing; only the non-percent (pixel) branch still calls IntValue(), where values are bounded and integral by nature. The accompanying test constructs an observer with rootMargin ‘3e9%’ and asserts the round-tripped rootMargin is ‘3.00000e+9% 3.00000e+9% 3.00000e+9% 3.00000e+9%’ with no exception, confirming both that the UB is gone and that large percentages now serialize faithfully as floats.

Key code

Percent lengths now serialized via Percent() (float) instead of IntValue() (int cast) (intersection_observer.cc)

  const auto append_length = [&](const Length& length) {
-    string_builder.AppendNumber(length.IntValue());
     if (length.IsPercent()) {
+      string_builder.AppendNumber(length.Percent());
       string_builder.Append('%');
     } else {
+      string_builder.AppendNumber(length.IntValue());
       string_builder.Append(base::byte_span_from_cstring("px"));
     }
  };

Patch walkthrough

  • third_party/blink/renderer/core/intersection_observer/intersection_observer.cc — The append_length lambda used in rootMargin serialization is restructured. The unconditional AppendNumber(length.IntValue()) is removed. In the IsPercent() branch it now appends length.Percent() (a float) then ‘%’; in the else branch it appends length.IntValue() then ‘px’. This ensures the int cast only runs on pixel lengths, so out-of-range percentages never reach the undefined float-to-int conversion.
  • third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc — Adds TEST_F(IntersectionObserverTest, RootMarginPercentOverflow): creates an IntersectionObserver with rootMargin ‘3e9%’, asserts no exception, and expects observer->rootMargin() to equal the four-sided float serialization ‘3.00000e+9% …’. This is a regression test proving the overflow path and the corrected float serialization.

Background

IntersectionObserver rootMargin — A JS-configurable CSS margin (length or percentage per side) added around the root’s intersection rectangle. Fully web-reachable and serialized back via the observer.rootMargin getter.

Length::IntValue() vs Percent() — Blink’s Length stores a float. IntValue() returns it cast to int (narrowing); Percent() returns the percentage as a float. Only IntValue() risks float-cast-overflow for large magnitudes.

float-cast-overflow — An UBSan check for converting a floating-point value to an integer type when the value is outside the target integer’s representable range — undefined behavior in C++.

INT_MAX boundary — A 32-bit signed int maxes near 2.147e9; a percentage like 3e9% exceeds it, so the cast is out-of-range.

Vulnerability window

  1. Latent — rootMargin serialization always narrowed lengths via IntValue(), silently truncating percentages and invoking UB for out-of-range percentage magnitudes.
  2. Reported — Bug 441524792 filed for float-cast-overflow in IO rootMargin, almost certainly surfaced by UBSan/fuzzing on ‘3e9%’-style input.
  3. 2026-01-09 — Philip Rogers lands 2522215e62 using Percent() for percentage values, with a regression test (CL 7428240, main@{#1567337}).
  4. Backport — No [M###] tag on the subject; trunk-only unless a separate merge exists.

Proof of concept

The commit shipped a test, so the trigger is precisely reconstructable from it. In JS: create an IntersectionObserver with rootMargin ‘3e9%’ and read back the getter. On a fixed build it round-trips as float (‘3.00000e+9%’ per side); on a pre-fix UBSan build it triggers float-cast-overflow inside IntValue(). This demonstrates only reachability of the UB and the serialization change — not any exploit. No memory-safety impact is shown.

// Web-reachable trigger (mirrors intersection_observer_test.cc RootMarginPercentOverflow)
const io = new IntersectionObserver(() => {}, { rootMargin: '3e9%' });
// Reading the getter exercises the serialization path that called Length::IntValue():
console.log(io.rootMargin);
// Fixed build: '3.00000e+9% 3.00000e+9% 3.00000e+9% 3.00000e+9%'
// Pre-fix UBSan build: float-cast-overflow on the int narrowing of ~3e9

Exploitation

  1. Reachability — Trivial: any script on any page can set rootMargin to an out-of-range percentage and read it back. No user interaction or privilege needed.
  2. Impact ceiling — On stock release builds the UB manifests as an unspecified/implementation-defined integer (commonly saturated or wrapped), feeding only a StringBuilder for the getter. There is no write, no length used for allocation or indexing shown here, and thus no path to memory corruption.
  3. Sanitizer vs Stable — The concrete failure (a hard abort) exists only under UBSan/sanitizer builds; on Stable the observable effect pre-fix was incorrect serialization of large percentages, a correctness bug rather than a security-exploitable one.

Detection & hunting

For defenders and detection engineers:

  • UBSan float-cast-overflow in Length::IntValue via IntersectionObserver — Sanitizer/fuzzing pipelines should key on float-cast-overflow reports originating in intersection_observer.cc append_length / Length::IntValue; these indicate out-of-range length-percentage inputs.
  • Anomalous rootMargin values — Telemetry or CSP-style monitoring for IntersectionObserver rootMargin containing scientific-notation or very large percentages can flag content probing this path, though it is low-signal.

Audit directions

  • Other Length::IntValue() callers on web-controlled percentages — Search Blink for AppendNumber(length.IntValue()) or bare IntValue() applied to Lengths that may be percentages from web APIs (scroll margins, other observer margins, CSS OM serialization). The same narrowing UB may recur where percentages can exceed INT_MAX.
  • Float-to-int narrowing near length math — Audit places where a Length or LayoutUnit float is cast to int and then used for allocation, indexing, or arithmetic rather than just serialization — those are where a similar overflow could have real (not cosmetic) consequences.

Before / after

Loading diff…