Skip to content

Take libtiff scanline geometry tags from the image, not tiffinfo/exif - #10226

Merged
hugovk merged 2 commits into
python-pillow:mainfrom
hugovk:ghsa-xcvg-w56q-r89m
Oct 16, 2026
Merged

hugovk merged 2 commits into
python-pillow:mainfrom
hugovk:ghsa-xcvg-w56q-r89m

Conversation

@hugovk

@hugovk hugovk commented Oct 16, 2026

Copy link
Copy Markdown
Member

In TiffImagePlugin._save's libtiff branch, only BITSPERSAMPLE was pinned to the real image's value when building the encoder's tag list. IMAGEWIDTH, IMAGELENGTH and PLANAR_CONFIGURATION were set from im.size but then overwritten by the tiffinfo=/exif= merge loop, and SAMPLESPERPIXEL was never set at all for single-band modes, letting a value from the caller's tags -- or from a re-saved source TIFF's own IFD -- reach TIFFSetField unfiltered.

ImagingLibTiffEncode hands TIFFWriteScanline a row buffer sized from the real image, with no check against libtiff's own scanline size (computed from the tag-described geometry). Depending on which tag was overridden, this let libtiff read (SamplesPerPixel/ImageWidth too large) or, with Predictor=2, write (horizontal differencing rewrites the scanline in place) past the end of that buffer.

Confirmed live on this build:

  • Re-saving a JPEG with exif={SamplesPerPixel: 3, Photometric: RGB} via im.save(out, "TIFF", exif=exif, compression="tiff_lzw"): on the unfixed baseline, 80 of the 128 heap bytes trailing the row buffer leaked verbatim into the saved file's row 0. On the fixed build the real 1-sample-per-pixel geometry is used regardless of the supplied tags, so the (now self-inconsistent, Photometric=RGB but 1 sample) output is rejected as unreadable rather than leaking heap -- matching the reporter's documented expected outcome.
  • Re-saving the same JPEG with an oversized ImageWidth tag: the unfixed baseline crashed with a bus error (this platform's equivalent of the report's SIGSEGV) trying to read a huge scanline out of a 64-byte buffer. The fixed build ignores the malicious width entirely (geometry always comes from the real image) and completes the save normally with exit code 0.

Fix (reporter's patch 1 of 2, chosen over the Python-only patch 2 for defense in depth):

  • src/PIL/TiffImagePlugin.py: pre-seed atts with SAMPLESPERPIXEL, IMAGEWIDTH, IMAGELENGTH and PLANAR_CONFIGURATION taken from the image being saved, alongside the existing BITSPERSAMPLE, so no tiffinfo/exif/source-IFD tag can override the geometry libtiff sizes its scanline from. This closes the tiffinfo=, exif= and re-saved-source-IFD routes alike, without touching the raw-writer behaviour relied on by test_tiff_chunks.
  • src/libImaging/TiffDecode.c, ImagingLibTiffEncode: defense in depth -- reject any remaining configuration where TIFFScanlineSize64() exceeds the row buffer before the encode loop runs, raising IMAGING_CODEC_CONFIG instead of over-reading/over-writing. Reuses ImagingLibTiffEncodeCleanup() on both this new path and the existing TIFFWriteScanline error path so the TIFF handle is always closed before its buffer is freed (the same use-after-free class fixed for GHSA-vvxp-fm22-fchw).

Adds test_geometry_tags to Tests/test_file_libtiff.py, parametrised over oversized IMAGEWIDTH/IMAGELENGTH, SAMPLESPERPIXEL=3 and PLANAR_CONFIGURATION=2 via tiffinfo=.

Copilot AI and others added 2 commits October 16, 2026 19:09
In TiffImagePlugin._save's libtiff branch, only BITSPERSAMPLE was
pinned to the real image's value when building the encoder's tag
list. IMAGEWIDTH, IMAGELENGTH and PLANAR_CONFIGURATION were set from
im.size but then overwritten by the tiffinfo=/exif= merge loop, and
SAMPLESPERPIXEL was never set at all for single-band modes, letting
a value from the caller's tags -- or from a re-saved source TIFF's
own IFD -- reach TIFFSetField unfiltered.

ImagingLibTiffEncode hands TIFFWriteScanline a row buffer sized from
the real image, with no check against libtiff's own scanline size
(computed from the tag-described geometry). Depending on which tag
was overridden, this let libtiff read (SamplesPerPixel/ImageWidth
too large) or, with Predictor=2, write (horizontal differencing
rewrites the scanline in place) past the end of that buffer.

Confirmed live on this build:
- Re-saving a JPEG with exif={SamplesPerPixel: 3, Photometric: RGB}
  via `im.save(out, "TIFF", exif=exif, compression="tiff_lzw")`: on
  the unfixed baseline, 80 of the 128 heap bytes trailing the row
  buffer leaked verbatim into the saved file's row 0. On the fixed
  build the real 1-sample-per-pixel geometry is used regardless of
  the supplied tags, so the (now self-inconsistent, Photometric=RGB
  but 1 sample) output is rejected as unreadable rather than leaking
  heap -- matching the reporter's documented expected outcome.
- Re-saving the same JPEG with an oversized ImageWidth tag: the
  unfixed baseline crashed with a bus error (this platform's
  equivalent of the report's SIGSEGV) trying to read a huge scanline
  out of a 64-byte buffer. The fixed build ignores the malicious
  width entirely (geometry always comes from the real image) and
  completes the save normally with exit code 0.

Fix (reporter's patch 1 of 2, chosen over the Python-only patch 2 for
defense in depth):
- src/PIL/TiffImagePlugin.py: pre-seed `atts` with SAMPLESPERPIXEL,
  IMAGEWIDTH, IMAGELENGTH and PLANAR_CONFIGURATION taken from the
  image being saved, alongside the existing BITSPERSAMPLE, so no
  tiffinfo/exif/source-IFD tag can override the geometry libtiff
  sizes its scanline from. This closes the tiffinfo=, exif= and
  re-saved-source-IFD routes alike, without touching the raw-writer
  behaviour relied on by test_tiff_chunks.
- src/libImaging/TiffDecode.c, ImagingLibTiffEncode: defense in depth
  -- reject any remaining configuration where TIFFScanlineSize64()
  exceeds the row buffer before the encode loop runs, raising
  IMAGING_CODEC_CONFIG instead of over-reading/over-writing. Reuses
  ImagingLibTiffEncodeCleanup() on both this new path and the
  existing TIFFWriteScanline error path so the TIFF handle is always
  closed before its buffer is freed (the same use-after-free class
  fixed for GHSA-vvxp-fm22-fchw).

Adds test_geometry_tags to Tests/test_file_libtiff.py, parametrised
over oversized IMAGEWIDTH/IMAGELENGTH, SAMPLESPERPIXEL=3 and
PLANAR_CONFIGURATION=2 via tiffinfo=.

Reported-by: carlini
Fixes: GHSA-xcvg-w56q-r89m

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hugovk
hugovk merged commit 53318b5 into python-pillow:main Oct 16, 2026
66 of 68 checks passed
@hugovk
hugovk deleted the ghsa-xcvg-w56q-r89m branch October 16, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants