再次审视SQLite的WAL-Reset漏洞

Lobsters Hottest 工具

摘要

本文讨论了SQLite WAL-reset机制中一个长期存在的漏洞,该漏洞导致数据丢失和损坏。通过一个100行的C语言工作负载进行复现,以展示竞争条件。

<p><a href="https://lobste.rs/s/b15udy/another_look_at_sqlite_s_wal_reset_bug">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/25 11:42

# 重新审视SQLite的WAL-Reset漏洞 来源:https://theconsensus.dev/p/2026/08/23/another-look-at-sqlite-wal-reset.html 关于软件基础设施。 ## 重新审视SQLite的WAL-Reset漏洞 一次过时的读取操作导致SQLite检查点(checkpoint)错误地丢弃了已提交的WAL(预写日志)帧,这一漏洞潜伏了十六年。我们通过一个100行的C语言工作负载复现了该竞态条件,仅使用公开的SQLite API,就能在几秒内同时造成数据写入丢失和数据库文件损坏。 作者:Phil Eaton | 2026年8月23日 关注(https://theconsensus.dev/p/2026/08/23/another-look-at-sqlite-wal-reset.html?focus=1) 作为订阅者,您正在提前阅读本文。您的支持使这类文章得以呈现,感谢。 SQLite采用物理形式的预写日志(Write-Ahead Logging)机制。其工作原理是在内存中编辑数据库页,并将每个更改的页排队写入磁盘的WAL(预写日志)中。WAL以"帧"(frame)的形式存储页(帧由帧头和实际页数据组成),并将同一事务内对同一页的多次修改合并为一个帧。 当WAL达到默认1000页的阈值大小时(默认阈值参见[此处](https://sqlite.org/wal.html?utm_source=theconsensus.dev&utm_medium=referral#ckpt)),SQLite会触发"检查点"(checkpoint)。检查点操作将页从WAL复制到磁盘上的永久位置。但是长期运行的读取操作会阻止SQLite进行检查点操作。反过来,过大的WAL会使读取变慢(当页缓存失效且必须从磁盘获取页时),因为WAL中的页对读取没有有益的排序。查找所需页的时间与WAL的大小成正比。 在最温和的检查点模式("passive")中,仅复制WAL中未被其他事务使用的页。在最严厉的模式("truncate")中,检查点会尝试等待所有读写操作完成,然后复制所有页,并将WAL截断为0字节。 通常,在所有页都从WAL复制出来后,WAL可以重置并重用空间(截断模式除外,它将从0字节开始重新分配空间)。否则,当某些页仍被某个打开的事务使用时,WAL会按需增长以容纳新页。 自动检查点器仅执行被动检查点。如果应用程序认为它可以更好地调度检查点操作(特别是在它知道数据库空闲的时段),或者希望进行更积极的检查点操作,应用程序允许(参见[此处](https://sqlite.org/wal.html?utm_source=theconsensus.dev&utm_medium=referral#application_initiated_checkpoints))通过如`PRAGMA wal_checkpoint`等方法自行执行。 本月初,Tailscale[撰文](https://tailscale.com/blog/sqlite-wal-reset-bug?utm_source=theconsensus.dev&utm_medium=referral)描述了在并发检查点操作中遇到的一个漏洞,该漏洞导致检查点器错误地将WAL中的页标记为已复制。这至少导致数据丢失,并偶尔在`PRAGMA integrity_check`检测到数据不仅缺失而且与索引不一致时,发现数据损坏。 SQLite于2026年3月[修复了此漏洞](https://www.sqlite.org/wal.html?utm_source=theconsensus.dev&utm_medium=referral#the_wal_reset_bug)。(Tailscale仅在最近才发布博客,可能更多是因为他们现在才确信漏洞已真正修复。) 让我们来自然地触发这个漏洞,看看能造成多大的破坏! ## 追踪漏洞 我们将使用两个线程和三个数据库连接: * 线程1,数据库连接A(检查点器)执行检查点操作。 * 线程2,数据库连接B(写入者)更新Z表中的一行并提交。 * 线程1,数据库连接C(读取者)从Z表读取。 在正确的并发条件和时序下,这三者的交错执行会触发该漏洞。这些交互完全发生在`wal.c`中([链接](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral))。以下是一个交错执行的示例: | 行号 | 检查点器 | 写入者 | 读取者 | 备注 | | :--- | :--- | :--- | :--- | :--- | | [4359](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L4359) | `walIndexReadHdr` 看到 `mxFrame=360` | | | (窗口开启) | | [4056](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L4056) | `walRestartLog`: `readLock==0`, `nBackfill>0` | | | | | [2146](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L2146) | `walRestartHdr`: `mxFrame=0`, `salt++`, `nBackfill=0` | | | 读取标记被清除 | | | | 提交5个新帧 | | | | [2216](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L2216) | `nBackfill` (0) < `mxFrame` (360) | | | 过时的检查通过(窗口关闭) | | [2227](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L2227) | `mxSafeFrame = 360` | | | | | [2318](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L2318) | `nBackfill = 360` | | | (丢失帧) | | [3239](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L3239) | `minFrame = 361` | | | | | [3571](https://github.com/sqlite/sqlite/blob/version-3.51.2/src/wal.c?utm_source=theconsensus.dev&utm_medium=referral#L3571) | 跳过帧1..5(这些是活跃数据) | | | | 这个漏洞的关键在于写入者在检查点器的竞态窗口期间重新启动了日志。幸运的是,有我们可以利用的点。`wal.c`在该窗口期间对数据库文件执行了`munmap`操作。如果我们让数据库变得特别大,`munmap`操作就会花费足够长的时间,使得写入者能够插入一个日志重启操作。 ## 观察漏洞 在一个并发线程中,我们将向第二个"金丝雀"表写入一个单调递增的值(一个值,一行数据),并且仅在收到`SQLITE_OK`后才写入下一个值。`SQLITE_OK`意味着插入已提交。 在我们尝试让SQLite表现出漏洞时,我们会检查是否曾读取到的行数少于写入线程声明写入到金丝雀表的行数。如果我们读取的行数少于我们知道成功写入的行数,那么就发生了数据丢失。 偶尔,数据丢失也会导致数据文件损坏。Tailscale通过完整性检查注意到该漏洞在某种程度上是幸运的,因为这个漏洞不一定涉及数据损坏,更常见的是数据丢失。而且即使修复后,SQLite也没有防止写入丢失的机制。 下面是我们的完整工作负载。大约100行C代码。我们将在几秒内看到漏洞发生。 ```c #include "sqlite3.h" #include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <stdatomic.h> static long scalar(sqlite3 *db, const char *buf) { sqlite3_stmt *p = 0; long v = -1; if (sqlite3_prepare_v2(db, buf, -1, &p, 0) == SQLITE_OK) { if (sqlite3_step(p) == SQLITE_ROW) v = (long)sqlite3_column_int64(p, 0); sqlite3_finalize(p); } return v; } static sqlite3 *burstDb; static atomic_int burstStop; static long nCommitted; static void *burst(void *arg) { char buf[64]; while (!burstStop) { sqlite3_snprintf(sizeof(buf), buf, "INSERT INTO canary VALUES(%ld)", nCommitted + 1); if (sqlite3_exec(burstDb, buf, 0, 0, 0) == SQLITE_OK) nCommitted++; } return 0; } static sqlite3 *openDb(const char *init) { sqlite3 *db = 0; sqlite3_open("race.db", &db); sqlite3_busy_timeout(db, 5000); sqlite3_exec(db, init, 0, 0, 0); return db; } int main(void) { setvbuf(stdout, NULL, _IONBF, 0); // 使用大的 mmap_size 以产生大的 munmap 操作。数据库本身也要足够大,这样我们才能实际获得大的 mmap/munmap 操作。 sqlite3 *db = openDb( "PRAGMA journal_mode=wal; PRAGMA mmap_size=1073741824;" "CREATE TABLE t1(a INTEGER PRIMARY KEY, b);" "CREATE TABLE canary(a INTEGER PRIMARY KEY);" "WITH s(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM s WHERE i<65536)" " INSERT INTO t1 SELECT NULL, randomblob(3900) FROM s;" "PRAGMA wal_checkpoint(TRUNCATE)"); sqlite3 *helper = openDb(""); burstDb = openDb(""); for (int i = 0; i < atoi(getenv("ATTEMPTS") ?: "200"); i++) { pthread_t t; // 1. 映射大表,缓存过时的共享内存值。必须在步骤2之前运行。 scalar(db, "SELECT count(*) FROM t1 WHERE b IS NOT NULL"); // 2. 确保 `isChanged` 为真(允许我们进入 munmap 路径)。 sqlite3_exec(helper, "UPDATE t1 SET b=randomblob(3900) WHERE a<=20", 0, 0, 0); // 3. 尝试完全回填 WAL。必须在 helper 上而不是 db 上运行,这样 db 的值保持过时。 for (int i = 0; i < 50; i++) { int nLog, nCkpt; sqlite3_wal_checkpoint_v2(helper, "main", SQLITE_CHECKPOINT_PASSIVE, &nLog, &nCkpt); if (nCkpt >= nLog) break; } // 4. 在不相关的线程/连接中,在检查点期间提交。 burstStop = 0; pthread_create(&t, 0, burst, 0); sqlite3_exec(db, "PRAGMA wal_checkpoint", 0, 0, 0); burstStop = 1; pthread_join(t, 0); if (scalar(helper, "SELECT count(*) FROM canary") < nCommitted) break; /* 丢失了写入。 */ } sqlite3_exec(helper, "PRAGMA wal_checkpoint(TRUNCATE)", 0, 0, 0); /* 现在 WAL 中没有页了。它们是在数据库中,还是我们丢失了写入? */ long recovered = scalar(helper, "SELECT count(*) FROM canary"); if (recovered < 0) { fprintf(stderr, "database unreadable: %s\n", sqlite3_errmsg(helper)); return 1; } printf("permanently lost: %ld transactions\n", nCommitted - recovered); printf("integrity_check: %s\n", scalar(helper, "SELECT integrity_check='ok' FROM pragma_integrity_check()") ? "ok" : "failed"); return nCommitted != recovered; } ``` walrace.c 获取`clang`和`unzip`,以及有漏洞的和修复后的SQLite合并文件(amalgamation)。 ```bash sudo apt update -y sudo apt-get install -y unzip clang curl curl -O https://sqlite.org/2026/sqlite-amalgamation-3510200.zip curl -O https://sqlite.org/2026/sqlite-amalgamation-3530000.zip unzip -q sqlite-amalgamation-3510200.zip unzip -q sqlite-amalgamation-3530000.zip ``` 构建两个版本。 ```bash cc -O2 -o walrace-present walrace.c sqlite-amalgamation-3510200/sqlite3.c \ -I sqlite-amalgamation-3510200 -lpthread -lm cc -O2 -o walrace-absent walrace.c sqlite-amalgamation-3530000/sqlite3.c \ -I sqlite-amalgamation-3530000 -lpthread -lm ``` 代码不会为你删除数据库文件,因此请在运行前确保删除了它们。你将看到类似这样的变化: ```bash $ rm -f race.db* && time ./walrace-present database unreadable: database disk image is malformed real 0m4.286s user 0m2.414s sys 0m1.756s $ rm -f race.db* && time ./walrace-present permanently lost: 1 transactions integrity_check: ok real 0m3.454s user 0m2.042s sys 0m1.302s ``` 上面的代码在第一次丢失事务后就停止循环了。如果让它循环运行30秒,你会看到更多丢失的事务。 另外,试试基于SQLite 3.53.0构建的版本(漏洞已修复),你将不再看到这些丢失的写入和损坏的数据文件。 ```bash $ rm -f race.db* && time ./walrace-absent permanently lost: 0 transactions integrity_check: ok real 0m15.946s user 0m10.814s sys 0m5.037s ``` 在测试工作负载的不同变体时,我注意到另一件事。使用SQLite的调试模式(debug mode)构建修复后的合并文件,然后再次运行工作负载。 ```bash cc -O2 -o walrace-absent walrace.c sqlite-amalgamation-3530000/sqlite3.c \ -I sqlite-amalgamation-3530000 -lpthread -lm -DSQLITE_DEBUG ``` 增加循环次数,给它更多尝试运行的机会。 ```bash $ rm -f race.db* && time ATTEMPTS=4000 ./walrace-absent walrace-absent: sqlite-amalgamation-3530000/sqlite3.c:69238: void walMerge(const u32 *, ht_slot *, int, ht_slot **, int *, ht_slot *): Assertion `iRight>=nRight || aContent[aRight[iRight]]>dbpage' failed. Aborted (core dumped) real 2m24.642s user 1m46.527s sys 0m38.251s ``` 嘿,我们大概不应该遇到这个。然而,这里没有明显的漏洞,只是断言失败了,也许应该更新这个断言。 ## 线程消毒器(Thread Sanitizer) 另一件有趣的事情是,如果我们开启线程消毒器(这个部分在macOS上最可靠,Linux上不太可靠)。 ```bash cc -O1 -g -fno-inline -fsanitize=thread -o walrace-present-tsan walrace.c \ sqlite-amalgamation-3510200/sqlite3.c -I sqlite-amalgamation-3510200 -lpthread -lm ``` 增加循环大小并允许工作负载持续丢失事务。 ```bash $ rm -f race.db* && TSAN_OPTIONS="halt_on_error=0 history_size=7" ./walrace-present-tsan 2> tsan.txt permanently lost: 1 transactions integrity_check: ok [1] 24066 abort TSAN_OPTIONS="halt_on_error=0 history_size=7" ./walrace-present-tsan 2> ``` 将线程消毒器的输出与源代码行关联起来。 ```bash $ awk -v A=sqlite-amalgamation-3510200/sqlite3.c '/#0 /&&match($0,/sqlite3\.c:[0-9]+/){n=substr($0,RSTART+10,RLENGTH-10);c="sed -n "n"p "A;c|getline s;close(c);sub(/^ +/,"",s);print " "$2" "n": "s;next}{if(/^ (Read|Write|Previous|Atomic)/)print " "$0}' tsan.txt ... 已省略 ... Read of size 4 at 0x0001009f8060 by main thread (mutexes: write M0): walCheckpoint 68972: if( pInfo->nBackfillHdr.mxFrame ){ Previous atomic write of size 4 at 0x0001009f8060 by thread T26 (mutexes: write M1): walRestartHdr 68911: AtomicStore(&pInfo->nBackfill, 0); ... 已省略 ... ``` 看起来线程消毒器也捕获了这个漏洞?有趣的是,与上面断言失败相关的一行也在线程消毒器输出中出现。线程消毒器似乎很有用! ## 结语 我们获得了一个廉价的漏洞复现方法,无需修改源代码、无需使用中间层等。我想展示的是,你可以同时造成数据丢失(未警告)和数据损坏(如果运行完整性检查则会警告)。我花了一些时间在该区域寻找更多漏洞,但没有找到。这个漏洞似乎仍然很罕见。但是既然有修复,升级总比不升级好。更通用的写入丢失保护机制可能是好的。 发现错误?有问题或评论?请[写信给编辑](https://theconsensus.dev/cdn-cgi/l/email-protection#215149484d61554944424e4f52444f5254520f454457)。

相似文章

打破 WAL

Hacker News Top

作者描述了使用 Antithesis 与 Claude 在 15 分钟内重现了一个长期存在的 SQLite WAL 重置错误,并在 3.51.3 版本中验证了修复。

SQLite的WAL模式可能锁定短期读取器

Lobsters Hottest

SQLite的WAL模式可能会导致短生命周期的只读连接出现“数据库已被锁定”错误,原因是WAL索引文件的内部锁定;设置忙超时或切换到DELETE模式可以解决该问题。