CVE-2026-112402: Pillow: Double free in ImagingScaleAffine on calloc failure via Image.transform

Published Oct 19, 2026
·
Updated

============================================================================ Double free in ImagingScaleAffine on calloc failure via Image.transform Location: src/libImaging/Geometry.c:866 in ImagingScaleAffine() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================

There is a double free in Pillow's Image.transform() that crashes the process when an oversized transform runs out of memory. Instead of only raising MemoryError, Pillow frees the output image's memory once while handling the error and then again when the Python object is cleaned up. Anyone who can choose the output size of Image.transform() can trigger it, but only on a worker with a memory limit (a common hardening for image workers) or a 32-bit process. The result is a crash of the worker, plus corruption of its heap that has not been shown to be controllable.

The bug is in ImagingScaleAffine() in src/libImaging/Geometry.c (line 866). When its calloc() for the per-column lookup table of 4 × width bytes fails, it calls ImagingDelete(imOut). When the call comes from Image.transform(), imOut is the Imaging struct owned by the live Python ImagingCore output object. dealloc() in src/imaging.c later runs ImagingDelete() on the freed struct again, writing its mutex and refcount in freed memory and freeing the pixel blocks a second time. The path is reached with Transform.EXTENT, or Transform.AFFINE with a pure scale/translate matrix, under the default NEAREST resampling. A very wide, 1-row output size makes the lookup table the allocation that fails while the image itself still fits. On 64-bit Linux with default overcommit, that requires an address-space limit such as ulimit -v, uwsgi --limit-as or systemd LimitAS=. resize(), thumbnail() and rotate() are not affected. The attached reproducer runs on a stock build with a 1 GB limit: transform((300000000, 1), EXTENT) raises and catches MemoryError, then the process dies with SIGSEGV in free() called from dealloc(). A memory-checking build reports the same path as a heap use-after-free. The faulty line was inherited from PIL 1.1.7 in the original fork commit, so every Pillow release since 1.0 is affected. A patch is attached.

Details

ImagingScaleAffine() frees an image it does not own. On the one error path where its calloc() for the per-column lookup table fails, it calls ImagingDelete(imOut). When reached through Image.transform(), imOut is the Imaging struct held by a live Python ImagingCore object; that object's dealloc() later calls ImagingDelete() on the already-freed struct and frees its pixel blocks a second time.

Shortest demonstration on the stock build (the address-space limit stands in for ulimit -v / uwsgi --limit-as / systemd LimitAS= on a worker):

python import resource, gc from PIL import Image resource.setrlimit(resource.RLIMITAS, (1000000000,) 2) src = Image.new("L", (8, 8)) try: src.transform((300000000, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) except MemoryError: pass # expected, and the process should carry on gc.collect() # instead: SIGSEGV in dealloc -> ImagingDelete -> free()

The 300 MB output image allocates; calloc(300000000, 4) (1.2 GB) does not. MemoryError is raised as intended, but the interpreter then dies with SIGSEGV, gdb showing free() ← memoryreturnblock/ImagingDestroyArray (src/libImaging/Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← dealloc (src/imaging.c:188). An AddressSanitizer build of the same sources (with maxallocationsizemb used to make the calloc fail instead of an rlimit) reports heap-use-after-free in ImagingDelete src/libImaging/Storage.c:257 called from dealloc src/imaging.c:188, on a 128-byte region "freed by" ImagingDelete ← ImagingScaleAffine src/libImaging/Geometry.c:866 ← ImagingTransformAffine Geometry.c:1043 ← ImagingTransform Geometry.c:1135 ← transform src/imaging.c:2153, "previously allocated by" ImagingNewPrologueSubtype Storage.c:53 ← … ← new src/imaging.c:756.

Code path

Image.transform() creates the output image as an ordinary Python-owned image and then transforms into it:

python src/PIL/Image.py:3008…3021 im = new(self.mode, size, fillcolor) … im.transformer( (0, 0, size), self, method, data, resample, fillcolor is None )

transformer() rewrites Transform.EXTENT into an axis-aligned affine matrix (src/PIL/Image.py:3040-3046: data = (xs, 0, x0, 0, ys, y0), method = Transform.AFFINE) and calls self.im.transform(box, image.im, method, data, resample, fill) (Image.py:3117). resample defaults to Resampling.NEAREST (Image.py:2936).

In C, transform() passes its own image as the destination:

c / src/imaging.c:2153 / imOut = ImagingTransform( self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill );

ImagingTransformAffine() takes the scaling fast path because the filter is NEAREST and the matrix has no shear:

c / src/libImaging/Geometry.c:1035-1043 / if (filterid || imIn->type == IMAGINGTYPEI16) { return ImagingGenericTransform(…); } if (a[1] == 0 && a[3] == 0) { / Scaling / return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill); }

and ImagingScaleAffine() allocates a table of imOut->xsize ints:

c / src/libImaging/Geometry.c:863-868 / / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { ImagingDelete(imOut); / <-- imOut belongs to the caller / return (Imaging)ImagingErrorMemoryError(); }

ImagingDelete() (src/libImaging/Storage.c:250-279) drops the refcount to 0, returns the pixel blocks to the arena / free()s them via im->destroy, and free(im). self->image in the ImagingCore object still points at the freed struct. transform() returns NULL, Image.transform() propagates MemoryError, the half-built output Image loses its last reference, and dealloc() (src/imaging.c:188) calls ImagingDelete(imagep->image) again: it locks im->mutex and decrements im->refcount inside freed memory, then walks im->blocks (already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freed blocks array had been overwritten, so free() receives a mangled pointer and the process segfaults; with other heap states it is a plain double free of a 300 MB block.

Every other error return in Geometry.c (ImagingErrorModeError, ImagingErrorMismatch, "bad filter number", "bad transform method") leaves imOut alone; line 866 is the only one that deletes it. The delete is only correct for the other caller, resize() (src/imaging.c:2026-2030), where imOut is a fresh ImagingNewDirty() temporary that nothing else references. The delete predates Pillow's git history: it came in with the PIL 1.1.7 import, 9a640e315715 ("Forking PIL", 2010-07-30), so every release since Pillow 1.0 is affected.

The output width is what decides whether the path is hit: the output image costs width × height × pixelsize bytes, the table an additional 4 × width. A very wide, 1-row output (300 000 000 × 1 "L": 300 MB image, 1.2 GB table) makes the table the allocation that fails. ImagingNewPrologueSubtype() caps xsize at INTMAX/4 - 1, so the table is at most ~2 GB.

Impact

Applications that let a user choose the output size of Image.transform() — Transform.EXTENT (crop-and-scale), Transform.AFFINE with a pure scale/translate matrix, or the ImageTransform.ExtentTransform/AffineTransform helpers, all with the default NEAREST resampling — can have an allocation failure turned into a use-after-free and double free inside the worker process instead of the MemoryError the API is meant to raise. resize(), thumbnail() and rotate() do not reach this path; Image.resize(..., NEAREST, box=...) goes through ImagingScaleAffine() but owns its output, and raises a clean MemoryError. The second precondition is that the output image itself allocates but the extra 4 × width bytes do not; on 64-bit Linux with default overcommit this requires an address-space limit on the process (ulimit -v, uwsgi --limit-as, systemd LimitAS=, an in-app setrlimit), which is a common hardening for image workers; 32-bit processes can reach it through ordinary address-space exhaustion. The demonstrated consequence is a crash of the process handling the request after MemoryError has already been raised and possibly handled. The second ImagingDelete() also writes into the freed 128-byte struct (mutex, refcount) and double-frees an attacker-sized block, which is heap corruption rather than a clean abort, but no control over the written data is shown.

Reproducing

Run repro (a self-contained Python script) from any directory. It uses the installed stock build, so nothing is rebuilt, and has no companion files. It runs the demonstration above in a child interpreter (python3 -c, taking WIDTH as an argument): the child disables core files, sets RLIMITAS to 1 GB (the kind of limit image workers are commonly given), creates an 8×8 "L" image, calls src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) with the default NEAREST resampling, prints MemoryError caught if that raises, then runs gc.collect() and prints alive. EXTENT with NEAREST is a pure scale, so the call goes through ImagingScaleAffine(), which callocs a 4 × WIDTH-byte table in addition to the WIDTH-byte output image.

The script runs the child twice:

- Control: width 50 000 000. The 50 MB image and the 200 MB table both fit under the limit, and the transform succeeds, which shows the limit itself is harmless. - Attack: width 300 000 000. The 300 MB output image is allocated, but calloc(300000000, 4) (1.2 GB) fails. Pillow correctly raises MemoryError, and the child catches it. But ImagingScaleAffine() has already called ImagingDelete() on the output image it does not own. When the output image is released, it is freed again. The child prints MemoryError caught and then dies with SIGSEGV instead of printing alive.

If the attack child was killed by a signal and gdb is installed, the script runs the attack again under gdb and prints the backtrace. The crash is in free() ← memoryreturnblock/ImagingDestroyArray (Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← dealloc (imaging.c:188). In other words, the Python object's destructor is deleting the already-freed struct a second time. The verdict does not depend on this step.

The last line is BUG when the control exits 0 and the attack prints MemoryError caught and then dies from a signal without printing alive. Otherwise it is OK. With the attached patch applied, the attack prints MemoryError caught, then alive, exits 0, and the script prints OK.

Suggested fix

ImagingScaleAffine() never owns imOut. The attached patch removes the ImagingDelete() at src/libImaging/Geometry.c:866 and makes the one caller that relied on it, resize() in src/imaging.c, clean up its own temporary, the same idiom gaussianblur()/unsharpmask() already use. This also fixes resize() passing a possible NULL from ImagingNewDirty() into ImagingTransform(), and leaking the temporary on the other ImagingTransform() error returns:

diff --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + / imOut is owned by the caller; don't delete it here / return (Imaging)ImagingErrorMemoryError(); } --- a/src/imaging.c +++ b/src/imaging.c @@ -2024,10 +2024,16 @@ resize(ImagingObject self, PyObject args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else {

Every ImagingTransform() path returns the same imOut on success, so wrapping the original pointer in PyImagingNew() in resize() is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer prints OK and Image.resize(..., NEAREST, box=...) under the same limit raises a clean MemoryError.

The patch

Stop ImagingScaleAffine() deleting the caller-owned output image on calloc failure; make resize() free its own temporary instead

Applies to src/libImaging/Geometry.c at the commit above.

diff diff --git a/src/imaging.c b/src/imaging.c index 7e9c0e391..ac1873292 100644 --- a/src/imaging.c +++ b/src/imaging.c @@ -2024,10 +2024,16 @@ resize(ImagingObject self, PyObject args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { imOut = ImagingResample(imIn, xsize, ysize, filter, box); } diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 1bf92e26e..2baf000a5 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( / malloc check ok, uses calloc for overflow / xintab = (int )calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + / imOut is owned by the caller; don't delete it here / return (Imaging)ImagingErrorMemoryError(); }

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 2,064 bytes (click to open)</summary>

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

python #!/usr/bin/env python3 UAF/double free in ImagingScaleAffine() (src/libImaging/Geometry.c:866): on calloc() failure it ImagingDelete()s imOut, which for Image.transform() is the Imaging struct owned by the live Python output image; that image's dealloc then ImagingDelete()s it again. Stock /src build, no rebuild. A 1 GB RLIMITAS makes the 300 MB output image fit but the 1.2 GB 4width lookup table fail. Expected: MemoryError, process survives. import subprocess, sys, textwrap, signal

child = textwrap.dedent(''' import resource, gc, sys from PIL import Image resource.setrlimit(resource.RLIMITCORE, (0, 0)) resource.setrlimit(resource.RLIMITAS, (1000000000,) 2) w = int(sys.argv[1]) src = Image.new("L", (8, 8)) try: src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) print("transform ok") except MemoryError: print("MemoryError caught") gc.collect() print("alive") ''')

def run(width): p = subprocess.run([sys.executable, "-u", "-c", child, str(width)], captureoutput=True, text=True, timeout=50) print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}") return p

ctrl = run(50000000) # 50 MB image + 200 MB table fit under the limit att = run(300000000) # 300 MB image fits, 1.2 GB table does not bug = (ctrl.returncode == 0 and "MemoryError caught" in att.stdout and "alive" not in att.stdout and att.returncode < 0) if att.returncode < 0: print("attack child killed by", signal.Signals(-att.returncode).name) import shutil if shutil.which("gdb"): # evidence only; verdict does not depend on it g = subprocess.run(["gdb", "-q", "-batch", "-ex", "run", "-ex", "bt 6", "--args", sys.executable, "-c", child, "300000000"], captureoutput=True, text=True, timeout=120) print("\n".join(l for l in g.stdout.splitlines() if l.startswith("#") or "signal" in l)) print("BUG" if bug else "OK") </details>

---

Other sources

Pillow is a Python imaging library. Prior to 13.0.0, Pillow can double free image memory when ImagingScaleAffine encounters a selective allocation failure. The affected fast path handles Transform.EXTENT or pure scale-and-translate Transform.AFFINE operations with the default NEAREST resampling. The output image allocation must succeed while the additional per-column lookup allocation fails, after which the error path deletes image storage that later cleanup releases again. An attacker who can drive such a large Image.transform() operation under memory pressure may trigger the failure. This can crash the process and may corrupt memory. This issue is fixed in version 13.0.0.

— NVD

Affected Software

15 affected componentsFixes available
pypi/Pillow<13.0.0
pip/pillow<=12.3.0
13.0.0
Python Pillow<13.0.0
bitnami/pillow<13.0.0
13.0.0
ubuntu/pillow*
ubuntu/pillow*
ubuntu/pillow*
ubuntu/pillow*
ubuntu/pillow-python2*
ubuntu/pillow*
ubuntu/pillow*
ubuntu/pillow*
ubuntu/pillow*
IBM App Connect Operator<=CD: 12.1.0 - 12.21.0 12.0 LTS: 12.0.0 - 12.0.28 13 LTS: 13.0.0 - 13.6.0
IBM App Connect EnterpriseCertified Containers Operands<=CD: 12.0.11.1-r1 - 12.0.12.5-r1, 13.0.1.0-r1 - 13.0.6.2-r1 12.0 LTS: 12.0.12-r1 - 12.0.12-r28 13 LTS: 13.0.7.0-r1 - 13.0.9.0-r1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  2. Upgrade

    Upgrade bitnami/pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  3. Upgrade

    Upgrade Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0

Event History

Oct 19, 2026
CVE Published
via MITRE·09:18 PM
Data Sourced
via MITRE·09:18 PM
DescriptionWeakness
Data Sourced
via OSV·09:18 PM
DescriptionSeverityWeakness
Data Sourced
via Red Hat·09:21 PM
DescriptionSeverityAffected Software
Data Sourced
via NVD·10:17 PM
RemedyDescriptionSeverityWeaknessAffected Software
Oct 21, 2026
Advisory Published
via GitHub·08:03 PM
Data Sourced
via GitHub·08:03 PM
DescriptionWeaknessAffected Software
Oct 26, 2026
Advisory Published
via OSV·05:55 AM
Nov 4, 2026
Data Sourced
via OSV·08:03 PM
Affected Software
Nov 9, 2026
Data Sourced
via IBM·12:00 AM
DescriptionAffected Software

Parent advisories

This vulnerability appears in the following advisories.

Frequently Asked Questions

1

Which deployments are most likely to be exposed?

Deployments using Pillow before 13.0.0 that process attacker-controlled images or allow attackers to influence Image.transform() parameters are the relevant exposure cases. Exploitation specifically requires a large transform to run while the process is under memory pressure.

2

What transform settings reach the affected code path?

The affected fast path is used for Transform.EXTENT and for pure scale-and-translate Transform.AFFINE operations when using the default NEAREST resampling. Other transform types or resampling settings are not identified as affected by the provided information.

3

Does exploitation require authentication or user interaction?

The CVSS vector indicates no privileges and no user interaction are required. An attacker must nevertheless be able to cause the application to perform a qualifying large Image.transform() operation and induce the required selective allocation failure.

4

What can be done if upgrading is not immediately possible?

Avoid or restrict large Image.transform() operations using Transform.EXTENT or pure scale-and-translate Transform.AFFINE with NEAREST resampling, especially for attacker-influenced inputs. Reducing memory pressure can also reduce the specific allocation-failure condition required for the bug.

5

How can I determine whether an application is affected?

Check whether it uses Pillow versions earlier than 13.0.0 and invokes Image.transform() with the affected transform and resampling conditions. Affected executions require the output image allocation to succeed while the additional per-column lookup allocation fails.

Contact

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