CVE-2026-64713
Overview
Background
- Largest Contentful Paint (LCP)
- A performance API reporting when the largest content element rendered, exposed to script via PerformanceObserver.
- Timing side channel
- Using precise time measurements to infer secret state (cache hits, visited links) that is otherwise not directly readable.
- Resolution coarsening
- Rounding timestamps to a coarse quantum (here 4ms) to deny attackers fine-grained timing oracles.
Root Cause Analysis
This fixes a high-resolution timing side channel in the Largest Contentful Paint (LCP) performance entry that could be used to infer cross-origin/visited state. LargestContentfulPaintData::potentiallyAddLargestContentfulPaintEntry set the entry’s renderTime directly to the raw paintTimestamp for every entry (pendingEntry->setRenderTime(paintTimestamp)), exposing an unrounded paint time to script via the PerformanceObserver LCP API. The LCP specification requires renderTime for image entries to be coarsened to 4ms to avoid exactly this kind of timing oracle. Because the value was not coarsened, a page could measure fine-grained render timing and use it as a side channel — the advisory states the concrete consequence is that ‘websites may know if the user has visited a given link,’ i.e. differences in paint/render timing (for example driven by :visited styling or cached-resource paint timing) leak browsing history.
The fix moves renderTime assignment into the image branch, where it coarsens the value with a reduceResolution helper to a 4ms resolution (renderTimeSecondsResolution = 4_ms, floor(value/resolution)*resolution) per the spec, and keeps the raw paintTimestamp only for the non-image branch.
The restored invariant is that image LCP render times are quantized to 4ms so they cannot serve as a fine-grained timing oracle. This is an information-disclosure fix, not memory corruption.
Attack Path
- Observe LCP entries Register a PerformanceObserver for ’largest-contentful-paint’ to receive renderTime for rendered elements.
- Induce a timing-dependent paint Lay out content whose paint timing depends on a secret — e.g. :visited link styling or a cross-origin resource’s cache state.
- Read fine-grained renderTime Use the unrounded renderTime to distinguish the two timing outcomes.
- Infer the secret Deduce whether the link was visited / resource was cached, leaking browsing history.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
LargestContentfulPaintData::potentiallyAddLargestContentfulPaintEntrySource/WebCore/page/LargestContentfulPaintData.cpp |
modified | Coarsens image entries' renderTime to 4ms via a reduceResolution(floor(value/resolution)*resolution) helper per the LCP spec, and only uses the raw paintTimestamp for non-image entries. |
Files Changed
Source/WebCore/page/LargestContentfulPaintData.cpp
Audit Directions
- Other performance timestampsAudit Performance* entry types (paint timing, resource timing, element timing) for renderTime/startTime values exposed without reduced-resolution coarsening.
- reducedResolution usageGrep for relativeTimeFromTimeOriginInReducedResolution vs raw timestamps to find timing values that skip coarsening before reaching script.
Patch
diff --git a/Source/WebCore/page/LargestContentfulPaintData.cpp b/Source/WebCore/page/LargestContentfulPaintData.cpp
index 772b835081bc..45d59aa9db63 100644
--- a/Source/WebCore/page/LargestContentfulPaintData.cpp
+++ b/Source/WebCore/page/LargestContentfulPaintData.cpp
@@ -198,13 +198,21 @@ void LargestContentfulPaintData::potentiallyAddLargestContentfulPaintEntry(Eleme
pendingEntry->setURLString(image->url().string());
auto loadTimestamp = protect(window->performance())->relativeTimeFromTimeOriginInReducedResolution(loadTime);
pendingEntry->setLoadTime(loadTimestamp);
- }
+
+ // FIXME: Adopt ReducedResolutionSeconds: webkit.org/b/316824.
+ auto reduceResolution = [](Seconds value, Seconds resolution) {
+ return Seconds(std::floor(value.value() / resolution.value()) * resolution.value());
+ };
+
+ // https://w3c.github.io/largest-contentful-paint/#sec-report-largest-contentful-paint coarsens renderTime to 4ms for images.
+ static constexpr auto renderTimeSecondsResolution = 4_ms;
+ pendingEntry->setRenderTime(reduceResolution(Seconds::fromMilliseconds(paintTimestamp), renderTimeSecondsResolution).milliseconds());
+ } else
+ pendingEntry->setRenderTime(paintTimestamp);
if (element.hasID())
pendingEntry->setID(element.getIdAttribute().string());
- pendingEntry->setRenderTime(paintTimestamp);
-
LOG_WITH_STREAM(LargestContentfulPaint, stream << " making new entry for " << element << " image " << (image ? image->url().string() : emptyString()) << " id " << pendingEntry->id() <<
": entry size " << pendingEntry->size() << ", loadTime " << pendingEntry->loadTime() << ", renderTime " << pendingEntry->renderTime());