All posts

The Great Glyph Grift, and How to Detect It Without the Sample

10 min read

Calif’s write-up on CVE-2026-86950, The Great Glyph Grift, by Dion Blazakis, Josh Maine and Anna Groza, is the kind of patch-analysis work I enjoy most: take a one-line advisory from Apple, “may have been exploited in an extremely sophisticated attack against specific targeted individuals,” and reconstruct the entire exploit chain from binary diffs of CoreGraphics and a changelog in WhatsApp’s attachment checker. The bug is a unit conversion error inside Apple’s anti-aliased path rasterizer, reached through TrueType glyph rendering, exploited in the wild, credited to Meta Product Security, and fixed in iOS 26.7.1 / macOS 26.7.1.

People immediately called it FORCEDENTRY++. The comparison is accurate in the ways that matter for detection: a file that renders on arrival, a zero-click path through a messaging preview, and an attack surface in a code path that no legitimate file ever needs to exercise. It is also exactly the situation ELEGANTBOUNCER was built for, because the sample does not exist in public and the structural rule does not need it.

What the patch says#

Calif started from the ipsw-diffs report for 26.7.1 and found the same small inline function patched more than 20 times across 8 CoreGraphics functions, all named aa_*. Those belong to the anti-aliased rasterizer, the code that turns vector outlines into pixels with smooth edges. The patched function converts a floating point coordinate into fixed point:

static inline int32_t aa_double_to_fixed(double px) {
  double v = 0.5 + 4096.0 * px;
  return (int32_t)v;             // 26.7: no clamping, UB past int32
}

The rasterizer tracks coverage at 4096 sub-pixel steps per pixel, so every device coordinate entering the scan-conversion code goes through that multiply-then-cast. The fix added three lines: clamp the value to ±1073737729 before casting, and zero out NaN.

Casting a double outside int32 range is undefined behaviour, and clang made the two consequences differ by call site. aa_moveto compiled to FCVTZS Wd, Dn, which saturates at INT32_MAX or INT32_MIN. aa_lineto compiled the same conversion through 64-bit registers and truncated the low 32 bits. Two path operations feeding the same edge table, two different conversion semantics. That differential is the exploitable part, and it lives in the bounding box accumulation:

int32_t dx = new_x - cur_x;        // int32 subtraction — wraps
if (dx < 1) {
  // compare against new_x / cur_x  (wrong endpoint after wrap)
} else {
  // compare against cur_x / new_x  (neither expands the bbox)
}

Both call sites run the same cast, but the compiled conversions disagree on overflow: aa_moveto saturates at INT32_MAX while aa_lineto truncates the low 32 bits, so the int32 delta between the two endpoints wraps and its sign is attacker data rather than geometry.

The delta sign-wraps, the wrong endpoints get compared, and the bounding box stays small while the recorded edges stay large. Downstream, aa_cache_render sizes its per-scanline coverage buffer from the bounding box:

width_px  = (bbox_max_x - bbox_min_x) / 4096;
coverage  = (width_px > 1015) ? malloc(4*(width_px+16))
                              : alloca(4*(width_px+16));

Then it writes an alpha value for every edge crossing each scanline, past the end of a buffer sized from the corrupted bbox. Heap or stack, depending on the forged width. A controlled 16-bit increment on two adjacent 16-bit values, which is a fine primitive to work with.

The wrapped delta keeps the bounding box at the small forged width while the recorded edges run to 16.4M font units, so aa_cache_render sizes the scanline coverage buffer from the bbox and the per-edge alpha writes continue past its end.

The gate you have to pass#

The conversion bug sits in code that CoreGraphics also uses for every ordinary path it rasterizes. What makes the glyph path special is the flags on the rasterizer context. When aa_create is called through ripr_Acquire, the context gets its clamping flags set and a device coordinate clip rectangle established, and aa_moveto clamps coordinates to ±1e15 and to the clip rectangle before the fixed point conversion. Under those flags, no coordinate a PDF can express survives to the cast.

The second call site does not set them. CGGlyphBitmapCreateWithPathAndDilation creates a context with the flags clear and never installs a clip rectangle. Every glyph rendered through the glyph-bitmap cache path flows through it, which is what the Calif backtrace shows: CGContextShowGlyphsAtPositions → ripc_DrawGlyphs → draw_glyph_bitmaps → render_glyphs → CGGlyphBuilderLockBitmaps → CGGlyphBitmapCreateWithPathAndDilation → aa_cache_render.

On the PDF side, the same chain is reached from the preview pipeline: CGPDFTextLayoutDrawGlyphs → op_Tj → the glyph machinery above, all under CGContextDrawPDFPageWithOptions behind ImageIO’s PDFReadPlugin. In their demo, the crash happens while the system builds a thumbnail. Zero interaction.

There is one more constraint, and it is the one that turns the exploit into a detectable structure. The write-up notes that ripc_DrawGlyphs only takes the glyph-bitmap cache path while the text matrix scale stays within a bound, and the largest font size that still passes is Tf = 1024. The PDF Text matrix, the font’s unitsPerEm, and every composite glyph transform in the font compose into the device coordinates that reach the vulnerable cast. So the widest coordinate an attacker can deliver is:

device_px  = font_units * 1024 / unitsPerEm
fixed      = 4096 * device_px          // must reach 2^31

Inverting for font units gives the threshold the glyph has to cross:

font_units >= 2^31 * unitsPerEm / (4096 * 1024) = 512 * unitsPerEm

Effective glyph coordinates of 512 em. A legitimate glyph is a drawing inside its em box, typically within ±40% of it. Half a thousand em is three orders of magnitude past anything a real font contains, and a font that contains one is not a font, it is an exploit with a cmap table. The threshold also means the rule cannot be tuned into a false negative by an attacker’s choice of unitsPerEm or point size, because the threshold is derived from the same numbers the attacker must use to reach the bug.

On a logarithmic axis of effective glyph coordinates, every real font clusters below 1 em with Century Gothic at 0.7 em, the detection threshold sits at 512 em, and the Calif trigger lands at 1000 em, so three orders of magnitude separate legitimate outlines from the smallest structure that reaches the cast.

The chain that gets there#

Font units that large cannot be stored in a glyph. Simple glyph coordinates are int16 deltas, so the leaf outline lives at ordinary coordinates, and the inflation happens through composition. A TrueType composite glyph references another glyph and applies an F2Dot14 transform, whose maximum scale is 32767/16384, just under 2. Stack nine composites, each scaling by just under 2, and coordinates grow by 2^9.

The Calif trigger font is a clean example of the structure:

  • unitsPerEm = 16384, an unusually large em that lowers the number of composites needed
  • a leaf glyph with two contours: a small rectangle that seeds the bounding box, and a two-point line at x = 32000
  • a chain of nine composite glyphs, each scaling by 32767/16384, rooted at the “A” glyph

The leaf’s 32000 font units become 32000 × (32767/16384)^9 ≈ 16.4M font units, which against a 16384 em is about 1000 em, double the 512 em requirement. The trigger then wraps the font in a three-object PDF, /F1 1024 Tf (A) Tj, with the font program Flate-compressed in a FontFile2 stream off the FontDescriptor.

One detail in the generator deserves a flag of its own: it sets recalcBBoxes = False and forges the int16 xMin/xMax headers of the composite glyphs to 0/33. Those headers are what a font engine would use for fast bounding boxes, and the forged values are what keep the render-time coverage buffer small while the real edges run far outside it. Any detection rule that reads the stored bbox headers instead of the actual outline coordinates reads the attacker’s chosen values and reports the file clean. The rule below parses the point deltas and ignores the headers entirely.

The leaf glyph stores a two point contour at x = 32000 font units, and nine composite glyphs each scale by the F2Dot14 maximum of 32767/16384, so the expanded outline of the root A glyph reaches about 16.4M font units while its forged int16 xMin/xMax headers still declare 0 to 33.

The rule#

Detection walks the same chain the renderer does:

  1. Parse the sfnt table directory, read head for unitsPerEm and indexToLocFormat, maxp for the glyph count, then loca and glyf.
  2. For every simple glyph, walk contour end points, skip the hinting bytecode, expand flag repeats, and accumulate the x and y coordinate deltas into the true outline extremes. The stored bbox header is never consulted.
  3. For every composite glyph, recurse into each component and apply its F2Dot14 transform (single scale, x/y scales, or full 2x2) to the child’s expanded bounding box as the recursion unwinds, so a parent sees effective coordinates in its own units. Composite cycles are cut at depth 32; the OpenType spec’s own maxComponentDepth bound. CFF-flavoured programs (OTTO) and collections (ttcf) are rejected, since they carry no glyf outlines for this path.
  4. Flag the font if any glyph’s effective coordinate reaches 512 * unitsPerEm font units.

Against the trigger: glyph 1 holds the 32000-unit contour, glyphs 2 through 10 are the composite chain, glyph 10’s expanded outline reaches ≈16,379,500 font units against a limit of 8,388,608. Detection fires on the font, and on the PDF after resolving the FontFile2 stream through the descriptor and inflating it.

Standalone .ttf files are scanned directly. PDFs are handled by walking every object, resolving the /FontFile, /FontFile2 and /FontFile3 references from their dictionaries, decompressing the streams, and running the same rule on each embedded program. The California PoC embeds exactly one TrueType program in one FontFile2 stream, which is the common shape for a PDF-borne exploit, and also why the scanner ignores Tf and the Text matrix entirely: they only shrink the attacker’s margin below the threshold.

The verdicts describe geometry, not signatures:

StructureVerdict
Any glyph with effective coordinate ≥ 512 emCVE-2026-86950 trigger
Leaf contour at 32000 units in a 16384-em font, 9 composites at F2Dot14 maxPoC, fires at ≈1000 em
Real fonts: outlines within ±40% of the emClean, 3 orders of magnitude of margin

The false-positive margin is worth stating as a number, because the threshold is exact rather than heuristic. Century Gothic, a font with ordinary 2048-unit em geometry, has its largest effective coordinate around 0.7 em. The rule fires at 512 em. Nothing in typography operates anywhere near that: glyph extents beyond a few em would already break every layout engine that assumes glyphs draw near their advance width.

The WhatsApp angle#

Calif spent a section on the delivery path, and it is the part defenders should read twice. Meta got the credit, and the WhatsApp changes around versions 26.37.73 and 26.38.74 show why: their Kaleidoscope attachment-scoring framework (Rust, cross-platform, parse-before-render) gained a strict PDF validation mode behind a ks_pdf_strict_validation_enabled flag, and with it PDF object stream parsing including embedded FontFile object streams. Three new font defect tags: MalformedFontProgram, UndecodableFontProgram, UnverifiedFontProgram. Any of them and the attachment gets a high risk score and stops being parsed automatically.

That is a defensive change built specifically around font programs inside PDFs, shipped by the company that reported the bug, weeks before Apple’s advisory. When the reporter’s mitigation names your exact attack vector, the delivery path is not a guess. WhatsApp’s own attachment previews have historically rendered PDF content on arrival, which is the zero-click property the whole class depends on. For iOS backups, ELEGANTBOUNCER’s messaging mode already walks WhatsApp’s attachment stores and hands each file to the scanners, and this rule now covers those files.

One more observation from Calif’s closing: the primitive is a controlled 16-bit increment on two adjacent 16-bit values, with attacker-chosen allocation size and a heap-or-stack switch. In-the-wild samples would answer how an exploit developer actually spent that primitive, and whether a second bug was needed to get the preview to render without a tap. That sample does not exist publicly. The structural rule catches the delivery regardless.

Scanning#

cargo install --git https://github.com/msuiche/elegant-bouncer
elegantbouncer --scan suspicious.pdf
elegantbouncer --scan suspicious.ttf
elegantbouncer --scan -r -e pdf,ttf,otf /path/to/uploads

Standalone TTF and OTF files are scanned directly, PDFs through their embedded font programs. The scanner runs alongside the existing FORCEDENTRY, BLASTPASS and TRIANGULATION rules on PDFs, so one pass over a directory covers all four classes of malicious PDFs.

Against the Calif PoC, the trigger TTF fires at glyph 10 with an effective coordinate of ≈16.4M font units against the 8.4M limit, and the trigger PDF fires through its embedded FontFile2. Century Gothic, DNG, HEIF and WebP samples in the test corpus all stay clean, including the FORCEDENTRY GIF, which shares the PDF delivery vector but carries no font program. The rule is shipped in ELEGANTBOUNCER as of commit e6cc700, with the PoC samples in the test corpus.

References#