vLLM patched CVE-2026-116177 this week (disclosed October 25): versions 0.10.2 through 0.24.x, CVSS 7.5, unauthenticated. The reported mechanism is almost embarrassingly small — the Nemotron-VL multimodal processors globally set Pillow's Image.MAX_IMAGE_PIXELS to None, and after that, a pathological decompression-bomb image sent to /v1/chat/completions can OOM-kill the worker.
I don't have a GPU box to run vLLM on, so I did the part I can verify exactly: what that one global actually does in Pillow (10.2.0, the install on this machine). It turns out the entire defense is three lines, and None deletes all of them.
The guard, in Pillow's own source
# PIL/Image.py, line 75
MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # 89,478,485
# PIL/Image.py, _decompression_bomb_check(), lines 3173-3187 (condensed)
if MAX_IMAGE_PIXELS is None:
return # <- no check at all
if pixels > 2 * MAX_IMAGE_PIXELS:
raise DecompressionBombError(...)
if pixels > MAX_IMAGE_PIXELS:
warnings.warn(DecompressionBombWarning(...))
89,478,485 pixels is the warn line. 178,956,970 is the hard-error line. None is the "off" position — and because it's a module-level global, any library in the process that sets it changes the rule for every image opened afterwards, by anyone, in that process.
I built 68-byte PNGs that lie about their size
A PNG declares its dimensions in the IHDR header. Pillow runs the bomb check at open() time, from the header, before decoding pixel data. So I crafted header-only PNGs — 68 bytes each, with a truncated IDAT — and watched the three regimes:
crafted PNG, file_bytes = 68 in all three cases
declared 1000x1000 1,000,000 pixels
open(): size (1000, 1000), warnings: []
declared 12000x12000 144,000,000 pixels
open(): size (12000, 12000)
warnings: ['DecompressionBombWarning: Image size (144000000 pixels)
exceeds limit of 89478485 pixels, could be decompr...']
declared 40000x40000 1,600,000,000 pixels
open(): DecompressionBombError: Image size (1600000000 pixels)
exceeds limit of 178956970 pixels, could be
decompression bomb DOS attack.
The file never gets decoded in the third case. The header alone is refused. That's the whole protection working as intended — one comparison, at open time, against a global.
Then I set the global to None, the way the advisory describes
Image.MAX_IMAGE_PIXELS = None # the CVE pattern
before: 89478485
after setting None: None
1.6B-pixel header now opens, size: (40000, 40000), warnings: 0
restored: 89478485
Same 68-byte file, same 1.6-billion-pixel claim. With the default: refused at the door. With None: welcomed in, no warning, no error — and the next step in a real server is load(), which is where the memory gets allocated.
Why a bomb works: the amplification is ~1,000x
The reason this matters is the ratio between wire size and decoded size. Solid-color PNGs compress absurdly well, and "solid color" is the worst case for the defender, not the attacker being clever:
8000x8000 PNG file 202,505 bytes -> raw RGB 192 MB (948x)
12000x12000 PNG file 445,032 bytes -> raw RGB 432 MB (971x)
20000x20000 PNG file 1,207,698 bytes -> raw RGB 1,200 MB (993x)
And it's not theoretical allocation arithmetic. In a fresh process that does nothing but open and decode the file (peak RSS via getrusage):
bomb8000.png (202,505 bytes on disk) -> decoded, peak RSS 264 MB
bomb12000.png (445,032 bytes on disk) -> decoded, peak RSS 569 MB
bomb20000.png (1,207,698 bytes) -> refused at open() under
defaults: 400,000,000 pixels
> the 178,956,970 error line
The 12000x12000 decode — 445 KB in, 569 MB of peak process memory — only emitted a warning on the way in, because 144M pixels sits between the warn line and the error line. Warnings, in a server log nobody is tailing, are not a defense.
A ~1.2 MB upload that wants 1.2 GB of RAM before any model code touches it. On a GPU inference host — where RAM is shared with everything else keeping the expensive cards fed — an unauthenticated request that allocates unbounded memory is not a nuisance, it's the whole incident. And note where the 20000x20000 case lands under defaults: 400,000,000 pixels is above the 2x error line, so default Pillow refuses it. The bomb only gets its allocation when somebody turned the guard off.
The lesson isn't "don't set MAX_IMAGE_PIXELS to None"
Somebody on the Nemotron-VL path presumably needed very large images to work, and the shortest path was disabling the limit — globally, as a side effect of initializing a processor, for the whole process, including the unauthenticated HTTP endpoint. Three properties made that a CVE instead of a config choice:
- The limit is process-global mutable state. One component's requirement silently rewrites every other component's safety rule. There is no per-image or per-request override in this API; it's the global or nothing.
- The check happens at open(), the allocation at load(). Anything that weakens the open-time check exposes the load-time cost, and the cost ratio is ~1,000x against you.
- Initialization did it, not a request handler. You can't find this by auditing the endpoint. The endpoint looks fine. The vulnerability lives in a side effect three imports away.
If you run any Python service that opens user-supplied images — inference or otherwise — the cheap audit is one grep: does anything in your dependency tree assign MAX_IMAGE_PIXELS? If yes, your decompression-bomb limit is whatever that assignment says it is, regardless of what you configured.
Scope, honestly: I reproduced the Pillow mechanism locally (Pillow 10.2.0) and verified the guard's source, thresholds, and the effect of setting the global to None. I did not run a vLLM server. The CVE details — affected versions 0.10.2–0.24.x, the Nemotron-VL processors setting the global to None, the /v1/chat/completions path, CVSS 7.5 — are from the published advisory coverage (TheHackerWire CVE-2026-116177 analysis; 0dayNews, October 26, 2026). Fixed in vLLM 0.25.0 per that coverage — upgrade if you serve it.
Top comments (0)