一个数据库漏洞,藏了16年没人发现。揪出它的不是数据库厂商,而是一家做VPN和网络连接服务的公司——Tailscale。 这家公司花了几个月时间调查自家服务为什么不稳定,最后发现问题出在SQLite身上。他们把整个过程写进了官方博客:问题是什么、怎么应对、又是怎么帮SQLite找到这个陈年漏洞的。 从备份损坏开始 Tailscale从2022年起把SQLite当作主要数据库。用户访问Tailscale的端点时会连上「控制平面」,控制平面由多个「分片」组成。安全的私有网络「Tailnet」落在其中某个分片上,必要时可以在分片之间无缝迁移。每个分片都有一套SQLite数据库,存放自己管理的Tailnet的全部信息,由单个Go进程独占访问。 备份机制是每隔几分钟给数据库拍一次完整快照,把整个SQLite文件上传到Amazon S3的存储桶。这套配置从2023年初开始一直运行正常,直到2025年8月,S3备份里报告了数据库损坏。 损坏在6个月里发生了19起,影响范围只限于控制平面的配置数据。但每次损坏都得停掉控制平面进程去修复或恢复数据库,承载该分片的控制平面因此出现停机。 查不到触发条件 团队先排查最近的改动,没找到可疑项。跟SQLite打交道的底层代码都是几年前写的,因为一直没出问题,也没改过。把代码翻了个遍,也没发现能导致数据损坏的写法。 触发条件不明,漏洞就复现不了,只能靠生产环境里的取证遥测。更麻烦的是,事故有时隔几小时就发生一次,有时几周都风平浪静。判断这不是能轻松修好的问题后,Tailscale和SQLite的开发者签了专业支持合同,一起验证问题。 调查期间平台照常运行,为了自动恢复、缩短停机时间,他们做了几件事: 把分片配置成数据库一损坏就立即硬停止 引入自动备份 改进运维手册和值班培训 这些措施把响应时间压到了1小时以内,也意外成了发现问题的线索来源。 事务日志带来的转机 为了在不丢数据、不冒风险的前提下恢复服务,Tailscale搭了一条事务日志管道,把所有修改数据库的SQL语句流式写入日志文件。拿最新的正常备份重放这些事务,就能把数据库恢复到最新状态,从而安全绕开损坏。 这条管道不仅按预期工作,还提供了线索。有2起事故中事务日志重放失败,细查后发现:某个事务写入并已提交的数据,在后续事务里却看不到。 情况逐渐指向SQLite检查点流程的某个环节。SQLite数据库由一系列「页」组成,也就是信息的小块。更新数据库时,需要用包含新信息的页替换掉部分页。为了提升性能和并发性,SQLite会启用「预写式日志」(WAL):新页不直接写进数据库文件,而是先写进WAL。 新页不能无限往WAL里写,到某个时间点必须复制回主数据库文件,这个过程就叫「检查点」。通常终端用户和开发者不需要手动执行检查点,SQLite会自动完成。但控制平面为了做快速且一致的备份,手动控制检查点流程——这是一种非标准做法。 另一条线索是:数据库损坏时,指标显示SQLite从WAL复制的页数超过了实际可用的页数。SQLite由多层构成,最上层是解析器和代码生成器,把SQL语句转成内部数据结构;这些结构交给分页器,由它拆成写入磁盘的单个页;真正落盘由操作系统接口,也就是「虚拟文件系统」处理。这种分层让不同层可以换成不同实现,也能包住已有层来获取更多信息。 一个调试垫片锁定真凶 为协助诊断,SQLite开发者写了一个虚拟文件系统包装层,用来输出额外追踪信息和数据库变更日志。这个包装层里包含一个调试诊断用的VFS垫片「tmstmpvfs shim」,它扩展并包装了SQLite的虚拟文件系统(VFS)层。 包装层部署到生产环境后,紧接着发生的一次数据库损坏中,tmstmpvfs shim留下的额外日志终于让SQLite开发团队找到并修复了漏洞。原因是检查点与写事务之间偶发的数据竞争:检查点处理过程中如果发生写事务,检查点流程就会混乱,导致数据丢失。 SQLite开发者把这个漏洞命名为「WAL-Reset漏洞」,并推断它至少在SQLite里潜伏了16年。它极少发生,Tailscale却屡屡撞上,原因在于Tailscale手动控制检查点创建流程,而且创建得极其频繁。 SQLite开发者发布了修复WAL-Reset漏洞的SQLite 3.52.0,但因为出现另一个问题撤回了3.52.0,改为只包含WAL-Reset修复的3.51.3,最终发布所有问题都已解决的SQLite 3.53.0。 修复应用后还有最后一步:确认即使触发漏洞的「写事务与WAL重置冲突」再次发生,数据库也不会损坏。大约2个月后,那个「期待已久」的告警终于出现,确认数据库不再损坏。 最终Tailscale确认WAL-Reset漏洞的修复有效,此后已连续4个月没有发生数据库事故。这段经历凸显了用非标准方式
发表评论