Low chrome Memory Corruption 📄 Reporter bug report

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Embargoed
Fix Status
ImpactMemory corruption in Fonts
DescriptionMemory corruption in Fonts
ComponentFonts
Sub-componentBlink>Fonts
Bug ClassMemory Corruption
Tracker562223550
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Fix not yet public

No public source fix for this bug has been identified on the main branch yet — it is embargoed or not yet disclosed. Root-cause analysis is withheld until the fix commit is available.

Original Bug Report

reported by [email protected]

Type confusion in FcCacheOffsetsValid leads to arbitrary memory read in browser process

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: On desktop Linux, Chrome’s bundled fontconfig runs in the unsandboxed browser process and mmaps on-disk font cache files at startup, gated only by FcCacheOffsetsValid. That validator never checks a serialized value’s declared type against its pattern element’s object, and skips all validation of the 8-byte value union for scalar types such as FcTypeInteger. A crafted cache can therefore attach an Integer-declared value holding an arbitrary qword to an FC_FAMILY element; the family comparators dispatch by object and dereference that qword as a string pointer on every font query. Web content that obtains one File System Access write grant to the legacy ~/.fontconfig cache directory (absent from the FSA blocklist) can plant such a cache and, after the next launch, read-walk attacker-chosen browser-process addresses and exfiltrate memory contents via a font-match equality oracle.

Affected files:

Estimated timestamp from git blame: 2025-06-17

Summary

FcCacheOffsetsValid (third_party/fontconfig/src/src/fccache.c:872-1060) is the only barrier between an attacker-writable cache file and the browser-process font matcher. It validates the value union only for declared types String/CharSet/LangSet/Range; for FcTypeVoid/Integer/Double/Bool/Matrix/FTFace the arm is an explicit nop, and e[j].object is never consulted, so nothing ties the declared type to the element’s object.

Comparators are dispatched by object, not value type (third_party/fontconfig/src/src/fcmatch.c:407-433). A cache pattern whose FC_FAMILY element carries a value node declared FcTypeInteger with an attacker qword in the union passes validation, and every subsequent font query dereferences that qword as a string pointer, walking memory byte-by-byte until NUL.

Consequences (all demonstrated by execution on a Linux ASan build):

  • Browser-process read at an attacker-chosen absolute address (tag bit clear) or unbounded cache-relative offset (tag bit set — no ASLR knowledge needed).
  • SIGSEGV at the chosen address when unmapped — a mapping oracle across restarts.
  • A non-crashing content-equality oracle: if the bytes at the probed address (case/blank-folded, up to NUL) equal one of the CSS-controlled query family strings, the poisoned font wins the match and the renderer observes the flip via font metrics — repeatable, with unlimited guesses per planted address and multiple planted addresses per cache.

Delivery assumptions (held on the test host; distro/host-dependent for arbitrary victims): the host /etc/fonts/fonts.conf includes the legacy ~/.fontconfig cachedir (upstream default per the bundled template), that directory pre-exists (FSA cannot create it since $HOME itself is picker-blocked), the victim grants an FSA write permission to it via the directory picker, and the browser is subsequently restarted.


Root Cause Analysis

  1. Validator skips the union for scalar-declared types and never cross-checks the object: third_party/fontconfig/src/src/fccache.c:943-950 — throughout the value loop (fccache.c:937-1054), e[j] is used only as an address bound; e[j].object is never read.

    // [`third_party/fontconfig/src/src/fccache.c:943-951`](https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fccache.c;l=943)
    switch (l->value.type) {
    case FcTypeVoid:
    case FcTypeInteger:   // <-- union qword accepted verbatim
    case FcTypeDouble:
    case FcTypeBool:
    case FcTypeMatrix:
    case FcTypeFTFace:
        break; /* nop */
    case FcTypeString:    // <-- the arm that WOULD enforce FcIsEncodedOffset
    

    The String arm’s checks (fccache.c:951-961) are bypassed because dispatch follows the attacker-declared type.

  2. Cache patterns bypass the runtime type guarantee: FcObjectValidType (which rejects Integer for FAMILY) is enforced only for runtime adds (third_party/fontconfig/src/src/fcpat.c:621, :678). FcConfigAddCache adds the raw mmapped patterns directly to the system font set (third_party/fontconfig/src/src/fccfg.c:482), reached from browser FcInit() via FcConfigBuildFonts → FcDirCacheRead.

  3. Acceptance requires no local knowledge: a header with fc_version = INT64_MAX satisfies FcCacheIsNewVersion (fccache.c:820-824), bypassing dir-mtime validation (fccache.c:1126-1132), and the freshly written file wins newest-mtime arbitration over the legitimate cache (fccache.c:1192-1206).

  4. Type-blind dereference on every query: FcCompareFamilies calls FcValueString on every family node of every scored font — the poisoned pattern does not need to win the match.

    // [`third_party/fontconfig/src/src/fcmatch.c:596-598`](https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcmatch.c;l=596)
    for (v2 = v2orig; v2; v2 = FcValueListNext (v2)) {
        key = FcValueString (&v2->value);                 // no type check
        if (FcHashTableFind (table, key, (void **)&e)) {  // hashes bytes AT key
    

    FcValueString is FcPointerMember (third_party/fontconfig/src/src/fcint.h:164,191,198): with the tag bit clear the qword is used as a raw pointer, and FcStrHashIgnoreBlanksAndCase (third_party/fontconfig/src/src/fcstr.c:290) walks bytes at that address until NUL; bucket hits additionally run FcStrCmpIgnoreBlanksAndCase (fcstr.c:361-379).

  5. Reachable in the browser process from web content: FcInit() runs in the browser process (ui/gfx/linux/fontconfig_util.cc:58; bundled fontconfig per third_party/fontconfig/fontconfig.gni:10), and renderer-controlled family strings reach FcFontMatch/FcFontSort via FontService (content/browser/font_service.cc:16-28, components/services/font/font_service_app.cc:101-164, third_party/skia/src/ports/SkFontConfigInterface_direct.cpp:629,683,691). The browser’s own startup UI font queries also trigger the walk with no renderer involvement.

  6. Delivery gap: ~/.fontconfig is a legacy cachedir in the bundled default config template (third_party/fontconfig/src/fonts.conf.in:90, marked “will be removed in the future”) but is not in Chrome’s FSA blocklist — $HOME is blocked kDontBlockChildren and only .ssh/.gnupg/.config/.dbus/~/.cache children are blocked (chrome_file_system_access_permission_context.cc:378-493) — so a single directory-picker write grant lets web content plant the cache file.

Reproduction

  1. Host precondition (held on the test system; upstream-default but host-dependent): /etc/fonts/fonts.conf includes the ~/.fontconfig cachedir and that directory exists.
  2. A web page calls showDirectoryPicker(); the victim selects ~/.fontconfig and clicks Allow on the “Allow this site to edit files?” chooser.
  3. The page writes <md5(font dir)>-le64.cache-12 (e.g. for /usr/share/fonts): magic FC_CACHE_MAGIC_MMAP (0xFC02FC04), version 12, size = file size, fc_version = INT64_MAX, valid dir/dirs/set offsets, and one pattern containing an honest FC_FILE string naming a real font plus an FC_FAMILY element whose value list includes a node {type = FcTypeInteger, union = TARGET_ADDRESS} (or RELATIVE_OFFSET|1 for cache-relative probing).
  4. Restart the browser.
  5. Expected: the cache is rejected — an Integer-typed value is never legal on FC_FAMILY (FcObjectValidType).

Observed (executed): the browser process crashed during startup, before any page load, with SEGV_MAPERR at exactly the planted address 0x424242424240, in FcStrHashIgnoreBlanksAndCase ← FcHashTableFind ← FcCompareFamilies/FcCompare ← FcFontSetMatchInternal ← FcFontMatch ← SkFontConfigInterfaceDirect::matchFamilyName ← gfx::FontList (startup font-pref query). The full web-content delivery was also executed: the FSA chooser granted write access to ~/.fontconfig (the directory passes the blocklist), the page wrote the cache, and the next launch crashed. In oracle mode (mapped probe address, tag bit set), when the probed bytes equaled the query family string “sans-serif” the poisoned font won the match and the renderer observed the flip via canvas.measureText (500.78 → 481.64, bit=1 vs bit=0 A/B runs); unmapped probes instead produce the crash/mapping oracle.

Severity

high — an attacker-chosen-address read plus content-disclosure oracle in the unsandboxed browser process is a memory-safety violation with all-origins disclosure scope, matching the high tier for browser-process memory-safety bugs gated on a permission-chooser prompt (here an FSA directory-picker write grant plus a restart; post-plant triggering is deterministic on every font query with zero further interaction). It is not critical because the primitive is read-only (no write/RCE demonstrated) and delivery additionally depends on host configuration: the legacy cachedir present in the host /etc/fonts/fonts.conf and a pre-existing ~/.fontconfig. These are social/configurational preconditions, not the timing-sensitive trigger complexity that would demote to medium.

Suggested Fix Direction

In FcCacheOffsetsValid, reject any serialized value whose declared type is not valid for the element’s object — the same FcObjectValidType(e[j].object, l->value.type) predicate already enforced for runtime adds — and bounds-check the remaining pointer-typed nop arm (FcTypeMatrix). This check was prototyped and verified to reject the poisoned caches while legitimately generated caches still load. Independently, consider adding ~/.fontconfig to the File System Access blocklist.


POC: FcCacheOffsetsValid never cross-checks value.type against elt object: poisoned cache smuggles raw pointers through nop-arm FcTypeInteger into type-blind FcCompareFamilies/FcCompareFamily — attacker-chosen-address read walk in browser process on every font query, with content-equality exfil oracle

Strategy: web-content Bug Type: type confusion (CWE-843) → attacker-controlled-address read in the unsandboxed browser process (SEGV at chosen address / non-crashing content-equality disclosure oracle)

FcCacheOffsetsValid (third_party/fontconfig/src/src/fccache.c:943-950) validates an attacker-writable on-disk fontconfig cache but treats FcTypeInteger (and the other nop-arm types) as requiring no validation of the 8-byte value union, and never checks that the declared value type is valid for the element’s object. The FC_FAMILY comparators dispatch by object, not type (fcmatch.c:596-598, fcmatch.c:68-83), and call FcValueString() on every family value node: a node declared FcTypeInteger whose union holds an attacker qword is dereferenced as a raw pointer (fcint.h:164,191 — tag bit clear = absolute address, tag bit set = unbounded offset relative to the mmapped cache) and FcStrHashIgnoreBlanksAndCase reads the bytes at that address until NUL (fcstr.c:290) — in the browser process, on every font query.

Contents

file purpose
build_cache.py crafts the poisoned <md5(fontdir)>-le64.cache-12 (crash + oracle modes)
fsa-plant.html delivery page: writes the cache into ~/.fontconfig via the File System Access API
oracle.html renderer-side readout of the memory-content equality oracle
server.py serves the POC on http://localhost:8080
payload.bin pre-built crash-mode cache served to fsa-plant.html
crash-browser-process.log browser stderr + llvm-symbolized stack of the SEGV at 0x424242424240
fsa-permission-prompt.png / fsa-planted.png screenshots of the FSA grant + successful plant
fsa-restart-crash.log crash of the next browser launch after the web-content plant
oracle-results.log A/B runs of the equality oracle (renderer console + match-flip evidence)
bandaid-fix.patch minimal validator fix (verified: poisoned caches rejected, no crash)

Prerequisites

  • Linux desktop Chromium ASAN build: /chromium_src/src/out/asan/chrome (bundled fontconfig 2.18.3, cache version 12; verified statically linked).
  • Host /etc/fonts/fonts.conf listing the legacy cachedir ~/.fontconfig (true of the upstream default template and of this host: <cachedir>~/.fontconfig</cachedir>), with ~/.fontconfig existing. /usr/share/fonts is the first <dir>; md5("/usr/share/fonts") = 3830d5c3ddfd5cd38a049b759396e72e matches the cache file name planted below.
  • xdotool only if you want to replay the scripted FSA delivery.

Reproduction

A. Full web-content chain (delivery + trigger)

cd /poc-artifacts && python3 server.py &     # serves fsa-plant.html + payload.bin

rm -rf /tmp/chrome-poc-profile
XDG_CACHE_HOME=$HOME/.cache /chromium_src/src/out/asan/chrome \
  --no-first-run --no-default-browser-check --ignore-gpu-blocklist \
  --no-proxy-server --user-data-dir=/tmp/chrome-poc-profile \
  http://localhost:8080/fsa-plant.html

In the page: click Show me fonts!, pick /home/<user>/.fontconfig in the directory picker (Ctrl+L, type the path, click Open), click Allow on the “Allow this site to edit files?” chooser. The page prints PLANTED 3830d5c3...-le64.cache-12 (4096 bytes). This is the only user interaction in the chain (the report’s stated precondition; in our run it was scripted with xdotool — see fsa-permission-prompt.png, fsa-planted.png).

Quit the browser and start it again (any URL):

XDG_CACHE_HOME=$HOME/.cache /chromium_src/src/out/asan/chrome \
  --no-first-run --no-default-browser-check --ignore-gpu-blocklist \
  --no-proxy-server --user-data-dir=/tmp/chrome-poc-profile2 about:blank

The browser process SEGVs during startup at the attacker-chosen address — before any page is loaded — because its first font query (PrefsTabHelper::RegisterProfilePrefs → gfx::FontList → Skia → FcFontMatch) walks the poisoned FC_FAMILY list.

B. Direct plant (skips the FSA UI; identical bytes)

python3 /poc-artifacts/build_cache.py --mode crash --addr 0x424242424240
# wrote ~/.fontconfig/3830d5c3ddfd5cd38a049b759396e72e-le64.cache-12
# then launch chrome as above -> same crash

C. Content-equality exfiltration oracle (non-crashing, renderer-visible)

The poisoned qword can instead carry a cache-relative offset (tag bit set). The demo places a “secret” string inside the cache to have deterministic bytes at the probed location; against a real target the same node carries an absolute address of victim browser memory.

# bit=1 case: bytes at the probed address are "sans-serif"
python3 /poc-artifacts/build_cache.py --mode oracle --family Verdana --secret "sans-serif"
rm -rf /tmp/chrome-poc-profile && XDG_CACHE_HOME=$HOME/.cache \
  /chromium_src/src/out/asan/chrome --no-first-run --no-default-browser-check \
  --ignore-gpu-blocklist --no-proxy-server --enable-logging=stderr \
  --user-data-dir=/tmp/chrome-poc-profile http://localhost:8080/oracle.html
# renderer console: ORACLE ... bit=1   (CSS "sans-serif" text renders with the
#                                       poisoned font's file, DejaVuSansMono.ttf)

# bit=0 case: any other bytes
python3 /poc-artifacts/build_cache.py --mode oracle --family Verdana --secret "WrongBytes99"
# rerun chrome -> ORACLE ... bit=0     (normal DejaVu Sans)

Mechanics of the readout: on every FcFontMatch, FcCompareFamilies hashes the bytes read through the smuggled pointer against the query’s family hash table (built from the CSS-controlled family list, fcmatch.c:536-574,596-614). If they case/blank-fold-equal the query’s strong key "sans-serif" (index ~92 after fonts.conf alias expansion), the poisoned font’s PRI_FAMILY_STRONG beats every real font (which all score 1e99 strong) and it wins the match; its FC_FILE is then returned to the renderer as the FontIdentity, flipping canvas.measureText metrics for a page using font-family: "sans-serif" (quoted = concrete family name, which is what reaches fontconfig verbatim). The pattern carries an honest FONTFORMAT="TrueType" so the winning match passes Skia’s SK_FONT_CONFIG_INTERFACE_ONLY_ALLOW_SFNT_FONTS check (SkFontConfigInterface_direct.cpp:521-528,557) and an honest decoy family (“Verdana”, weak index 3 > DejaVu Sans’s 2) so it loses cleanly when the probe misses. One probe = one equality bit at one address; multiple poisoned patterns give multiple probed addresses per cache, re-plantable without limit. Unmapped probe addresses crash the browser instead (run A) — a mapping oracle across restarts.

Expected Result

  • Run A/B: browser process dies with Received signal 11 SEGV_MAPERR 424242424240 (the attacker-chosen address; cr2/rdi = 0x424242424240) inside FcStrHashIgnoreBlanksAndCase ← FcHashTableFind ← FcCompareFamilies/FcCompare ← FcFontSetMatchInternal ← FcFontMatch — see the symbolized stack in crash-browser-process.log.
  • Run C: ORACLE ... bit=1 with secret "sans-serif", bit=0 otherwise (oracle-results.log; the browser-side match flip is also visible as 21-vs-0 Fontconfig warning: FcPattern object family does not accept value ...(i) stderr lines — that warning fires exactly when the winning pattern is the poisoned one).

Output

Fontconfig warning: We will not regenerate the cache because some cache files were
generated by a newer version (0x7fffffffffffffff) of Fontconfig. ...
    [= the planted cache was accepted via the fc_version freshness bypass]

Received signal 11 SEGV_MAPERR 424242424240
#4  FcStrHashIgnoreBlanksAndCase ./../../third_party/fontconfig/src/src/fcstr.c:290:6
#5  FcHashTableFind ./../../third_party/fontconfig/src/src/fchash.c:101:26
#6  FcCompare ./../../third_party/fontconfig/src/src/fcmatch.c:598:6   [FcCompareFamilies inlined]
#7  FcFontSetMatchInternal ./../../third_party/fontconfig/src/src/fcmatch.c:881:11
#8  IA__FcFontMatch ./../../third_party/fontconfig/src/src/fcmatch.c:1046:12
#9  SkFontConfigInterfaceDirect::matchFamilyName(...) SkFontConfigInterface_direct.cpp:683
#10 SkFontMgr_FCI::onMatchFamilyStyle(...)
#11 gfx::FontList::FirstAvailableOrFirst(...)
#12 PrefsTabHelper::RegisterProfilePrefs(...)          [browser process, startup]
  r di: 0000424242424240 ... cr2: 0000424242424240
ORACLE probe("sans-serif")=481.64 ctrl(DejaVu Sans)=500.78 ctrl(DejaVu Sans Mono)=481.64
  => memory@target == "sans-serif" : bit=1        [secret = "sans-serif"]
ORACLE probe("sans-serif")=500.78 ctrl(DejaVu Sans)=500.78 ctrl(DejaVu Sans Mono)=481.64
  => memory@target == "sans-serif" : bit=0        [secret = "WrongBytes99"]

Bandaid Fix

bandaid-fix.patch (against third_party/fontconfig/src) makes FcCacheOffsetsValid reject any serialized value whose declared type is not valid for the element’s object (FcObjectValidType, the same predicate already enforced for runtime adds at fcpat.c:621,678 — so every cache legitimately written by fontconfig still passes), and additionally bounds-checks the FcTypeMatrix pointer arm (the one remaining pointer-typed nop arm that is valid for its own object). Verified: with the fix applied, the poisoned cache is rejected (FC_DEBUG=16 shows invalid cache: value type 1 is not valid for object 1), fontconfig falls back to rescanning the directory, and the browser starts and renders normally with both the crash-mode and oracle-mode caches planted.

cd /chromium_src/src/third_party/fontconfig/src && git apply /poc-artifacts/bandaid-fix.patch
cd /chromium_src/src && autoninja -C out/asan chrome

Notes

  • Cache acceptance needs no timing/mtime knowledge: fc_version = INT64_MAX satisfies FcCacheIsNewVersion (fccache.c:820-824,1130-1131), and the fresh mtime wins newest-cache-wins arbitration (fccache.c:1192-1206) over any legitimate cache.
  • The crafted cache preserves the font directory’s real subdirectory list, so all system fonts keep working in the non-crashing (oracle) configuration — nothing looks broken to the victim.
  • XDG_CACHE_HOME=$[HOME/.c](https://source.chromium.org/chromium/chromium/src/+/main:HOME/.c)ache in the run commands only normalizes a stale sandbox environment variable to its desktop default; it plays no role in the bug (the planted cachedir is ~/.fontconfig, resolved via $HOME).
  • The delivery matches the vulnerability report’s threat model: one FSA directory-picker write grant to the hidden, blocklist-exempt ~/.fontconfig plus a browser restart. All post-plant behavior is zero-interaction.

Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker
Links in the report