Railway十月安全事件后,轮换9个密钥
要点
- 把Mistboard存放在Railway上的凭据全部轮换一遍,用了9次轮换,删除了22个变量,在19小时内花了约2小时实际操作,外加一次我自己造成的12分钟数据库故障。
- 我最大的暴露面是Postgres密码:数据库开着一个公共TCP代理,泄露的连接字符串在互联网上任何地方都能用。
- 故障的起因是:运行中的容器还拿着旧密码,我就改了这个密码。再加一个数据库角色,新旧密码就能并存,同样的轮换可以做到零停机。
- 一次指纹比对发现了一个过期的发布令牌,它本会挡住下一次部署,而比对全程没有打印任何一方的值。
太平洋时间10月21日下午4:55,Railway发来一封邮件,标题是“Urgent: Railway Security Disclosure - Rotate Credentials”。第一条指示是:“立即吊销并重新签发存放在Railway中的所有环境变量,包括所有API密钥。”
Mistboard是我一个人运营的象棋及其变体网站。它在Railway上以三个服务运行:web(Node服务器和网站)、engine-worker(机器人背后的对弈引擎)和Postgres。这些服务用到的每一个密钥,都是Railway的环境变量。
Railway报告了安全事件,并暂停构建一天
Railway的披露公告称,他们“检测到涉及存储客户环境变量的系统的异常活动”,未经授权的第三方“可能访问了加密的环境变量,并获取了其中部分变量的解密内容”。影响“可能仅限于部分用户”,这些用户会收到邮件。我收到的是普通通知,所以我把每个值都当作已经泄露。两天后,第二封邮件缩小了范围:我的工作区中的变量符合攻击者用来搜索AI和云服务商API密钥的模式,不过Railway无法确定其中是否有变量被解密。那时,工作区里的每个密钥都已经轮换过了。
构建暂停决定了之后的一切。运行中的容器还在用某个密钥时就把它吊销,相应功能会一直坏着,直到新的构建上线;而在大半天里,没有任何新构建能上线。
数据库密码是我最大的暴露面
我按照持有者能对网站或玩家做什么,给每个密钥排了序。
| 密钥 | 持有者能做什么 | 我查到的情况 |
|---|---|---|
| Postgres密码(带公共TCP代理) | 从任何地方读写任意表 | 没有新增角色,没有无法解释的管理会话 |
| 邮件API密钥 | 以mistboard.com的名义发邮件,比如伪造的登录验证码 | 发送日志无异常 |
| LLM API密钥(论坛翻译) | 用我的账单跑推理 | 该密钥30天内的每次调用,都与服务器存储的一条翻译对得上 |
| 分析平台个人API密钥 | 通过完整API读取网站的分析数据 | 已轮换 |
| 私有引擎仓库的部署密钥 | 读取引擎源代码 | 已替换,旧密钥已删除 |
| 支付密钥 | 很少:计费尚未上线 | 已轮换 |
| 两个内部服务令牌 | 调用网站自己的内部接口 | 已重新生成 |
各服务通过Railway的私有网络连接数据库,但数据库还在一个公共端口上开着TCP代理,留着它是为了能在笔记本电脑上跑管理脚本。有了这个代理,连接字符串在任何机器上都是一个能用的登录凭据。删掉它只需点一下,生产环境没有任何影响,这也是我最先做的事情之一。现在管理脚本通过一条短时的SSH隧道进入私有网络来连接数据库。
数据库的副本里不会有任何密码。登录用的是通过邮件发送的一次性验证码,网站只以哈希形式存储登录验证码和会话令牌,所以读到的内容不足以让人以玩家身份登录。
从收到邮件到最后一次检查,用了19小时
| 时间(PDT) | 谁 | 发生了什么 |
|---|---|---|
| 10月21日 00:24 | Railway | 第一条状态公告;构建暂停 |
| 10月21日 04:33 | Railway | 控制台、API和CLI宕机;所有区域暂停构建 |
| 10月21日 16:55 | Railway | 披露邮件:全部轮换 |
| 10月21日 17:30 | 我 | 建立跟踪表;盘点代码读取的每一个密钥 |
| 10月21日 17:40 | 我 | 数据库审查:没有新增角色,没有无法解释的管理会话 |
| 10月21日 18:04 | 我 | 停用LLM密钥;论坛翻译关闭,直到下次部署 |
| 10月21日 18:08 | 我 | 删除数据库公共代理;邮件发送日志无异常 |
| 10月22日 约00:10 | Railway | 付费套餐恢复构建,随后几小时构建缓慢 |
| 10月22日 10:31 | 我 | 在各服务商处轮换分析、支付和Webhook密钥 |
| 10月22日 10:38 | 我 | 推送8个新值;web和engine-worker重新构建,各需20分钟以上 |
| 10月22日 10:52 | 我 | 删除22个失效变量 |
| 10月22日 11:04 | 我 | engine-worker的构建用新部署密钥克隆了引擎仓库;旧密钥已删除 |
| 10月22日 11:23 | 我 | 重新生成数据库密码;故障开始 |
| 10月22日 11:25 | 我 | Postgres恢复;engine-worker在重启时崩溃 |
| 10月22日 11:35 | 我 | web使用新密码上线;故障结束 |
| 10月22日 11:38 | 我 | engine-worker使用新密码上线 |
| 10月22日 11:50 | 我 | 通过新邮件密钥收到登录验证码,旧密钥已删除;机器人对局和论坛翻译正常 |
| 10月22日 11:55 | 我 | 发现并修复过期的发布令牌;轮换完成 |
实际操作时间是当晚约40分钟,次日上午90分钟。第一个晚上做的是不需要构建的事。其余的一切都在等构建队列。
大多数服务商我都让新旧并存。邮件密钥一直保持有效,直到通过替换后的密钥收到了一封登录验证码;旧的部署密钥则在一次构建用新密钥克隆了引擎仓库之后才删除。Postgres是例外。
这些工作我是和Claude Code一起完成的。Claude维护跟踪表,根据代码建立清单,执行数据库和日志审查,编写脚本,并运行了每一项检查。服务商那边的每一步都由我来做:轮换每个密钥,把每个新值输入macOS Keychain,删除代理,删除变量,按下Regenerate。Claude只看到密钥的名字,从未看到值:一个脚本把每个值从Keychain带到Railway,并清洗掉输出,所以没有任何密钥出现在对话记录、日志或文件里。
修改数据库密码,让所有数据库读取中断了12分钟
Railway的Regenerate按钮会同时修改数据库密码和对应变量。我原以为web和engine-worker从已经构建好的镜像重新部署时,只会有一分钟左右的连接失败。Railway的文档却不是这么说的:重新部署会“从该部署最初的源代码重新构建镜像”。重启会复用现有部署,连同其中的变量,所以重启后用的还是旧密码。
Postgres两分钟就干净地恢复了,而运行中容器发出的每条查询都失败了。网站本身没有宕机:页面能加载,但凡是读数据库的功能都失败了。进行中的对局断开,棋局研究、论坛、聊天和对局查看器都在报错,直到新的web构建上线。Railway的构建队列还没从事件中恢复,所以故障持续的时间恰好等于一次缓慢的构建;旧密码已经没了,也没有更快的出路。
再加一个登录角色,同样的轮换就能做到零停机。创建新角色,让服务指向它,部署,等新部署运行正常后再停用旧角色。无论构建要多久,新旧角色都能同时工作(#614)。
engine-worker让情况更糟。Postgres重启时,worker的数据库客户端抛出了一个没有任何代码监听的错误,进程直接退出,而不是重新连接(#612)。数据库重启是常规事件(维护、故障切换、更换套餐),却从来没有测试让worker经历过一次。
一次指纹比对,在下一次发布前发现了过期令牌
发布时会先排空进行中的对局再重启,而排空请求要携带一个令牌。发布脚本先在Keychain里按旧的条目名查找,再按正式名称查找。轮换时新值写在正式名称下,旧条目却留在原处,所以下一次发布会发送旧令牌,收到401,无法排空。
九月有两次发布因为排空时返回401而失败,之后我写了drain:token-check。它按发布读取的顺序,对本地每一份令牌副本计算指纹,并与生产容器里的值逐一比对,全程不打印任何一个值。在下一次定期发布前4小时,它报告这个令牌被遮蔽了:生产环境的值确实在笔记本上,但一份更旧的副本会先被读到。删掉旧条目就修好了。
写这个检查的时候,我就知道有两个名字。我做了一个检测器,却留下了隐患,结果轮换又把它重新制造了出来。修复办法是只用一个名字,并让推送脚本在每次轮换后运行这项检查(#613)。
22个变量早已失效
第一版清单来自代码:服务器和脚本读取的每一个环境变量名。控制台呈现的却是另一番景象。22个变量已经失效(已下线游戏变体的开关、我不再构建的引擎的安装开关),而代码读取的密钥名中有3个根本没有在任何地方设置。目前还剩大约30对功能开关,每个游戏变体一对,两个服务上都有。为了找出9个密钥,我按名字读了每一个变量。
CI没有需要轮换的东西。调用生产环境的定时工作流使用短时的GitHub OIDC令牌认证,由服务器验证,所以仓库的secrets里没有存放任何凭据。
我要保留的7条规则
- 每个凭据都要新旧重叠。 在旧凭据失效之前,新凭据已经可用,所有服务商都如此,包括数据库。
- 切换之前,先测重新部署要多久。 如果旧凭据先失效,构建时间就是故障时间。读清楚平台说的“重新部署”和“重启”分别指什么,然后两者都计时。
- 每个长时间运行的进程,都要扛得过数据库重启。 测试方法:断开客户端的套接字,断言下一条查询成功。
- 每个密钥只用一个名字存储。 旧名字下的第二份副本,迟早会被先读到。值不能打印的地方就比对指纹,并让执行轮换的工具来做这项比对。
- 密钥清单要短,并与配置分开。 轮换时读的应该是一份9项的清单。功能开关应该放在代码里,或放在单独一份清单里(#615)。
- 关闭没人需要的入站通道。 数据库公共代理给管理脚本省了一步,却让数据库在几个月里都能从互联网访问,只隔着一个密码。
- 优先使用不需要存储的凭据。 有了OIDC,CI里的轮换次数是零。
下一步是一次计时演练:把Postgres转储恢复到另一台主机上,再让web连上它启动,这样在别处恢复所需的时间就是一个已知的小时数。