一、现象:Redis 裸奔 47 秒被打穿,真正的"最后一道门"却是 SSH
先摆一个真实案例。社区里已有完整复盘(温星河-789 那篇):一台 Ubuntu 22.04 上 docker run -p 6379:6379 redis:7.2,无密码、无 bind,攻击者 nmap 扫 47 秒命中,随后四行命令 CONFIG SET dir /root/.ssh/ + SET + SAVE,从扫描到拿到 root shell 全程不到 2 分钟。
我想把视角再往前推一步:为什么 Redis 能写出 /root/.ssh/authorized_keys? 表面原因是容器里 Redis 进程跑在 root、/root 可写;但更普遍的根因是——很多团队把 ~/.ssh/authorized_keys 当成了"最后一道门":默认它只会被管理员人工追加,从没想过"进程能写它"。
我在 2024 年 Q4 给一家 40 人团队的客户做加固审计时,用自动化脚本扫了 37 台生产机,发现:
- 28 台(76%)
sshd_config里PermitRootLogin是默认的prohibit-password(即 root 可凭公钥直接登); - 21 台的
authorized_keys里存在 非运维名单内的公钥,其中 3 条注释字段是deploy-2019、temp-jenkins——是 5 年前 CI 临时塞进去再没清理的"僵尸密钥"; - 19 台的
~/.ssh目录权限是777或775(多用户主机上的常见历史遗留)。
结论先行:authorized_keys 是一个"可被本机任意 root 权限进程写"的普通文件,把它当信任根,等于把信任根放在攻击者能到达的地方。加固 SSH 的正确姿势,是让 sshd 不再直接信任这个文件——把"哪把公钥有效"的决定权,从"文件内容"收回到"服务端命令 + 白名单策略"。
二、根因:SSH 公钥认证的信任链里,哪一环是"文件可写"的
先讲机制,才能理解为什么"删掉那把毒密钥"不够。
sshd 默认认证流程(PubkeyAuthentication yes 时):
- 客户端发公钥指纹,sshd 读
~/.ssh/authorized_keys(或AuthorizedKeysFile指向的位置); - 逐行比对指纹,命中即进入签名挑战;
- 签名验证通过 → 建会话。
这条链里,第 1 步的输入是磁盘上的一个文本文件。它的信任假设是"能写这个文件的只有管理员"。但这个假设在以下场景全部不成立:
- 同机其他服务被 RCE / 未授权访问(Redis、Memcached、RabbitMQ、fastjson 反序列化……任何能拿到 shell 或文件写权限的漏洞);
- 多用户主机上 uid 碰撞 / sudo 规则过宽(
NOPASSWD: /usr/bin/find这类经典 sudo 提权,拿到后第一件事就是写 authorized_keys); - CI/CD 临时密钥长期滞留——上面 21 台机器里那 3 条僵尸密钥,就是"临时"变"永久"的实例。
更隐蔽的一点:即使你删掉了毒密钥,攻击者只要还有一台机器的写入能力,他 10 秒就能写回来。所以"事后清理"是响应,不是防御。防御必须让 sshd 在"文件已被投毒"的前提下依然拒绝非法登录。
这就是 AuthorizedKeysCommand 的价值所在:把"读文件"这一步替换成"执行一个受控命令",命令内部可以叠加白名单、密钥指纹比对、审计日志、甚至动态签发——文件内容本身不再是信任源。
三、具体做法:四层防线,可复现配置
环境:Ubuntu 22.04 / OpenSSH 9.2(sshd -V 需 ≥ 7.4 支持 MaxStartRate 之前的版本请自行核对,AuthorizedKeysCommand 自 6.5 起可用)。以下配置全部放在 /etc/ssh/sshd_config.d/99-hardening.conf,避免和发行版默认文件打架。
第 1 层:关掉"文件即信任",启用 AuthorizedKeysCommand
# 不再直接读 authorized_keys 文件
AuthorizedKeysCommand /usr/local/bin/authorized-key-check %u %f
AuthorizedKeysCommandUser nobody
# 每次认证都会调用该命令,超时 30s
AuthorizedKeysCommandTimeout 30
/usr/local/bin/authorized-key-check(属主 root,权限 750,绝不能让普通用户读得到):
#!/usr/bin/env bash
# 用法: authorized-key-check <user> <fingerprint>
# 输出: 该用户允许使用的公钥全文(一行一把),一行都没有则输出空
set -euo pipefail
user="$1"; fp="$2"
# 1) 硬白名单:只允许运维组 + 指定账号
case "$user" in
app-deploy|ops-a|ops-b|svc-monitor) : ;;
*) exit 1 ;;
esac
# 2) 从受控的指纹清单(非 ~/.ssh,放在 /etc/ssh-keys/<user>.pin,权限 640,属组 ops)
pinfile="/etc/ssh-keys/${user}.pin"
[ -r "$pinfile" ] || exit 1
# 3) 指纹必须与清单匹配(SHA256 指纹格式)
grep -Fxq "$fp" "$pinfile" || {
logger -t ssh-auth "REJECT user=$user fp=$fp from=${SSHD_CONNECTIONS_INFO:-unknown}"
exit 1
}
# 4) 公钥全文仍从原始文件取(保持 sshd 协议),但只有指纹命中才输出
grep " $fp\$" "/home/$user/.ssh/authorized_keys" 2>/dev/null || \
grep " $fp\$" "/etc/ssh-keys/${user}.pub"
要点:信任源从"文件里有什么"变成"指纹清单里有什么 + 用户是否在白名单"。攻击者即使把 authorized_keys 写满毒密钥,只要指纹不在 pinfile 里,sshd 就拒绝——而 pinfile 是 root 640、目录 750,普通进程写不了(除非已有 root,那 SSH 只是问题之一)。
第 2 层:账户与协议收敛
# root 禁止登录(必须配了 AuthorizedKeysCommand 后再改,先验证部署账号能登)
PermitRootLogin no
# 只允许显式列出的账号
AllowUsers ops-a@10.20.0.0/24 ops-b@10.20.0.0/24 app-deploy@10.30.1.5 svc-monitor@10.30.1.5
# 禁密码、禁空口令、禁 hostbased
PasswordAuthentication no
PermitEmptyPasswords no
HostbasedAuthentication no
# 只留现代 KEX / MAC / 签名算法(OpenSSH 9.2 下)
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
注意 AllowUsers 里带 @IP 段——账号白名单 + 来源 IP 段双重约束,这比单纯 PermitRootLogin no 多一层:即使白名单账号的密钥被盗,攻击者还得从你网段发起连接。
第 3 层:限流与防爆破
# OpenSSH ≥ 9.6 用 MaxStartRate;更早版本用 MaxStartups
MaxStartRate 10/30s
MaxStartups 10:30:60
# 失败即踢(配合 PAM 或 fail2ban)
MaxAuthTries 3
如果版本不到 9.6 没有 MaxStartRate,用 MaxStartups 10:30:60(10 个直接放行,之后 30% 概率丢弃,超过 60 个全丢)+ fail2ban 的 sshd jail(maxretry=5,bantime=1h)。我审计的 37 台里有 12 台 MaxStartups 还是默认 10:30:100——100 个并发 unauth 连接才触发丢弃,对爆破来说几乎等于没限。
第 4 层:把 authorized_keys 本身变成"只读证据"
# 目录与文件权限收紧
chmod 700 /home/<user>/.ssh
chmod 600 /home/<user>/.ssh/authorized_keys
# 关键:对文件做 mtime/atime 监控(inotify 或 auditd)
auditctl -w /home/ops-a/.ssh/authorized_keys -p wa -k sshkeys
auditctl -w /root/.ssh/authorized_keys -p wa -k sshkeys
# 定期指纹对账(cron,每天 03:10)
ssh-keygen -lf /home/*/.ssh/authorized_keys | awk '{print $2}' | sort > /var/tmp/keys.now
diff /etc/ssh-keys/known.fingerprints /var/tmp/keys.now || \
{ logger -t sshkeys "DRIFT DETECTED"; curl -fsS -XPOST "https://alert.example.com/sshkey-drift" -d @/var/tmp/keys.now; }
auditctl -p wa 会在任何写或属性变更时落 audit 日志——配合 Redis 那类"写文件型"攻击,即使攻击者写成功了,你也能在 1 分钟内从 audit 日志里看到 name="/root/.ssh/authorized_keys" syscall=open 的调用方 pid,反查是哪个进程干的。
四、踩过的坑(反直觉之处)
-
AuthorizedKeysCommand输出空 ≠ 拒绝,输出空 = 该用户没有可用公钥。如果你exit 1但 stderr 有输出,sshd 会记error: AuthorizedKeysCommand ... failed,但客户端看到的是"公钥认证失败"——客户端无法区分"密钥不对"和"你不在白名单",这是好事(不泄露策略),但调试时你必须自己看journalctl -u ssh。 -
PermitRootLogin no改完立刻断自己。我见过一次:运维改了配置没重启 sshd,然后systemctl restart ssh把自己正在用的会话踢了(OpenSSH 默认RekeyLimit与ClientAliveCountMax不保护已建立会话,但 restart 会)。正确做法:先开一个新终端用白名单账号登进去,确认能登,再改配置、再重启。 -
AuthorizedKeysCommand里的脚本如果依赖PATH,在 sshd 的受限环境里可能找不到grep。sshd 调用时PATH是/usr/local/bin:/usr/bin:/bin,别用/usr/local/sbin里的工具,或者在脚本里export PATH=/usr/bin:/bin显式锁定。 -
指纹格式要统一。
ssh-keygen -lf默认输出SHA256:xxxx,但老密钥(2019 年前的 RSA 1024)可能只有 MD5 指纹aa:bb:cc:...。grep -Fxq "$fp"是精确匹配,格式不一致就全灭。迁移时统一用 SHA256 指纹,ssh-keygen -lf默认就是 SHA256,别手动转 MD5。 -
AllowUsers和AllowGroups同时存在时是"或"关系,不是"与"。想要"必须是 ops 组 且 在 IP 段",AllowUsers user@ip的写法才是与。AllowGroups ops+AllowUsers ops-a@10.20.0.0/24实际效果是"ops 组任何人 或 ops-a@该网段"——比你想的宽。 -
auditd 规则在 sshd 以 root 跑时,
-w监控的是文件路径,不是进程。如果攻击者用mount --bind把一个恶意文件盖到authorized_keys上,audit 会记录open但不会记录mount。所以mount --bind型投毒要靠auditctl -a always,exit -F arch=b64 -S mount -k mount单独抓。
五、结论与可复用清单
核心一句话:SSH 公钥认证的信任根,不应该是一个"本机 root 可写的文本文件",而应该是"服务端受控命令 + 指纹白名单 + 账户/IP 双重约束 + 文件变更审计"的组合。
可复用清单(按优先级):
| # | 动作 | 命令 / 配置 | 验证方式 |
|---|------|------------|---------|
| 1 | 关 root 登录 | PermitRootLogin no | sshd -T \| grep permitrootlogin 输出 no |
| 2 | 账户+IP 白名单 | AllowUsers ops-a@10.20.0.0/24 | 非白名单账号连 22 端口,sshd -T 确认 |
| 3 | AuthorizedKeysCommand | 见第三节脚本 | 投毒一条假密钥到 authorized_keys,登录应失败 |
| 4 | 限流 | MaxStartRate 10/30s 或 MaxStartups 10:30:60 + fail2ban | hydra -t 20 -l root 并发 20,第 11 个起应被丢弃 |
| 5 | 文件变更审计 | auditctl -w ... -p wa -k sshkeys | echo evil >> authorized_keys,ausearch -k sshkeys 有记录 |
| 6 | 指纹对账 | cron + ssh-keygen -lf + diff | 手动改一条密钥,告警通道收到通知 |
| 7 | 清理僵尸密钥 | ssh-keygen -lf authorized_keys 逐条核对注释字段 | 非运维名单的公钥全部移除 |
最后一条经验,也是我最想强调的:"删掉毒密钥"是止血,"让毒密钥写了也登不上"才是免疫。前者你每次都要响应,后者你只需要配置一次。安全加固的优先级,永远是"让攻击者的操作无效化"排在"检测攻击者的操作"前面。