---
id: "CVE-2026-112938"
kind: "vulnerability"
aliases: ["CVE-2026-112938", "GHSA-m462-7rxw-wvpg"]
title: "filelock: StrictSoftFileLock follows a symbolic link swapped in at <path>.filelock and creates claim files in a directory another local user chooses"
severity: "medium"
cvss: 5.8
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "filelock", "fixed": ["4.2.1"], "affected": ">= 3.30.0, <= 4.2.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-363", "filelock", "multi-user-hosts", "package", "python", "self-hosted"]
not_affected_if: ["default-config", "locks-in-private-directories", "single-user-host"]
published_at: "2026-10-28T21:08:23+00:00"
first_seen_at: "2026-10-28T21:12:45+00:00"
updated_at: "2026-10-28T21:13:42+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-m462-7rxw-wvpg", "first_seen_at": "2026-10-28T21:12:45+00:00"}]
---

# filelock: StrictSoftFileLock follows a symbolic link swapped in at <path>.filelock and creates claim files in a directory another local user chooses

StrictSoftFileLock checks its `<path>.filelock` directory once and then follows that name by path, so a local user who can rename it can swap in a symbolic link during the gap. The lock user's process then creates claim directories and files with its own privileges in a directory the attacker chooses, which can make another strict lock time out until an operator clears the stranded claims.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:filelock affected >= 3.30.0, <= 4.2.0 fixed 4.2.1

## How to check

- Run `python -c "import importlib.metadata as m; print(m.version('filelock'))"` and check whether the result is between `3.30.0` and `4.2.0`.
- Check whether lock directories sit in a world-writable or group-writable directory without the sticky bit, using `ls -ld` on the directory that holds `<path>.filelock`.

## Fix

- Upgrade the `filelock` package to `4.2.1`.
- Keep lock files out of shared writable directories, or make sure every ancestor is owned by the lock user or root and is not writable by others or is sticky.

## Timeline

- 2026-10-28T21:12:45+00:00 ghsa https://github.com/advisories/GHSA-m462-7rxw-wvpg

## From the sources

`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#

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
