- 为什么这条最容易被忽略、却最重要(指将服务器主机公钥配置到github上):每次 CD 都是一台全新的 runner,(Github)~/.ssh/known_hosts 是空的。如果不预置主机公钥,就只能用「首次连接即信任」——而每次部署都是一次「首次连接」,等于每次都开一个中间人窗口。有人劫持了到那个 IP 的连接,你的 runner 会毫无察觉地把私钥用于认证。预置之后,工作流里的 StrictHostKeyChecking=yes 才有意义:主机公钥对不上就直接断开。
预置known_hosts如何防止中间人攻击(MITM)?
文章目录
- 不是"伪造 IP"那么简单,是中间人攻击(MITM)
- 你的 Runner 连接服务器的过程
- 攻击者在哪里动手脚?
- 攻击成功后会发生什么?
- 最坏的情况是什么?
- 预置 known_hosts 后怎么防住的?
- 总结
- 在预置known_hosts过程中,服务器私钥也要参与
- 为什么必须用到服务器私钥?
- SSH 握手时的真实过程(公钥与私钥的配合)
- 理清容易混淆的"两对密钥"
- 第一对:服务器主机密钥(Host Key)—— 证明服务器身份
- 第二对:用户登录密钥(User Key)—— 证明 Runner 身份
- 总结
不是"伪造 IP"那么简单,是中间人攻击(MITM)
让我用一个具体场景解释:
你的 Runner 连接服务器的过程
GitHub Runner (美国某个数据中心) │ │ ssh deploy@your-server.com │ │ 第一步:DNS 解析 your-server.com → 1.2.3.4 │ 第二步:TCP 连接 1.2.3.4:22 │ 第三步:SSH 握手,服务器返回主机公钥 │ 第四步:Runner 用私钥认证 │ 第五步:执行部署命令 │ ▼ 你的服务器 (1.2.3.4)攻击者在哪里动手脚?
攻击者不需要入侵你的服务器,只需要在网络路径上做手脚:
场景一:DNS 污染
Runner 做 DNS 解析时,攻击者返回一个假 IP:
Runner 查询:your-server.com 的 IP 是多少? 攻击者回答:是 6.6.6.6(攻击者的服务器)场景二:BGP 劫持
攻击者在互联网路由层面宣告"我能到达 1.2.3.0/24 这个网段",把流量引到自己那里。这是真实发生过的攻击,Cloudflare、AWS 都遭遇过。
场景三:同网络 ARP 欺骗
GitHub 的 Runner 跑在共享数据中心,如果同网络有恶意机器,可以声称自己是网关,截获 Runner 的出站流量。
攻击成功后会发生什么?
Runner 攻击者 (6.6.6.6) 你的服务器 │ │ │ │── SSH 连接 ─────────────────────▶│ │ │ │ │ │◀─ 返回攻击者的主机公钥 ──────────│ │ │ │ │ │ (StrictHostKeyChecking=no) │ │ │ "管他呢,接受!" │ │ │ │ │ │── 用私钥签名认证 ───────────────▶│ ← 攻击者看到你用哪个密钥、 │ │ 哪个用户名、部署什么代码 │ │ │ │◀─ 攻击者假装是你的服务器 ────────│ │ │ "部署成功!" │ │ │ │ │ │ Runner 以为部署成功了 │ 实际上代码根本没到你的服务器 │ 开心地结束了 │最坏的情况是什么?
攻击者不只是"看",他可以:
| 攻击者能做的 | 后果 |
|---|---|
| 看到部署的代码 | 泄露你的源码 |
| 看到你用的 SSH 密钥名和用户名 | 为进一步攻击收集信息 |
| 返回假的"部署成功" | 你以为上线了,其实服务器没更新 |
| 执行恶意操作后假装成功 | 比如在你的服务器上留后门,然后告诉你"部署正常" |
注意:SSH 协议本身不会直接泄露你的私钥(私钥只在本地签名,不会传输)。但攻击者能看到所有其他信息,而且如果你的服务器配置了密码认证,密码会被捕获。
预置 known_hosts 后怎么防住的?
Runner 攻击者 (6.6.6.6) │ │ │── SSH 连接 ─────────────────────▶│ │ │ │◀─ 返回攻击者的主机公钥 ──────────│ │ │ │ (StrictHostKeyChecking=yes) │ │ 对比 known_hosts: │ │ 期望: AAAAC3NzaC1lZDI1... │ │ 实际: BBBBD4NzbC1lZDI1... │ │ │ │ "公钥不匹配!断开连接!" ❌ │ │ │ │ 部署失败,你收到告警 │ │ 攻击者什么也得不到 │(注意:因为服务器公钥是公开的,即使攻击者拿到了服务器真实公钥,与Github known_hosts中的服务器公钥对比后一致,后续也会验证服务器私钥,若私钥不匹配,仍会断开连接)
总结
没有预置 known_hosts: Runner 对任何自称是你服务器的机器都信任 = 在公共网络上裸奔 预置 known_hosts + StrictHostKeyChecking=yes: Runner 只信任持有特定公钥的机器 = 即使网络被劫持,也能立刻发现并断开这不是理论风险。2024 年就有安全研究员演示过通过 DNS 劫持拦截 CI/CD 管道的 SSH 连接。只是大多数人没被攻击过,就觉得"不会发生在我身上"。
在预置known_hosts过程中,服务器私钥也要参与
在这个过程中,服务器的私钥也用到了,而且起着决定性的作用。只是它永远留在服务器本地,不会传输到网络上,所以你在 Runner 端感知不到它。
如果只用公钥,这个安全机制就形同虚设了。下面解释为什么必须用到服务器私钥。
为什么必须用到服务器私钥?
公钥是公开的,谁都能复制。
假设验证过程只用公钥(比对字符串):
Runner 的 known_hosts 里存着:你的服务器公钥 A 攻击者做的事: 1. 连上你的服务器,把公钥 A 抄下来 2. 放在自己的钓鱼服务器上 3. Runner 连过来时,攻击者把公钥 A 发给 Runner 4. Runner 比对:哇,一模一样!信任!如果这样,攻击者轻易就能冒充你的服务器。
所以必须用私钥来"自证清白"。
SSH 握手时的真实过程(公钥与私钥的配合)
在 SSH 握手阶段,服务器不仅要把公钥发给 Runner,还要证明自己是这个公钥的真正主人:
你的服务器 GitHub Runner │ │ │── 1. 发送:这是我的服务器公钥 ───────────▶ │ │ │ │── 2. 发送:这是我用「服务器私钥」 │ │ 对刚才的握手数据做的签名 ──────────▶ │ │ │ │ 3. 验证: │ 用 known_hosts 里的「服务器公钥」 │ 去解密/验证这个签名。 │ │ │ ├── 验证通过 ✅ │ │ 说明对方确实持有对应的私钥 │ │ 身份确认! │ │ │ └── 验证失败 ❌ │ 说明对方只有公钥,没有私钥 │ 是冒牌货!断开连接!结论:
- 服务器私钥:在服务器端用于签名,证明"我是我"。
- 服务器公钥:在 Runner 端(known_hosts)用于验证签名,确认"你是你"。
理清容易混淆的"两对密钥"
在这个 CI/CD 部署场景中,其实有两对密钥在同时工作,很多人会把它们搞混:
第一对:服务器主机密钥(Host Key)—— 证明服务器身份
- 作用:防止中间人攻击(就是你问的这个)。
- 服务器私钥:存在服务器的
/etc/ssh/ssh_host_ed25519_key,用于签名。 - 服务器公钥:存在 Runner 的
~/.ssh/known_hosts,用于验证。
第二对:用户登录密钥(User Key)—— 证明 Runner 身份
- 作用:让服务器知道"这个 Runner 有权限登录并部署"。
- Runner 私钥(部署私钥):存在 GitHub Secrets,Runner 用它签名,证明自己有权限登录。
- Runner 公钥(部署公钥):存在服务器的
~/.ssh/authorized_keys,服务器用它验证 Runner 的身份。
总结
| 密钥 | 存放位置 | 作用 | 是否离开本机 |
|---|---|---|---|
| 服务器私钥 | 你的服务器/etc/ssh/ | 签名握手数据,证明服务器身份 | ❌ 绝不离开服务器 |
| 服务器公钥 | Runnerknown_hosts | 验证服务器签名 | ✅ 提前配置到 GitHub Secrets |
| 部署私钥 | GitHub Secrets | 签名登录请求,证明 Runner 权限 | ❌ 绝不离开 Runner |
| 部署公钥 | 你的服务器authorized_keys | 验证 Runner 登录权限 | ✅ 手动添加到服务器 |
所以,服务器的私钥不仅用到了,而且是整个"防伪造"机制的核心。没有它参与签名,公钥比对就只是一场毫无安全意义的字符串匹配游戏。