LCP renderTime should have 4ms granularity
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
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
Patch Details
변경되는 함수는 하나입니다. 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가 추가되며, paintTimestamp를 Seconds::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된 값을 보고하게 됩니다.
Background
Largest Contentful Paint. LCP는 viewport에서 가장 크게 그려진 이미지나 텍스트 블록을 보고하는 Performance Timeline metric입니다. 렌더링이 진행되면서 candidate들이 평가되고, 최종적으로 선정된 candidate가 LargestContentfulPaint entry를 생성하며, 이 entry는 PerformanceObserver를 통해 페이지 JavaScript로 전달됩니다. 어떤 페이지든 이를 구독할 수 있습니다. 별도의 권한이나 user gesture, 특별한 context는 필요하지 않습니다.
Entry의 두 timestamp. 각 entry는 loadTime과 renderTime을 함께 가집니다. 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를 생성하고 필드를 채워 넣습니다.
Analysis
이 문제는 field 단위 sanitisation의 공백에서 비롯됩니다. 엔진 내부 시계와 페이지에 노출되는 script 사이의 경계는 타입이나 emit 경로가 아니라 entry를 구성하는 지점에서 field별로 개별 방어되고 있었고, 그중 한 field의 방어 코드가 애초에 작성되지 않았던 것입니다.
paintTimestamp ──┐ loadTime ──┐
│ │
│ relativeTimeFromTimeOrigin
│ InReducedResolution() ◄── clamp
│ │
▼ ▼
setRenderTime(raw) setLoadTime(reduced)
│ │
└──────────► LargestContentfulPaint ◄┘
│
PerformanceObserver
▼
page JavaScript
이 다이어그램의 두 열을 패치 이전 소스와 나란히 놓고 보면 비대칭이 뚜렷하게 드러납니다. loadTime과 paintTimestamp는 같은 방식으로 생성되고, 같은 객체에 자리 잡으며, 같은 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을 쥐어준 셈입니다.
Insight
여러 개의 script-visible timestamp를 채워 넣는 entry-construction 지점이야말로 이런 비대칭이 숨기 좋은 곳입니다. 올바른 sanitiser가 해당 함수 안에 실제로 존재하기 때문에, 리뷰어는 field별 커버리지가 아니라 그 존재 자체를 패턴 매칭 대상으로 삼기 쉽습니다. 어떤 record가 외부에 노출되는 field를 N개 가지고 있고 sanitiser가 N번 호출되어야 한다면, 감사 시 던져야 할 질문은 항상 "어느 호출이 빠졌는가"입니다. 그리고 근본적인 해결 방향은 이 transform을 타입이나 emit 경로 안으로 옮겨 건너뛸 수 없게 만드는 것이며, 이는 여기서 ReducedResolutionSeconds를 도입하는 것이 정확히 달성하려는 목표이기도 합니다.
Audit directions
-
경계가 transform을 소유해야 할 자리에서 field별 sanitisation이 이루어지는 패턴. Narrow:
Source/WebCore/page에 있는 다른 Performance Timeline entry 생성자들, 즉PerformanceResourceTiming,PerformanceElementTiming,PerformanceEventTiming,PerformancePaintTiming,PerformanceNavigationTiming을 살펴보고,DOMHighResTimeStamp를 세팅하는 모든 setter 인자가relativeTimeFromTimeOriginInReducedResolution()또는reduceTimeResolution()에서 나오는지 확인해야 합니다. 단서가 되는 신호는setXxxTime(...)의 인자가 helper 함수의 반환값이 아니라 raw parameter나 단순 산술식으로 되어 있는 경우입니다. Wider: 같은 형태는 구조체가 field 단위로 조립된 뒤 trust boundary를 넘는 모든 곳에서 재발할 수 있습니다. 일부 인자만MESSAGE_CHECK로 검증하고 나머지는 검증하지 않는 IPC encoder, 다중 property 인터페이스에서 accessor 하나만 origin-check하는 DOM binding wrapper, diagnostic payload의 일부 field만 redact하는 serialization 경로 등이 해당합니다. 검색 결과에서는 연속된 setter 호출들 중 일부 인자만 wrapping되어 있는 패턴을 찾아보는 것이 단서가 됩니다. Widest: 이는 경계 단위가 아니라 field 단위로 sanitisation이 이루어지는 일반적인 취약점 클래스이며, marshalling 계층을 가진 어떤 시스템에서도 성립합니다. Chromium의 Mojo struct trait, field-level redaction을 수행하는 protobuf/JSON serializer, column별로 PII를 마스킹하는 ORM DTO mapper 등이 그 예입니다. -
Deterministic quantisation used as a side-channel mitigation. Invariant는 다음과 같습니다. clamp가 k비트의 정밀도를 제거하는 것은, quantisation phase가 attacker 입장에서 예측 불가능할 때만 의미가 있습니다. Narrow: 새로 추가된
reduceResolutionlambda를 엔진의 canonical path —Performance::reduceTimeResolution과 webkit.org/b/316824에서 추적 중인ReducedResolutionSeconds작업 — 와 비교해볼 필요가 있습니다. Canonical path가 jitter를 추가하는지, 아니면 이쪽도 단순 floor 연산에 불과한지 확인해야 합니다. 판별 지점은std::floor(value / resolution) * resolution형태의 식에 entropy source가 전혀 없다는 점입니다. Wider: WebCore/WTF 내 다른 곳에서 truncation으로 구현된 privacy clamp도 같은 틀로 살펴볼 수 있습니다. 반올림된 device-pixel-ratio 보고,deviceMemory/hardwareConcurrency류의 quantised hint, truncated된 geolocation·sensor sampling rate 등이 해당됩니다. 눈여겨볼 형태는, script가 읽을 수 있다는 이유만으로 값에 적용된std::floor/std::round/bit-mask 연산이며, 주석에 layout이나 units가 아니라 privacy 또는 spec-mandated coarsening이 언급되어 있다면 그 지점이 단서입니다. Widest: "privacy mitigation으로서의 deterministic quantisation"이라는 클래스는 differential-privacy noise budget, k-anonymity bucketing, fixed-window rate limiting, 그리고 V8과 SpiderMonkey의 clock clamping까지 포괄합니다. 각각에 대해 두 가지를 물어야 합니다. 구현이 randomness를 실제로 끌어오는지, 그리고 그 randomness가 origin별·session별로 분리되어 있어 평균화로 무력화될 수 없는지입니다. -
type discriminant의 한쪽 arm에만 guard가 적용되고, 형제 arm은 permissive한 상태로 남은 경우. Narrow:
potentiallyAddLargestContentfulPaintEntry()에서 image arm은renderTime을 coarsen하지만elsearm은 그렇지 않습니다. 어떤 caller가 text arm에 도달하는지 추적하고, text candidate가 cross-origin에 의존하는 load timing을 가질 수 없는지 확인해야 합니다. Webfont fetch가 특히 흥미로운 케이스인데, text paint가 font load에 gate될 수 있기 때문입니다. 판별 지점은,!imagecandidate의 paint 시점이 별도로 fetch되는 subresource에 여전히 의존하는 경로가 존재하는가입니다. Wider: 동일한 패턴은 WebCore가 resource provenance에 따라 얼마나 많은 detail을 노출할지 분기하는 곳이라면 어디서든 재발할 수 있습니다. Resource timing의 TAO-gated 필드, error reporting과SecurityOrigincheck에서의 same-origin·cross-origin 분기, cross-origin-tainted canvas readback 등이 그렇습니다.if (isSameOrigin)/if (image)/if (passesTimingAllowCheck)형태로 분기되면서 한쪽 arm만 redaction helper를 호출하는 패턴을 찾아야 합니다. Widest: "예외 arm이 예전의 permissive한 동작을 그대로 물려받는" 현상은, discriminated union 위에 policy가 나중에 얹히는 시스템이라면 어디서든 나타납니다. 새 path에만 적용된 feature-flag rollout, v2 payload는 sanitize하면서 v1은 그대로 통과시키는 migration shim, 한쪽 subclass override에만 추가되고 형제 override에는 빠진 permission check 등이 그 예입니다. 한쪽 branch에 guard를 추가했다면, 형제 branch들을 모두 나열하고 기존 동작이 안전했다고 가정하는 대신 각각의 예외에 대해 명시적인 근거를 요구해야 합니다.