BREW-DSTACK-CVE-2026-112911 (GHSA-9C4X-GM8V-WGC4)

Vulnerability from osv_homebrew – Published: 2026-10-29 21:42 – Updated: 2026-10-29 21:42 – Source website
VLAI
Summary
filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion
Details

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 SoftReadWriteLock half has no such counterpart: its redirected creates, renames and unlinks land outside the lock's own files.
  • A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user's own tree aside, and no check inside the lock can tell that from a first use.

These protections against another local user hold only when 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. 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.

Affected code

At the scanned commit b831496: _ensure_protocol_directory returns for any existing real directory, whatever its owner or mode. StrictSoftFileLock._acquire calls it for both levels and does not look at directory ownership again. _attempt_doorway decides from the claim scan alone, and _unlink_owner_path_result counts a missing claim as removed on release. The sibling site is OsFiles.prepare. Its mkdir(mode=0o700) takes effect only when it creates the directory, and its lstat check reads neither st_uid nor the permission bits. It runs once per instance, and every later OsFiles method takes a full path, with O_NOFOLLOW guarding only the last component.

Introduced

The strict site came with the claims protocol in 650c17c79c54 ("feat(strict): replace shared markers with owner claims", #659, 2026-07-14), first released in 3.30.0. The SoftReadWriteLock site came with the <path>.rw tree itself in f9743288a288 ("fix(soft-rw): replace the state mutex with a generation log", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.

Mechanism

The owner of a directory can unlink and rename every entry in it, whatever the entries' own owner and mode. A strict hold consists of two names, intent-v1-<token>.claim and held-v1-<token>.claim, in <path>.filelock/claims. The sentinel (the lock file at <path>) is permanent and says nothing about who holds. When the other user runs the equivalent of mkdir -m 777 -p work.lock.filelock/claims before the lock user first uses work.lock, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A's claims; both unlink calls succeed, because write permission on the directory is enough.

Lock user process B calls acquire() while A is inside its critical section. _attempt_doorway starts with if _read_existing_claims(...): return False, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.

A is not told. A strict holder does not look at its claims between acquire and release, and on release _unlink_owner_path_result takes the FileNotFoundError for each missing claim as "already removed", so _release finishes without error. The break involves neither a lifetime nor a lease, and neither break_lock() nor force_break(). The benign path shows what the check is missing. When the lock user's first acquire creates the directories itself they are 0755 and owned by the lock user (umask 022). The other user's unlink then fails with EACCES, its rename or rmdir of work.lock.filelock fails with EPERM under the sticky bit, and B raises Timeout.

OsFiles.prepare has the same gap for SoftReadWriteLock. The code suppresses EEXIST from mkdir(mode=0o700), and the check that follows looks only at S_ISDIR and S_ISLNK. The class docstring's protection against "same-host different-UID users via 0o600 / 0o700 permissions" therefore holds only for whoever gets there first. With the other user owning /tmp/app.lock.rw, gen and holders before the lock user first uses SoftReadWriteLock("/tmp/app.lock"), the scanner observed the following, with the same two accounts.

Creates and renames redirected through a symlink. The other user's tree holds a generation log (the record of who holds the lock, under gen/) naming a live writer, so the lock user polls. Once the lock user's record appears in holders/ (its prepare() is over), the other user renames gen aside and puts a symlink to /home/victim/project/.git, a repository inside a 0700 directory, in its place. The lock user's write_lock() returns without error. By then GenerationLog.commit() has created epoch, 00000000000000000001 and 00000000000000000002 in .git and renamed a record over .git/HEAD, which now reads <epoch>\nfilelock-rw/1\ngeneration=2\n, owned by the lock user; git status answers fatal: not a git repository. With holders swapped instead, Participant._sweep() unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for stale_threshold. This needs no race: every later acquisition on an instance that has passed prepare() goes through the swapped gen.

Two writers, no on_compromise. OsFiles.read() accepts any regular file, so the owner of gen writes snapshots itself. While lock user process V1 is inside write_lock(), the other user creates the next gen/<N> naming no holder, and V2's write_lock(timeout=2) succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering ok, because Participant.heartbeat() tests only self.token in latest.members; compromise was still None in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing gen/ and holders/ gives the generation and the tokens.

Hangs, memory growth and bare OSErrors. The protocol code treats every record in gen/ and holders/ as written by a cooperating peer, so the tree's owner can do several things. They can make Participant.leave() retry a refused commit without end (read_lock(timeout=1) spins at full CPU instead of raising Timeout). They can keep GenerationLog._probe() walking by appending records ahead of it (3 s of writes kept read_lock(timeout=1) busy for about 60 s). They can make every acquire_write(timeout=1) and release() on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky gen/ (a hard link is a second name for the same file), which _list() and _compact_through() walk again on each poll because OsFiles.unlink() swallows the EPERM.

They can also pin 1 MiB of the lock user's memory per sparse file planted in holders/, because Ledger.observe() keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when acquire_write() returns). Under an address-space limit the sweep raises MemoryError inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After stale_threshold a second lock user process gets the write lock while the first still has compromise None. And they can make every acquire of a free lock raise a bare OSError with a mode-000 orphan in holders/ or a symlink at gen/HEAD. None of these is reachable in a tree the lock user created itself: it is 0700 at all three levels and the other user's writes fail with EACCES.

Reproduction

The scanner's reproducer for ANT-2026-Q5VK5JEE, quoted as received. It ran against commit b831496b0273.

repro

#!/usr/bin/env python3
import os
import pwd
import subprocess
import sys
import tempfile

# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d
victim_worker = open("/tmp/repro.d/victim_worker.py").read()
attacker_plant_directory = open("/tmp/repro.d/attacker_plant_directory.py").read()
attacker_remove_claims = open("/tmp/repro.d/attacker_remove_claims.py").read()
as_victim = ["runuser", "-u", "victim", "--", sys.executable, "-c", victim_worker]
as_attacker = ["runuser", "-u", "attacker", "--", sys.executable, "-c"]


def two_victims_contend_while_attacker_removes_claims(lock_path):
    claims = f"{lock_path}.filelock/claims"
    first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
    first_lines = [first.stdout.readline().strip()]
    claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name

    removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)

    # the first process has not been sent its line yet: it is still inside its critical section
    second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
    second_lines = [second.stdout.readline().strip()]

    second_lines += second.communicate("\n")[0].split()
    first_lines += first.communicate("\n")[0].split()
    print(f"  {claims} is owned by {claims_owner}")
    print(f"  attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}")
    print(f"  first victim process: {first_lines}")
    print(f"  second victim process, started while the first is inside: {second_lines}")
    return first_lines, second_lines


# set up: a root-owned, sticky, world-writable directory, like /tmp itself
shared = tempfile.mkdtemp(prefix="shared-", dir="/tmp")
os.chmod(shared, 0o1777)

# control: the victim's first acquire creates control.lock.filelock/claims itself
print("CONTROL  victim creates the protocol directories")
_, control_second = two_victims_contend_while_attacker_removes_claims(f"{shared}/control.lock")

# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock
print("OBSERVED attacker creates the protocol directories first")
subprocess.run([*as_attacker, attacker_plant_directory, f"{shared}/work.lock"], check=True)
first, second = two_victims_contend_while_attacker_removes_claims(f"{shared}/work.lock")

# check: the second process must be kept out while the first is inside, as it is in the control round
inside_and_released_cleanly = ["acquired", "is_locked=True", "released"]
control_excludes = control_second == ["Timeout"]
both_inside = first == second == inside_and_released_cleanly
print(f"control_excludes={control_excludes} both_inside={both_inside}")
print("BUG" if control_excludes and both_inside else "OK")

repro.d/attacker_plant_directory.py

import os
import sys

os.umask(0)  # 0777: the victim must be able to publish its claims inside
os.makedirs(f"{sys.argv[1]}.filelock/claims")

repro.d/attacker_remove_claims.py

import os
import sys

claims = f"{sys.argv[1]}.filelock/claims"
for name in sorted(os.listdir(claims)):
    try:
        os.unlink(f"{claims}/{name}")
        print(f"removed {name}")
    except PermissionError as error:
        print(f"cannot remove {name}: {error.strerror}")

repro.d/victim_worker.py

import sys

from filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout

lock = StrictSoftFileLock(sys.argv[1], timeout=1)
try:
    lock.acquire()
except (Timeout, SoftFileLockProtocolError) as error:
    print(type(error).__name__, flush=True)
    sys.exit()
print("acquired", flush=True)

sys.stdin.readline()  # the critical section: it lasts until the parent sends a line

print(f"is_locked={lock.is_locked}", flush=True)
lock.release()
print("released", flush=True)

Attack sequence

sequenceDiagram
    participant X as other account (uid attacker)
    participant D as work.lock.filelock/claims (sticky /tmp)
    participant A as holder A (uid victim)
    participant B as contender B (uid victim)
    rect rgb(255, 214, 214)
        X->>D: mkdir -m 777 -p, before the lock user's first use
        A->>D: acquire(): directory exists, is a directory, adopted
        A->>D: publish intent-v1-<token>.claim and held-v1-<token>.claim
        X->>D: unlink both claims (owner of the directory may)
        B->>D: acquire(): scan finds no claims, publishes its own, wins
        Note over A,B: both inside, is_locked True in both
        A->>D: release(): claims missing, counted as removed, no error
    end
    Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout

Evidence

The scanner's reproducer (victim uid 1000, attacker uid 1001, lock work.lock in a root-owned 1777 directory under /tmp, StrictSoftFileLock(path, timeout=1), Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on control.lock, whose directories victim creates, it prints claims is owned by victim, the unlinks by attacker answer Permission denied, the first process prints ['acquired', 'is_locked=True', 'released'] and the second ['Timeout']. In the observed round on work.lock, planted by attacker, it prints claims is owned by attacker and removed held-v1-….claim; removed intent-v1-….claim, and both processes print ['acquired', 'is_locked=True', 'released'], the second having acquired before the first was let out. The verdict line reads control_excludes=True both_inside=True and then BUG after about 2 s. The scanner ran the variant that plants only the outer <path>.filelock (then renames the claims of victim aside) by hand with the same result, and each SoftReadWriteLock effect listed under Mechanism in its own run with the same two accounts.

Our review ran the reproducer against the 4.2.0 release image and the scanned commit (BUG on both) and against the head of #839 (OK). The review's regression tests for both sites need a sticky /tmp of another UID and ran there.

Impact

Another local user who can create names in the lock's directory puts two processes of the lock user, root included, into the same StrictSoftFileLock critical section at a moment of the other user's choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.

Against SoftReadWriteLock the same position decides when two writers, or a writer and readers, are inside together and keeps compromise silent in all of them. It also gets file operations made with the lock user's privileges (root's, for a root service) in any directory the lock user can write: a file named HEAD replaced, files named epoch and 0000000000000000000N created, files with 32-hex-digit names deleted. The bytes written are filelock's own records, not content the other user chooses. It can keep the lock user's acquire or release running past any timeout, or grow the lock user's memory by 1 MiB per planted file. It is not a disclosure of the lock user's data, and it does not reach a path the lock user has already used while that tree still exists.

Workarounds

Keep strict and soft read-write locks in a directory other users cannot write, rather than /tmp or another sticky shared directory. Where a shared directory is unavoidable, create <path>.filelock/claims (or <path>.rw with gen and holders) as the service user before any other account can, for example at install or boot, and check stat -c %U of an existing tree before the first use. A tmp cleaner, a reboot or the <path>.rw recovery removes the tree and reopens the window. The kernel refuses a planted 0755 strict tree (the lock user's acquire fails with PermissionError), so an acquire that raises that error on a new path without a known cause is worth investigating.

Patched version

4.2.1.

Fix: https://github.com/tox-dev/filelock/pull/839

Credits

Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.

  • 2026-10-15 23:08 UTC: report received (finding ANT-2026-Q5VK5JEE, scanned commit b831496b0273)
  • 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with ANT-2026-AD6SUOON, ANT-2026-SVFEYX89, ANT-2026-M9OPCLPB and ANT-2026-6BC3LKF9 moved to advisories of their own
  • 2026-10-16 01:31 UTC: fix PR #839 opened

{
  "affected": [
    {
      "ecosystem_specific": {
        "fix": "bump",
        "range_state": "fixed",
        "resource": "filelock",
        "resource_purl": "pkg:pypi/filelock@4.2.4",
        "upstream_fixed_in": "4.2.1"
      },
      "package": {
        "ecosystem": "Homebrew",
        "name": "dstack",
        "purl": "pkg:brew/dstack"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.20.28"
            },
            {
              "fixed": "0.22.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "database_specific": {
    "confidence": "high",
    "source": "matched",
    "strategy": "registry",
    "upstream_evidence": [
      {
        "ecosystem": "PyPI",
        "key": "pkg:pypi/filelock@4.2.4",
        "name": "filelock",
        "resource": "filelock",
        "strategy": "registry",
        "subject_version": "4.2.4"
      }
    ]
  },
  "details": "`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`\u003cpath\u003e.filelock/claims`, `\u003cpath\u003e.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\u0027s 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\u0027s owner remove or rename it. The other user then owns the tree. They can unlink the lock user\u0027s 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.\n\nAgainst `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user\u0027s own creates and renames into a directory the other user picks (the scanner replaced a repository\u0027s `.git/HEAD` with a filelock record written with the lock user\u0027s privileges). It also keeps the lock user\u0027s acquire or release running past any `timeout`. Affected versions are filelock 3.30.0 through 4.2.0 (finding `ANT-2026-Q5VK5JEE`).\n\n## Severity\n\n**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\u0027s first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented `\u003cpath\u003e.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\u0027s 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 `\u003cpath\u003e.rw` can make the lock user\u0027s `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).\n\nClassification 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.\n\n## Scope and preconditions\n\n- 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.\n- The other user gets there first: `\u003cpath\u003e.filelock` (strict) or `\u003cpath\u003e.rw` (soft read-write) must not exist when the lock user\u0027s 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.\n- For the strict lock the planted directory must be writable by the lock user (`0777`); a planted `0755` directory makes the lock user\u0027s acquire fail with `PermissionError` instead. Planting only the outer `\u003cpath\u003e.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.\n- The Linux `fs.protected_regular`, `fs.protected_symlinks`, `fs.protected_hardlinks` and `fs.protected_fifos` sysctls (kernel settings, all enabled in the scanner\u0027s 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\u0027s own non-sticky directory.\n- 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 `SoftReadWriteLock` half has no such counterpart: its redirected creates, renames and unlinks land outside the lock\u0027s own files.\n- A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user\u0027s own tree aside, and no check inside the lock can tell that from a first use.\n\nThese protections against another local user hold only when no other user can replace the lock\u0027s 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. 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.\n\n## Affected code\n\nAt the scanned commit b831496: [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) returns for any existing real directory, whatever its owner or mode. [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L128) calls it for both levels and does not look at directory ownership again. [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181) decides from the claim scan alone, and [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim as removed on release. The sibling site is [`OsFiles.prepare`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). Its `mkdir(mode=0o700)` takes effect only when it creates the directory, and its `lstat` check reads neither `st_uid` nor the permission bits. It runs once per instance, and every later `OsFiles` method takes a full path, with `O_NOFOLLOW` guarding only the last component.\n\n## Introduced\n\nThe strict site came with the claims protocol in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. The `SoftReadWriteLock` site came with the `\u003cpath\u003e.rw` tree itself in f9743288a288 (\"fix(soft-rw): replace the state mutex with a generation log\", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.\n\n## Mechanism\n\nThe owner of a directory can unlink and rename every entry in it, whatever the entries\u0027 own owner and mode. A strict hold consists of two names, `intent-v1-\u003ctoken\u003e.claim` and `held-v1-\u003ctoken\u003e.claim`, in `\u003cpath\u003e.filelock/claims`. The sentinel (the lock file at `\u003cpath\u003e`) is permanent and says nothing about who holds. When the other user runs the equivalent of `mkdir -m 777 -p work.lock.filelock/claims` before the lock user first uses `work.lock`, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A\u0027s claims; both `unlink` calls succeed, because write permission on the directory is enough.\n\nLock user process B calls `acquire()` while A is inside its critical section. `_attempt_doorway` starts with `if _read_existing_claims(...): return False`, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.\n\nA is not told. A strict holder does not look at its claims between acquire and release, and on release `_unlink_owner_path_result` takes the `FileNotFoundError` for each missing claim as \"already removed\", so `_release` finishes without error. The break involves neither a `lifetime` nor a lease, and neither `break_lock()` nor `force_break()`. The benign path shows what the check is missing. When the lock user\u0027s first acquire creates the directories itself they are `0755` and owned by the lock user (umask `022`). The other user\u0027s `unlink` then fails with `EACCES`, its `rename` or `rmdir` of `work.lock.filelock` fails with `EPERM` under the sticky bit, and B raises `Timeout`.\n\n`OsFiles.prepare` has the same gap for `SoftReadWriteLock`. The code suppresses `EEXIST` from `mkdir(mode=0o700)`, and the check that follows looks only at `S_ISDIR` and `S_ISLNK`. The class docstring\u0027s protection against \"same-host different-UID users via `0o600` / `0o700` permissions\" therefore holds only for whoever gets there first. With the other user owning `/tmp/app.lock.rw`, `gen` and `holders` before the lock user first uses `SoftReadWriteLock(\"/tmp/app.lock\")`, the scanner observed the following, with the same two accounts.\n\n**Creates and renames redirected through a symlink.** The other user\u0027s tree holds a generation log (the record of who holds the lock, under `gen/`) naming a live writer, so the lock user polls. Once the lock user\u0027s record appears in `holders/` (its `prepare()` is over), the other user renames `gen` aside and puts a symlink to `/home/victim/project/.git`, a repository inside a `0700` directory, in its place. The lock user\u0027s `write_lock()` returns without error. By then `GenerationLog.commit()` has created `epoch`, `00000000000000000001` and `00000000000000000002` in `.git` and renamed a record over `.git/HEAD`, which now reads `\u003cepoch\u003e\\nfilelock-rw/1\\ngeneration=2\\n`, owned by the lock user; `git status` answers `fatal: not a git repository`. With `holders` swapped instead, `Participant._sweep()` unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for `stale_threshold`. This needs no race: every later acquisition on an instance that has passed `prepare()` goes through the swapped `gen`.\n\n**Two writers, no `on_compromise`.** `OsFiles.read()` accepts any regular file, so the owner of `gen` writes snapshots itself. While lock user process V1 is inside `write_lock()`, the other user creates the next `gen/\u003cN\u003e` naming no holder, and V2\u0027s `write_lock(timeout=2)` succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering `ok`, because `Participant.heartbeat()` tests only `self.token in latest.members`; `compromise` was still `None` in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing `gen/` and `holders/` gives the generation and the tokens.\n\n**Hangs, memory growth and bare `OSError`s.** The protocol code treats every record in `gen/` and `holders/` as written by a cooperating peer, so the tree\u0027s owner can do several things. They can make `Participant.leave()` retry a refused commit without end (`read_lock(timeout=1)` spins at full CPU instead of raising `Timeout`). They can keep `GenerationLog._probe()` walking by appending records ahead of it (3 s of writes kept `read_lock(timeout=1)` busy for about 60 s). They can make every `acquire_write(timeout=1)` and `release()` on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky `gen/` (a hard link is a second name for the same file), which `_list()` and `_compact_through()` walk again on each poll because `OsFiles.unlink()` swallows the `EPERM`.\n\nThey can also pin 1 MiB of the lock user\u0027s memory per sparse file planted in `holders/`, because `Ledger.observe()` keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when `acquire_write()` returns). Under an address-space limit the sweep raises `MemoryError` inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After `stale_threshold` a second lock user process gets the write lock while the first still has `compromise` `None`. And they can make every acquire of a free lock raise a bare `OSError` with a mode-`000` orphan in `holders/` or a symlink at `gen/HEAD`. None of these is reachable in a tree the lock user created itself: it is `0700` at all three levels and the other user\u0027s writes fail with `EACCES`.\n\n## Reproduction\n\nThe scanner\u0027s reproducer for `ANT-2026-Q5VK5JEE`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport subprocess\nimport sys\nimport tempfile\n\n# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d\nvictim_worker = open(\"/tmp/repro.d/victim_worker.py\").read()\nattacker_plant_directory = open(\"/tmp/repro.d/attacker_plant_directory.py\").read()\nattacker_remove_claims = open(\"/tmp/repro.d/attacker_remove_claims.py\").read()\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"-c\", victim_worker]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"-c\"]\n\n\ndef two_victims_contend_while_attacker_removes_claims(lock_path):\n    claims = f\"{lock_path}.filelock/claims\"\n    first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n    first_lines = [first.stdout.readline().strip()]\n    claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name\n\n    removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)\n\n    # the first process has not been sent its line yet: it is still inside its critical section\n    second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n    second_lines = [second.stdout.readline().strip()]\n\n    second_lines += second.communicate(\"\\n\")[0].split()\n    first_lines += first.communicate(\"\\n\")[0].split()\n    print(f\"  {claims} is owned by {claims_owner}\")\n    print(f\"  attacker, while the first victim process holds the lock: {\u0027; \u0027.join(removal.stdout.splitlines())}\")\n    print(f\"  first victim process: {first_lines}\")\n    print(f\"  second victim process, started while the first is inside: {second_lines}\")\n    return first_lines, second_lines\n\n\n# set up: a root-owned, sticky, world-writable directory, like /tmp itself\nshared = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# control: the victim\u0027s first acquire creates control.lock.filelock/claims itself\nprint(\"CONTROL  victim creates the protocol directories\")\n_, control_second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/control.lock\")\n\n# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock\nprint(\"OBSERVED attacker creates the protocol directories first\")\nsubprocess.run([*as_attacker, attacker_plant_directory, f\"{shared}/work.lock\"], check=True)\nfirst, second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/work.lock\")\n\n# check: the second process must be kept out while the first is inside, as it is in the control round\ninside_and_released_cleanly = [\"acquired\", \"is_locked=True\", \"released\"]\ncontrol_excludes = control_second == [\"Timeout\"]\nboth_inside = first == second == inside_and_released_cleanly\nprint(f\"control_excludes={control_excludes} both_inside={both_inside}\")\nprint(\"BUG\" if control_excludes and both_inside else \"OK\")\n```\n\n**`repro.d/attacker_plant_directory.py`**\n\n```python\nimport os\nimport sys\n\nos.umask(0)  # 0777: the victim must be able to publish its claims inside\nos.makedirs(f\"{sys.argv[1]}.filelock/claims\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\nfor name in sorted(os.listdir(claims)):\n    try:\n        os.unlink(f\"{claims}/{name}\")\n        print(f\"removed {name}\")\n    except PermissionError as error:\n        print(f\"cannot remove {name}: {error.strerror}\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], timeout=1)\ntry:\n    lock.acquire()\nexcept (Timeout, SoftFileLockProtocolError) as error:\n    print(type(error).__name__, flush=True)\n    sys.exit()\nprint(\"acquired\", flush=True)\n\nsys.stdin.readline()  # the critical section: it lasts until the parent sends a line\n\nprint(f\"is_locked={lock.is_locked}\", flush=True)\nlock.release()\nprint(\"released\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant X as other account (uid attacker)\n    participant D as work.lock.filelock/claims (sticky /tmp)\n    participant A as holder A (uid victim)\n    participant B as contender B (uid victim)\n    rect rgb(255, 214, 214)\n        X-\u003e\u003eD: mkdir -m 777 -p, before the lock user\u0027s first use\n        A-\u003e\u003eD: acquire(): directory exists, is a directory, adopted\n        A-\u003e\u003eD: publish intent-v1-\u003ctoken\u003e.claim and held-v1-\u003ctoken\u003e.claim\n        X-\u003e\u003eD: unlink both claims (owner of the directory may)\n        B-\u003e\u003eD: acquire(): scan finds no claims, publishes its own, wins\n        Note over A,B: both inside, is_locked True in both\n        A-\u003e\u003eD: release(): claims missing, counted as removed, no error\n    end\n    Note over A,D: control: directory created by the lock user is 0755, the other account\u0027s unlink gets EACCES, B raises Timeout\n```\n\n## Evidence\n\nThe scanner\u0027s reproducer (`victim` uid 1000, `attacker` uid 1001, lock `work.lock` in a root-owned `1777` directory under `/tmp`, `StrictSoftFileLock(path, timeout=1)`, Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on `control.lock`, whose directories `victim` creates, it prints `claims is owned by victim`, the unlinks by `attacker` answer `Permission denied`, the first process prints `[\u0027acquired\u0027, \u0027is_locked=True\u0027, \u0027released\u0027]` and the second `[\u0027Timeout\u0027]`. In the observed round on `work.lock`, planted by `attacker`, it prints `claims is owned by attacker` and `removed held-v1-\u2026.claim; removed intent-v1-\u2026.claim`, and both processes print `[\u0027acquired\u0027, \u0027is_locked=True\u0027, \u0027released\u0027]`, the second having acquired before the first was let out. The verdict line reads `control_excludes=True both_inside=True` and then `BUG` after about 2 s. The scanner ran the variant that plants only the outer `\u003cpath\u003e.filelock` (then renames the `claims` of `victim` aside) by hand with the same result, and each `SoftReadWriteLock` effect listed under Mechanism in its own run with the same two accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review\u0027s regression tests for both sites need a sticky `/tmp` of another UID and ran there.\n\n## Impact\n\nAnother local user who can create names in the lock\u0027s directory puts two processes of the lock user, root included, into the same `StrictSoftFileLock` critical section at a moment of the other user\u0027s choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.\n\nAgainst `SoftReadWriteLock` the same position decides when two writers, or a writer and readers, are inside together and keeps `compromise` silent in all of them. It also gets file operations made with the lock user\u0027s privileges (root\u0027s, for a root service) in any directory the lock user can write: a file named `HEAD` replaced, files named `epoch` and `0000000000000000000N` created, files with 32-hex-digit names deleted. The bytes written are filelock\u0027s own records, not content the other user chooses. It can keep the lock user\u0027s acquire or release running past any `timeout`, or grow the lock user\u0027s memory by 1 MiB per planted file. It is **not** a disclosure of the lock user\u0027s data, and it does not reach a path the lock user has already used while that tree still exists.\n\n## Workarounds\n\nKeep strict and soft read-write locks in a directory other users cannot write, rather than `/tmp` or another sticky shared directory. Where a shared directory is unavoidable, create `\u003cpath\u003e.filelock/claims` (or `\u003cpath\u003e.rw` with `gen` and `holders`) as the service user before any other account can, for example at install or boot, and check `stat -c %U` of an existing tree before the first use. A tmp cleaner, a reboot or the `\u003cpath\u003e.rw` recovery removes the tree and reopens the window. The kernel refuses a planted `0755` strict tree (the lock user\u0027s acquire fails with `PermissionError`), so an acquire that raises that error on a new path without a known cause is worth investigating.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\n\n## Credits\n\nReported by Anthropic\u0027s OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (finding `ANT-2026-Q5VK5JEE`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with `ANT-2026-AD6SUOON`, `ANT-2026-SVFEYX89`, `ANT-2026-M9OPCLPB` and `ANT-2026-6BC3LKF9` moved to advisories of their own\n- 2026-10-16 01:31 UTC: fix PR #839 opened",
  "id": "BREW-dstack-CVE-2026-112911",
  "modified": "2026-10-29T21:42:36Z",
  "published": "2026-10-29T21:42:36Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-9c4x-gm8v-wgc4"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112911"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/pull/839"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/tox-dev/filelock"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1"
    }
  ],
  "schema_version": "1.7.3",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion",
  "upstream": [
    "GHSA-9c4x-gm8v-wgc4",
    "CVE-2026-112911"
  ]
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.