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

URL: https://www.msuiche.com/posts/the-great-glyph-grift-and-how-to-detect-it-without-the-sample/
Date: 2026-10-27
Author: Matt Suiche
Tags: CVE-2026-86950, CoreGraphics, TrueType, PDF, ELEGANTBOUNCER, Detection, WhatsApp


> Structural detection for CVE-2026-86950, the CoreGraphics glyph rasterizer out-of-bounds write fixed in iOS 26.7.1, from the composite glyph chain down to the exact effective-coordinate threshold

---


Calif's write-up on [CVE-2026-86950, The Great Glyph Grift](https://calif.io/research/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](https://github.com/msuiche/elegant-bouncer) 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:

```c
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:

```c
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.](./images/cast-differential.svg)

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.](./images/bbox-coverage-overflow.svg)


## 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.](./images/threshold-axis.svg)


## 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.](./images/composite-scaling-chain.svg)


## 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:

| Structure | Verdict |
|---|---|
| Any glyph with effective coordinate ≥ 512 em | CVE-2026-86950 trigger |
| Leaf contour at 32000 units in a 16384-em font, 9 composites at F2Dot14 max | PoC, fires at ≈1000 em |
| Real fonts: outlines within ±40% of the em | Clean, 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

```bash
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](https://github.com/califio/publications/tree/main/MADBugs/CVE-2026-86950), 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](https://github.com/msuiche/elegant-bouncer) as of commit e6cc700, with the PoC samples in the test corpus.

## References

- [CVE-2026-86950: The Great Glyph Grift](https://calif.io/research/the-great-glyph-grift), Dion Blazakis, Josh Maine and Anna Groza, Calif
- [Apple security advisory HT149226](https://support.apple.com/en-us/149226)
- [Calif PoC repository: trigger font, PDF and render harness](https://github.com/califio/publications/tree/main/MADBugs/CVE-2026-86950)
- [ELEGANTBOUNCER](https://github.com/msuiche/elegant-bouncer)

