Attacker-controlled images reach Pillow with no limits or isolation #65

Closed
opened 2026-07-28 05:57:13 +00:00 by claude-bot · 2 comments

Severity: MEDIUM

The bug

backend/app/services/exif_extractor.py:43-47 calls Image.open(path) and img._getexif()
in-process, in the request handler, on fully attacker-controlled bytes — with no pixel limit,
no verify(), no timeout, no memory cap, and no isolation.

  • Decompression bombs: Pillow's default MAX_IMAGE_PIXELS emits a warning, not an
    exception, and the app never configures it or catches it. A small crafted file can expand to
    enormous memory. Image.open is lazy, but _getexif() triggers work on some formats, and any
    future thumbnailing decodes fully.
  • Memory safety: Pillow's C decoders have a long CVE history. A parser bug executes in the
    API process, which holds the session-signing key and the database handle.

The bare except Exception at line 46 catches Python exceptions but does nothing for a segfault
or an OOM kill — and it silently swallows genuine corruption, making a photo whose EXIF failed
to parse indistinguishable from one that has none.

Note the DoS is pre-validation: _validate_upload only inspects a header, so bomb bytes reach
Pillow regardless.

Fix

  • Set Image.MAX_IMAGE_PIXELS deliberately and turn the warning into an error.
  • Validate magic bytes and call Image.open(...).verify() before any other processing.
  • Enforce dimension limits before decode.
  • Move EXIF extraction into the worker process (#2) with RLIMIT_AS/RLIMIT_CPU, so a crash
    cannot take down the API.
  • Distinguish "no EXIF" from "EXIF failed to parse" in the result, so corruption is visible.

Done when

  • A decompression bomb is rejected rather than consuming memory
  • Non-image bytes are rejected before Pillow does substantial work
  • Parse failures are recorded distinctly from absent EXIF
  • Image parsing runs outside the API process, or under explicit resource limits

References

  • backend/app/services/exif_extractor.py:43-47
  • backend/app/services/ingest.py:115

Related: #2 (worker runtime) is the natural home for the isolation half.

## Severity: MEDIUM ## The bug `backend/app/services/exif_extractor.py:43-47` calls `Image.open(path)` and `img._getexif()` in-process, in the request handler, on fully attacker-controlled bytes — with no pixel limit, no `verify()`, no timeout, no memory cap, and no isolation. - **Decompression bombs:** Pillow's default `MAX_IMAGE_PIXELS` emits a *warning*, not an exception, and the app never configures it or catches it. A small crafted file can expand to enormous memory. `Image.open` is lazy, but `_getexif()` triggers work on some formats, and any future thumbnailing decodes fully. - **Memory safety:** Pillow's C decoders have a long CVE history. A parser bug executes in the API process, which holds the session-signing key and the database handle. The bare `except Exception` at line 46 catches Python exceptions but does nothing for a segfault or an OOM kill — and it silently swallows genuine corruption, making a photo whose EXIF failed to parse indistinguishable from one that has none. Note the DoS is *pre-validation*: `_validate_upload` only inspects a header, so bomb bytes reach Pillow regardless. ## Fix - Set `Image.MAX_IMAGE_PIXELS` deliberately and turn the warning into an error. - Validate magic bytes and call `Image.open(...).verify()` before any other processing. - Enforce dimension limits before decode. - Move EXIF extraction into the worker process (#2) with `RLIMIT_AS`/`RLIMIT_CPU`, so a crash cannot take down the API. - Distinguish "no EXIF" from "EXIF failed to parse" in the result, so corruption is visible. ## Done when - [ ] A decompression bomb is rejected rather than consuming memory - [ ] Non-image bytes are rejected before Pillow does substantial work - [ ] Parse failures are recorded distinctly from absent EXIF - [ ] Image parsing runs outside the API process, or under explicit resource limits ## References - `backend/app/services/exif_extractor.py:43-47` - `backend/app/services/ingest.py:115` Related: #2 (worker runtime) is the natural home for the isolation half.
claude-bot added this to the v0.1.1 milestone 2026-07-28 05:57:13 +00:00
Author

Status check against the four "done when" items, since three are now closed and the fourth is the one that needs a decision.

  • A decompression bomb is rejected rather than consuming memoryverify_image_content sets a deliberate MAX_IMAGE_PIXELS (300 MP; a 600dpi A4 scan is ~35 MP) and catches DecompressionBombError. Pillow's own default only warns, which the app never configured or caught.
  • Non-image bytes are rejected before Pillow does substantial workverify() runs before any decode, and the format is allowlisted, so SVG and friends are refused rather than trusted.
  • Parse failures are recorded distinctly from absent EXIF — done in 03bb2b3, see below.
  • Image parsing runs outside the API process, or under explicit resource limits — outstanding, and it is what I want your call on.

What landed just now (03bb2b3)

extract_exif caught every exception and returned an empty ExifResult, collapsing three different situations into one indistinguishable answer: a scanned print with no EXIF block (the ordinary case), a photo whose EXIF is corrupt, and a file Pillow could not open at all.

The second and third mean something is wrong with an original in an archive of irreplaceable scans, and nothing anywhere would have shown it — the photo would appear to have no EXIF permanently, and nobody would have reason to look again while the physical print is still around to rescan. ExifResult now carries an ExifStatus (absent / present / unreadable) plus the exception class name, and the ingest audit event records both. The class name rather than the message deliberately: Pillow embeds file paths in some messages, and this string goes into the ledger.

One thing I found while there: the pixel ceiling was only ever set inside verify_image_content, which restores the previous value on exit — so the Image.open / _getexif() calls in extract_exif ran under Pillow's default limit, which merely warns. Harmless today because ingest verifies first, but extract_exif is exactly what the worker will call, possibly on bytes that never went through verification. It is now wrapped in a shared pixel_ceiling() context manager, so the bound travels with the Pillow call instead of depending on the caller's discipline.

Also noted in the code: _getexif is private Pillow API. If a release removes it, the result is now unreadable for every file — loud and visible in the ledger — rather than a silent archive-wide "no EXIF".

16 tests in backend/tests/test_exif_failure_visibility.py.

The remaining item

Process isolation genuinely depends on the worker runtime (#2, v0.2.0), as the issue itself anticipated ("#2 is the natural home for the isolation half").

I could build it now with a ProcessPoolExecutor and resource.setrlimit(RLIMIT_AS/RLIMIT_CPU) around the Pillow call. I do not think that is a good trade: it is a throwaway mini-worker for one call site, it adds process-spawn latency to every upload, and #2 will replace it wholesale. The remaining risk it would mitigate is a memory-safety bug in Pillow's C decoders reached through the ingest endpoint — which is now admin-only, behind the allowlist (#56), behind CSRF origin checking (#63), behind verify() and a pixel ceiling, and behind a rate limit and byte quota (#68). Pillow is also pinned and held from Renovate automerge, and pip-audit now gates CI and reports it clean.

Raising the milestone question with the user rather than deciding it myself, since it gates "when can this go back online".

Status check against the four "done when" items, since three are now closed and the fourth is the one that needs a decision. - [x] **A decompression bomb is rejected rather than consuming memory** — `verify_image_content` sets a deliberate `MAX_IMAGE_PIXELS` (300 MP; a 600dpi A4 scan is ~35 MP) and catches `DecompressionBombError`. Pillow's own default only *warns*, which the app never configured or caught. - [x] **Non-image bytes are rejected before Pillow does substantial work** — `verify()` runs before any decode, and the format is allowlisted, so SVG and friends are refused rather than trusted. - [x] **Parse failures are recorded distinctly from absent EXIF** — done in 03bb2b3, see below. - [ ] **Image parsing runs outside the API process, or under explicit resource limits** — outstanding, and it is what I want your call on. ## What landed just now (03bb2b3) `extract_exif` caught every exception and returned an empty `ExifResult`, collapsing three different situations into one indistinguishable answer: a scanned print with no EXIF block (the ordinary case), a photo whose EXIF is corrupt, and a file Pillow could not open at all. The second and third mean something is wrong with an **original in an archive of irreplaceable scans**, and nothing anywhere would have shown it — the photo would appear to have no EXIF permanently, and nobody would have reason to look again while the physical print is still around to rescan. `ExifResult` now carries an `ExifStatus` (`absent` / `present` / `unreadable`) plus the exception class name, and the ingest audit event records both. The class name rather than the message deliberately: Pillow embeds file paths in some messages, and this string goes into the ledger. One thing I found while there: the pixel ceiling was only ever set inside `verify_image_content`, which restores the previous value on exit — so the `Image.open` / `_getexif()` calls in `extract_exif` ran under Pillow's *default* limit, which merely warns. Harmless today because ingest verifies first, but `extract_exif` is exactly what the worker will call, possibly on bytes that never went through verification. It is now wrapped in a shared `pixel_ceiling()` context manager, so the bound travels with the Pillow call instead of depending on the caller's discipline. Also noted in the code: `_getexif` is private Pillow API. If a release removes it, the result is now `unreadable` for every file — loud and visible in the ledger — rather than a silent archive-wide "no EXIF". 16 tests in `backend/tests/test_exif_failure_visibility.py`. ## The remaining item Process isolation genuinely depends on the worker runtime (#2, v0.2.0), as the issue itself anticipated ("#2 is the natural home for the isolation half"). I could build it now with a `ProcessPoolExecutor` and `resource.setrlimit(RLIMIT_AS/RLIMIT_CPU)` around the Pillow call. I do not think that is a good trade: it is a throwaway mini-worker for one call site, it adds process-spawn latency to every upload, and #2 will replace it wholesale. The remaining risk it would mitigate is a memory-safety bug in Pillow's C decoders reached through the ingest endpoint — which is now **admin-only**, behind the allowlist (#56), behind CSRF origin checking (#63), behind `verify()` and a pixel ceiling, and behind a rate limit and byte quota (#68). Pillow is also pinned and held from Renovate automerge, and `pip-audit` now gates CI and reports it clean. Raising the milestone question with the user rather than deciding it myself, since it gates "when can this go back online".
Author

Done in 5303f22. All four "done when" items now closed.

You were right to overrule me, and I want to record why my reasoning was wrong rather than just say so.

Two flaws, both only visible once the code existed:

  1. I counted verify() and the pixel ceiling as mitigations. Both are executed by Pillow. They reduce how much of Pillow runs on hostile bytes; they do nothing about what happens when Pillow misbehaves, because they are the thing that might misbehave. I listed a control as mitigating the risk it is part of.

  2. "Throwaway until #2 lands" was wrong. The worker contains a crash to the worker; it does not contain a crash within it. A worker that parses images in-process still dies mid-job on a decoder bug, possibly with a half-written transaction. This module is what the worker will call, not what it replaces.

What I got right was only the latency, and it turned out smaller than the argument needed.

Implementation, and one thing worth knowing

A plain subprocess, not multiprocessing. I tried all three start methods. fork is unusable — uvicorn runs sync endpoints in a thread pool, forking a process whose other threads hold locks deadlocks, and the child would inherit the API's whole address space including its secrets. spawn and forkserver both run _fixup_main_from_path in every child, re-executing the parent's __main__; the forkserver preload list changes what the helper imports, not that.

I found this the hard way — a benchmark script without a main guard failed on every parse. A security control whose correctness depends on whatever entry point is running being import-safe, failing at runtime on a real upload, is not one worth having. python -m app.services.image_sandbox has no such coupling, behaves identically on Windows, and costs ~45 ms of interpreter and Pillow startup.

The child gets a minimal environment. Secrets live in CIRCA_* variables, and the process parsing hostile input has no business reading CIRCA_SECRET_KEY. No fork-based approach can offer this — the child inherits everything. The subprocess boundary makes it a two-line allowlist.

Results cross back as JSON, never pickle. The child is the process most likely to be compromised; unpickling its output would hand control of the parent straight through the boundary this exists to build.

RLIMIT_FSIZE is zero alongside the memory and CPU caps. Nothing in a parse legitimately writes a file, and #55 was an arbitrary-file-write bug in this same request path.

Verified, not asserted

Every containment claim has a test that produces the real failure in a real process:

Hostile behaviour Outcome
SIGKILL mid-parse (stands in for a decoder segfault) crashed, exit −9, parent serves the next request
4 GiB allocation under a 256 MiB RLIMIT_AS MemoryError, contained
Infinite loop under a 5 s RLIMIT_CPU killed at 5.1 s
8 KB write under RLIMIT_FSIZE=0 refused, nothing on disk
Hang wall-clock timeout
Child reads CIRCA_SECRET_KEY None

All become a 415; none reach the client as a distinction, since which of them occurred is diagnostic information about our defences and the caller supplied the bytes.

Cost, and one thing removed

~125 ms per parse. That made the ingest route's duplicate verification worth dropping: ingest_photo already verifies before any storage or DB work and is the authoritative source of the stored MIME type, so the route's copy was paying a second process launch per file for no additional guarantee. Net effect on an upload is roughly break-even.

Configurable via CIRCA_IMAGE_SANDBOX_ENABLED / _MEMORY_MB / _CPU_SECONDS / _TIMEOUT_SECONDS. Enabled by default — a security control that ships off is not a control, and there is a test asserting that default.

Caveat: on Windows you get process isolation but not the kernel limits (resource is POSIX-only). The rlimit tests skip there. Production is Linux, so this is a developer-machine caveat only.

316 tests pass, verified on the real CI image (Python 3.11, package pip-installed) as well as locally.

Done in 5303f22. All four "done when" items now closed. **You were right to overrule me, and I want to record why my reasoning was wrong rather than just say so.** Two flaws, both only visible once the code existed: 1. **I counted `verify()` and the pixel ceiling as mitigations.** Both are *executed by Pillow*. They reduce how much of Pillow runs on hostile bytes; they do nothing about what happens when Pillow misbehaves, because they are the thing that might misbehave. I listed a control as mitigating the risk it is part of. 2. **"Throwaway until #2 lands" was wrong.** The worker contains a crash *to* the worker; it does not contain a crash *within* it. A worker that parses images in-process still dies mid-job on a decoder bug, possibly with a half-written transaction. This module is what the worker will call, not what it replaces. What I got right was only the latency, and it turned out smaller than the argument needed. ## Implementation, and one thing worth knowing **A plain subprocess, not `multiprocessing`.** I tried all three start methods. `fork` is unusable — uvicorn runs sync endpoints in a thread pool, forking a process whose other threads hold locks deadlocks, and the child would inherit the API's whole address space including its secrets. `spawn` and `forkserver` both run `_fixup_main_from_path` in **every child**, re-executing the parent's `__main__`; the forkserver preload list changes what the *helper* imports, not that. I found this the hard way — a benchmark script without a main guard failed on every parse. **A security control whose correctness depends on whatever entry point is running being import-safe, failing at runtime on a real upload, is not one worth having.** `python -m app.services.image_sandbox` has no such coupling, behaves identically on Windows, and costs ~45 ms of interpreter and Pillow startup. **The child gets a minimal environment.** Secrets live in `CIRCA_*` variables, and the process parsing hostile input has no business reading `CIRCA_SECRET_KEY`. No fork-based approach can offer this — the child inherits everything. The subprocess boundary makes it a two-line allowlist. **Results cross back as JSON, never pickle.** The child is the process most likely to be compromised; unpickling its output would hand control of the parent straight through the boundary this exists to build. **`RLIMIT_FSIZE` is zero** alongside the memory and CPU caps. Nothing in a parse legitimately writes a file, and #55 was an arbitrary-file-write bug in this same request path. ## Verified, not asserted Every containment claim has a test that produces the real failure in a real process: | Hostile behaviour | Outcome | |---|---| | `SIGKILL` mid-parse (stands in for a decoder segfault) | `crashed`, exit −9, parent serves the next request | | 4 GiB allocation under a 256 MiB `RLIMIT_AS` | `MemoryError`, contained | | Infinite loop under a 5 s `RLIMIT_CPU` | killed at 5.1 s | | 8 KB write under `RLIMIT_FSIZE=0` | refused, nothing on disk | | Hang | wall-clock timeout | | Child reads `CIRCA_SECRET_KEY` | `None` | All become a 415; none reach the client as a distinction, since which of them occurred is diagnostic information about our defences and the caller supplied the bytes. ## Cost, and one thing removed ~125 ms per parse. That made the ingest route's duplicate verification worth dropping: `ingest_photo` already verifies before any storage or DB work and is the authoritative source of the stored MIME type, so the route's copy was paying a second process launch per file for no additional guarantee. Net effect on an upload is roughly break-even. Configurable via `CIRCA_IMAGE_SANDBOX_ENABLED` / `_MEMORY_MB` / `_CPU_SECONDS` / `_TIMEOUT_SECONDS`. Enabled by default — a security control that ships off is not a control, and there is a test asserting that default. **Caveat:** on Windows you get process isolation but not the kernel limits (`resource` is POSIX-only). The rlimit tests skip there. Production is Linux, so this is a developer-machine caveat only. 316 tests pass, verified on the real CI image (Python 3.11, package pip-installed) as well as locally.
Sign in to join this conversation.
No description provided.