← All reports

Remove relation caching from `MatchResultCache::copy()`

The style cache that outlived the elements it was still pointing at.

Component: WebCore Style Resolution | f457aa2

CSS style resolution 과정에서 동일한 selector를 반복해서 다시 매칭하지 않도록 WebCore는 MatchResultCache를 두고 있습니다. 특히 :has()의 경우 descendant와 sibling을 대상으로 speculative matching이 필요하기 때문에, 이런 캐시의 효용이 큽니다. :has() 매칭이 실행되면 Style::Relation 항목이 기록되는데, 여기에는 raw Element* pointer와 FirstChild/LastChild 같은 위치 정보가 함께 담깁니다. 기록된 relation은 이후 commitRelations()를 통해 해당 element들에 적용되며, 그래야 이후 DOM이 변경될 때 style invalidation이 정확하게 수행됩니다. 문제는 캐시 항목의 수명과 relation이 가리키는 element들의 수명 사이에 아무런 연결 고리가 없다는 점입니다.

이번 commit은 MatchResultCache::copy()에서 relation caching을 전부 제거했습니다. 기존에는 copy()가 Style::Relation 벡터를 깊은 복사해 오래 유지되는 캐시 항목 안에 보관했습니다. 이때 캐시에 저장된 시점과 이후 commitRelations() 호출 사이에 DOM이 변경되면, raw Element*가 stale 상태가 됩니다. element가 제거되더라도 캐시는 정리되지 않기 때문입니다. 수정은 invalidation을 추가하는 대신 FullWithMatchResultCache 경로에서 relation caching 자체를 걷어내는 방향을 택했습니다. 해당 경로는 resetStyleRelations()를 호출하지 않으므로 이 방식이 성립합니다. FirstChild/LastChild 플래그는 이미 copyRelations()를 통해 resolution 사이에 유지되고 있습니다.

  Before:
  resolve(el)  ──► :has() match ──► Relation{ Element* e, FirstChild }
                                       └─► MatchResultCache::copy()  (long-lived)
  remove(e)    ──► element freed; cache entry untouched
  resolve(el)  ──► cache hit ──► commitRelations() ──► e->setFlag()   ← UAF

  After:
  MatchResultCache::copy()  ──► match result only, no relations
                                 (FirstChild/LastChild persist via copyRelations())

style resolution 단계의 heap use-after-free가 이번 수정으로 차단되었으며, 이 경로는 featureless한 위치 지정자나 sibling 인자를 쓴 :has()를 통해 웹 콘텐츠에서 도달 가능합니다. 조작된 CSS나 script를 이용하면 캐시 항목의 수명과 그 relation이 가리키는 element들의 수명이 어긋나는 상황을 유도할 수 있습니다. 그 결과 style resolution 도중 memory corruption으로 이어질 가능성이 있습니다.

여기서 뽑아낼 수 있는 일반적인 패턴은 이렇습니다. raw pointer를 품은 레코드를 깊은 복사해 저장하면서, 정작 그 구조의 eviction 정책은 해당 pointer들의 수명과 무관한 기준으로 돌아가는 캐시입니다. Narrow: Style::Relation을 생산하고 사용하는 나머지 지점들을 점검해 보십시오. 같은 resolution 안에서 commit하지 않고 relation을 저장해 두는 코드가 대상입니다. 저장하는 쪽에 element 제거 시 동작하는 purge hook이 걸려 있는지 확인이 필요합니다. Wider: resolution 주기를 넘어 살아남으면서 Element*, RenderObject*, Node* 멤버를 들고 있는 다른 style/layout 측 캐시들도 형태가 동일합니다. 이때 들고 가야 할 질문은 어떤 mutation 경로가 캐시를 실제로 purge하고 어떤 경로는 dirty 표시만 남기는가입니다. :has() invalidation은 의도적으로 mutation 지점에서 멀리 떨어진 element까지 건드리기 때문입니다. Widest: key와 value의 수명 소유자가 서로 다른 memoization 계층이라면 어디든 같은 위험을 물려받습니다. 찾아야 할 invariant는 어느 쪽이 더 오래 사는지에 대한 명시적인 서술이며, 그런 서술이 없다는 사실 자체가 곧 finding입니다. Code-review tell: bare pointer를 담은 struct 벡터를 그대로 넘기는 copy() 또는 clone 메서드가 캐시로 설명되는 컨테이너로 향하는 경우입니다.