← Back

How CVE-2026-2441 Actually Works

Recently in tech news, a fairly simple use-after-free bug with a public proof of concept was published in the Chromium renderer process. This is interesting because these kinds of bugs are usually either quietly patched without much fanfare, or disclosed without a PoC. The fact that a public PoC exists for this use-after-free while certainly not everyone has updated to the new version yet makes this noteworthy.

Iterators and entries

To understand the bug you need some JS knowledge, specifically what iterators and entries are.

const m = new Map([["a", 1], ["b", 2]]);

const iterator = m.entries();

console.log(iterator.next()); // { value: ["a", 1], done: false }
console.log(iterator.next()); // { value: ["b", 2], done: false }
console.log(iterator.next()); // { value: undefined, done: true }

iterator.next() returns the next object in a map.

Chromium has to do a lot of complicated things:

  • HTML parsing
  • CSS parsing
  • DOM tree construction
  • Layout calculation
  • Rendering

For this, Chromium uses Blink, the rendering engine. At the same time, Chromium uses V8 to execute JavaScript. These worlds overlap since you can obviously manipulate rendering things from JavaScript.

Importantly, V8 by itself knows nothing about:

  • CSS rules
  • Fonts
  • DOM nodes
  • Layout
  • Style recalculation

If we want to change the color of something in JavaScript:

document.body.style.color = "red"

This wouldn’t work in Node.js, because there is no DOM or CSS engine there.

This is where Blink comes in. Blink creates C++ objects that implement the web platform and exposes them to V8 via a bindings layer.

Looking back at the iterator example, that is completely standalone V8 code.

But what happens when we do this:

<style id="s">
@font-feature-values TestFont {
  @styleset {
    foo: 1;
    bar: 2;
  }
}
</style>

<script>
const sheet = document.getElementById("s").sheet;
const rule = sheet.cssRules[0];
const map = rule.styleset;

console.log(map); // CSSFontFeatureValuesMap {size: 2}
console.log(map.size); // 2
</script>

The @font-feature-values CSS at-rule lets you use a common name in the font-variant-alternates property for features activated differently in OpenType.

rule.styleset is not a real JavaScript Map but a CSSFontFeatureValuesMap object (in the Blink code a WTF::HashMap<String, Vector<int>>, see third_party/blink/renderer/core/css/style_rule_font_feature_values.h) where interaction goes through Blink when we call functions like:

map.entries()
map.set()
map.delete()
map.size

These functions behave the same on the JavaScript side (as they should), but are implemented entirely differently in Blink compared to V8’s Map.

When you call map.entries(), the C++ code runs the following function: FontFeatureValuesMapIterationSource, and this is where the bug lives.

third_party/blink/renderer/core/css/css_font_feature_values_map.cc:

namespace blink {

class FontFeatureValuesMapIterationSource final
    : public PairSyncIterable<CSSFontFeatureValuesMap>::IterationSource {
 public:
  FontFeatureValuesMapIterationSource(const CSSFontFeatureValuesMap& map,
                                      const FontFeatureAliases* aliases)
      : map_(map), aliases_(aliases), iterator_(aliases->begin()) {}

  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;
    map_value = iterator_->value.indices;
    ++iterator_;
    return true;
  }

  void Trace(Visitor* visitor) const override {
    visitor->Trace(map_);
    PairSyncIterable<CSSFontFeatureValuesMap>::IterationSource::Trace(visitor);
  }

 private:
  const Member<const CSSFontFeatureValuesMap> map_;
  const FontFeatureAliases* aliases_;
  FontFeatureAliases::const_iterator iterator_;
};

Reading through the C++, we can see map_(map), aliases_(aliases), iterator_(aliases->begin()). If you’re not familiar with C++ constructor initializer lists, this is roughly equivalent to:

this->map_ = map;
this->aliases_ = aliases;
this->iterator_ = aliases->begin();

How a hashmap actually works

A hashmap is a key-value store, nothing new there. A hashmap has a dynamic size, so it needs to be able to allocate more memory when you add too much data. A hashmap is actually quite clever: besides the key and value, the hash of the key is also stored (in WTF::HashMap). Based on the hash, it chooses the bucket in which the value is allocated. The hash function used is WTF::StringHasher.

Example: key: wout, value: 67. We take the hash of wout, e.g. 6724742. We take this number modulo the number of buckets we have, e.g. 10 buckets, so 6324742 % 10 = 2, so it goes in bucket[2]. If we want to quickly find the value later, we just do the same trick to land on the right bucket immediately.

If we do a lot of allocations, the hashmap needs to create new buckets, this is called a rehash. This frees the old buckets and recalculates all values to fit into new buckets. You can’t just append new extra buckets at the end, because the formula is based on the total number of buckets, and the result would no longer be correct: 6324742 % 15 != 2.

The issue

The issue is simply that the iterator is created on the live hashmap: iterator_(aliases->begin()).

By forcing a rehash through many set() calls, the hashmap allocates a new bucket array and frees the old bucket storage. The iterator that was previously created with map.entries() is still based on the old bucket storage. When we then call iterator.next(), Blink reads through that iterator from memory that has since been freed.

If a copy had been made, iterator_ would not be based on the bucket storage of the live hashmap, but on the bucket storage of a snapshot of the map at that point in time. A rehash of the original map would then have no effect on the storage the iterator is iterating over.

Turning this into an arbitrary read

Freed bucket storage can be reclaimed by our own allocations (heap grooming). This means the iterator can end up walking over attacker-controlled data that Blink interprets as internal HashMap entries. By placing a pointer in that fake entry, a subsequent .next() call will cause Blink to dereference that pointer when reading the key, resulting in a (step-by-step) arbitrary read.

The diff

The fix can be found in the Chromium source.