← All reports

LCP renderTime should have 4ms granularity

MediumWebCore Paint TimingInfoDisclosure

CVE: CVE-2026-64713 · Safari 26.6 · Released July 27, 2026 Impact: Websites may know if the user has visited a given link Apple's description: This issue was addressed with improved checks. Credit: Kwak Kiyong, Song Nuri

52cacff | Bugzilla 316827

Medium — entry를 구성하는 지점에서 script에 노출되는 timestamp 하나는 clamp 처리를 거쳤지만, 그 옆의 형제 필드는 raw 값 그대로 출시되었습니다. 여기서 나오는 것은 memory-safety primitive가 아닙니다. 엔진이 어떤 페이지에도 허용하지 않으려던 수준의 정밀한 시계이며, 그 바늘 끝은 cross-origin 리소스 상태를 정확히 가리키고 있습니다.

브라우저는 페이지 script가 도달할 수 있는 어떤 시계도 cache hit를 잴 만큼 정밀하지 않도록 만드는 데 상당한 엔지니어링 노력을 기울입니다. 이 노력은 Performance의 몇 안 되는 reduced-resolution helper 함수들에 집중되어 있으며, 페이지가 읽을 수 있는 모든 timestamp는 나가는 길에 이 helper 중 하나를 거치도록 되어 있습니다. LargestContentfulPaintData는 viewport에서 가장 크게 그려진 이미지나 텍스트 블록을 설명하는 performance entry를 구성하는데, 이때 entry마다 두 개의 timestamp를 채워 넣습니다. 하나는 해당 리소스의 로딩이 끝난 시점이고, 다른 하나는 candidate가 그려진 시점입니다. 그런데 이 중 하나만 coarsening 처리를 거치고 있었습니다.

관전 포인트: 페이지는 자신이 로드를 유발한 이미지에 대해 coarsening을 거치지 않은 paint timestamp를 읽을 수 있습니다. 이는 엔진의 다른 시계들보다 훨씬 정밀한 측정 채널을 페이지에 쥐어주는 셈이며, cross-origin 리소스가 이미 캐시되어 있었는지, 나아가 사용자가 특정 링크를 방문한 적이 있는지까지 추론할 수 있는 수준입니다.

Source/WebCore/page/LargestContentfulPaintData.cpp

if (image) {
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);
-

변경되는 함수는 하나입니다. LargestContentfulPaintData::potentiallyAddLargestContentfulPaintEntry()가 그 대상입니다. 구조적으로 이 수정은 새 로직 추가가 아니라 위치 재배치에 가깝습니다. 기존에는 if (image) 블록이 entry의 URL string과 load time을 설정한 뒤 닫혔고, 그로부터 몇 줄 뒤 — 블록 바깥, element-ID 처리 이후 — 단일한 무조건 호출 pendingEntry->setRenderTime(paintTimestamp)가 이미지와 텍스트 candidate 양쪽 모두를 처리하고 있었습니다. 수정 이후에는 이 무조건 호출이 삭제되고, 해당 할당이 if (image) / else로 나뉜 양쪽 분기 안으로 각각 옮겨졌습니다.

이미지 분기에는 세 가지가 새로 추가됩니다. 먼저 Seconds 값을 호출자가 지정한 grid 위로 내림 처리하는 로컬 reduceResolution 람다가 추가되고, 이 grid를 명명하는 static constexpr auto renderTimeSecondsResolution = 4_ms가 추가되며, paintTimestampSeconds::fromMilliseconds()로 감싸 람다에 통과시킨 뒤 .milliseconds()로 다시 변환하는 setRenderTime 호출이 추가됩니다. 주석은 이미지에 대해 4ms 값을 요구하는 LCP spec 조항을 인용하고 있으며, FIXME는 이 수작업 floor 로직이 webkit.org/b/316824에서 다룰 ReducedResolutionSeconds 도입 전까지의 임시 조치임을 기록하고 있습니다. else 분기는 기존 동작을 그대로 유지합니다. 텍스트 candidate는 여전히 raw paintTimestamp를 그대로 저장합니다.

  Before:                              After:
  if (image) {                         if (image) {
    setURLString(...)                    setURLString(...)
    setLoadTime(reduced(loadTime)) ✓     setLoadTime(reduced(loadTime))  ✓
  }                                      setRenderTime(floor4ms(paint)) ✓
  ...                                  } else
  setRenderTime(paintTimestamp)  ✗       setRenderTime(paintTimestamp)
    └─► script reads full-res clock    ...

파일의 다른 부분은 움직이지 않습니다. LCP 로깅 채널에서 renderTime을 출력하는 마지막의 LOG_WITH_STREAM도 그대로 남아 있으며, 이제 이미지에 대해 coarsening된 값을 보고하게 됩니다.

Largest Contentful Paint. LCP는 viewport에서 가장 크게 그려진 이미지나 텍스트 블록을 보고하는 Performance Timeline metric입니다. 렌더링이 진행되면서 candidate들이 평가되고, 최종적으로 선정된 candidate가 LargestContentfulPaint entry를 생성하며, 이 entry는 PerformanceObserver를 통해 페이지 JavaScript로 전달됩니다. 어떤 페이지든 이를 구독할 수 있습니다. 별도의 권한이나 user gesture, 특별한 context는 필요하지 않습니다.

Entry의 두 timestamp. 각 entry는 loadTimerenderTime을 함께 가집니다. loadTime은 candidate의 기반 리소스 로딩이 끝난 시점이고, renderTime은 candidate가 그려진 시점입니다. 두 값 모두 DOMHighResTimeStamp로, document의 time origin으로부터 측정된 floating-point 밀리초 값입니다. 이미지 candidate의 경우 entry에는 리소스 URL과 계산된 크기도 함께 담깁니다.

Reduced time resolution. 엔진은 페이지 코드가 timing API들을 조합해 고정밀 시계를 만들어내지 못하도록, script에 노출되는 timestamp를 의도적으로 거친 grid 위로 clamp합니다. WebCore에서 이 메커니즘은 Performance에 자리 잡고 있습니다. relativeTimeFromTimeOriginInReducedResolution()은 monotonic time을 time-origin 기준 DOMHighResTimeStamp로 변환하면서 clamp를 이미 적용해 반환하고, ReducedResolutionSeconds 타입은 같은 아이디어를 더 최신 방식으로, 우회하기 더 어렵게 표현한 것입니다. 이는 performance.now() clamping을 관장하는 것과 동일한 정책군에 속합니다.

Cache-timing inference. 메모리 캐시나 네트워크 캐시에서 제공되는 리소스는 네트워크로 새로 fetch된 리소스보다 측정 가능한 수준으로 더 빨리 사용 가능해집니다. 이 차이를 분해할 수 있을 만큼 정밀한 timestamp라면 두 상태를 구분하는 데 사용할 수 있으며, 이는 "이 사용자가 이 사이트를 이미 방문한 적이 있는가" 식의 history probing에서 표준적으로 쓰이는 building block에 해당합니다. Cross-origin 서버는 Timing-Allow-Origin 헤더를 통해 상세한 resource timing 노출에 opt-in할 수 있습니다. 이 opt-in이 없는 상태에서 cross-origin 리소스에 대해 coarsening되지 않은 timing이 노출된다면, 이는 단순한 편의가 아니라 경계 위반에 해당합니다.

WTF Seconds_ms literal. Seconds는 WTF의 typed duration wrapper이고, 4_ms는 그 밀리초 literal suffix입니다. Seconds::fromMilliseconds()는 raw DOMHighResTimeStamp를 typed 값으로 끌어올리고, .milliseconds()는 다시 원래 형태로 되돌립니다. 패치된 라인에서 flooring 단계 양쪽에 변환 코드가 붙어 있는 이유가 여기에 있습니다.

Execution flow. 페인트가 이루어지는 동안 렌더링 코드는 potentiallyAddLargestContentfulPaintEntry()를 호출하며, 이때 element, 선택적인 CachedImage, geometry rect들, load time, paint timestamp를 함께 전달합니다. 이 함수는 먼저 candidate를 필터링합니다. 빈 rect인지, user scroll이 있었는지, input이 이미 dispatch되었는지, 그리고 effective visual area를 m_largestPaintArea와 비교하는 과정을 거치고, 이 필터를 통과한 뒤에야 LargestContentfulPaint entry를 생성하고 필드를 채워 넣습니다.

이 문제는 field 단위 sanitisation의 공백에서 비롯됩니다. 엔진 내부 시계와 페이지에 노출되는 script 사이의 경계는 타입이나 emit 경로가 아니라 entry를 구성하는 지점에서 field별로 개별 방어되고 있었고, 그중 한 field의 방어 코드가 애초에 작성되지 않았던 것입니다.

  paintTimestamp ──┐                        loadTime ──┐
                   │                                   │
                   │                    relativeTimeFromTimeOrigin
                   │                     InReducedResolution()  ◄── clamp
                   │                                   │
                   ▼                                   ▼
        setRenderTime(raw)                     setLoadTime(reduced)
                   │                                   │
                   └──────────► LargestContentfulPaint ◄┘
                                        │
                                  PerformanceObserver
                                        ▼
                                  page JavaScript

이 다이어그램의 두 열을 패치 이전 소스와 나란히 놓고 보면 비대칭이 뚜렷하게 드러납니다. loadTimepaintTimestamp는 같은 방식으로 생성되고, 같은 객체에 자리 잡으며, 같은 observer callback을 통해 script에 도달합니다. 하지만 왼쪽 경로만 clamp를 건너뛰고 있었습니다. 올바른 helper는 이미 이 함수 안에, 문제의 누락 지점 바로 한 줄 위에 존재하고 있었습니다. 바로 이 근접성이 이 버그가 리뷰를 통과한 이유의 전부라고 할 수 있습니다. potentiallyAddLargestContentfulPaintEntry()를 훑어보는 리뷰어는 이미지 블록 안에서 relativeTimeFromTimeOriginInReducedResolution()을 발견하고, 이 함수가 resolution을 의식하고 있다고 판단한 뒤, field별 커버리지까지는 더 확인하지 않게 됩니다. 무조건 실행되는 setRenderTime(paintTimestamp)는 그로부터 열다섯 줄쯤 아래, if (image) 블록 바깥에 완전히 떨어져 위치해 있었습니다. 의문을 불러일으켰을 코드로부터 공간적으로 분리되어 있었던 셈입니다.

이 누락이 페이지에 쥐어주는 것은 memory primitive가 아니라 하나의 측정 채널입니다. 이미지 candidate의 renderTime은 엔진이 어떤 리소스에 대한 페인트를 끝낸 시점이며, 이 리소스의 가용성은 페이지가 다른 방식으로는 관찰할 수 없는 상태에 좌우됩니다. 가장 직접적으로는 그 리소스가 이미 캐시되어 있었는지 여부가 여기 해당합니다. Full resolution 상태에서는 이 값을 같은 entry의 loadTime, 다른 entry의 startTime, 또는 performance.now()와 차분할 수 있으며, 이 각각의 차분값은 페이지가 직접 수행하지 않은 작업에 대해 sub-frame 정밀도의 측정치를 제공합니다. LCP spec의 #sec-report-largest-contentful-paint가 이미지에 대해 4ms grid를 강제하는 이유가 바로 이런 차분이 그 정밀도로는 측정되지 않도록 하기 위해서입니다. Apple의 advisory는 이 impact를 "웹사이트가 사용자가 특정 링크를 방문했는지 알 수 있음"이라고 서술하고 있는데, 이는 cache-inference primitive를 한 단계 더 밀고 나간 결과에 해당합니다. 대상 사이트를 특징짓는 리소스를 probe하고, 페인트 시점을 측정하고, 이미 캐시되어 있었는지를 읽어내는 방식입니다.

Fix는 이미지 renderTime을 entry에 기록되기 전에 4ms grid 위로 내림 처리함으로써 이 invariant를 복구합니다. 텍스트 분기는 의도적으로 full resolution을 유지하는데, 텍스트 candidate는 별도로 fetch되는 cross-origin 리소스가 아니라 same-origin document content이기 때문에, 그 paint 시점이 동일한 수준의 cross-origin 상태를 인코딩하지 않기 때문입니다. Branch discriminant 자체로서는 이 논리가 타당합니다. 다만 여기서 discriminant는 "cross-origin에 의존하는가"가 아니라 image 여부라는 점은 짚어둘 필요가 있으며, 두 조건이 모든 경우에 동일한 predicate는 아닙니다.

이번에 적용된 mitigation을 완전한 방어선이 아니라 하나의 완화 조치로 다룰 때 눈여겨봐야 할 세부사항이 두 가지 있습니다. Coarsening 로직은 순수하게 std::floor(value / resolution) * resolution 형태이며, 표현식 어디에도 entropy가 섞여 있지 않습니다.

auto reduceResolution = [](Seconds value, Seconds resolution) {
    return Seconds(std::floor(value.value() / resolution.value()) * resolution.value());
};

결정적인 quantisation은 단일 sample에서는 정밀도를 제거하지만, 여러 sample의 population에서는 그렇지 않습니다. 측정 대상 이벤트의 phase를 grid에 대해 이동시킬 수 있고 여러 trial에 걸쳐 평균을 낼 수 있는 attacker라면, grid step 이하의 정밀도를 다시 복원할 수 있습니다. 그리고 FIXME는 이 람다가 ReducedResolutionSeconds 도입 전까지의 placeholder임을 명시하고 있으므로, 실제 잔여 정밀도를 결정짓는 것은 그 후속 버그가 될 것입니다. 이번에 반영된 것은 Blink parity와 spec conformance입니다. 실제 결함이었던 지점, 즉 해당 field가 raw 상태였다는 문제가 해소된 것입니다.

Script에 노출되는 record의 여러 field 중 하나에만 privacy clamp가 적용되고 그 형제 field에는 누락되면서, LCP 이미지 paint timestamp가 full resolution 상태로 남아 있었습니다. 어떤 페이지에나 cache-state oracle을 쥐어준 셈입니다.

여러 개의 script-visible timestamp를 채워 넣는 entry-construction 지점이야말로 이런 비대칭이 숨기 좋은 곳입니다. 올바른 sanitiser가 해당 함수 안에 실제로 존재하기 때문에, 리뷰어는 field별 커버리지가 아니라 그 존재 자체를 패턴 매칭 대상으로 삼기 쉽습니다. 어떤 record가 외부에 노출되는 field를 N개 가지고 있고 sanitiser가 N번 호출되어야 한다면, 감사 시 던져야 할 질문은 항상 "어느 호출이 빠졌는가"입니다. 그리고 근본적인 해결 방향은 이 transform을 타입이나 emit 경로 안으로 옮겨 건너뛸 수 없게 만드는 것이며, 이는 여기서 ReducedResolutionSeconds를 도입하는 것이 정확히 달성하려는 목표이기도 합니다.