事情是这样的:一个帮学校做教务系统的小团队找到我,说他们的一个查询接口最近老被「奇怪的」流量刷,来源 IP 分散在全国各地,有的还是境外机房。起初他们以为是接口没做限流被恶意调用,我让他们先把应用日志关掉、把反向代理上的访问记录单独拎出来查。一查才发现,根本不是有人在刷接口有人顺着页面上的一个「源码未混淆提示」爬到了前端 JS 里的接口地址,然后往下扒出了配置文件路径,最后在打包产物里翻到了一行没删干净的明文数据库密码。
拿到密码之后,对方直接连的是生产库的只读账号,一晚上拖走了两张核心表:一张是全校三万多名学生的学籍信息,另一张是带身份证号的成绩表。数据本身没有加密,落盘就是明文 CSV,拿到就能用。等客户发现的时候,备份已经被删掉了一部分因为那个账号同时有 DROP 权限,只是对方暂时还没动手。整件事从「代码里多写了一行密码」到「全校数据躺在别人硬盘上」,中间只隔了一次构建发布。
如果你手上有自己的服务器、正在跑一套自己的业务系统,这两家可以先看看:狗云DOGYUN(高性价比云服务器,多机房可选)、RackNerd(全球VPS,特价常推)。下面要讲的这些内容跟机器配置关系不大,但跟服务器本身的安全基线、备份策略强相关出事之后能不能快速止血、能不能追溯到泄露点,机器上留着的东西多少会决定你有多被动。

一、先止血:改密码比什么都快,但别只改密码
客户第一反应是要我帮忙把数据库密码改掉。改,必须改,而且得马上改。但我拦住了他「改完就完事」的想法,因为只改密码不改根因,等于把地雷的引线拔了、地雷还在原地。密码泄露和代码泄露是两个问题:密码是入口,代码才是被翻开的柜子。今天改了这一个密码,明天别人顺着同一段代码还能翻出你的对象存储密钥、你的第三方支付回调密钥、你的短信通道密钥这类「密码全家桶」在真实项目里几乎都是成套出现的。
所以第一步是止血,第二步才是根治。我给的处理顺序是:先在数据库侧禁用旧账号而不是删掉(保留取证线索,同时切断访问);然后在同一时间窗口内,把所有「跟数据库同源」的凭证全部换掉,包括数据库账号、对象存储 AK/SK、Redis 密码、消息队列的账号、JWT 签名密钥;接着在云厂商侧把旧密钥禁用、新密钥启用,并确认流量确实切到了新密钥上;最后才是回到代码里,把密钥从源码里搬出去。
这里有个坑要单独提:我们先改了数据库密码,结果应用半夜开始大面积报 502。原因是那套系统有 6 台应用机器,其中 2 台的部署配置是三个月前单独改过的,它的密码还停留在旧值。也就是说,密码压根不是存在一个中心的地方,而是散落在 N 份配置文件里。这次事故把这个「散」的代价放大到了极致如果你也有多台机器、多个部署环境,这一步千万别省。
二、代码里为什么不能写密码:把配置从代码里剥离出来
先说清楚一个常见误解:不是「配置文件里不能写密码」,而是「代码仓库里、会被打包进产物、会被推到 Git 的任何文本文件里,都不能出现明文密钥」。这两者的区别在于,配置文件可以只存在于目标机器上、权限收紧、不进版本库;而代码仓库里的东西,会被所有有权限的人看到,会被 CI 打包进镜像,会被推到第三方 Git 服务去。
典型的泄露姿势有这么几种,我基本每一类都见过:一是直接写在代码里,比如 DB_PASSWORD = "xxx" 这种;二是写在配置文件里,然后配置文件跟着代码一起提交了,.gitignore 忘了写或者写错了路径;三是写在前端代码里,编译成 JS 后随页面下发给任何人这个最隐蔽,因为开发者自己往往不会意识到 build 产物里有什么;四是写死在 Docker 镜像的 ENV 里,镜像推到仓库后,任何能 pull 的人都能 inspect 出全部环境变量;五是硬编码在 K8s 的 ConfigMap 里,而 ConfigMap 又被以明文形式写进了 Helm values.yaml,照样进了 Git。

我的做法是分三层走。第一层是「立刻能落地」的:把所有密钥换成从环境变量读,代码里只写读取逻辑,不写默认值。这一点很关键很多人在代码里写 os.getenv("DB_PASSWORD", "default_password"),看着像改了,其实默认值还躺在仓库里,等于没改。环境变量没有默认值,取不到就直接启动失败,这才是我们想要的失败方式:早失败比晚泄露便宜得多。
第二层是「让配置可管理」:把散在各处的配置收进一个统一的位置,比如 Nacos、Apollo、Consul 这类配置中心,或者自己用 systemd 的 EnvironmentFile 加一个 600 权限的独立文件。用 systemd 的做法很朴素,但足够可靠:

做法是给每个服务单元写一个 /etc/myapp/secrets.env,权限 600,属主是运行服务的那个系统账号(不要是 root),然后在 unit 文件里用 EnvironmentFile=/etc/myapp/secrets.env 引用。注意这个文件绝对不能进 Git,必须写进 .gitignore,团队里也要约定死不然半年后新人一提交就又漏回去了。
第三层是「让密钥有生命周期」:光把密码搬出去是不够的,密码一旦泄露过一次,它就永远是泄露过的。这次事故之后我们给每个凭证都建了一条台账,写清楚这个密钥是哪来的、谁在用、影响面多大、上次轮换是什么时候、下次什么时候轮换。这张表一开始只有 12 行,现在有 60 多行,每行对应一个具体的服务和负责人。轮换的频率不用一开始就定得很勤,先做到「每季度全量过一遍,高权限凭证(数据库、支付、云厂商主密钥)一个月一次」,比什么都没有强得多。

三、轮换怎么做才不会把自己搞挂
很多人怕轮换,故意的因为上次一换就出事了。原因基本是同一个:轮换是「一次只换一半」的活儿,而大多数人是「一晚上全换完」。正确的姿势是双密钥并存:新密钥先加上,确认服务已经在用新密钥且旧密钥没有任何调用记录了,再把旧密钥禁用。这一步顺序反了,业务就断。
数据库账号尤其要小心,常见做法是新建一个同名同权限的新账号,把密码换掉,重启应用验证新账号能连上,再 drop 掉旧账号。中间还要留一个观察窗口,别一验证通过就立刻删旧账号万一有某个定时任务或者某个备份脚本用的是另一个账号写的旧密码,你不会立刻发现,等到某天备份任务静默失败才发现就晚了。
另外,轮换这件事必须进版本控制的流程里。最朴素也最有效的做法是:在 CI 里加一个针对配置文件的扫描环节,扫到疑似明文密钥就直接让流水线失败;再在提交钩子里对 Git 历史做一次扫描,避免密钥在旧提交里躺着。历史泄露这件事有个很麻烦的地方:你在当前版本删掉了密码,但 Git 仓库的历史提交里还在,只要有人 clone 下来就能翻到。所以真正的补救必须是「先轮换密钥,再清理历史」,顺序反了等于没做。
四、这次事故里我做的几件额外的事
除了上面的流程,我还顺手补了几件一开始没人在意、事后证明很有用的事。
一是把云厂商侧的访问日志和对象存储访问日志打开留存。泄露账号的操作在数据库日志里留下了痕迹,但我一开始是拿着应用日志在找的那玩意儿早被轮转掉了。数据库审计日志、认证日志这类「低频但关键」的日志,值得单独存一份到别的地方。
二是给数据库账号做了最小权限收敛。原来那个应用账号居然有 DROP 权限,现在改成只有 SELECT 和必要的那几张表的写权限;备份账号独立出来,只在备份窗口里启用。这条改动很小,但把「拖库」变成「拖数据」,性质完全不一样。
三是把这次泄露涉及的域名和路径在 WAF 规则里做了记录和拦截。攻击者爬源码的那条路径猜测 /static/config.js 之类的常见位置是可以提前枚举掉的。这属于事后补救,但做一次就等于给以后省一次。
四是给全员开了一次会,把「哪些东西不能进 Git」写进了团队规范。我发现很多事故的根子不在技术,而在没人告诉过新人「配置文件也要 .gitignore」。技术能防住的只是一部分,另一部分靠约定。

五、如果只记三件事
第一,代码仓库、镜像、ConfigMap 里永远不出现明文密钥,环境变量读取不写默认值,取不到就启动失败。第二,密码泄露过之后,把它当成永久泄露处理,同源的那一整套凭证一起换,别只换一个。第三,轮换走双密钥并存:先加新的、确认新流量、再禁旧的,中间留观察窗口。至于凭证台账、CI 扫描、Git 历史清理,这些是把这套做法从「靠一个人记得」变成「靠流程保证」的关键,也是团队规模一大就不会崩的原因。
说句实在的,这套东西我们花了差不多两周才理顺,包括代码改造、6 台机器的部署调整、密钥台账的建立、以及一轮全员培训。真正花时间不是改代码,是搞清楚「到底有多少个地方存着密钥」答案通常是比你以为的多得多。


喜欢
顶
难过
囧
围观
无聊



