Handling untrusted images safely

Pillow · Expert · 12 min

Image decoders are a classic attack surface; user uploads must be treated as hostile input.

Accepting images from users means running complex C decoders on attacker-controlled bytes. Pillow is fuzzed and patched quickly, but your code still needs a defensive layer.

Decompression bombs: a tiny file can declare a gigantic size (a 50 KB PNG that decodes to 20 GB). Pillow compares width * height with Image.MAX_IMAGE_PIXELS (about 89 million by default): above it emits DecompressionBombWarning, above twice it raises DecompressionBombError. Turn the warning into an error with the warnings module and lower the limit to what your app needs.

Restrict formats: Image.open(fp, formats=["JPEG", "PNG", "WEBP"]) refuses other decoders, shrinking the attack surface. Never trust the filename extension or the client's content type.

Validate cheaply first: check im.size before load(). im.verify() checks file integrity for some formats without decoding pixels, but leaves the object unusable, so reopen afterwards.

Re-encode accepted images: decode, exif_transpose, resize to your maximum, and save in a format you choose. This drops embedded payloads, strips EXIF location data and normalises the output.

Also cap the upload size in bytes, decode in a worker process with memory and time limits for heavy traffic, catch (OSError, ValueError, Image.DecompressionBombError), keep Pillow updated, and never run Image.open on untrusted paths that could point to arbitrary files. ImageFile.LOAD_TRUNCATED_IMAGES = True hides corruption; avoid it for uploads.

Example

import io
import warnings
from PIL import Image

warnings.simplefilter("error", Image.DecompressionBombWarning)
Image.MAX_IMAGE_PIXELS = 40_000_000          # app limit: 40 MP

def load_upload(data: bytes, max_side=2048):
    if len(data) > 15_000_000: raise ValueError("file too large")
    im = Image.open(io.BytesIO(data), formats=["JPEG", "PNG", "WEBP"])
    im.load()                                # decode inside our guards
    im.thumbnail((max_side, max_side))
    out = io.BytesIO(); im.convert("RGB").save(out, "JPEG", quality=85)
    return out.getvalue()                    # re-encoded, no EXIF

ok = io.BytesIO(); Image.new("RGB", (3000, 2000)).save(ok, "PNG")
print(len(load_upload(ok.getvalue())) > 0)          # True

bomb = io.BytesIO(); Image.new("1", (10_000, 10_000)).save(bomb, "PNG")
print(len(bomb.getvalue()) < 50_000)                # True: tiny file, 100 MP
try:
    load_upload(bomb.getvalue())
except (Image.DecompressionBombWarning, Image.DecompressionBombError) as e:
    print("rejected:", type(e).__name__)

Remember

  • MAX_IMAGE_PIXELS plus warnings-as-errors stops decompression bombs
  • Restrict decoders with Image.open(..., formats=[...])
  • Check size before load(); re-encode accepted images yourself
  • Cap bytes, catch decoder errors and keep Pillow patched

Watch out for

  • Setting Image.MAX_IMAGE_PIXELS = None to silence warnings on a public upload endpoint.

Check yourself

  1. A 40 KB PNG upload declares 30000 x 30000 pixels. What protects you?
  2. Why re-encode accepted uploads instead of storing the original bytes?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open