PST-048: Company-logo and plugin-logo decoding accepts every Pillow format from bytes a hostile website chooses #777

Closed
opened 2026-10-10 10:56:20 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-048. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: attack-surface hardening
  • Location: src/postulo/core/pictures.py (pictures.py:93-130)
  • Found by: completeness review: coverage

Who can do it, and what they need. The operator of a website that a member captures (the Find logo action fetches up to six images from the company's site through the guarded client), or a member uploading a logo.

How it works.

Bytes reach pictures.as_stored(data) with kinds=None, so Image.open identifies whatever Pillow's ~40 plugins recognise and image.load() decodes it. Verified: logos.process() accepts and re-encodes BMP, TIFF, ICO, GIF, WEBP, PPM, TGA, PCX, SGI, DDS, QOI, JPEG 2000 and ICNS. An EPS header is identified (format 'EPS'); it is refused only because no Ghostscript binary is on PATH here (and none is in the Docker image), so on a bare-metal install that has gs, Pillow's EPS loader would run Ghostscript on attacker-supplied PostScript.

Impact. A wider decoder attack surface (libtiff, openjpeg, libwebp and Pillow's own parsers, plus Ghostscript where installed) than the PNG/JPEG logos actually need. No concrete vulnerability is shown. Side effect: decode() sets Image.MAX_IMAGE_PIXELS process-wide on every call and never restores it.

Evidence.

pictures.py:109-111: `Image.MAX_IMAGE_PIXELS = max_pixels` / `image = Image.open(io.BytesIO(data))` / `if kinds is not None and content_type_of(image) not in kinds`. Scratch output: 'BMP accepted as logo', 'TIFF accepted as logo', 'JPEG2000 accepted as logo', 'ICNS accepted as logo'; 'identified as: EPS (10, 10)' then UnusablePicture only for lack of gs.

Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Informational). Attacker: the operator of a website that a member captures (Find logo fetches up to six images through the guarded client), or a member uploading a logo, which gives only self-attack. Precondition for real impact: Ghostscript on PATH on a bare-metal install, plus a -dSAFER bypass or an unpatched Ghostscript. Without Ghostscript it is informational. Suggested fix: pass a formats allowlist to Image.open (for example PNG, JPEG, GIF, WEBP, ICO, plus BMP if wanted), or give logos the same kinds= enforcement as avatars. Also stop assigning Image.MAX_IMAGE_PIXELS globally.

Suggested remediation. Pass formats=("PNG","JPEG","GIF","WEBP","ICO") to Image.open (or kinds=) on the logo paths, keep EPS and the exotic formats out for good, and restore Image.MAX_IMAGE_PIXELS after use.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-048**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** attack-surface hardening - **Location:** `src/postulo/core/pictures.py` (pictures.py:93-130) - **Found by:** completeness review: coverage **Who can do it, and what they need.** The operator of a website that a member captures (the Find logo action fetches up to six images from the company's site through the guarded client), or a member uploading a logo. **How it works.** Bytes reach pictures.as_stored(data) with kinds=None, so Image.open identifies whatever Pillow's ~40 plugins recognise and image.load() decodes it. Verified: logos.process() accepts and re-encodes BMP, TIFF, ICO, GIF, WEBP, PPM, TGA, PCX, SGI, DDS, QOI, JPEG 2000 and ICNS. An EPS header is identified (format 'EPS'); it is refused only because no Ghostscript binary is on PATH here (and none is in the Docker image), so on a bare-metal install that has gs, Pillow's EPS loader would run Ghostscript on attacker-supplied PostScript. **Impact.** A wider decoder attack surface (libtiff, openjpeg, libwebp and Pillow's own parsers, plus Ghostscript where installed) than the PNG/JPEG logos actually need. No concrete vulnerability is shown. Side effect: decode() sets Image.MAX_IMAGE_PIXELS process-wide on every call and never restores it. **Evidence.** ```text pictures.py:109-111: `Image.MAX_IMAGE_PIXELS = max_pixels` / `image = Image.open(io.BytesIO(data))` / `if kinds is not None and content_type_of(image) not in kinds`. Scratch output: 'BMP accepted as logo', 'TIFF accepted as logo', 'JPEG2000 accepted as logo', 'ICNS accepted as logo'; 'identified as: EPS (10, 10)' then UnusablePicture only for lack of gs. ``` **Verification** (verdict: confirmed; severity after verification: Low; first-pass claim: Informational). Attacker: the operator of a website that a member captures (Find logo fetches up to six images through the guarded client), or a member uploading a logo, which gives only self-attack. Precondition for real impact: Ghostscript on PATH on a bare-metal install, plus a -dSAFER bypass or an unpatched Ghostscript. Without Ghostscript it is informational. Suggested fix: pass a formats allowlist to Image.open (for example PNG, JPEG, GIF, WEBP, ICO, plus BMP if wanted), or give logos the same kinds= enforcement as avatars. Also stop assigning Image.MAX_IMAGE_PIXELS globally. **Suggested remediation.** Pass formats=("PNG","JPEG","GIF","WEBP","ICO") to Image.open (or kinds=) on the logo paths, keep EPS and the exotic formats out for good, and restore Image.MAX_IMAGE_PIXELS after use.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:50 +00:00
Author
Owner

Fixed in c82645ad7 on main.

Fixed in c82645ad7 on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#777
No description provided.