cvekit
LIVE
All CWEs

CWE-363

Race Condition Enabling Link Following

BaseDraftSimple16 CVEs
The product checks the status of a file or directory before accessing it, which produces a race condition in which the file can be replaced with a link before the access is performed, causing the product to access the wrong file.

Extended description

While developers might expect that there is a very narrow time window between the time of check and time of use, there is still a race condition. An attacker could cause the product to slow down (e.g. with memory consumption), causing the time window to become larger. Alternately, in some situations, the attacker could win the race by performing a large number of attacks.

Common consequences1

  • ConfidentialityIntegrityRead Files or DirectoriesModify Files or Directories

Relationships2

CVEs referencing this CWE16

CVEDescriptionSeverityEPSSFlagsModified
CVE-2022-21658

Rust is a multi-paradigm, general-purpose programming language designed for performance and safety, especially safe concurrency. The Rust Security Response WG was notified that the `std::fs::remove_dir_all` standard library function is vulnerable a race condition enabling symlink following (CWE-363). An attacker could use this security issue to trick a privileged program into deleting files and directories the attacker couldn't otherwise access or delete. Rust 1.0.0 through Rust 1.58.0 is affected by this vulnerability with 1.58.1 containing a patch. Note that the following build targets don't have usable APIs to properly mitigate the attack, and are thus still vulnerable even with a patched toolchain: macOS before version 10.10 (Yosemite) and REDOX. We recommend everyone to update to Rust 1.58.1 as soon as possible, especially people developing programs expected to run in privileged contexts (including system daemons and setuid binaries), as those have the highest risk of being affected by this. Note that adding checks in your codebase before calling remove_dir_all will not mitigate the vulnerability, as they would also be vulnerable to race conditions like remove_dir_all itself. The existing mitigation is working as intended outside of race conditions.

MEDIUM6.3
1.38%p72
PoC
2026-06-17
CVE-2025-31133

runc is a CLI tool for spawning and running containers according to the OCI specification. In versions 1.2.7 and below, 1.3.0-rc.1 through 1.3.1, 1.4.0-rc.1 and 1.4.0-rc.2 files, runc would not perform sufficient verification that the source of the bind-mount (i.e., the container's /dev/null) was actually a real /dev/null inode when using the container's /dev/null to mask. This exposes two methods of attack: an arbitrary mount gadget, leading to host information disclosure, host denial of service, container escape, or a bypassing of maskedPaths. This issue is fixed in versions 1.2.8, 1.3.3 and 1.4.0-rc.3.

HIGH7.8
1.25%p69
PoC
2026-10-07
CVE-2025-52565

runc is a CLI tool for spawning and running containers according to the OCI specification. Versions 1.0.0-rc3 through 1.2.7, 1.3.0-rc.1 through 1.3.2, and 1.4.0-rc.1 through 1.4.0-rc.2, due to insufficient checks when bind-mounting `/dev/pts/$n` to `/dev/console` inside the container, an attacker can trick runc into bind-mounting paths which would normally be made read-only or be masked onto a path that the attacker can write to. This attack is very similar in concept and application to CVE-2025-31133, except that it attacks a similar vulnerability in a different target (namely, the bind-mount of `/dev/pts/$n` to `/dev/console` as configured for all containers that allocate a console). This happens after `pivot_root(2)`, so this cannot be used to write to host files directly -- however, as with CVE-2025-31133, this can load to denial of service of the host or a container breakout by providing the attacker with a writable copy of `/proc/sysrq-trigger` or `/proc/sys/kernel/core_pattern` (respectively). This issue is fixed in versions 1.2.8, 1.3.3 and 1.4.0-rc.3.

HIGH7.5
0.81%p57
2026-06-17
CVE-2025-52881

runc is a CLI tool for spawning and running containers according to the OCI specification. In versions 1.2.7, 1.3.2 and 1.4.0-rc.2, an attacker can trick runc into misdirecting writes to /proc to other procfs files through the use of a racing container with shared mounts (we have also verified this attack is possible to exploit using a standard Dockerfile with docker buildx build as that also permits triggering parallel execution of containers with custom shared mounts configured). This redirect could be through symbolic links in a tmpfs or theoretically other methods such as regular bind-mounts. While similar, the mitigation applied for the related CVE, CVE-2019-19921, was fairly limited and effectively only caused runc to verify that when LSM labels are written they are actually procfs files. This issue is fixed in versions 1.2.8, 1.3.3, and 1.4.0-rc.3.

HIGH7.5
0.80%p56
PoC
2026-10-07
CVE-2024-27102

Wings is the server control plane for Pterodactyl Panel. This vulnerability impacts anyone running the affected versions of Wings. The vulnerability can potentially be used to access files and directories on the host system. The full scope of impact is exactly unknown, but reading files outside of a server's base directory (sandbox root) is possible. In order to use this exploit, an attacker must have an existing "server" allocated and controlled by Wings. Details on the exploitation of this vulnerability are embargoed until March 27th, 2024 at 18:00 UTC. In order to mitigate this vulnerability, a full rewrite of the entire server filesystem was necessary. Because of this, the size of the patch is massive, however effort was made to reduce the amount of breaking changes. Users are advised to update to version 1.11.9. There are no known workarounds for this vulnerability.

HIGH8.5
0.55%p46
PoC
2026-06-17
CVE-2024-45310

runc is a CLI tool for spawning and running containers according to the OCI specification. runc 1.1.13 and earlier, as well as 1.2.0-rc2 and earlier, can be tricked into creating empty files or directories in arbitrary locations in the host filesystem by sharing a volume between two containers and exploiting a race with `os.MkdirAll`. While this could be used to create empty files, existing files would not be truncated. An attacker must have the ability to start containers using some kind of custom volume configuration. Containers using user namespaces are still affected, but the scope of places an attacker can create inodes can be significantly reduced. Sufficiently strict LSM policies (SELinux/Apparmor) can also in principle block this attack -- we suspect the industry standard SELinux policy may restrict this attack's scope but the exact scope of protection hasn't been analysed. This is exploitable using runc directly as well as through Docker and Kubernetes. The issue is fixed in runc v1.1.14 and v1.2.0-rc3. Some workarounds are available. Using user namespaces restricts this attack fairly significantly such that the attacker can only create inodes in directories that the remapped root user/group has write access to. Unless the root user is remapped to an actual user on the host (such as with rootless containers that don't use `/etc/sub[ug]id`), this in practice means that an attacker would only be able to create inodes in world-writable directories. A strict enough SELinux or AppArmor policy could in principle also restrict the scope if a specific label is applied to the runc runtime, though neither the extent to which the standard existing policies block this attack nor what exact policies are needed to sufficiently restrict this attack have been thoroughly tested.

LOW3.6
0.32%p25
2026-06-17
CVE-2025-62596

Youki is a container runtime written in Rust. In versions 0.5.6 and below, youki’s apparmor handling performs insufficiently strict write-target validation, and when combined with path substitution during pathname resolution, can allow writes to unintended procfs locations. While resolving a path component-by-component, a shared-mount race can substitute intermediate components and redirect the final target. This issue is fixed in version 0.5.7.

CRITICAL10.0
0.27%p19
2026-10-19
CVE-2025-62161

Youki is a container runtime written in Rust. In versions 0.5.6 and below, the initial validation of the source /dev/null is insufficient, allowing container escape when youki utilizes bind mounting the container's /dev/null as a file mask. This issue is fixed in version 0.5.7.

CRITICAL10.0
0.26%p18
2026-10-19
CVE-2026-112940

filelock is a platform-independent file lock for Python. From 3.20.4 until 4.2.1, ReadWriteLock let SQLite reopen the database and side files by name; on POSIX systems an attacker with rename access in a non-sticky parent can swap those names to cause overwrite or unlink operations, while a writable adjacent hot journal is additionally required for attacker-chosen content overwrites. This issue is fixed in version 4.2.1.

HIGH7.1
0.19%p9
2026-10-23
CVE-2018-6693

An unprivileged user can delete arbitrary files on a Linux system running ENSLTP 10.5.1, 10.5.0, and 10.2.3 Hotfix 1246778 and earlier. By exploiting a time of check to time of use (TOCTOU) race condition during a specific scanning sequence, the unprivileged user is able to perform a privilege escalation to delete arbitrary files.

MEDIUM5.3
0.18%p8
2026-06-17
CVE-2026-112914

filelock is a platform-independent file lock for Python. From 4.1.0 until 4.2.1, SoftFileLock can restore a stale marker by name after moving it aside, allowing another local user with rename access in a non-sticky lock directory to swap in a symbolic link and, on a runtime that follows the link and a same-filesystem target, make the lock user hard-link one of its files into the lock directory. This issue is fixed in version 4.2.1.

MEDIUM5.3
0.16%p5
2026-10-23
CVE-2026-71125

Vulnerability in the Oracle VM VirtualBox product of Oracle Virtualization (component: Core). The supported version that is affected is 7.2.14. Easily exploitable vulnerability allows unauthenticated attacker with logon to the infrastructure where Oracle VM VirtualBox executes to compromise Oracle VM VirtualBox. Successful attacks require human interaction from a person other than the attacker. Successful attacks of this vulnerability can result in unauthorized ability to cause a hang or frequently repeatable crash (complete DOS) of Oracle VM VirtualBox as well as unauthorized update, insert or delete access to some of Oracle VM VirtualBox accessible data. CVSS 3.1 Base Score 6.1 (Integrity and Availability impacts). CVSS Vector: (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:H).

MEDIUM6.1
0.13%p2
2026-10-22
CVE-2026-91203

A flaw was found in cockpit-files. This vulnerability allows a local attacker to exploit a timing issue, known as a symlink race condition, during privileged file operations such as changing file ownership or permissions. By manipulating directory entries and winning this race, the attacker can redirect these operations to unintended files. This could lead to unauthorized changes in file ownership and permissions on arbitrary files, potentially compromising system integrity and availability by altering system or application states or rendering services unusable.

MEDIUM6.0
0.11%p1
2026-10-16
CVE-2026-91205

A flaw was found in cockpit-files. A local unprivileged attacker can exploit a race condition during directory creation with owner assignment. By controlling a writable parent directory, the attacker can replace a newly created directory with a symbolic link (symlink) before the ownership change operation (chown) is applied. This allows the attacker to redirect the ownership change to an arbitrary file, potentially leading to information disclosure or unauthorized modification of sensitive files.

MEDIUM6.0
0.11%p1
2026-10-19
CVE-2025-13492

A potential security vulnerability has been identified in HP Image Assistant for versions prior to 5.3.3. The vulnerability could potentially allow a local attacker to escalate privileges via a race condition when installing packages.

HIGH7.0
0.08%p0
2026-10-18
CVE-2026-112938

`StrictSoftFileLock` and `AsyncStrictSoftFileLock` check `<path>.filelock` (the coordination directory that holds the lock's state) with one `lstat` call on its name. `lstat` reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches `<path>.filelock/claims` by pathname for every later operation (`mkdir`, scan, publish, link, unlink). `_open_directory` passes `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final `claims` component. The kernel follows a symbolic link in any earlier component. Another local user (the other user below) who can rename `<path>.filelock` exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as `/tmp`, only the user who created `<path>.filelock` before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a `claims` directory and claim files (the files whose presence says a process wants or holds the lock) inside a directory the other user chooses but only the lock user can write. When the link points at another strict lock's `.filelock` directory, the stranded claims make that lock time out for every process until an operator finds and force-breaks each one. The `claims` property and `force_break()` resolve the same path with no check at all. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding `ANT-2026-SVFEYX89`). ## Severity **CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H, 5.8 (Medium).** `AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): the swap must land between the `lstat` and the next `mkdir`. The other user does not aim at that window. It flips the name in a loop with `renameat2(RENAME_EXCHANGE)` (a system call that swaps two names in one step) across repeated acquires, and won after 1 to 31 runs of the lock user's program in the scanner's trials. A sticky directory also requires it to have created `<path>.filelock` first. `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): through `claims`, one file name per planted link from a directory only the lock user can list. `I:L` (low integrity impact): a directory named `claims` and files with filelock's fixed claim names and contents appear, created with the lock user's privileges, in a directory the other user chooses but cannot write. The process overwrites or truncates nothing that exists. `A:H` (high availability impact): aimed at another strict lock of the lock user, the stranded claims deny that lock to every process until an operator finds and force-breaks each one. Classification from the review: security, conditional on the other user being able to rename the intermediate component. The scanner rated it high (CWE-363). ## Scope and preconditions - The other user can rename `<path>.filelock`. In a writable directory without the sticky bit (a `0777` or group-writable project or cache directory), any user with write permission on the directory can, even though the lock user created and owns `<path>.filelock`. In a sticky directory such as `/tmp`, only the user who created `<path>.filelock` before the lock user's first use can, because that user then owns both the directory and the symbolic link (the planting precondition of GHSA-9c4x-gm8v-wgc4). - The link's target is a directory the lock user can write. The effect that matters is on that directory. An acquire that completes through the link also holds the shared lock with its claims elsewhere, but a user who can rename `<path>.filelock` can defeat that lock's exclusion anyway. - `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not interfere. The link is an intermediate path component, not the last one. In the sticky-directory case it also sits in a directory the other user owns. - Missed attempts show up to the lock user as `SoftFileLockProtocolError` or `FileNotFoundError`. The swap therefore works against a lock that a program takes more than once: a periodic job, a worker pool, or a caller that retries or waits with a timeout (each poll of a waiting acquisition is a new attempt). - The two entry points that need no race (`claims` and `force_break()` on a resting symbolic link) depend on the lock user inspecting or recovering the lock. The planted link invites this by making `acquire()` fail with a protocol error. The `force_break()` variant works only when the lock user breaks claims that name a live process on its own host without checking them first, as the recovery loop in `docs/tutorials.rst` does. `docs/how-to.rst` tells the operator to verify that the named owner is gone before breaking. - Platform: POSIX, demonstrated on Linux (tmpfs and overlayfs). Windows has no `dir_fd` (file operations relative to an open directory handle), and on Windows the documented requirement is a lock directory that untrusted users cannot write. 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 check and the first use are adjacent lines of [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L123-L128). There, [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) runs `lstat` on `<path>.filelock` and then `mkdir` on `<path>.filelock/claims`. [`_open_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L950-L954) passes `O_DIRECTORY | O_NOFOLLOW` but receives the full `<path>.filelock/claims`. The claim scan goes by pathname in [`_read_existing_claims`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L509-L514) (`directory.exists()`) and [`_list_claim_names`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L433-L439) (`os.scandir(directory)`). So do [`_publish_record`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L572-L596), [`_link_no_replace`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L864-L881), [`_unlink_in_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L912-L939) and the clock probe in [`_filesystem_now`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L745-L763). The [`claims` property](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L235-L239) and [`force_break()`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L260-L267) take the directory from [`_claim_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L314-L318) by string concatenation, and [`_require_exact_name`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L1016-L1019) confirms the name with a by-name `os.scandir`. ## Introduced The by-name claim directory came with 650c17c79c54 ("feat(strict): replace shared markers with owner claims", #659, 2026-07-14), first released in 3.30.0. One effect is newer. The clock-probe files that a swap at `claims` leaves behind need `_filesystem_now`, added in 508d0548538f ("fix(strict): age private records by the filesystem clock", #792, 2026-10-07), first released in 4.1.0. Before that commit a claim scan created nothing. We checked both against the git tags. ## Mechanism On the benign path, `_ensure_protocol_directory(coordination_directory)` finds `<path>.filelock` to be a real directory, and `_ensure_protocol_directory(claim_directory)` finds `claims` inside it. The doorway (the acquire step that scans existing claims and publishes the caller's own) then publishes into it. A symbolic link resting at `<path>.filelock` stops at the first call with "is not a real directory". That answer describes the name at that instant only; nothing carries it forward. With the name exchanged for a link to a directory `T` between the two calls: - The second call runs `mkdir("<path>.filelock/claims")`, which creates `T/claims` with the lock user's credentials. The `mkdir` succeeds, so `_ensure_protocol_directory` returns without looking at anything. - `_attempt_doorway` then works in `T/claims` for as long as the name still points there. `_read_existing_claims` uses `directory.exists()` and `os.scandir(directory)`. `_publish_record` opens the claim directory by its full path and creates `.intent-v1-<token>.claim.private-v1-<hex>.tmp` and `intent-v1-<token>.claim` (the intent claim, which announces a pending acquire) in it. When its intent wins the scan, `_link_no_replace` adds `held-v1-<token>.claim` (the held claim, which marks the lock as taken). `_open_directory` does pass `O_DIRECTORY | O_NOFOLLOW`, but on `"<path>.filelock/claims"`, so the flag covers `claims` only. - Cleanup resolves the name again. `_unlink_in_directory` opens `<path>.filelock/claims` by path. When the name is back at the real directory by then, the `unlink` gets `ENOENT` (the "no such file" error), which `_unlink_owner_path_result` counts as removed. The claim file stays in `T/claims`. When `T/claims` already exists, the `mkdir` takes the `FileExistsError` branch, `lstat` sees a real directory, and the same scan and publication run on the existing directory. With `T/claims` holding a file `secret-name.txt` and an old `.held-v1-<hex>.claim.private-v1-<hex>.tmp`, the lock user's process raises `SoftFileLockProtocolError: … claim 'secret-name.txt': unknown claim name or protocol version`. `_reclaim_private_record` unlinks the private-record file. `intent-*.claim` files and a clock-probe file from `_filesystem_now` stay behind. The window is a few lines of Python between two system calls, but the other user does not aim at it. `renameat2` with `RENAME_EXCHANGE` flips the name in a loop, so each system call of the lock user sees the directory or the link more or less at random. An attempt that sees the link at the `lstat` raises `SoftFileLockProtocolError`. One that sees the directory there and the link at the next `mkdir` is a hit. Four other ways reach the same by-name resolution. The scanner observed each of them at the same commit, with `victim` and `attacker` as separate accounts and default lock options. The first way strands claims in another strict lock. With the swapped-in link pointing at the coordination directory of a second lock of the lock user (`/home/victim/private/db.lock.filelock`, parent mode `0700`), 200 acquires of the shared lock left 7 to 17 well-formed `intent-v1-*.claim` and `held-v1-*.claim` files in the second lock's `claims`. No process holds that lock, yet `StrictSoftFileLock("/home/victim/private/db.lock", timeout=1)` raises `Timeout` from then on, until an operator force-breaks each stranded claim. The second way swaps one component lower, at `claims`. The claim scan does not use a descriptor (an open handle that stays bound to one directory whatever its name later points to). `os.scandir(directory)`, the record reads, `_reclaim_private_record` (called with its descriptor slot set to `None`) and the clock probe in `_filesystem_now` (`os.open(directory / name, O_CREAT | O_EXCL | O_NOFOLLOW)` followed by `probe.unlink()`) all go by pathname. For a sticky directory, the other user needs to have created `<path>.filelock` first. It then puts a real `claims` directory (mode `1777`) in it with one empty file named `.held-v1-<32 zeros>.claim.private-v1-<32 zeros>.tmp`, so that every scan calls the probe, and exchanges `claims` with a link in a loop. After 200 acquires by the lock user, 14 to 42 one-byte, mode `0600`, lock-user-owned files named `.held-v1-<hex>.claim.private-v1-<hex>.tmp` are in the `0700` directory the link points to. The probe open went through the link, and the pathname `unlink` ran after `claims` was a directory again and got `ENOENT`. A swap at this level does not affect publication or release, because there `_open_directory`'s `O_NOFOLLOW` does cover `claims`. The third way is `claims` with no race. The property calls `_read_existing_claims` on an instance that did not go through `_acquire`, and `directory.exists()` and `os.scandir(directory)` both follow a link. After the other user runs `mkdir /tmp/work.lock.filelock; ln -s /home/victim/private /tmp/work.lock.filelock/claims`, `acquire()` refuses with "is not a real directory". The inspection loop from `docs/how-to.rst` (`for claim in lock.claims: ...`) raises `SoftFileLockProtocolError: … claim 'customer-1123f274.db': unknown claim name or protocol version`. That names the first non-hidden entry, in sort order, of a directory the other user cannot list. A missing or empty target returns `()`, a non-directory gives "Not a directory" and an unreadable directory "Permission denied", so the call also tells those apart. The fourth way is `force_break()` with no race. It confirms the name with a by-name `os.scandir` in `_require_exact_name` and unlinks through `_unlink_in_directory`, where `O_NOFOLLOW` again covers `claims` only. After the other user's one command `ln -s /home/victim/private/db.lock.filelock /tmp/service.lock.filelock`, the recovery loop from `docs/tutorials.rst` run by the lock user on `/tmp/service.lock` (`for claim in stale.claims: stale.force_break(claim.name)`) lists and removes the `held` and `intent` claims of the live holder of `/home/victim/private/db.lock`. A second process then acquires that lock while the first is still in its critical section with `is_locked == True`. The first holder's `release()` later finds its claims gone, counts `ENOENT` as removed and returns without error. Only a directory named `claims` below the link's target is reachable, so the targets are other strict locks, not arbitrary files. ## Reproduction The scanner's reproducer for finding `ANT-2026-SVFEYX89`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`. **`repro`** ```python #!/usr/bin/env python3 import collections import os import pwd import shutil import stat import subprocess import sys as_victim = {"user": "victim", "group": "victim", "extra_groups": []} as_attacker = {"user": "attacker", "group": "attacker", "extra_groups": []} victim_job = [sys.executable, "/tmp/repro.d/victim_job.py"] attacker_swap_loop = [sys.executable, "/tmp/repro.d/attacker_swap_loop.py"] victim_uid = pwd.getpwnam("victim").pw_uid # set up: a directory both users can write (0777, no sticky bit) and one only the victim can write. # The shared one is on tmpfs to keep the attacker's rename loop off the container's disk. shared = "/dev/shm/filelock-repro-shared" private = "/tmp/filelock-repro-private" shutil.rmtree(shared, ignore_errors=True) shutil.rmtree(private, ignore_errors=True) os.mkdir(shared) os.chmod(shared, 0o777) os.mkdir(private, 0o700) shutil.chown(private, "victim", "victim") own_mkdir = subprocess.run(["mkdir", f"{private}/claims"], capture_output=True, text=True, **as_attacker) print(f"CONTROL attacker's own mkdir: {own_mkdir.stderr.strip()}") subprocess.run(["ln", "-s", private, f"{shared}/static.lock.filelock"], check=True, **as_attacker) refused = subprocess.run([*victim_job, f"{shared}/static.lock"], capture_output=True, text=True, check=True, **as_victim) print(f"CONTROL symlink resting at static.lock.filelock: {refused.stdout.strip()}; private directory holds {os.listdir(private)}") # the job has run before, undisturbed, so work.lock.filelock is the victim's own directory first_run = subprocess.run([*victim_job, f"{shared}/work.lock"], capture_output=True, text=True, check=True, **as_victim) coordination = os.lstat(f"{shared}/work.lock.filelock") print(f"CONTROL undisturbed job: {first_run.stdout.strip()}; work.lock.filelock is {stat.filemode(coordination.st_mode)}, owner uid {coordination.st_uid}") private_before_attack = os.listdir(private) print(f"CONTROL private directory holds {private_before_attack}") # attack: the attacker keeps exchanging work.lock.filelock with a symlink to the private directory # while the job keeps running; each run is one more chance to land between the check and the use swapper = subprocess.Popen([*attacker_swap_loop, f"{shared}/work.lock", private], **as_attacker) job_outcomes = collections.Counter() while job_outcomes.total() < 200 and not os.listdir(private): job = subprocess.run([*victim_job, f"{shared}/work.lock"], capture_output=True, text=True, **as_victim) job_outcomes[job.stdout.strip()] += 1 assert swapper.poll() is None, "the attacker's swap loop stopped on its own" swapper.kill() swapper.wait() # check: the victim's lock created "claims" inside the directory the attacker could not write print(f"OBSERVED {job_outcomes.total()} runs of the job printed {dict(job_outcomes)}") print(f"OBSERVED private directory holds {os.listdir(private)}") redirected = False if os.path.lexists(f"{private}/claims"): claims = os.lstat(f"{private}/claims") print(f"OBSERVED {private}/claims is {stat.filemode(claims.st_mode)}, owner uid {claims.st_uid} (victim is {victim_uid}), holds {os.listdir(f'{private}/claims')}") redirected = stat.S_ISDIR(claims.st_mode) and claims.st_uid == victim_uid print("BUG" if private_before_attack == [] and redirected else "OK") ``` **`repro.d/attacker_swap_loop.py`** ```python import ctypes import os import sys import time lock_path, target_directory = sys.argv[1], sys.argv[2] coordination_directory = f"{lock_path}.filelock".encode() symlink_to_target = f"{lock_path}.decoy".encode() os.symlink(target_directory, symlink_to_target) renameat2 = ctypes.CDLL(None, use_errno=True).renameat2 AT_FDCWD = -100 RENAME_EXCHANGE = 2 give_up_at = time.monotonic() + 300 while time.monotonic() < give_up_at: # one atomic syscall: the name always exists, as the victim's directory or as the symlink if renameat2(AT_FDCWD, coordination_directory, AT_FDCWD, symlink_to_target, RENAME_EXCHANGE) != 0: raise OSError(ctypes.get_errno(), "renameat2(RENAME_EXCHANGE)") ``` **`repro.d/victim_job.py`** ```python import sys from filelock import StrictSoftFileLock try: with StrictSoftFileLock(sys.argv[1], timeout=1): print("job ran") except OSError as error: # filelock.Timeout and SoftFileLockProtocolError are both OSError print(f"job skipped: {type(error).__name__}") ``` ## Attack sequence ```mermaid sequenceDiagram participant X as other local user participant N as work.lock.filelock (name in shared dir) participant V as job of the lock user (StrictSoftFileLock, timeout=1) participant T as /tmp/private (0700, lock user only) X->>N: plant work.lock.decoy -> T, loop renameat2(RENAME_EXCHANGE) rect rgb(207, 228, 255) V->>N: lstat work.lock.filelock: a real directory end rect rgb(255, 214, 214) X->>N: exchange lands: name is now the symbolic link to T V->>T: mkdir work.lock.filelock/claims creates T/claims (owned by the lock user) V->>T: scan, publish intent-v1-<token>.claim, link held-v1-<token>.claim in T/claims X->>N: exchange again: name is the directory V->>N: release(): unlink by path gets ENOENT, counted as removed Note over V,T: T/claims and its claim files stay behind#59; aimed at another lock's .filelock they block that lock end Note over V,N: a miss: lstat sees the symbolic link, SoftFileLockProtocolError, next run tries again ``` ## Evidence The scanner's reproducer (commit b831496, Linux 6.1 x86_64, CPython 3.14, `fs.protected_symlinks=1`) creates `/dev/shm/filelock-repro-shared` (`0777`, no sticky bit) and `/tmp/filelock-repro-private` (`0700`, owned by `victim`). Three controls run first. The other account's own `mkdir` in the private directory answers `Permission denied`. A link resting at `static.lock.filelock` gives `job skipped: SoftFileLockProtocolError` with the private directory holding `[]`. An undisturbed job prints `job ran` and leaves `work.lock.filelock` as `drwxr-xr-x`, owner uid 1000. Then the swap loop starts and the job (`with StrictSoftFileLock(path, timeout=1)`) runs up to 200 times until the private directory is no longer empty. That took 1 to 8 runs, about 1.5 s in total. Afterwards `claims`, a directory owned by uid 1000 and mode `0755`, is in the private directory; in some runs it holds `intent-v1-<token>.claim` and `held-v1-<token>.claim` files. Verdict `BUG`. In a `1777` directory on tmpfs (a memory-backed filesystem), with the other account having created `<path>.filelock` first, the lock user created `T/claims` after 4, 22 and 2 runs in three trials. The scanner ran the four further cases under Mechanism in separate runs with the same 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`: all 200 runs completed with the private directory empty). The review's regression tests on POSIX cover the `claims`-level swap and the `force_break()` case. We did not verify Windows. ## Impact Another local user who can rename `<path>.filelock` makes the lock user's process create a directory named `claims`, mode `0777 & ~umask` (umask: the process's default permission mask), inside any directory that process can write, and leave claim files in it. The other user chooses the location only. filelock fixes the directory name, and the files contain filelock's own claim record (magic, token, PID, host name, start token). The process overwrites or truncates nothing that exists. This amounts to directory and file creation outside the lock's directory with the lock user's privileges, not a write of chosen data. Where the chosen directory already has a `claims` subdirectory, the lock user's process also scans it, unlinks entries named like stale private records, and raises an error that quotes the first other file name in it. Aimed at another strict lock's `.filelock` directory, the stranded claims deny that lock to every process until an operator finds and `force_break()`s each one. Without this the other user has no way to affect a lock in a directory it cannot enter. The swap at `claims` leaves one-byte `0600` dot-files instead of claims. Through `claims`, another user learns one file name per planted link from a directory only the lock user can list, and whether a guessed path is missing or empty, a file or a directory. This needs the exception text or its `claim_name` to reach that user (a shared log, CI output, an error report); on its own this disclosure is minor. The `force_break()` variant puts two processes inside a different strict lock's critical section, one the other user can neither list nor write, with neither told. That happens only when the lock user runs the tutorial's blind recovery loop. This shares only the bug class with GHSA-j8f7-rjxc-mr56 (the symbolic link at the `ReadWriteLock` database path, fixed in 4.0.9). There the other user chose the file the lock opened. Here the link sits one component above the final one, the other user swaps it in after the check that refuses it, and it chooses only the parent directory. ## Workarounds Keep strict locks in a directory no other user can write. In a sticky shared directory, create `<path>.filelock` as the service user before any other account can, and check its owner before first use. Do not run the recovery loop from `docs/tutorials.rst` on a lock in a shared directory without first verifying, as `docs/how-to.rst` says, that the owner named in each claim is gone. Treat an unexpected `SoftFileLockProtocolError` that quotes an unknown claim name as a sign that `claims` may point elsewhere. Keep the exception text of `claims` out of logs other accounts can read. On Windows, keep the lock directory writable only by trusted users. ## 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-SVFEYX89`, scanned commit b831496b0273) - 2026-10-16 01:31 UTC: fix PR #839 opened - 2026-10-16 05:41 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)

MEDIUM5.8no EPSS
2026-10-28