PT-2026-120897

CVE-2026-112902

·

Publicado

2026-10-21

·

Atualizado

2026-10-21

Descrição por

CVSS 4.0
8.8
Alta
MétricasAV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N
============================================================================
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 memcpys 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 (SIGSEGV)
BUG
The script prints BUG if case 2 leaks or case 3 crashes. Both are deterministic on this build. A fixed Pillow prints OK: the tag does not come back as 65536 bytes of foreign data (it reads back as None, or save() raises ValueError/RuntimeError, which the script catches), and the child exits with something other than SIGSEGV (0, or 1 if save() raised).
The other branches can be checked by hand on the stock build in the same way (each as Image.open(f); im.save(BytesIO(), "TIFF") in a child process): a PackBits RGB TIFF with an extra IFD entry 320 / BYTE / 4 / "ABCD" → SIGSEGV every run; a PackBits palette TIFF whose ColorMap is 320 / BYTE / 2 saved with tiffinfo=im.tag v2 → SIGSEGV every run; a Pillow-written JPEG whose EXIF holds tag 50000 = RATIONAL×2 saved with compression="tiff lzw", exif=im.info["exif"] → SIGSEGV in most runs, RuntimeError ("Failed to allocate memory for custom tag binary object (-N elements of 4 bytes each)") in the rest.

Suggested fix

The encoder must not guess the calling convention; it has to ask libtiff what the tag's effective definition is after ImagingLibTiffMergeFieldInfo and marshal accordingly, or refuse. The attached patch does this for non-core tags:
  • a new ImagingLibTiffFindField() in src/libImaging/TiffDecode.c (declared in TiffDecode.h, next to the other libtiff wrappers) returns libtiff's TIFFFindField type, passcount and writecount for a tag;
  • in PyImaging LibTiffEncoderNew (src/encode.c), for a non-core tag that libtiff already knows, Pillow adopts libtiff's type when it has none of its own, and after ImagingLibTiffMergeFieldInfo a new tiff field matches() checks the value against libtiff's definition: a scalar only for a single-value field whose C argument type (int / uint64 t / double / char *) matches, an array only with libtiff's element size and, for fixed-count fields, exactly writecount elements. Anything else raises ValueError("Tag value does not match libtiff field definition") instead of reaching TIFFSetField — this covers the reproducer's DNGPrivateData and ActiveArea inputs, ASCII-typed Photoshop, short PrimaryChromaticities and the LONG8 tiffinfo= variant;
  • every tuple/bytes branch now goes through set tiff array field(), which passes len only when the field has passcount set; this also fixes the TIFF RATIONAL branch, which never passed a count (and extends it to TIFF SRATIONAL);
  • TIFF UNDEFINED fields are registered as variable-length, matching how their bytes values are passed;
  • Tests/test file libtiff.py gains a regression test (scalar DNGPrivateData, scalar and 2-tuple ActiveArea rejected; well-formed values round-trip).
Core tags and tags libtiff does not know keep their current behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer no longer triggers and the test suite passes. It is not a complete fix, however: the whole check sits inside if (!is core tag), so the BYTE-typed ColorMap variant stays open. The PackBits RGB TIFF carrying 320 / BYTE / 4, re-saved with a bare save(), no longer segfaults at this point but writes 1536 bytes — over-read from the 5-byte bytes object and two stale varargs, including 22 live heap pointers — into the output ColorMap, which is still a disclosure. Before merging, it needs a guard that accepts TIFFTAG COLORMAP only through the existing 768-tuple branch, never through the scalar or bytes branches, and ideally the same shape check for the other core tags libtiff special-cases in TIFFVSetField (YCbCrSubsampling 530, TransferFunction 301, ReferenceBlackWhite 532).
Narrower Python-side mitigations in save close individual entry points but not the class, and are worth adding alongside the C-side check: drop tags with TiffTags.lookup(tag).type is None from supplied tags (closes the Image.open()→save() path shown here but not tiffinfo= callers); remove COLORMAP from supplied tags next to EXIFIFD/SAMPLEFORMAT and extend the IPTC NAA CHUNK BYTE/UNDEFINED type guard to PHOTOSHOP CHUNK and XMP; add 318/319/529 to the blocklist next to REFERENCEBLACKWHITE; and have save ifd.tagtype.pop(tag, None) before assigning its own ColorMap/BitsPerSample/ICCProfile/… values so a tiffinfo=-imported type cannot reshape them. The C-side check is the fix to rely on.

The patch

Pass TIFF tag values to libtiff according to its own field definition, and raise ValueError when they do not fit
Applies to src/encode.c at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix.
diff
diff --git a/Tests/test file libtiff.py b/Tests/test file libtiff.py
index 3d804ea4a..adc4633e4 100644
--- a/Tests/test file libtiff.py
+++ b/Tests/test file libtiff.py
@@ -395,6 +395,43 @@ class TestFileLibTiff(LibTiffTestCase):
     with pytest.raises(ValueError, match="Incorrect tag value type"):
       im.save(out, tiffinfo=ifd)
 
+  @pytest.mark.parametrize(
+    "tag, value",
+    (
+      # DNGPrivateData is a BYTE array with a count to libtiff
+      (50740, 4096),
+      # ActiveArea is a LONG array of four values to libtiff
+      (50829, 64),
+      (50829, (0, 0)),
+    ),
+  )
+  def test libtiff field definition(
+    self, tag: int, value: Any, monkeypatch: pytest.MonkeyPatch, tmp path: Path
+  ) -> None:
+    monkeypatch.setattr(TiffImagePlugin, "WRITE LIBTIFF", True)
+
+    ifd = TiffImagePlugin.ImageFileDirectory v2()
+    ifd[tag] = value
+    ifd.tagtype[tag] = TiffTags.LONG
+
+    out = tmp path / "temp.tif"
+    im = Image.new("L", (1, 1))
+    with pytest.raises(ValueError, match="libtiff field definition"):
+      im.save(out, tiffinfo=ifd)
+
+    # Values that match the libtiff definition are written
+    ifd = TiffImagePlugin.ImageFileDirectory v2()
+    ifd[50740] = b"private"
+    ifd.tagtype[50740] = TiffTags.BYTE
+    ifd[50829] = (0, 0, 1, 1)
+    ifd.tagtype[50829] = TiffTags.LONG
+    im.save(out, tiffinfo=ifd)
+
+    with Image.open(out) as reloaded:
+      assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
+      assert reloaded.tag v2[50740] == b"private"
+      assert reloaded.tag v2[50829] == (0, 0, 1, 1)
+
   def test inknames tag(
     self, monkeypatch: pytest.MonkeyPatch, tmp path: Path
   ) -> None:
diff --git a/src/encode.c b/src/encode.c
index 9e19e9002..cbbd09f0d 100644
--- a/src/encode.c
+++ b/src/encode.c
@@ -675,6 +675,106 @@ PyImaging ZipEncoderNew(PyObject *self, PyObject *args) {
 
 #include <string.h>
 
+/* C type that TIFFSetField reads from its arguments for a single value of
+  the given type: int, uint64 t, double or char * */
+static int
+tiff scalar arg type(TIFFDataType type) {
+  switch (type) {
+    case TIFF BYTE:
+    case TIFF SBYTE:
+    case TIFF UNDEFINED:
+    case TIFF SHORT:
+    case TIFF SSHORT:
+    case TIFF LONG:
+    case TIFF SLONG:
+    case TIFF IFD:
+      return 1;
+    case TIFF LONG8:
+    case TIFF SLONG8:
+    case TIFF IFD8:
+      return 2;
+    case TIFF FLOAT:
+    case TIFF DOUBLE:
+    case TIFF RATIONAL:
+    case TIFF SRATIONAL:
+      return 3;
+    case TIFF ASCII:
+      return 4;
+    default:
+      return 0;
+  }
+}
+
+/* Size of the values that TIFFSetField copies from an array of the given
+  type. libtiff stores custom RATIONAL arrays as floats. */
+static int
+tiff array item size(TIFFDataType type) {
+  switch (type) {
+    case TIFF BYTE:
+    case TIFF SBYTE:
+    case TIFF UNDEFINED:
+    case TIFF ASCII:
+      return 1;
+    case TIFF SHORT:
+    case TIFF SSHORT:
+      return 2;
+    case TIFF LONG:
+    case TIFF SLONG:
+    case TIFF IFD:
+    case TIFF FLOAT:
+    case TIFF RATIONAL:
+    case TIFF SRATIONAL:
+      return 4;
+    case TIFF DOUBLE:
+    case TIFF LONG8:
+    case TIFF SLONG8:
+    case TIFF IFD8:
+      return 8;
+    default:
+      return 0;
+  }
+}
+
+/* Check that a value which will be passed to TIFFSetField as a single value
+  of the given type, or as an array of len values, matches the definition
+  of the field that libtiff will use. */
+static int
+tiff field matches(
+  TIFFDataType field type,
+  int passcount,
+  int writecount,
+  TIFFDataType type,
+  int is var length,
+  Py ssize t len
+) {
+  if (field type == TIFF ASCII && !passcount) {
+    // TIFFSetField reads a null-terminated string
+    return !is var length && type == TIFF ASCII;
+  }
+  if (!is var length) {
+    // TIFFSetField reads a single value
+    return !passcount && writecount == 1 && tiff scalar arg type(type) &&
+        tiff scalar arg type(type) == tiff scalar arg type(field type);
+  }
+  // TIFFSetField reads a pointer to the values, preceded by the number of
+  // values if passcount is set. Otherwise, the number of values is fixed.
+  if (!tiff array item size(type) ||
+    tiff array item size(type) != tiff array item size(field type)) {
+    return 0;
+  }
+  return passcount || (writecount > 1 && len == writecount);
+}
+
+static int
+set tiff array field(
+  ImagingCodecState state, int key, int passcount, Py ssize t len, void *av
+) {
+  if (passcount) {
+    return ImagingLibTiffSetField(state, (ttag t)key, len, av);
+  }
+  return ImagingLibTiffSetField(state, (ttag t)key, av);
+}
+
 PyObject *
 PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
   ImagingEncoderObject *encoder;
@@ -689,7 +789,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
   PyObject *key, *value;
   Py ssize t pos = 0;
   int key int, status, is core tag, is var length, num core tags, i;
-  TIFFDataType type = TIFF NOTYPE;
+  int is known tag, passcount, writecount;
+  TIFFDataType type = TIFF NOTYPE, field type;
   // This list also exists in TiffTags.py
   const int core tags[] = {256, 257, 258,  259,  262,  263,  266, 269, 274, 277,
               278, 280, 281,  282,  283,  284,  286, 287, 296, 297,
@@ -777,6 +878,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
     status = 0;
     is core tag = 0;
     is var length = 0;
+    is known tag = 0;
+    passcount = 1;
     type = TIFF NOTYPE;
 
     for (i = 0; i < num core tags; i++) {
@@ -800,6 +903,15 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
         }
         Py DECREF(tag type);
       }
+
+      // libtiff uses its own definition for tags that it already knows,
+      // rather than the field registered below.
+      is known tag = ImagingLibTiffFindField(
+        &encoder->state, key int, &field type, &passcount, &writecount
+      );
+      if (is known tag && type == TIFF NOTYPE) {
+        type = field type;
+      }
     }
 
     if (type == TIFF NOTYPE) {
@@ -838,7 +950,7 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
 
     if (!is core tag) {
       // Register field for non core tags.
-      if (type == TIFF BYTE) {
+      if (type == TIFF BYTE || type == TIFF UNDEFINED) {
         is var length = 1;
       }
       if (ImagingLibTiffMergeFieldInfo(
@@ -847,6 +959,28 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
         Py DECREF(item);
         continue;
       }
+
+      if (is known tag) {
+        // Check that the value will be passed to TIFFSetField in the
+        // form that libtiff's definition of the tag expects.
+        Py ssize t len = 0;
+        if (PyTuple Check(value)) {
+          len = PyTuple Size(value);
+        } else if (PyBytes Check(value)) {
+          len = PyBytes Size(value);
+        }
+        if (!tiff field matches(
+            field type, passcount, writecount, type, is var length, len
+          )) {
+          Py DECREF(encoder);
+          Py DECREF(item);
+          PyErr SetString(
+            PyExc ValueError,
+            "Tag value does not match libtiff field definition"
+          );
+          return NULL;
+        }
+      }
     }
 
     if (type == TIFF BYTE || type == TIFF UNDEFINED ||
@@ -857,9 +991,10 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
         PyErr SetString(PyExc ValueError, "Incorrect tag value type");
         return NULL;
       }
-      status = ImagingLibTiffSetField(
+      status = set tiff array field(
         &encoder->state,
-        (ttag t)key int,
+        key int,
+        passcount,
         PyBytes Size(value),
         PyBytes AsString(value)
       );
@@ -909,8 +1044,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (UINT16)PyLong AsLong(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -922,8 +1057,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (UINT32)PyLong AsLong(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -935,8 +1070,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (INT8)PyLong AsLong(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -948,8 +1083,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (INT16)PyLong AsLong(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -961,8 +1096,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (INT32)PyLong AsLong(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -974,8 +1109,8 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (FLOAT32)PyFloat AsDouble(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
@@ -987,12 +1122,12 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = PyFloat AsDouble(PyTuple GetItem(value, i));
           }
-          status = ImagingLibTiffSetField(
-            &encoder->state, (ttag t)key int, len, av
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
           );
           free(av);
         }
-      } else if (type == TIFF RATIONAL) {
+      } else if (type == TIFF RATIONAL || type == TIFF SRATIONAL) {
         FLOAT32 *av;
         /* malloc check ok, calloc checks for overflow */
         av = calloc(len, sizeof(FLOAT32));
@@ -1000,8 +1135,9 @@ PyImaging LibTiffEncoderNew(PyObject *self, PyObject *args) {
           for (i = 0; i < len; i++) {
             av[i] = (FLOAT32)PyFloat AsDouble(PyTuple GetItem(value, i));
           }
-          status =
-            ImagingLibTiffSetField(&encoder->state, (ttag t)key int, av);
+          status = set tiff array field(
+            &encoder->state, key int, passcount, len, av
+          );
           free(av);
         }
       }
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..e14dd50bb 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -923,6 +923,29 @@ ImagingLibTiffMergeFieldInfo(
   return TIFFMergeFieldInfo(clientstate->tiff, info, n);
 }
 
+int
+ImagingLibTiffFindField(
+  ImagingCodecState state,
+  int key,
+  TIFFDataType *field type,
+  int *passcount,
+  int *writecount
+) {
+  // Look up libtiff's own definition of a tag. TIFFMergeFieldInfo does not
+  // replace the definition of a tag that libtiff already knows, so this is
+  // what TIFFSetField will use for such tags.
+  TIFFSTATE *clientstate = (TIFFSTATE *)state->context;
+  const TIFFField *fip = TIFFFindField(clientstate->tiff, (ttag t)key, TIFF ANY);
+
+  if (!fip) {
+    return 0;
+  }
+  *field type = TIFFFieldDataType(fip);
+  *passcount = TIFFFieldPassCount(fip);
+  *writecount = TIFFFieldWriteCount(fip);
+  return 1;
+}
+
 int
 ImagingLibTiffSetField(ImagingCodecState state, ttag t tag, ...) {
   // after tif dir.c->TIFFSetField.
diff --git a/src/libImaging/TiffDecode.h b/src/libImaging/TiffDecode.h
index c31d0b9d8..ee1e4a5f9 100644
--- a/src/libImaging/TiffDecode.h
+++ b/src/libImaging/TiffDecode.h
@@ -41,6 +41,14 @@ ImagingLibTiffMergeFieldInfo(
   ImagingCodecState state, TIFFDataType field type, int key, int is var length
 );
 extern int
+ImagingLibTiffFindField(
+  ImagingCodecState state,
+  int key,
+  TIFFDataType *field type,
+  int *passcount,
+  int *writecount
+);
+extern int
 ImagingLibTiffSetField(ImagingCodecState state, ttag t tag, ...);
 
 /*

The reproducer

Reproducer — repro, 2,545 bytes (click to open)
Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
python
#!/usr/bin/env python3
# Pillow libtiff writer: a count-1 integer tag copied from the source TIFF is passed to
# TIFFSetField() as one scalar vararg; for tags libtiff itself defines as arrays it is
# consumed as a byte COUNT (DNGPrivateData 50740 -> N bytes of process memory written
# into the output) or as a POINTER (ActiveArea 50829 -> SIGSEGV). Application side is
# the ordinary "open upload, save as TIFF" (compression is inherited -> libtiff writer).
import io, struct, subprocess, sys, resource
from PIL import Image

def upload(tag, value):
  """Pillow-written 16x16 LZW TIFF, plus one extra IFD entry <tag> LONG[1] = value."""
  b = io.BytesIO()
  Image.new("RGB", (16, 16), "red").save(b, "TIFF", compression="tiff lzw")
  data = bytearray(b.getvalue()); assert data[:4] == b"II*0"
  (off,) = struct.unpack from("<I", data, 4)
  (n,) = struct.unpack from("<H", data, off)
  ents = [bytes(data[off + 2 + 12 * i: off + 14 + 12 * i]) for i in range(n)]
  ents.append(struct.pack("<HHII", tag, 4, 1, value))
  ents.sort(key=lambda e: struct.unpack from("<H", e)[0])
  data += b"0" * (len(data) & 1); new off = len(data)
  data += struct.pack("<H", len(ents)) + b"".join(ents) + struct.pack("<I", 0)
  struct.pack into("<I", data, 4, new off)
  return bytes(data)

def resave(src):            # the whole "service"
  im = Image.open(io.BytesIO(src)); out = io.BytesIO(); im.save(out, "TIFF")
  return out.getvalue()

if len(sys.argv) > 1 and sys.argv[1] == "child":
  resource.setrlimit(resource.RLIMIT CORE, (0, 0))
  resave(upload(50829, 64)); sys.exit(0)

print("PIL core:", Image.core. file )
ctl = Image.open(io.BytesIO(resave(upload(65000, 4096)))).tag v2.get(65000)
print("control tag 65000=4096 ->", repr(ctl))

N = 65536
src = upload(50740, N)
try:
  v = Image.open(io.BytesIO(resave(src))).tag v2.get(50740)
except (ValueError, RuntimeError) as e:  # a fixed Pillow may reject the tag
  v = e
leak = isinstance(v, bytes) and len(v) == N and v[:64] not in src
print(f"leak   tag 50740={N} from a {len(src)}-byte upload ->",
   f"{len(v)} bytes not present in input, head {v[:32].hex()}" if leak else repr(v)[:80])
if leak:
  so = open(Image.core. file , "rb").read()
  print("     leaked bytes found in imaging.so at file offset", hex(so.find(v[:256])))

rc = subprocess.call([sys.executable,  file , "child"])
crash = rc in (-11, 139)
print("crash  tag 50829=64 -> child exit", rc, "(SIGSEGV)" if crash else "")
print("BUG" if leak or crash else "OK")

Explorabilidade
Correção
Fornecedores
Produtos afetados
Identificadores relacionados
GHSA-XVH5-G6GV-559V
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾