- 为什么这条最容易被忽略、却最重要(指将服务器主机公钥配置到github上):每次 CD 都是一台全新的 runner,(Github)~/.ssh/known_hosts 是空的。如果不预置主机公钥,就只能用「首次连接即信任」------而每次部署都是一次「首次连接」,等于每次都开一个中间人窗口。有人劫持了到那个 IP 的连接,你的 runner 会毫无察觉地把私钥用于认证。预置之后,工作流里的 StrictHostKeyChecking=yes 才有意义:主机公钥对不上就直接断开。
预置known_hosts如何防止中间人攻击(MITM)?
文章目录
-
- [不是"伪造 IP"那么简单,是中间人攻击(MITM)](#不是"伪造 IP"那么简单,是中间人攻击(MITM))
-
- [你的 Runner 连接服务器的过程](#你的 Runner 连接服务器的过程)
- 攻击者在哪里动手脚?
- 攻击成功后会发生什么?
- 最坏的情况是什么?
- [预置 known_hosts 后怎么防住的?](#预置 known_hosts 后怎么防住的?)
- 总结
- 在预置known_hosts过程中,服务器私钥也要参与
-
- 为什么必须用到服务器私钥?
- [SSH 握手时的真实过程(公钥与私钥的配合)](#SSH 握手时的真实过程(公钥与私钥的配合))
- 理清容易混淆的"两对密钥"
-
- [第一对:服务器主机密钥(Host Key)------ 证明服务器身份](#第一对:服务器主机密钥(Host Key)—— 证明服务器身份)
- [第二对:用户登录密钥(User Key)------ 证明 Runner 身份](#第二对:用户登录密钥(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/ |
签名握手数据,证明服务器身份 | ❌ 绝不离开服务器 |
| 服务器公钥 | Runner known_hosts |
验证服务器签名 | ✅ 提前配置到 GitHub Secrets |
| 部署私钥 | GitHub Secrets | 签名登录请求,证明 Runner 权限 | ❌ 绝不离开 Runner |
| 部署公钥 | 你的服务器 authorized_keys |
验证 Runner 登录权限 | ✅ 手动添加到服务器 |
所以,服务器的私钥不仅用到了,而且是整个"防伪造"机制的核心。没有它参与签名,公钥比对就只是一场毫无安全意义的字符串匹配游戏。