Skip to content

Harden planar row-size checks in PcxDecode and ZipDecode - #10313

Open
Altafk960 wants to merge 4 commits into
python-pillow:mainfrom
Altafk960:harden-planar-row-size-checks
Open

Altafk960 wants to merge 4 commits into
python-pillow:mainfrom
Altafk960:harden-planar-row-size-checks

Conversation

@Altafk960

Copy link
Copy Markdown

Systematic hardening of the decoder-side row-size arithmetic, completing the cleanup that #10186 (392ee6f) started for the decoders whose line size is computed independently of decode.c:_setimage. Production image-file paths are not affected: PcxImagePlugin computes planar-correct strides itself ("Don't trust the passed in stride", CVE-2020-35653), and PNG never combines planar raw modes with the ZIP decoder — the surface in play is the frombytes()/frombuffer() decoder API with caller-supplied geometry, the same API surface #10186 was written against (CVE-2026-112394 is cited here only as the maintainer's threat-model precedent for that surface, not as a severity reference).

This is a cleanup of integer arithmetic that eliminates the duplicated packed-row-size design pattern: ImagingPcxDecode still validated its input line with the packed-pixel formula (xsize * bits + 7) / 8 and a separate bits == 2 || bits == 4 planar heuristic, while _setimage and ImagingRawDecode were converted in #10186 to a planes-aware, overflow-guarded expression. Every copy of that derivation is a potential gap; this patch makes each copy express the same formula.

Changes (2 commits, with overflow guards and regression tests)

  1. PcxDecode: planar-aware, overflow-guarded line-size check — ImagingPcxDecode now checks its input line against planes * ((xsize + 7) / 8) for planar raw modes (P;2L/P;4L) and the packed formula otherwise, both with INT_MAX overflow guards (the same idiom as decode.c:_setimage and RawDecode.c). The band-interleave collapse now keys on state->planes — the single source of truth for "is this planar?" — instead of the bits == 2 || bits == 4 heuristic. Truncated planar input now fails cleanly (ValueError: cannot decode image data) instead of reading up to 3 bytes past the decode buffer.
  2. ZipDecode: reject planar raw modes for interlaced data — the interlaced bits < 8 path stages one bit-pixel at a time in a single byte and calls the shuffle once per pixel, while unpackP2L/unpackP4L read planes padded bytes per call. The combination is now rejected at decoder construction (ValueError: unrecognized raw mode), mirroring the putpalette rejection in Use bit plane row size for P;2L and P;4L rawmodes #10186. Non-interlaced planar ZIP is unchanged and regression-tested.

The tests complete #10186's test_pXl_decoder_buffer_size matrix (which covered hex and packbits) for the two decoders it didn't cover: pcx and zip.

Behavior change

Behavior changes only for:

  • invalid or unbounded geometry, which is now rejected cleanly (previously: out-of-bounds reads past the decode buffer, and an unguarded xsize * bits multiplication in the check itself);
  • packed L;2/L;4/P;2/P;4 rows decoded via frombytes() with a padded stride — the old bits == 2 || bits == 4 collapse heuristic treated these packed rows as bit planes and de-interleaved them whenever the stride carried padding. The previous sizing was the buggy one here; the collapse now applies only to actual planar raw modes (state->planes). Tests/test_lib_pack.py::test_pcx_packed_low_bit_padded_row pins the correct packed behavior for all four raw modes.

Everything else decodes exactly as before. File-format decoding (2/4-plane PCX, PNG) is unchanged — Tests/test_file_pcx.py and Tests/test_file_png.py pass.

Verification

  • Full test suite passes (4870 passed, remaining skips for optional codecs not built locally).
  • clang-format (v23.1.2, the pre-commit pin) clean on both C files.
  • The new tests fail against the unpatched decoder (both the planar buffer-size cases and all four packed-mode cases) and pass after the patch.

ImagingPcxDecode validated its input line against the packed-pixel
formula (xsize * bits + 7) / 8, but the P;2L/P;4L unpackers consume
planes * ((xsize + 7) / 8) bytes per line, with each bit plane padded
to a byte (python-pillow#10186).  A caller-supplied stride equal to the packed size
passed the check while unpackP2L/unpackP4L read up to three bytes past
the decode buffer.

Validate the line against the same planes-aware, overflow-guarded
expression now used by decode.c:_setimage and ImagingRawDecode, and key
the band-interleave collapse on state->planes instead of the
"bits == 2 || bits == 4" heuristic, so that packed L;2/L;4/P;2/P;4
rows are no longer de-interleaved when the stride carries padding.

Tests extend python-pillow#10186's test_pXl_decoder_buffer_size matrix to the pcx
decoder and pin the packed low-bit row layout with padded strides.
The interlaced bits < 8 path of ImagingZipDecode stages one bit-pixel
at a time in a single byte and calls the shuffle with that one-byte
buffer, while the P;2L/P;4L unpackers read planes padded bytes per
call and would read past it.

Reject the combination at decoder construction with "unrecognized raw
mode", mirroring the putpalette rejection in python-pillow#10186.  Non-interlaced
planar raw modes keep working and are covered by regression tests.
@radarhere

Copy link
Copy Markdown
Member

@akx would you like to review this?

@Altafk960

Copy link
Copy Markdown
Author

Codecov shows the patch at 80%. The two uncovered lines are the new guards in PcxDecode.c that return IMAGING_CODEC_MEMORY — they only run when the image width is within a few pixels of the largest 32-bit value, and reaching them in a test would mean allocating a multi-gigabyte image. Is a test at that size welcome in the suite, or would you rather leave those two lines uncovered? Everything else in the diff is covered.

(The three failing checks also fail on current main — the CodSpeed job (Python 3.15), run 40194988170, and the Docker jobs, run 40194988114 — so they don't look related to this change.)

@Altafk960

Copy link
Copy Markdown
Author

The MSYS2 failure is in the Install dependencies step (pacman) - both Build Pillow and Test Pillow were skipped, so the patch is not involved. Looks like runner/mirror flakiness. Could someone re-run the failed jobs when MSYS2 settles? Happy to push a retrigger commit instead if that is easier.

This branch has not been deployed

No deployments
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