53226c0f8d7dc81188026c745157321599853a58 LCP: Add null guard for MediaTiming in image trace data
Triage note: Adds a local null guard around the trace output. Reachable from web content but only when tracing (devtools.timeline) is active; effect is a null deref crash, not exploitable corruption.
The bug at a glance
This is a real memory-safety-adjacent crash: an ImageRecord’s MediaTiming, a garbage-collected object referenced only through a raw stack pointer, could be collected by an intervening allocation/GC between the point the code effectively relied on it being non-null and the point it was dereferenced to emit trace data. The commit message is explicit and credible: the compiler is free to drop the local raw pointer once it’s no longer needed, so a call that can allocate (and thus trigger GC) can reap the MediaTiming, after which GetMediaTiming() returns a cleared field and ->DiscoveryTime()/LoadStart()/LoadEnd() dereference null. The result is a renderer crash. It stays watch-tier rather than patch-now for three reasons: (1) the dereference targets a now-null field, giving a null-page fault (deterministic crash) rather than a controllable freed-object reuse — Oilpan clears the Member, it does not hand back attacker-reallocated memory; (2) the sink is trace-event/DevTools output on the LCP calculation path, only exercised when tracing is enabled or on the web-exposed candidate update, not a hot attacker-driven loop; (3) no controlled data crosses into the crash. Medium/CVSS 4.3 reflects a renderer-local DoS with a nondeterministic GC race trigger.
Force a large-contentful-image update to run while heap pressure makes a GC likely during UpdateWebExposedLargestContentfulImage(), so the MediaTiming backing the current largest image is collected between the implicit non-null assumption and the trace serialization. The attacker’s lever is heap timing plus rapid LCP-candidate churn (swapping large images, detaching image elements) to maximize the chance the MediaTiming becomes unreferenced exactly when a trace value is being built.
Root cause
In largest_contentful_paint_calculator.cc, the web-exposed candidate trace builder read largest_image.GetMediaTiming()->DiscoveryTime() (and LoadStart/LoadEnd) three times in a row, each call dereferencing the MediaTiming pointer. GetMediaTiming() returns the MediaTiming held by the ImageRecord via a GC Member. Between the moment the record was known valid and these dereferences, window_performance_->MonotonicTimeToDOMHighResTimeStamp(…) and the TracedValue construction can allocate; an allocation can trigger Oilpan GC; if nothing else keeps the MediaTiming alive, GC collects it and the Member is cleared, so a subsequent GetMediaTiming() yields null and the chained -> deref faults. The commit notes that even a local raw ptr on the stack does not protect it, because the optimizer can elide the dead local. The fix hoists a single const MediaTiming* media_timing = largest_image.GetMediaTiming(); into an if, so the pointer is captured once, null-checked once, and only used inside the guarded block — and, critically, the extra MediaTiming-derived fields are simply omitted from the trace when null, since DevTools does not require them. Capturing the pointer in a named local inside the guard also keeps it live across the calls within the block.
Key code
Single capture + null guard replaces three unguarded chained GetMediaTiming() dereferences in the trace builder
- value->SetDouble("imageDiscoveryTime",
- window_performance_->MonotonicTimeToDOMHighResTimeStamp(
- largest_image.GetMediaTiming()->DiscoveryTime()));
- value->SetDouble("imageLoadStart",
- window_performance_->MonotonicTimeToDOMHighResTimeStamp(
- largest_image.GetMediaTiming()->LoadStart()));
- value->SetDouble("imageLoadEnd",
- window_performance_->MonotonicTimeToDOMHighResTimeStamp(
- largest_image.GetMediaTiming()->LoadEnd()));
+ if (const MediaTiming* media_timing = largest_image.GetMediaTiming()) {
+ value->SetDouble("imageDiscoveryTime",
+ window_performance_->MonotonicTimeToDOMHighResTimeStamp(
+ media_timing->DiscoveryTime()));
+ value->SetDouble("imageLoadStart",
+ window_performance_->MonotonicTimeToDOMHighResTimeStamp(
+ media_timing->LoadStart()));
+ value->SetDouble("imageLoadEnd",
+ window_performance_->MonotonicTimeToDOMHighResTimeStamp(
+ media_timing->LoadEnd()));
+ }
Patch walkthrough
third_party/blink/renderer/core/paint/timing/largest_contentful_paint_calculator.cc— The three SetDouble calls that each re-invoked largest_image.GetMediaTiming()-> are wrapped in a single if that binds const MediaTiming* media_timing once. If the field is already null (GC cleared it) the whole block is skipped and the trace value simply omits imageDiscoveryTime/imageLoadStart/imageLoadEnd — the commit argues these are non-essential for DevTools. Binding to a named local inside the guarded scope also removes the re-fetch-per-call pattern that widened the race window. Nothing outside the trace-data path changes; the LCP computation and web-exposed reporting values are untouched.
Background
MediaTiming — A garbage-collected object recording an image/media resource’s discovery, load-start and load-end timestamps, used by paint-timing to report LCP metadata. Held by ImageRecord via an Oilpan Member; when the record/resource is no longer referenced, GC can reclaim it.
Oilpan raw-pointer hazard — Blink’s GC can move/collect objects at allocation points. A raw C++ pointer to a GC object is not a strong reference; if the only keep-alive is a raw local and the optimizer proves the local dead, an intervening allocation may collect the pointee. The safe patterns are holding a Member/Persistent or re-checking after any allocation.
UpdateWebExposedLargestContentfulImage / trace path — The calculator builds a TracedValue describing the current LCP image candidate for the LargestContentfulPaint::Candidate trace event and web-exposed reporting. TracedValue construction and MonotonicTimeToDOMHighResTimeStamp can allocate, opening the GC window.
Null Member semantics — When Oilpan collects a weakly/uniquely-held object, the clearing yields a null Member, so GetMediaTiming() returns nullptr — hence the crash is a null deref of a cleared field, not a dangling-pointer read of reallocated memory.
Vulnerability window
- introduction — Present since the trace builder read GetMediaTiming() three times without capturing or null-checking, relying on an implicit liveness that the optimizer could break.
- discovery — crbug 473917093 — rare crash in UpdateWebExposedLargestContentfulImage() traced to the MediaTiming being GCed between null-relevant point and trace access despite a stack raw ptr.
- fix — Commit 53226c0f8d (main@{#1567005}, 2026-01-09) by Scott Haseley; guards the trace output and omits the optional fields when null. Reviewed by mmocny.
- status — Landed with a plain descriptive subject, Fixed: tag, no explicit security label. Watch-tier.
Proof of concept
No test is included in this diff, so no PoC is reconstructed and no exploit is demonstrated. Honest repro recipe (crash only): enable tracing for the loading/LCP categories (or otherwise drive the web-exposed LCP candidate update), then rapidly churn large contentful images — swap the largest image element for progressively larger ones and detach the prior image nodes to make their MediaTiming unreachable — while applying heap pressure (allocate large JS/DOM structures) so a GC is likely to fire during the candidate trace serialization. Success is a nondeterministic renderer crash with a null deref in the trace-builder frame. This is a reliability race; it is not a controllable memory-corruption primitive and no exploit code is provided.
Exploitation
- trigger — Win the GC race inside UpdateWebExposedLargestContentfulImage()’s trace path: MediaTiming must become collectable exactly when the builder is between capture and deref. Requires tracing/candidate-update to be active.
- primitive — Dereference of a cleared GC Member -> null-page fault -> renderer crash. Because Oilpan zeroes the field rather than handing back reused memory, there is no read of attacker data and no write primitive from this diff.
- ceiling — Renderer-local DoS. Nondeterministic (GC-timing dependent), on a metrics/tracing path, single-origin, no info leak evident. Not an exploitable-grade UAF.
Detection & hunting
For defenders and detection engineers:
- Crash signature —
- Fuzz/stress —
Audit directions
- Repeated GetX()-> without capture across allocation points —
- Trace-only sinks touching GC objects —
- MediaTiming/ImageRecord liveness —