Vulnerability research

Skia RenameFont OOB. Google Won't Fix It.

A crafted font drives an attacker-controlled heap overflow through a public Skia API on Windows. It reproduces end to end. Google declined it - real bug, wrong threat model.

A stock verdana.ttf, one directory entry changed. I loaded it through a public API on a real Windows build of Skia, and AddressSanitizer printed the sentence I was hoping for: a heap-buffer-overflow, a write of 243,300 bytes, zero bytes past the end of a 280-byte allocation. The length is mine. The bytes are mine. The stack runs straight from main down through the public font manager into the function that did it.

That is the whole finding, and it reproduces every time. What happened after I reported it is the part worth writing down, because it is not a story about a bug being wrong. It is a story about a bug being real and declined anyway - and about what that says about the word "reachable."

Disclosure status

Reported to Google's issue tracker on 2026-06-23 (issue 527060475, not public). A Chromium security reviewer returned Won't Fix (Not Reproducible) on 2026-06-24. The bug itself is real: verified end to end under AddressSanitizer through a public API, with a one-hunk fix that closes it, and it remains unpatched in main. There is no CVE and no embargo. The crash was not disputed - what was declined was its severity under a web-only threat model.

The bug

Skia's SkOTUtils::RenameFont rewrites a font's name table. On the Windows GDI backend, Skia renames any stream-loaded font before it hands it to the operating system, so this runs on attacker bytes as the very first thing that touches the font. The function sizes an output buffer from the font's declared table sizes, then copies data using the font's declared table offset. It never checks either number against the actual file (src/sfnt/SkOTUtils.cpp, revision 488a6fc):

size_t oldNameTablePhysicalSize = (SkEndian_SwapBE32(tableEntry.logicalLength) + 3) & ~3;  // attacker-controlledsize_t oldNameTableOffset       =  SkEndian_SwapBE32(tableEntry.offset);                    // attacker-controlledsize_t originalDataSize = fontData->getLength() - oldNameTablePhysicalSize;   // (1) size_t underflow-pronesize_t newDataSize      = originalDataSize + nameTablePhysicalSize;auto rewrittenFontData = SkData::MakeUninitialized(newDataSize);SK_OT_BYTE* data = static_cast<SK_OT_BYTE*>(rewrittenFontData->writable_data());if (fontData->read(data, oldNameTableOffset) < oldNameTableOffset) {          // (2) reads offset bytes into the newDataSize buffer    return nullptr;}

There are two attacker knobs on a single name directory entry, and they pull against each other in exactly the wrong direction. A large logicalLength shrinks newDataSize, the buffer that gets allocated. A large offset becomes the count for the read that fills it. Declare a small buffer and a large read of the same entry, and line (2) writes offset attacker bytes into a buffer that was sized without ever looking at offset. Push logicalLength past the file length and line (1) also underflows size_t, wrapping newDataSize down to something tiny and widening the overflow further.

Nothing enforces the invariant that the name table has to lie inside the font. The SFNT format says it does; the code trusts that it does; the file gets to decide.

Schematic: one name-table directory entry feeds two values. logicalLength sizes a small output buffer; offset becomes the read count that fills it. The read is larger than the buffer, so the copy runs past the end.
One directory entry, two knobs pulling opposite ways: the declared length sizes the buffer, the declared offset sizes the copy. Nothing checks that either fits.

I found it by reading, not by fuzzing. The stream and SAX parsers around it are well covered by oss-fuzz and were clean. The hand-rolled size and offset arithmetic in a utility function, one layer out from the fuzzed surface, was not. That pattern - trusting a container's self-declared table offsets and lengths - is a recurring font and codec bug class, and it tends to live exactly where the fuzzers are not pointed.

Reaching it through the front door

A crash in a helper is a lead, not a finding. The finding is the path from a public API to that helper with nothing in between that revalidates the input. RenameFont has exactly one caller in the entire tree, and the path is short:

SkFontMgr_New_GDI()->makeFromStream/Data/File   (public API, attacker font bytes)
  -> SkFontMgr::makeFromStream                   (nullptr check only)
  -> SkFontMgrGDI::onMakeFromStreamIndex         (sole gate: ttcIndex == 0, the default)
  -> create_from_stream                          (RenameFont is the first op on the stream)
  -> SkOTUtils::RenameFont                         -> heap out-of-bounds write

So I built it end to end. Skia at 488a6fc, compiled for Windows with MSVC and GN's ASan mode, GDI font backend on, and a harness that calls nothing but the public SkFontMgr_New_GDI()->makeFromStream(malicious_verdana.ttf, ttcIndex=0). The crafted font is stock verdana.ttf with its name entry patched to offset=243300, logicalLength=243240:

==ERROR: AddressSanitizer: heap-buffer-overflow  WRITE of size 243300
  #1 SkMemoryStream::read              src/core/SkStream.cpp:349
  #2 SkOTUtils::RenameFont             src/sfnt/SkOTUtils.cpp:91
  #3 create_from_stream                src/ports/SkFontHost_win.cpp:1809
  #4 SkFontMgrGDI::onMakeFromStreamIndex src/ports/SkFontHost_win.cpp:2290
  #5 SkFontMgr::makeFromStream         src/core/SkFontMgr.cpp:189
  #6 main   <- public API
0 bytes to the right of 280-byte region

A 243,300-byte write into a 280-byte buffer, driven through a shipped public API, with attacker-controlled length and contents. A cheaper Linux harness that links the real SkOTUtils.cpp reproduces the same primitive at trivial cost - a synthetic 1000-byte font produces a 900-byte write into a 188-byte buffer - but the Windows run is the one that matters, because it goes through the front door rather than reaching around to the helper.

The caveat I could not argue away

Here is where honest reachability analysis cuts against you. RenameFont is called by the GDI backend and only the GDI backend. Skia's other Windows font backend, DirectWrite, never calls it. Modern Chrome on Windows uses DirectWrite. So default Chrome web-font loading does not hit this, and no amount of clever @font-face will make it.

What it does hit is any Windows application or embedder configured with the GDI font manager that loads untrusted fonts through makeFromStream, makeFromData, or makeFromFile: legacy Chromium configurations, some Skia-based document, SVG, and HTML-to-image renderers, embedded Windows software. RenameFont is also a public SkOTUtils entry point in its own right, so any embedder that renames untrusted fonts is exposed directly. That is a real population. It is not the web.

Schematic: attacker font bytes fork into two paths. The DirectWrite path (default Chrome) never reaches RenameFont and is marked no overflow. The GDI path reaches RenameFont and the heap out-of-bounds write. A dashed boundary labelled 'Google's threat model: the web' encloses only the DirectWrite path, leaving the real, verified, reachable GDI path outside it.
The same crafted font takes two paths. Only the GDI backend reaches the overflow - a real, verified bug that happens to fall outside the boundary Google scores severity against. Out of scope is their verdict, not a property of the bug.

The verdict

I filed it through Google's issue tracker with the security template, GDI scope stated plainly, the ASan crash and the end-to-end harness attached. A Chromium security reviewer replied the next day:

Status: Won't Fix (Not Reproducible).

The reproduction needs to demonstrate that it is reachable from the web. The description claims that web fonts can reach this path; if so, it should be possible to provide a HTML reproduction of this issue.

The reviewer is not wrong. There is no HTML reproduction, and there cannot be one, because the web path is DirectWrite and DirectWrite does not call RenameFont. Under a threat model where "font security" means "a website cannot corrupt memory in the browser," this bug is out of scope, and Won't Fix is the correct call on those terms. Nobody disputed the crash. What was declined was its severity, and severity is the thing the threat model assigns.

I want to be careful not to turn this into a grievance, because it is not one. Arguing web-reachability would have meant arguing something false, and I would have deserved to lose. The reviewer and I agree on every fact. We disagree, if that is even the word, about which facts are in scope.

Reachability is a property of the threat model

The thing I keep coming back to is that "reachable" sounds like a property of the bug and is actually a property of the boundary you draw around it. The overflow does not know whether the bytes arrived from a website or a document renderer or a local file dialog. It corrupts the heap identically in all three cases. The only variable is whether the path that delivered those bytes is inside the circle the vendor is willing to defend.

For an organization the size of Google, drawing that circle around the web path is a defensible resourcing decision. Skia's security burden is dominated by Chrome, and Chrome is DirectWrite. But the circle is a choice, and the choice is what determines whether a verified memory-corruption write is a security bug or a curiosity. Same code, same crash, same attacker control. The disposition is set entirely by who is standing inside the boundary, and the GDI embedders who load untrusted fonts are standing just outside it.

This is the same lesson I took from fuzzing libpng's write path: reachability is half the severity story, and it is the half that gets decided by someone other than the person who found the bug. There I scoped a finding down honestly and watched most of the apparent blast radius evaporate under inspection. Here I scoped it down honestly and watched the finding fall out of scope entirely. Both times the rigor that made the report credible is the same rigor that shrank it.

The fix nobody will merge as a security fix

The fix is one hunk. Reject any name entry that does not lie entirely within the font, before allocating or copying anything:

const size_t fontLength = fontData->getLength();
if (oldNameTablePhysicalSize > fontLength ||
    oldNameTableOffset > fontLength - oldNameTablePhysicalSize) {
    return nullptr;
}

The subtraction is written that way on purpose, so it cannot underflow. With the patch applied and rebuilt, the crafted font is rejected and a legitimate verdana.ttf still loads - correct, and not over-broad. It ships with a regression test.

The security channel is closed, so the only route left for this code is to land the bounds check as a plain robustness change through Gerrit, with no security framing at all, judged on its own merit by a maintainer who may still decide that GDI plus untrusted fonts is not a configuration Skia supports. That is a legitimate outcome too. It would just mean the bug stays live in main, documented, verified, and unowned.

What I take from it

When you report a memory-safety bug, you are doing two things that look like one. You are proving that the code can be made to corrupt memory, and you are arguing that the way in belongs inside the reader's threat model. The first is a technical claim you can settle with a sanitizer and a stack trace. The second is a negotiation, and you can be completely right on the first and still lose the second.

I would rather lose it honestly than win it by overstating reach. The overflow is real, it reproduces through a public API, and it is unpatched. Whether that is a security bug depends on a boundary I do not get to draw. That is not a satisfying place to end, but it is the true one, and I would rather write down the true one.

Key questions

Is the Skia RenameFont heap overflow patched?

No. A Chromium security reviewer declined it as won't-fix (Not Reproducible) on 2026-06-24, and it remains unpatched in Skia's main branch. A verified one-hunk bounds-check fix exists, but there is no CVE and no shipped patch.

What software is affected by the RenameFont overflow?

Skia's Windows GDI font backend, reached through the public SkFontMgr_New_GDI() makeFromStream, makeFromData, and makeFromFile APIs. That covers Windows applications and embedders configured with the GDI font manager that load untrusted fonts, such as legacy Chromium configurations and some Skia-based document, SVG, or HTML-to-image renderers. Modern Chrome uses the DirectWrite backend, which never calls RenameFont, so default Chrome web-font loading is not affected.

Can the bug be triggered from a website?

Not through default Chrome. On Windows the web font path is DirectWrite, and DirectWrite never calls RenameFont, so there is no HTML reproduction. The overflow is reachable only through the GDI backend, which is why the finding is scoped to GDI-configured applications rather than the browser.

Why did Google decline to fix it?

A Chromium security reviewer returned Won't Fix (Not Reproducible) because the report is not reachable from the web under Google's threat model, which scopes font security to the Chrome DirectWrite path. They did not dispute the crash, a verified heap out-of-bounds write through a public API, only its security severity. The disagreement is about threat model, not facts.

Who found it, and is there a CVE?

Ariel Koren found it by manually auditing the bounds arithmetic in Skia's SFNT code, then verified it end to end under AddressSanitizer through the public GDI API - a 243,300-byte write into a 280-byte buffer. It was reported to Google's issue tracker (issue 527060475) on 2026-06-23. There is no CVE, because Google declined the report.