GHSA-xvh5-g6gv-559v

Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags

At a glance

Published
Updated
CVE
CVE-2026-112902
CVSS_V4
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N

Description

``` ============================================================================ Memory disclosure and segfault in libtiff encoder via crafted TIFF tags Location: src/encode.c:1014 in PyImaging_LibTiffEncoderNew() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted TIFF file of a few hundred bytes makes Pillow copy an attacker-chosen amount of its own process memory into the TIFF it writes back out, or crash the process outright. The trigger is an ordinary `Image.open()` followed by a bare `im.save(fp, "TIFF")`. The only precondition is that the application re-saves images as TIFF. When the uploaded file is compressed, Pillow routes that save through libtiff automatically, with no options from the application. The attached reproducer does this with a 356-byte LZW TIFF: one crafted tag comes back holding 65536 bytes taken from `_imaging.so`'s read-only data, and a second crafted tag makes the worker die with SIGSEGV on every run. The bug is in `PyImaging_LibTiffEncoderNew` (`src/encode.c`). It decides what arguments to pass to the variadic `TIFFSetField()` from the shape of the Python value (scalar, tuple or bytes). It assumes its `ImagingLibTiffMergeFieldInfo()` call defined how libtiff will read them. But `TIFFMergeFieldInfo()` never overrides libtiff's built-in field table, so libtiff reads the arguments according to its own definition of the tag: - For a counted array tag such as DNGPrivateData (50740), a count-1 integer in the source file is taken as the byte count. The data pointer is then read from an argument slot Pillow never wrote, and that many bytes are copied into the output. - For a fixed-size array tag such as ActiveArea (50829), the integer itself is used as the source address. Where that unwritten slot points depends on the compiler. In the tested clang build it points into `_imaging.so`, and a larger count returned thousands of pointers into libpython, libc and libtiff, which defeats address-space randomisation for anyone chaining another bug. When the application preserves metadata with `tiffinfo=im.tag_v2`, a 64-bit entry turns ActiveArea into a 16-byte read from an address the attacker chooses. All observed memory errors are reads; no out-of-bounds write was found. Other branches of the same loop share the defect and are reachable from crafted TIFFs or from EXIF in JPEG/PNG/WebP re-saved as compressed TIFF. These are the `bytes`, RATIONAL-tuple and typed-tuple branches, and a BYTE-typed ColorMap. The file-reachable forwarding was introduced in `a3d45e9cefd0` ("Added custom int and float TIFF tags") and first released in 5.4.0. ## Details `PyImaging_LibTiffEncoderNew` (`src/encode.c:777-1060`) chooses the argument list it hands to the variadic `TIFFSetField()` from the *Python* value of each tag — scalar vs. tuple vs. `bytes`, and a type that is either autodetected or copied from the source file's IFD — and assumes that its own `ImagingLibTiffMergeFieldInfo()` registration defines how libtiff will read the `va_list`. It does not: libtiff keeps its built-in definition for every tag it already knows, and core tags are never registered at all. libtiff therefore consumes the varargs as `(count, pointer)`, `(pointer)` or three pointers according to *its* field table, whatever Pillow actually pushed. The encoder never consults that table (`TIFFFindField` / `TIFFFieldPassCount` / `TIFFFieldWriteCount` / `TIFFFieldDataType`) before the call. The most severe instance, and the one the attached reproducer exercises: when a TIFF is re-saved through the libtiff writer, every tag of the source IFD that Pillow does not recognise is forwarded to libtiff with `TIFFSetField()`. A count-1 integer entry is passed as a single scalar vararg. If libtiff's own field table defines that tag number as an *array* field, libtiff consumes the varargs as `(count, pointer)` or `(pointer)` instead, so the attacker's integer becomes either the number of bytes to copy from a never-written vararg slot, or the source address itself. The same mismatch is reachable through the typed tuple branches (tuple length consumed as a pointer for fixed-count built-in tags such as `CFARepeatPatternDim`, even when the source file is well-formed), the `bytes`, `TIFF_RATIONAL`-tuple and core-tag branches of the same loop; those are listed under *Other branches with the same defect* below and need to be covered by the same fix. A 181-byte TIFF (16×16 RGB, one LZW strip, plus one extra IFD entry `50740 / LONG / count 1 / value 4096` — DNGPrivateData) opened with `Image.open()` and saved with a bare `im.save(buf, "TIFF")` produces a 4296-byte output whose tag 50740 holds 4096 bytes that occur nowhere in the input; scanning `/proc/self/mem` finds them in the read-only data segment of `_imaging.so`. Changing the extra entry to `50829 / LONG / 1 / 64` (ActiveArea) makes the same two calls segfault in `memcpy(dst, src=0x40, n=16)` under `ImagingLibTiffSetField` ← `PyImaging_LibTiffEncoderNew`. An ASan build of the extensions reports `READ of size 4096` in `memcpy` called from `src/encode.c:1014` for the first input. ### Code path `_save` copies all tags of the opened image into the libtiff attribute list. For a tag that is neither in `LIBTIFF_CORE` nor in `TiffTags.TAGS_V2`, no type is recorded, so the value goes through untyped: ```python # src/PIL/TiffImagePlugin.py:1782 supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} … # :1959-1982 for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): if tag not in TiffTags.LIBTIFF_CORE: … elif isinstance(value, (int, float, str, bytes)) or …: type = TiffTags.lookup(tag).type # None for 50740, 50829, … if type: types[tag] = type if tag not in atts and tag not in blocklist: … atts[tag] = value # plain int 4096 ``` `compression` defaults to the source image's `im.info["compression"]` (`TiffImagePlugin.py:1731-1746`), so a compressed source selects the libtiff writer without the application asking for it. In the encoder the untyped `int` is autodetected as `TIFF_LONG` (`src/encode.c:805-815`), the tag is "registered" as a scalar custom field, and then set with one 32-bit vararg: ```c // src/encode.c:844 (is_var_length = 0 → writecount 1, passcount 0) if (ImagingLibTiffMergeFieldInfo(&encoder->state, type, key_int, is_var_length)) { … continue; } … // src/encode.c:1013-1016 } else if (type == TIFF_LONG || type == TIFF_IFD) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value) ); ``` The registration does not do what the caller assumes. `TIFFMergeFieldInfo()` only adds definitions for tags libtiff does not already know and returns 0 (success) either way, so for tags in libtiff's built-in table the built-in definition stays in force. Queried against the linked libtiff 4.5.0 after exactly Pillow's merge call: ``` 50740 DNGPrivateData type=BYTE passcount=1 writecount=-1 (unchanged) 50829 ActiveArea type=LONG passcount=0 writecount=4 (unchanged) 65000 CustomField type=LONG passcount=0 writecount=1 (Pillow's definition, tag was unknown) ``` libtiff's setter for `FIELD_CUSTOM` tags then interprets the `va_list` according to *its* definition: for `passcount=1` it reads `va_arg(ap, int)` as the element count — the attacker's 4096 — allocates `count * size` bytes and `memcpy`s that many bytes from `va_arg(ap, void *)`, a slot `PyImaging_LibTiffEncoderNew` never wrote. On x86-64 that slot is register `rcx`; in this build it holds a pointer into `_imaging.so`'s `.rodata`, so the copy succeeds deterministically (a value of 2 000 000 also succeeds and yields a 2 000 200-byte output). `TIFFWriteDirectory` writes the copied bytes into the output file as the tag's value. For fixed-count array fields (`passcount=0`, `writecount=4` for ActiveArea) there is no count argument, so the attacker's scalar is consumed as the `void *` source and 16 bytes are read from address 64 → SIGSEGV. Tags in libtiff 4.5's built-in table that Pillow forwards untyped and that take the `(count, pointer)` form include DNGPrivateData 50740, CFAPattern 33422, CFAPlaneColor 50710, LinearizationTable 50712, BlackLevel 50714, WhiteLevel 50717, ColorMatrix1/2 50721/50722, AsShotNeutral 50728, OriginalRawFileName/Data 50827/50828, AsShotICCProfile 50831, CurrentICCProfile 50833. Fixed-count ones (pointer only → crash) include ActiveArea 50829, DNGVersion/DNGBackwardVersion 50706/50707, DefaultScale/DefaultCropOrigin/DefaultCropSize 50718–50720, AsShotWhiteXY 50729, CFARepeatPatternDim 33421, BlackLevelRepeatDim 50713, ImageLayer 34732, RawDataUniqueID 50781. When the application saves with `tiffinfo=im.tag_v2` (the documented way to preserve metadata), the file-declared type is honoured, and a `LONG8` entry reaches the `(uint64_t)PyLong_AsLongLong(value)` branch at `src/encode.c:1049-1052`. For ActiveArea that is a 16-byte read from a full 64-bit attacker-chosen address into the output: a source entry `50829 / LONG8 / 1 / <address of a Python bytes object>` produced an output ActiveArea of `(b'SECR', b'ET-T', b'OKEN', b'-012')` in testing. The comment above `blocklist +=` at `TiffImagePlugin.py:1938-1944` already notes that array tags with the wrong length "will result in segfaults"; the blocklist covers only a handful of tags Pillow itself knows, not the ones libtiff knows and Pillow does not. Forwarding of these tags was introduced in `a3d45e9cefd0` ("Added custom int and float TIFF tags", 2018-10-25), first released in 5.4.0; releases 2.0.0–3.0.0 forwarded them as scalars too, until `9fa6634f4` ("Partial fix for #1597") limited `_save` to `LIBTIFF_CORE`. ### Other branches with the same defect The scalar-integer branch above is one of several places in the same loop where the call shape is derived from the Python value rather than from libtiff's definition. All of the following were confirmed on this commit (stock build crashes and/or ASan reports, stack always `memcpy ← libtiff ← ImagingLibTiffSetField src/libImaging/TiffDecode.c:934 ← PyImaging_LibTiffEncoderNew`), and all are reachable from a sub-1 KB crafted file followed by an ordinary `save()`: * **Typed tuple → `(len, av)` for a fixed-count built-in field** (`src/encode.c:904-994`, the SHORT/LONG/SBYTE/SSHORT/SLONG/FLOAT/DOUBLE tuple branches all call `ImagingLibTiffSetField(..., len, av)`). For libtiff built-in fields with `passcount=0` and a fixed `writecount` — `CFARepeatPatternDim` 33421 (SHORT×2), `BlackLevelRepeatDim` 50713 (SHORT×2), `ImageLayer` 34732 (LONG×2), `ActiveArea` 50829 (LONG×4), `MatrixWorldToScreen/Camera` 33305/33306 (FLOAT×16) — libtiff takes the *first* vararg as the source pointer, so the tuple length becomes the `memcpy` source address. A perfectly well-formed 206-byte 8×8 PackBits TIFF carrying `33421 / SHORT / 2 / (2, 2)` re-saved with a bare `im.save(out, "TIFF")` dies with SIGSEGV in `memcpy` (gdb: `rsi = 0x2`, the tuple length) on every run; so does `Image.new("L", (8, 8)).save(buf, "TIFF", compression="packbits", tiffinfo={33421: (2, 2)})`, and a JPEG whose EXIF carries the tag saved with `exif=im.getexif()` into a compressed TIFF. `bytes` values for built-in fixed-count BYTE tags (e.g. `DNGVersion` 50706, BYTE×4) crash the same way through the `(PyBytes_Size, PyBytes_AsString)` call at `src/encode.c:860-865`. The mirror image for `passcount=1` fields: `Photoshop` 34377 declared SHORT, count 1, value 60000 takes the scalar branch at `:1009-1012`, libtiff reads 60000 as the count and an unpassed vararg as the pointer, and the re-save *succeeds* with a 60162-byte output whose tag 34377 holds 60000 bytes of process memory (here C prototype strings from `_imaging.so`'s `.rodata`). * **`bytes` value → single `char *`** (`src/encode.c:805-815` autodetects `bytes` as `TIFF_ASCII`; `:1033-1042` passes `PyBytes_AsString(value)` alone). `ImageFileDirectory_v2._setitem` stores a BYTE-typed entry as bare `bytes` and an ASCII-typed one as `str` (encoded to `bytes` by `_save`), so the file's declared type selects this branch. - `ColorMap` (320) declared BYTE with 4 bytes of data in a non-palette PackBits TIFF: 320 is in `core_tags[]`, no type is consulted, the value skips the 768-tuple `TIFFTAG_COLORMAP` branch (`:871-895`) and libtiff reads three `uint16_t *` and copies `1 << BitsPerSample` shorts from each — a 512-byte over-read of the 5-byte Python bytes buffer, then dereference of two stale argument registers → SIGSEGV on every run of `Image.open(f); im.save(out, "TIFF")`. `COLORMAP` is not in `_save`'s `blocklist`. - `Photoshop` (34377) declared ASCII: `_save` copies value *and* type (`TiffImagePlugin.py:1802-1818`; only `IPTC_NAA_CHUNK` has a BYTE/UNDEFINED type guard there, from commit 2245df0a — `PHOTOSHOP_CHUNK` and `XMP` do not). libtiff's definition is BYTE, `passcount=1`, so the low 32 bits of the `char *` become the byte count for a `memcpy` from a stale register (ASan: `READ of size 3518384`; stock: SIGSEGV in about half of runs, otherwise `RuntimeError`). `ICCProfile` (34675) declared ASCII behaves the same way via `im.info["icc_profile"]` → `ifd[ICCPROFILE]` (`TiffImagePlugin.py:1822-1824`). * **`TIFF_RATIONAL` tuple → array with no count** (`src/encode.c:995-1006`; compare the `TIFF_FLOAT` branch at `:969-981`, which passes `len, av`). - For a private tag (e.g. 50000) Pillow itself registers the field with `passcount=1` (`TiffDecode.c:904-908`), so libtiff takes the low 32 bits of `av` as the element count and the next stale vararg as the source: `heap-buffer-overflow READ of size 119872 … right of 8-byte region` under ASan (`119872 == 4 * low32(av)`), SIGSEGV in ~4 of 5 stock runs. Reached by a JPEG whose EXIF carries one private RATIONAL×2 tag saved with `im.save(out, "TIFF", compression="tiff_lzw", exif=im.info["exif"])`, or a TIFF re-saved with `tiffinfo=im.tag_v2` (`_setitem` auto-types a tuple of `IFDRational` as RATIONAL, `TiffImagePlugin.py:689-701`; `types[tag] = ifd.tagtype[tag]` at `:1968`). - For libtiff's built-in fixed-count float tags `WhitePoint` (318, 2), `PrimaryChromaticities` (319, 6) and `YCbCrCoefficients` (529, 3), which `_save` forwards from any source TIFF and types RATIONAL via `TiffTags.lookup`, the pointer-only convention happens to match, but libtiff copies its *fixed* count from an array that is only as long as the file declared: a PackBits TIFF with `PrimaryChromaticities` count 1 and a bare `im.save(out, "TIFF")` gives `READ of size 24 … right of 4-byte region`, and the over-read floats are written to tag 319 of the output. `REFERENCEBLACKWHITE`/`TRANSFERFUNCTION` are blocklisted at `TiffImagePlugin.py:1941-1952` for exactly this reason; 318/319/529 are not. * **Core tag reached as a scalar via a stale file-supplied type.** With the re-save idiom `im.save(out, "TIFF", tiffinfo=im.tag_v2)`, `_save` copies `ifd.tagtype[key] = info.tagtype[key]` for every key (`TiffImagePlugin.py:1768-1776`) and later assigns its own values to some of the same tags without resetting the type (`ifd[COLORMAP] = colormap` at `:1870-1877`; likewise BitsPerSample, SamplesPerPixel, ExtraSamples, ICCProfile, …). For a 418-byte PackBits *palette* TIFF whose ColorMap entry is declared BYTE, `_setitem` (`:751-770`) truncates `_save`'s 768-entry palette to a single `int` (with only a "too many entries" warning), the encoder receives `(320, 0)`, autodetects `TIFF_LONG`, and `src/encode.c:1013-1016` calls `TIFFSetField(TIFFTAG_COLORMAP, (UINT32)0)` — libtiff reads three `uint16_t *` and faults at an address ≤ 65280 on every run. The stale-tagtype copy is a Python-side hygiene bug in its own right, but its memory-safety consequence exists only because the encoder lets a non-tuple value reach `TIFFSetField(COLORMAP)`. ## Impact Any service that accepts a TIFF and writes it back out as TIFF through libtiff — which happens with a plain `im.save(fp, "TIFF")` whenever the uploaded file is compressed, or whenever a `compression=` other than raw is requested — returns to the uploader, inside one metadata tag of the produced file, an attacker-chosen number of bytes read from a stale pointer in the worker process, deterministically and without retries. Which memory that is depends on the compiler's register allocation for `PyImaging_LibTiffEncoderNew`: in this clang build the pointer is `_imaging.so + 0x1588`, so a request for 262 144 bytes (which succeeded every time in testing, as did 2 000 000) covers the extension's `.rodata`, `.data.rel.ro`/GOT and `.data` and returned 2 800+ resolved pointers into libpython, libc, libtiff, libjpeg and others — a complete ASLR bypass for chaining with any other memory bug; a gcc build of the same source left a different, unrelated address in that slot. The fixed-count variant kills the worker process with SIGSEGV from a 181-byte upload on every request. With `tiffinfo=im.tag_v2` the source address is fully attacker-chosen (16-byte arbitrary read into the output for ActiveArea, or a crash). No non-default Pillow setting is involved; the only precondition is TIFF-to-TIFF re-encoding. The other branches add further deterministic process-kill inputs (BYTE-typed ColorMap in any RGB or palette TIFF; `thumbnail()` keeps `tag_v2`, so open→thumbnail→save-as-TIFF is affected too) and extend the reach to JPEG/PNG/WebP uploads whose EXIF is preserved with `exif=` when converting to libtiff-compressed TIFF. All the memory errors are reads; libtiff sizes its destination buffers with the same count it copies, so no out-of-bounds write was found. ## Reproducing `repro` is one Python script, run against the stock build with the installed `PIL`. It needs no compiler and no extra files. `upload(tag, value)` has Pillow write a 16×16 red RGB image as an LZW-compressed TIFF, then appends a new IFD holding the same entries plus one extra `<tag> / LONG / count 1 / value` (kept in tag order) and points the header at it — a 356-byte input on this tree. The source is LZW-compressed on purpose. A bare `im.save(out, "TIFF")` keeps the source's compression, so the save goes through the libtiff writer even though the application passes no arguments. `resave()` is the whole "application": `Image.open(BytesIO(src)); im.save(out, "TIFF")`. The script runs three cases: 1. **Control, tag 65000 = 4096.** libtiff does not know this tag, so the file is re-saved correctly and the output tag reads back as the integer `4096`. This is what should happen to every tag. 2. **Leak, DNGPrivateData 50740 = 65536.** libtiff's built-in definition of this tag is a counted BYTE array. It reads Pillow's single scalar as the *count* and takes the data pointer from a vararg slot Pillow never wrote. The output tag comes back as `bytes` of length 65536 that do not occur in the input. The script prints the first 32 of them and the file offset at which they occur in `_imaging.so` on disk. On this build they always do (offset `0x57588`): the stale pointer aims into the extension's read-only data. 3. **Wild read, ActiveArea 50829 = 64.** libtiff defines this tag as a fixed LONG[4] array with no count, so it uses the scalar 64 as the *source address*. The script re-runs itself as a child process (`repro child`, core dumps disabled) to do this re-save, and the child exits with `-11`, meaning SIGSEGV. Expected output on the affected tree: ``` control tag 65000=4096 -> 4096 leak tag 50740=65536 from a 356-byte upload -> 65536 bytes not present in input, head a661fcffc661fcff3360fcff7b61fcffd361fcff085ffcffe061fcff3360fcff leaked bytes found in _imaging.so at file offset 0x57588 crash tag 50829=64 -> child exit -11 (SIGS

Affected packages

PackageVersions
PyPI · pillowintroduced 0, fixed 13.0.0

References

Sources

SourceLicence
OSV.devCC-BY 4.0 — GitHub Advisory Database