2021 年 3 月 28 日,一条名为 [skip-ci] Fix typo 的提交出现在 PHP 源码树中,署名为项目创建者 Rasmus Lerdorf。标题写得像是一次日常清理,diff 却在 ext/zlib/zlib.c 中增加了 11 行代码。这些代码会查找拼错的 User-Agentt HTTP header,检查它的值是否包含 zerodium,再从固定的八字节偏移处开始对该值求值,由此在解释器内部埋下一个任意代码执行后门。[2]

代码很快被发现并回退。当天稍晚,第二条恶意提交以核心开发者 Nikita Popov 的名义恢复了这段代码,随后也被回退。PHP 官方说明称,两条提交都在进入发行版、触及终端用户之前被撤销;项目随后暂停发布两周,以便调查。[1][4]

这样的遏制结果,很容易让人把事件记成开放审查击败笨拙攻击者的一次胜利。它确实体现了这一点,完整的事后复盘还要看到另一半。九天后,Popov 解释了项目最初的取证判断为何出错:可疑写入没有出现在 PHP 预期的 SSH 与 gitolite 工作流日志里,因为它们经由第二条使用密码认证的 HTTPS 推送通道进入,完全绕开了 gitolite。[3]

恶意 C 代码很快就被识别出来,事件首先暴露的问题,是团队没有记全所有能让 C 代码成为权威版本的通道。

diff 十分醒目,署名却令人熟悉

第一条提交几乎醒目得带有戏剧性。它改动了 zlib 输出压缩流程,加入一个多写了字母“t”的 HTTP_USER_AGENTT 触发器,搜索一家与漏洞利用收购有关的厂商名称,还把攻击者提供的文本交给 PHP 自身的求值器。全部改动只有新增的 11 行。提交消息写着“typo”,实际行为却是一条远程命令通道。[2]

把 Lerdorf 的姓名放在提交上,只添了一层单薄的社交伪装。Git 的作者姓名和电子邮件地址,都是创建提交的人自行填写的元数据;仅凭这些字段,无法证明署名者曾向接收服务器完成认证。Popov 最初的反应仍然合乎情理:回退改动,撤销 Lerdorf 的提交权限。后门随后以 Popov 自己的姓名再次出现,先前归因于单个账户的解释也就失去了依据。[3]

在任何源码控制调查中,这项区分都很重要。“这条提交声称由谁创作?”与“哪组凭据通过哪种协议让服务器接受了它?”属于两个问题。签名提交可以把内容与签名密钥关联起来;托管平台可以把一次推送与经过认证的身份主体关联起来;服务器和身份提供方的日志还能把该身份主体与具体事件关联起来。普通作者字段所连接的,只有一段文本和另一段文本。

PHP 的公开 GitHub 镜像保留了两个恶意对象,包括提交 ID c730aa262b0f239b,即使其改动效果已经被回退,相关对象仍然可见。[1][2] 这种可见性让审查者得以检查事件的确切经过,却没有解释这些写入如何通过授权。

缺失的日志记录把调查引向了错误的机器

预期中的写入通道使用 SSH、gitolite 和公钥认证。Popov 检查 gitolite 后,找到了相邻合法提交的记录,却没有看到两条恶意提交对应的 git-receive-pack 记录。团队据此推断 git.php.net 服务器本身遭到入侵,放在当时是合理的判断。3 月 28 日,项目宣布将停用这台自托管服务器,并把现有 GitHub 镜像升为权威仓库;加入 PHP GitHub 组织也将以双重身份认证为必要条件。[1][3]

这项推断促成了及时遏制,因而发挥了作用;最终原因随后才浮现。

深入检查期间,Popov 发现 git.php.net 有意开放了另一条写入通道。贡献者可以通过 git-http-backend 走 HTTPS 推送,Apache HTTP Digest 认证则会对照 master.php.net 上的用户数据库检查凭据。这些流量从未经过 gitolite,因此 gitolite 日志没有记录才是预期现象。Web 访问日志显示,攻击者先多次猜测失败,随后通过这条 HTTPS 通道,使用 rasmusnikic 用户名成功发起 git-receive-pack 请求。[3]

这处盲点同时存在于架构和组织认知中。一位参与调查的维护者此前并不知道这条备用推送通道存在。它使用不同的协议、认证方式、凭据存储和审计记录,却能改动同一个权威仓库。明面上的写入控制由“SSH 密钥与 gitolite”组成,实际系统还包含旧式 Web 认证,以及与其相连的另一套账户系统。

The Register 的独立跟进报道记录了修订后的判断:团队已放弃 Git 服务器本身遭入侵这一解释,并把 master.php.net 用户数据库泄露列为一种尚未证实的原因。[6] 这里最重要的是“尚未证实”。日志证明有人通过密码认证,在 HTTPS 通道上完成了推送;攻击者如何取得所需凭据材料,日志没有给出答案。Popov 还指出,攻击者在成功认证前猜过多个用户名,这一行为也让简单且已经解释清楚的数据库失窃说难以成立。[3]

旧版兼容需求让薄弱的凭据格式延续下来

账户系统进一步扩大了安全疑虑。Popov 的更新说明称,master.php.net 运行着非常老旧的代码,操作系统和 PHP 版本也已陈旧。密码以兼容 HTTP Digest 认证的形式存储,实质上相当于普通的 MD5 哈希,因为 Git 和 Subversion 服务仍然依赖这种方案。复盘没有确认账户应用存在某项具体漏洞,因而不能把这段历史改写成已经查明的应用漏洞。复盘确认的是,某项少有人留意的兼容需求阻碍了项目采用现代密码哈希。[3]

这是一类常见的基础设施陷阱:仍在使用中的最弱消费者,会决定共享身份存储的安全属性。旧服务因为用户很少,看上去可以位于系统外围;只要它还能写入源码、发布制品、重置凭据或签发 token,它就仍属于核心安全范围。无论是否有人在文档中如此定义,其兼容需求都已经成为生产环境的安全要求。

PHP 选择直接移除这项依赖,放弃继续修补原有安排。项目把 git.php.netsvn.php.net 都设为只读,将仍然活跃的 Subversion 仓库迁移到 GitHub,重置所有 php.net 密码,把账户应用迁到名为 main.php.net 的新 PHP 8 系统,改用 bcrypt 存储密码,并开始把数据库访问转向参数化查询。HTTP 认证写入退出后,兼容 Digest 的不安全密码表示形式也失去了存在条件。[3]

项目还接受了工作流上的改变。GitHub 从镜像升为权威主机,组织成员名单成为写入权限名册,强制 2FA 取代了部分自建的“karma”权限系统。[1] 这项变化无从证明托管基础设施天然安全。项目作出的选择,是停止运行一套使用者少、清点不完整却对安全敏感的技术栈,因为社区的大部分工作早已转移到其他地方。

为什么没有出现遭投毒的 PHP 发行版

攻击者触及了权威开发历史,事态十分严重;在抵达用户之前,攻击已被截断。两项遏制措施造成了这一差别,公开镜像则强化了取证记录。

首先,有人足够密切地查看变更流,及时捕捉到这个体量很小却荒诞醒目的 diff,并迅速回退;正是这次即时回退,让代码没有触及用户。其次,面对仓库完整性仍存疑问的状况,项目把它当作发布问题处理:3 月 30 日,PHP 暂停发布两周,用于核查根因和影响范围。[4][5] 公开 GitHub 镜像保留了恶意对象和回退记录,供外界检查,但可见性本身没有完成阻断。源码分支与已经发布的解释器之间,仍隔着一道发布流程。

因此,这起事件也拆解了两种令人安心的简化理解。PHP 得以脱险,靠的不只是代码处于公开可见状态;只有人员或自动化工具真正检查变更时,可见代码才会发挥作用。同样,仓库遭入侵也不能直接等同于每一套 PHP 安装均已失陷;发布流程留出的时间、审查和紧急停止能力,将遭投毒的提交与已分发制品隔开。

这些控制也解释了为何仅以“后门很显眼”带过事件,仍不足以完成复盘。更隐蔽的改动、更低调的维护者身份,或是夹在合法重构附近的推送时机,都能让恶意代码停留得更久。检测质量缩短了这名攻击者的驻留时间,移除未被纳入清单的写入通道,则压缩了下一名攻击者的机会。

这起事件至今仍要求怎样的工程审查

实际审查可以从一张完整通道图开始,画出每一条能够改动用户信任内容的路线。先列入直接 Git 推送和 pull request 合并,再加入 bot 账户、CI token、发布自动化、软件包注册表、容器标签、文档部署程序、能够升为主库的镜像、账户恢复系统,以及为某一个扩展或某一名贡献者保留的任何旧协议。针对每一条路线,提出四个问题:

  1. 什么负责认证这次写入?
  2. 哪项策略批准这次写入?
  3. 事件在哪里记录,又在哪里触发告警?
  4. 哪一道独立关卡阻止它进入发行版?

如果两条通道都能改动同一个目标,就应提供同等强度的保证,不能在不为人知的地方采用不同标准。无法满足当前认证、审查和遥测标准的旧通道,需要一项明确列名的例外、补偿性控制、负责人和移除日期。“没有人使用”本身支持删除这条通道,也不能成为放任其失去监控的理由。

监控还应跟随受保护的对象,范围需要超出单一常用入口。PHP 的调查者最初搜索 gitolite,因为他们相信所有写入都由 gitolite 经手。仓库级变更流、不可篡改的接收日志,或独立通道上的镜像比较,都能揭示服务器已经接受的 ref 变更,无论它来自 SSH、HTTPS、自动化 token,还是管理员通道。入口日志用于解释事件,权威状态监控用于发现事件。

最后还要演练紧急停止流程。PHP 能够把仓库设为只读、切换权威主机、重置凭据、审查历史并暂停发布。[1][3][4] 如果一个项目连谁有权在事件期间冻结推送和发布都说不清,恢复过程从一开始就会更慢。

这起事件留下的核心意象,应当从拼错的 header 移到那次空白的日志查询上:调查者查看了按理应记录每一次已接受推送的位置,结果一无所获。这片空白没有结束调查,反而引出了真正的问题:还有哪一台机器仍能回答“允许”?

来源

  1. Nikita Popov,PHP Internals,“Changes to Git commit workflow”,2021 年 3 月 28 日——首次披露、两条恶意提交的 ID、权威仓库迁往 GitHub,以及组织成员强制启用 2FA。
  2. PHP php-src,提交 c730aa26,“[skip-ci] Fix typo”——保留下来的原始 diff,显示伪造署名、新增 11 行代码、拼错的 header 触发器、字符串检查和求值器调用。
  3. Nikita Popov,Externals 收录的 PHP Internals 归档,“Update on git.php.net incident”,2021 年 4 月 6 日——修订后的原因判断、HTTPS 推送日志、认证架构、凭据来源的不确定性和修复措施。
  4. PHP,“PHP releases on hold”,2021 年 3 月 30 日——官方确认两条提交已在触及用户前被回退,发布也暂停了两周。
  5. PHP.Watch,“Update to recent git.php.net incident”,2021 年 4 月 7 日——对两次推送、两次回退、仓库迁移和账户系统变更的独立技术时间线与梳理。
  6. Tim Anderson,The Register,“Update on PHP source code compromise: User database leak suspected”,2021 年 4 月 7 日——对修订后判断和旧认证通道的独立报道。
  7. Wikimedia Commons,“Rasmus Lerdorf August 2014.JPG”——William Stadtwald Demchick 在新西兰 PHP 大会上拍摄的 PHP 创建者照片;原图为 3,456 × 4,608 像素,采用 CC BY-SA 4.0 许可,本文缩放为 1,280 × 1,707。