江博

Jiang in Thinking

Tailscale如何追踪到一个存在了16年的SQLite漏洞

2026-08-12


翻译了下tailscale写的一篇SQLite事故复盘, 有几个有意思的点:

自从@bradfitz从Go团队跳槽到了Tailscale, 就一直关注和使用他们的产品, 还是非常牛的!



去年年底,我们的服务可用性相当不稳定。你可以在我们的状态页面上看到这一趋势,而这种不稳定一直延续到了新的一年。其中许多次故障都源于 SQLite 深处的同一个漏洞。为了追踪到它,我们进行了数月高强度的取证分析。

如今已是夏天,我们确信已经找到了这个漏洞,理解了它的原理——更重要的是,我们已经修复了它。

我们知道,客户期望 Tailscale 是一项可靠的服务,而有好几个月,我们都未能兑现这一承诺。这给大家造成了困扰,我们深感抱歉。我们发布这篇博客,是为了解释哪里出了问题、我们如何应对,以及我们最终如何协助发现 SQLite 数据库核心中一个长期存在的漏洞。

Tailscale 的数据库架构

虽然我们的客户端将控制平面视为单一的公共端点(controlplane.tailscale.com)与之交互,但在内部,我们的控制平面被拆分成一系列协调服务器(或称“分片”)。每个 tailnet 在任一时刻都位于一个内部分片上,但可以在不同分片之间无缝迁移。这些分片属于内部实现细节:你不会知道自己的 tailnet 位于哪个分片,也永远不需要知道。

每个分片都有一个 SQLite 数据库,其中保存着该分片上所有 tailnet 的全部信息。一个 Go 进程独占访问这个数据库,并为这些 tailnet 提供控制平面服务。这种单写入者设计正是 SQLite 的预期使用方式。

架构图,展示 Tailscale 控制平面如何由彼此隔离的分片组成,每个分片都有各自独立的 SQLite 数据库。

2022 年以来,我们一直使用 SQLite 作为主要数据库。之所以选择它,是因为它广为人知、可靠且应用广泛。SQLite 是一种“乏味的技术”——这里的“乏味”是褒义。许多公司在规模大得多的部署中使用 SQLite,也从未遇到问题;我们原本也期待拥有同样省心的使用体验。

在当前的备份流水线中,我们每隔几分钟就会为数据库创建一份完整快照,然后将整个 SQLite 文件上传到 S3 存储桶。自 2023 年初以来,这套方案一直平稳运行,从未发生过事故。

时间快进到去年 8 月,一个读取这些 S3 备份的数据流水线报告称,我们的某个数据库出现了错误。我们对该备份运行了 SQLite 的 PRAGMA integrity_check 命令,发现它确实已经损坏。SQLite 数据库有可能损坏,但这种情况极为罕见,在正常运行中不应遇到。我们修复了受影响的数据库,也调查了原因,却一无所获。

在大规模运行的系统中,即便是罕见事件也可能以一定频率发生,因此,当它再次发生——然后一再发生、再三发生——时,我们本不该感到意外。在最终解决底层漏洞之前的六个月里,我们总共遭遇了 19 次相互独立的数据库损坏事件。

听到“数据库损坏”这个说法,人们自然会担心数据丢失。由于我们的控制平面只处理配置数据,因此这些数据库中包含的是有关你的 tailnet 和设备的元数据,绝不会包含你的私有加密密钥或网络流量。在最早的几次事件中,恢复过程导致少量新添加的设备或配置变更未能持久保存,还有少量元数据需要重新录入。

架构图,展示当单个分片上的数据库出现问题时,Tailscale 控制平面如何应对。SQLite 数据库损坏会影响该分片上的流量,但其他分片不受影响。

每当发生损坏时,我们都必须停止该分片上的控制平面进程,以便修复或还原数据库。对于该分片上的 tailnet 而言,这非常痛苦,因为在恢复期间,它们的整个控制平面都消失了。在早期事件中,停机时间超过一个小时;但随着事件一次次发生,我们逐步加快了恢复流程。

每个 tailnet 都是一个网状网络,其中的设备通过 WireGuard® 相互建立点对点连接。当一台设备加入 tailnet 时,必须先从控制平面获取其他设备的列表,然后才能建立新连接——因此,如果某台设备在 SQLite 停机期间上线,它就无法连接。数据库修复期间,已经在线的设备仍会保持彼此连接,但无法获知网络发生的变更。这些 tailnet 也会暂时无法访问基于 Web 的管理控制台和 Tailscale API。

此外,这还会对信任产生更广泛的影响。即使只有少量 tailnet 受到影响,我们也会在状态页面上发布全局事件。许多人会看到一条其实并未影响到自己的故障事件。事实上,绝大多数分片和 tailnet 从未卷入任何数据库损坏事件!尽管如此,无论你是否直接受到影响,反复停机都会侵蚀信任。

从第一次发生损坏起,我们就知道这是对服务可靠性的严重威胁,也投入了大量工程时间来解决这个问题——但修复它并不容易。

尝试找出问题所在

这个漏洞躲过了我们最初所有查找它的尝试。

我们检查了近期的变更,但没有任何一项看起来与此相关。没有人在改动与 SQLite 交互的底层代码,因为这些代码早在几年前就已经写好,而且此前从未出现过问题。我们用最细致的方式重新审查了所有相关代码,寻找之前遗漏的漏洞,但没有发现任何会导致眼前这种损坏的因素。

我们寻找了各次损坏事件之间的共同因素,却毫无发现。问题与某个特定分片、客户、tailnet 功能、一天中的特定时段或负载水平都无关。我们完全不知道究竟是什么触发了这种行为。

由于缺少可靠的触发条件,我们无法通过人工构造的方式复现这个漏洞。相反,我们只能在生产环境中部署被动式的取证遥测,以期当场捕获数据库损坏。为数据库问题收集实时诊断信息是我们最不愿意做的事,但我们别无选择。

更麻烦的是,损坏并不按固定规律发生。有时两次事件只相隔数小时,有时却相隔数周。这让我们很难预测进展或安排后续工作,因为我们永远不确定下一份诊断转储何时才会出现。从 10 月到 12 月,我们曾连续六周没有发生任何损坏事件,随后它们又像一份不受欢迎的圣诞礼物一样卷土重来。

由于这不可能是一个能够快速或轻松修复的问题,我们联系了 SQLite 开发者,签订了一份专业支持合同。事实证明,这是一个极好的决定。我们得以直接借助他们深厚的专业知识和丰富经验,并围绕我们的架构和故障事件进行了许多深入的技术交流。

Tailscale 工程团队和 SQLite 核心开发者共同梳理了多种可能导致损坏的理论,其中包括 close() 导致 POSIX 锁失效、错误管理 SQLite 所拥有的内存,或在禁用线程安全的情况下意外从多个线程使用 SQLite。每次事件发生后,我们都会收集更多数据、增加更多诊断手段,并系统性地排除这些理论。我们正逐步逼近真正的漏洞。

那些没有“吠叫”的事务

在调查根本原因的同时,我们仍要维持线上平台正常运行。我们采取了积极措施来自动执行恢复并尽量缩短停机时间:

这些工作将我们的响应时间缩短到了一个小时以内——随后,我们发现了一条意想不到的线索。

我们希望找到一种恢复服务的方法,既不必回滚到上一个已知完好的备份(那会丢失大量数据),也不必修复已知损坏的数据库(这可能存在风险)。

为此,我们构建了一条事务日志流水线。我们将每条修改数据库的 SQL 语句流式写入一个单独的日志文件。由于 SQLite 是一个采用单写入者模式的数据库,并支持可串行化事务,因此我们的事务历史完全是线性且确定的。(在 Postgres 或 MySQL 这样的多写入者数据库中,情况并非如此。)在最新的已知完好备份上重放这些事务,理论上应当能够将数据库恢复到最近的状态,同时安全绕过损坏部分。

示意图,展示如何通过重放两次备份之间发生的事务,重建这两次备份之间的变更。

这条流水线奏效了,但随后它做了一件更有价值的事:为我们提供了一条线索。

在两次事件中,我们的事务日志都无法顺利重放。仔细检查后,我们发现,某个事务写入并提交的数据,竟莫名其妙地对后续事务不可见。一次写入在没有引发任何错误的情况下凭空消失了。这本应是不可能发生的!

WAL 上的线索

在这些事件持续发生期间,SQLite 开发者一直在开发一种新的调试工具。有一段时间,我们怀疑漏洞藏在检查点处理过程中的某个地方。他们正在构建一种新工具,以便更清楚地观察检查点期间发生的情况。

要理解这个工具发现了什么,我们需要先简要说明 SQLite 检查点的工作原理。

SQLite 数据库由一系列“页”组成,页是承载信息的小型数据块。更新数据库时,其中一些页需要替换为包含更新后信息的新页。

为了获得更好的性能和更高的并发性,我们使用预写式日志(Write-Ahead Logging)模式运行 SQLite。这意味着新页不会直接写入数据库文件,而是写入“预写式日志”,即“WAL 文件”。

架构图,展示数据库文件与预写式日志(也称“WAL 文件”)之间的区别。两者都由单独的“页”构成,新页会先写入 WAL 文件。

新页不能无限期地写入 WAL 文件;在某个时刻,它们必须被复制回主数据库文件。这个过程称为“检查点处理”(checkpointing)。

架构图,展示 SQLite 的检查点处理过程。WAL 文件中的页会被复制回数据库文件。新页可以替换数据库文件中任意位置的现有页,也可以追加到文件末尾。

在大多数部署中,SQLite 会自行决定何时执行检查点处理,这个过程对最终用户和开发者不可见。在我们的控制平面中,我们手动控制检查点处理过程,以便快速创建一致的备份。随着潜在原因被逐一排除,这种非标准做法变得越来越可疑。

其中一条线索是:在数据库损坏事件期间,我们的指标显示,SQLite 报告从 WAL 文件复制的页数会多于实际存在的页数。如果 WAL 文件中有 10 页,却有 20 页被复制到数据库中,那显然有问题。

为了弄清这些异常检查点期间发生了什么,SQLite 开发者为虚拟文件系统层创建了一种新的调试工具。

SQLite 分为多个层次。最上层是解析器和代码生成器,它们将 SQL 语句转换为 SQLite 的内部数据结构。这些数据结构随后被传递给分页器(pager),由分页器将其拆分为要写入磁盘的各个页。真正将它们写入磁盘的工作由操作系统接口,也就是“虚拟文件系统”负责。目前,SQLite 有两种主流的虚拟文件系统实现——Unix 和 Windows。

架构图,展示 SQLite 的内部结构。它接收 SQL 语句作为输入,语句依次经过三个层次:解析器/代码生成器、分页器,以及操作系统接口/虚拟文件系统。文件系统层负责将变更写入磁盘。

如果你有兴趣深入了解这些内部机制,我推荐观看 SQLite 主要作者 Richard Hipp 的这场讲座

这种设计让你可以用不同实现替换不同层,或者包装现有层来获取更多信息。为了帮助诊断我们的问题,SQLite 开发者为虚拟文件系统创建了一个包装层,用于写入额外的跟踪信息以及数据库变更日志。这个包装层称为 tmstmpvfs 垫片,其源代码可在 SQLite 公共代码仓库中查看。

架构图,展示加入新调试层后的 SQLite 内部结构。操作系统接口/虚拟文件系统层现在由一个 tmstmpvfs 垫片包装。

我们将这个垫片部署到生产环境,然后等待下一次损坏发生。幸运的是,我们没有等太久。

WAL-Reset 漏洞

下一次损坏事件发生后,新 tmstmpvfs 垫片提供的额外日志让 SQLite 开发者得以找到并修复这个漏洞:SQLite 源代码中,检查点处理与写事务之间存在一种罕见的数据竞争。

具体来说,如果在检查点处理期间的某个特定时刻发生写入,检查点进程就会陷入混乱——它以为某些页已经从 WAL 复制到了主数据库文件,但实际上并没有。这些页永远不会写入数据库文件,相应数据也会永久丢失。与此同时,引用这些页的其他页(例如索引)却会被写入数据库,于是数据库文件就会损坏。

SQLite 开发者将其命名为 “WAL-Reset 漏洞”,并估计它已在 SQLite 中存在至少 16 年。它能潜伏如此之久,是因为它极为罕见——罕见到 SQLite 开发者不得不添加代码,才能在测试环境中刻意触发它。他们的修复方案在检查点处理函数中增加了一项额外检查,用于检测 WAL 是否已被另一个线程重置。

他们确认,这个漏洞导致了我们所见的全部费解行为。它解释了数据库损坏、无法顺利应用的事务日志,以及前后不一致的检查点统计数据。他们还解释了为什么我们比其他 SQLite 用户更容易遇到这个漏洞:我们手动控制检查点处理过程,而且执行检查点的频率非常激进。即使某个漏洞只有在罕见条件下才会触发,最终也必然会被我们撞上。

这是一个令人振奋的时刻。经历数月的困惑与不确定后,我们终于有了一个能够合理解释损坏原因的理论,以及一个可以部署来防止问题再次发生的修复方案。

SQLite 开发者在 SQLite 3.52.0 中发布了这项修复,我们也做好准备,等版本一发布就立即部署。

已经修复,但虚惊一场

我们谨慎地推出 SQLite 3.52.0——先部署到少数几个金丝雀分片;确认运行顺利后,再部署到控制平面的其他部分。

我们的备份监控器很快就亮起红灯,报告 13 个不同的数据库发生损坏。这极其令人担忧,但我们遵循恢复流程,修复了所有所谓的损坏,随后一切恢复正常。最终事实证明,这些数据库并未真正损坏,而是受到了该 SQLite 版本中另一个问题的影响。

我们将遇到的错误告知 SQLite 开发者,由此发现了 SQLite 中一个与陈旧表达式索引有关的漏洞。如果你基于一个计算值创建索引,之后该计算发生变化,索引中就会包含不匹配的值,而 PRAGMA integrity_check 会将其报告为数据库损坏。

在我们的案例中,我们将一些高精度时间戳存储为文本,再通过一个 VIRTUAL 生成列将其转换为浮点数。而修复数据竞争的 SQLite 3.52.0 版本还包含一项优化,它细微地改变了文本转浮点数时的舍入行为。我们的金丝雀分片中恰好没有任何时间戳会触发这种舍入行为变化,因此分阶段推出时我们没有发现这个问题。

由于这项变更会引发错误的数据库损坏警告,SQLite 开发者撤回了 3.52.0 版本,转而发布 3.51.3,其中只包含针对 WAL-Reset 漏洞的修复。

我们在自身系统中降低了时间戳的精度,将其改为整数秒,从而解决了问题;文本到整数的转换不存在歧义。与此同时,SQLite 开发者在 3.53.0 中创建了一项自动化的索引自愈功能,以防止陈旧表达式索引问题。

庆祝时刻!

当修复已部署到整个控制平面后,我们准备宣布胜利,但仍然十分谨慎。没有发生损坏事件并不意味着问题已经修好——此前我们就经历过一次具有迷惑性的六周平静期。

我们希望获得确凿证据,证明这种数据竞争确实会在生产环境中发生。既然我们已经理解了漏洞成因——写事务与 WAL 重置发生碰撞——便修补了 SQLite 驱动,使其在这两项操作重叠时记录一条警告。如果警告被触发,但数据库并未损坏,我们就能确认这项修复让我们躲过了一次潜在的数据库损坏事件。

我们部署了这项警告,然后开始等待。等了又等。一直等。继续等。数周过去,我们开始疑惑为何始终看不到它。警告机制坏了吗?我们的理论错了吗?真正的漏洞是否仍潜伏在黑暗之中?

终于在两个月后,我们一直等待的警报触发了:

Alert Manager 通知,显示 SQLitePartyMode 警告:SQLite 在 party mode 下试图损坏 shard2.corp.ts.net:8383 上的数据,但系统阻止了它。详细信息包括警告代码、主机、实例、作业、命名空间、严重性和分片信息。消息建议检查服务器日志,以了解数据库损坏事件的详情。

这条警报证明,触发 WAL-Reset 漏洞的精确条件确实会在我们的生产环境中出现,这意味着它很可能正是导致我们那六个月服务可用性不稳定的元凶。

截至本文写作时,自那条令人莫名欣喜的警报触发以来,我们又平稳运行了四个月,没有发生任何数据库事件。我们终于可以松一口气了。

离开成熟可靠的常规路径

没有人希望我们花六个月时间在 SQLite 中查找漏洞。对于客户和员工而言,这都是一次极其令人沮丧的经历;如今终于能够摆脱这种不稳定,我们所有人都感到欣慰。

这次调查给了我们一个有益的提醒:**以非标准方式运行乏味的技术是有风险的。**常见路径和标准配置都经过了极其充分的测试,可靠性很高。大多数人使用标准配置运行 SQLite,从未遇到过这类问题。我们所做的一切都属于公开、有文档记录且受支持的配置——但当我们手动控制检查点处理过程,并以自己设定的激进频率运行它时,就偏离了成熟可靠的常规运维路径。

解决这些事件是一项规模庞大的跨职能工作,涉及数十人——包括 Tailscale 的工程与支持团队,以及 SQLite 的核心维护者。正是所有人的共同努力,才让这些事件造成的影响没有严重得多。

我们知道,无论受影响的人数有多少,反复停机都会侵蚀信任。在我们追查问题期间,客户给予了耐心和支持,我们对此深表感谢。

尽管这段时期令人沮丧,但如今我们的处境比以前更加稳固。SQLite 中这个长期存在的漏洞已经修复,而我们也在寻找它的过程中修复了数十个偶然发现的其他问题。我们资助开发了开源的 SQLite VFS 垫片,它几乎立刻就帮助我们隔离出了竞争条件,未来也将有助于追踪类似漏洞。最后,我们完善了数据库备份和恢复流程,并在实际生产环境中测试了十多次。

希望我们不会再遇到这样的数据库事件——但如果真的再次发生,我们已经做好了准备。