SSH 私钥被 chmod 644 却能登录成功?—— 权限闸门两处失效(keygen 放开私钥权限 / bootstrap 吞掉退出码)
关键词:SSH 私钥权限 0644、
Permissions 0644 for 'xxx' are too open、私钥权限太开放、 ssh 免密登录突然失败、StrictModes、sshd -T生效配置、Include覆盖 sshd_config、stat -c %a八进制字符串、bash8#只接受 3 位八进制、setuid 4 位 mode、chmod 644、 sshd 拒绝启动、退出码未传导、安全闸门失效、keypolicy、entrypoint吞掉退出码、 root 绕过 mode 位、su watcher、docker restartvs--force-recreate、COPY进镜像必须--build、docker compose复现、伪缺陷、umask 077分支:
2026-09-29-0800| 场景:scenario/2026-09-29-0800/| Issue:#105 技术栈:OpenSSH / sshd_config / Linux 文件权限 / bash 退出码 / Docker Compose
一、问题:安全巡检说坏了,真实登录说没坏
运维给发布机加了一批 SSH 免密登录(deployer@release),验过一遍,能登。
第二天凌晨安全巡检脚本报出一条:
[FAIL] private key file mode is group/other readable: /srv/keys/deployer_id_ed25519 (0644)
可同一时刻的连通性探针全绿:
[OK] deployer@sshd 登录成功 rc=0
[conncheck] reachable=true
也就是说:私钥已经 0644 摆了一晚上,ssh 一次都没拦过。 安全巡检说坏了、真实登录说没坏 —— 两者都是「证据」,只能信一个。
更刺眼的一幕在启动日志里:
[keypolicy] VIOLATION: 私钥可被非属主读取: /srv/keys/deployer_id_ed25519 (0644)
[keypolicy] 私钥保管策略未满足,拒绝启动
[bootstrap] keypolicy 未通过(rc=9)
[bootstrap] starting sshd with /run/sshd/sshd_config (port=2222) ← 判完照常起
Server listening on 0.0.0.0 port 2222.
判定在、闸门不在。
二、两个最容易被表象骗到的点
2.1 ssh -i 报的 Permissions 0644 are too open 是客户端在拦,不是服务端
很多人一看「私钥 0644 登录被拒」,就以为服务端 sshd 在校验私钥权限 —— 错了。
OpenSSH 对私钥权限的校验在 ssh 客户端(ssh/ssh-add)里:
私钥 group/other 可读时客户端直接拒绝加载。
而 sshd 服务端根本不看用户的私钥(它只持有自己的主机私钥),
服务端管的是 ~/.ssh、~/.ssh/authorized_keys 的属主与权限 —— 那由 StrictModes 控制。
$ sshd -T -f /run/sshd/sshd_config | grep -i strictmodes
strictmodes yes
strictmodes 千真万确等于 yes,可它管的不是这件事。
「配置里写了严格校验」≠「严格校验在执行」「严格校验管的是这个坑」。
2.2 tools 容器默认是 root,而 root 绕过 mode 位
在 root 下测「私钥可不可以被别人读」,结论永远是「读不到」——这等于没测。 本项目里这条假绿一度把排查带偏。正解是换成镜像里备好的非 root 用户:
docker compose exec -T tools su watcher -c 'head -c 26 /srv/keys/deployer_id_ed25519'
# -----BEGIN OPENSSH PRIVATE KEY
三、根因:两处独立缺陷
缺陷① app/keygen.sh 的 mode-b 分支把私钥事后改成 0644
case "$GEN" in
mode-a) chmod 600 … ;;
mode-b)
# 历史遗留:曾用 root umask 统一 chmod,私钥被设成 0644
chmod 644 /srv/keys/deployer_id_ed25519 /srv/keys/deployer_id_rsa \
/srv/keys/hop2_id_ed25519 /srv/keys/hop2_id_rsa # ← 元凶
;;
esac
gen() 用的是 ssh-keygen(OpenSSH 6.5+ 私钥默认落盘就是 0600),
是这条 case 语句事后把它改坏的。conf/app.env 里 DEPLOY_KEYGEN=mode-b,
所以每次启动都放宽一次。
危害面:同组用户、读同一挂载的邻居容器(jump/tools 都挂了 keys 卷)、
备份与日志进程,都能把私钥整把拿走。
缺陷② app/bootstrap.sh 拿到了非 0 退出码却只 echo
/srv/app/keypolicy.sh
kp_rc=$?
if [ "$kp_rc" -ne 0 ]; then
echo "[bootstrap] keypolicy 未通过(rc=$kp_rc)" # ← 只有 echo,没有 exit
fi
echo "[bootstrap] starting sshd …"
exec /usr/sbin/sshd -D -e -f /run/sshd/sshd_config # ← 照常启动
判定在、闸门不在。 问题态这一支的外层退出码是 0,随后 exec sshd 把 PID 1 换成 sshd,
于是 docker ps 里 sshd 照常 Up。日志里那句 VIOLATION 成了没人理的告示。
四、避坑:本题还有一个伪缺陷(这条比缺陷本身更有价值)
题面(README 线索、Issue #105 正文)、参考解注释、经验索引条目都写着:
keypolicy.sh把stat -c %a的八进制字符串当数值再取十进制位($(( 8#644 ))=420) → 644 被判成「属主以外不可读」→ 安全闸门恒绿。
实测不成立。 直接把脚本本体对着 4 把 0644 私钥跑:
D=/tmp/kpcheck && rm -rf $D && mkdir -p $D
for n in deployer_id_ed25519 deployer_id_rsa hop2_id_ed25519 hop2_id_rsa; do echo k > $D/$n; done
chmod 644 $D/*
DEPLOY_KEY_DIR=$D KEEP_PRIVATE_KEYS_UNREADABLE=1 bash app/keypolicy.sh; echo "EXIT=$?"
[keypolicy] VIOLATION: 私钥可被非属主读取: /tmp/kpcheck/deployer_id_ed25519 (0644)
[keypolicy] VIOLATION: 私钥可被非属主读取: /tmp/kpcheck/deployer_id_rsa (0644)
[keypolicy] VIOLATION: 私钥可被非属主读取: /tmp/kpcheck/hop2_id_ed25519 (0644)
[keypolicy] VIOLATION: 私钥可被非属主读取: /tmp/kpcheck/hop2_id_rsa (0644)
[keypolicy] 私钥保管策略未满足,拒绝启动
EXIT=9
闸门是好的,根本不恒绿。逐值对照三种实现(bash 5.2):
| mode | 当前实现 v=$(( 8#$mode )) + 位运算 | 按字符取位 8#${mode:1:1} | 注释所说的错法 (mode/10)%10 |
|---|---|---|---|
| 600 | OK | OK | OK |
| 640 | VIOLATION | VIOLATION | VIOLATION |
| 604 | VIOLATION | VIOLATION | VIOLATION |
| 644 | VIOLATION | VIOLATION | VIOLATION |
| 660 | VIOLATION | VIOLATION | VIOLATION |
| 666 | VIOLATION | VIOLATION | VIOLATION |
| 755 | VIOLATION | VIOLATION | VIOLATION |
| 777 | VIOLATION | VIOLATION | VIOLATION |
前两列完全一致 —— 8# + 位运算与「按字符取位」是等价实现,
代码里从来没有过「把八进制当十进制取位」的写法。那段注释描述的是
「另一种写法会怎么错」,却被当成了当前代码的真实行为。
补一句:即使真写成注释里的错法,644 也不会被判成合规(十位 4、个位 4 都带读位)。 更正:我原先在这里举的「漏检用例 604」是错的,已删除。
mode=604时(604/10)%10=0、604%10=4 →(oth&4)=4 非 0,照样判 VIOLATION (实跑:对 4 把 0604 私钥bash app/keypolicy.sh→VIOLATION×4、EXIT=9),根本没被漏掉。穷举 512 个三位 mode 后的准确口径(三层写法与现行
v=$(( 8#$mode ))+右移位 对比):
写法 判定不一致数 其中漏检 举例 8#${mode:1:1}按字符取位0 / 512 0 —— 真正等价,可互相替换 (mode/10)%10、mode%10十进制取位20 / 512 14 014、024、034v=$(( 10#$mode ))整体十进制再位运算164 / 512 82 016、064、136关键细节:
(mode/10)%10在 bash 里不是「按字符取第 2 位」。mode是字符串,裸用变量名参与$(( ))时前导0触发八进制解析 (mode=014→ 数值 12),于是(12/10)%10=1、12%10=2, 与真实权限位(grp=1、oth=4)对不上 —— 这才是它漏检014的原因。 「整体当十进制数值」得用10#$mode强制,漏检面更大。结论修正:注释里那种写法确实是错的(但错的用例是
014这类,不是 604), 只是当前代码没有用它 —— 所以「安全闸门恒绿」这个论断仍然不成立。
结论:本题真实的独立缺陷是 2 处,不是 3 处。 Issue #105 的正文不可编辑,已在评论中更正。
通用教训(值得单独记一条):stat -c %a 返回 644 这样的八进制字符串,而 $(( 8#644 ))、8#${mode:1:1}、
grp=$(( (v>>3)&7 )) 这些写法彼此等价、都对 644 出正确结论。
于是很容易写出一段「看起来在解释 bug、其实和代码行为无关」的注释,
再被下游文档抄成「已知缺陷」。
注释里描述的机制,必须用一次可执行的对照实验去证伪 —— 本次就是靠上面那张 8 个 mode 值的三列对照表推翻它的。
五、修法
只改 app/ 下三个文件(conf/ 契约键与 ops/verify.sh 一字未动)。
① keygen:落盘即设权限,且不要对已存在的私钥无脑 chmod
删掉 mode-b 那段无条件 chmod 644,DEPLOY_KEYGEN 只作为留痕字段回显;
把权限钉在「密钥落盘那一刻」:
gen() {
local path="$1"
[ -f "$path" ] && return 0
# umask 可能是 022(放开同组/其他),落盘后立即收紧
( umask 077; ssh-keygen -q -t ed25519 -N '' -C "$(basename "$path")" -f "$path" >/dev/null )
chmod 600 "$path"
chmod 644 "$path.pub" 2>/dev/null || true
}
这里踩了本轮最大的坑:第一版我在删掉
mode-b之后,顺手加了一句 「对 4 把私钥无条件chmod 600」兜底。结果verify-host.sh只从FAIL=3降到FAIL=1—— A1/A2 绿了,但 B3 恒红(容器仍running 0,期望exited 9)。 原因:无条件chmod 600在启动时把「卷上被人改回 0644」的坏现场静默抹平,keypolicy永远拿不到坏输入 —— 闸门是好的,但它拿到的输入永远是好的。正确分工:
keygen负责"落盘那一刻就对",keypolicy负责"发现不合规就大声拒绝启动"。 通用结论:权限/配置类修复要区分「新建时设对」与「发现时修正」,后者会让安全控制退化成摆设。
② bootstrap:把退出码真的用起来
- /srv/app/keypolicy.sh
- kp_rc=$?
- if [ "$kp_rc" -ne 0 ]; then
- echo "[bootstrap] keypolicy 未通过(rc=$kp_rc)"
- fi
+ /srv/app/keypolicy.sh
+ kp_rc=$?
+ if [ "$kp_rc" -ne 0 ]; then
+ echo "[bootstrap] FATAL: 私钥保管策略未满足(keypolicy rc=$kp_rc),拒绝启动 sshd" >&2
+ exit "$kp_rc"
+ fi
两处与参考解不同的取舍:exit "$kp_rc" 而非硬编码 exit 9(容器退出码直接携带闸门判定结果,
排障不用翻日志);不做"只拦 rc=9、其他放过"(闸门自身异常也必须拒绝启动,
否则「闸门坏了」等价于「没有闸门」)。
③ keypolicy:保留位权式,补 ${mode: -3} 兼容 4 位 mode(代码语义未变)
参考解把判定改成「按字符取位」8#${mode:1:1}。经穷举验证这与原实现在 3 位 mode 下完全等价,
但在 4 位 mode 下会错位(4600 会被误判为"组可读")。
所以本轮有意不采用该写法,只补一处稳健性:
- v=$(( 8#$mode )) # 4 位 mode(如 4600)会让 bash 报 arithmetic expression: expecting EOF
+ v=$(( 8#${mode: -3} )) # 先剥掉 setuid/setgid/sticky 位,再按位权解析
grp=$(( (v >> 3) & 7 ))
oth=$(( v & 7 ))
第三轮补记(fix_round=3):第一、二轮只改了文档三处,却漏了会继续分发的代码注释 —— 于是「文档说 604 反例已删」和「
app/keypolicy.sh/solution/*注释里 604 还在」同时成立, 正好又踩中本题的核心教训。第三轮把三处注释逐字统一(app/keypolicy.sh、solution/keypolicy.sh.fixed、solution/keypolicy.sh.problem),仍只动注释、代码零改动。 同时把口径再收紧一层(见踩坑第 9 条):stat -c %a会抹掉前导 0, 所以「014会被漏检」只在手动补足三位时成立;对stat真实输出(14)来说,(mode/10)%10这种写法与正解结果一致,真正会漏检644的是整体十进制那种写法。
六、验证
6.1 脚本本体级实测(不依赖容器)
| 断言 | 实测输出 | 结论 |
|---|---|---|
| 闸门对 0644 有牙 | VIOLATION×4,EXIT=9 | 通过(同时是「缺陷③是伪缺陷」的正面证据) |
| 闸门不误报(600) | 私钥保管策略满足,EXIT=0 | 通过 |
| bootstrap 退出码不传导 | 问题态分支:starting sshd + 外层 rc=0 | 缺陷②复现成功 |
| bootstrap 退出码传导 | 修复分支:FATAL: …拒绝启动 + 外层 rc=9 | 修复有效 |
| keygen 权限链 | 600 → chmod 644 → 644;修复路径保持 600 | 缺陷①/修复均成立 |
| 语法 | bash -n app/*.sh solution/*.sh 全部无输出 | 通过 |
6.2 容器级验收(docker 可用,全部实跑)
cd scenario/2026-09-29-0800
docker compose down -v && docker compose up -d --build
bash ops/verify-host.sh
问题态实跑 PASS=12 FAIL=3 / RESULT: BAD,红的三条正是 A1 / A2 / B3:
[FAIL] A1 私钥仍可被属主以外读取(0644),未收敛为 0600
[FAIL] A2 非属主进程仍能读走私钥内容(真实暴露未消除):-----BEGIN OPENSSH PRIVATE KEY-…
[FAIL] B3 闸门是「纸糊的」:keypolicy 已判定不合规(日志出现「keypolicy 未通过」),sshd 却照常监听
修复后实跑 PASS=15 FAIL=0 / RESULT: OK;ops/repro.sh 由 RESULT: BAD → RESULT: OK。
特别注意 B1:verify.sh 专门造一个 /tmp/p644 现场(4 把 0644 私钥)喂给 keypolicy,
要求它报 VIOLATION + RC=9。它在问题态就是 PASS ——
如果「闸门恒绿」的说法成立,B1 在问题态就该是红的。这是伪缺陷的又一条结构性证据。
B3 闸门回归(本轮修复的核心证据):在容器外把卷上 4 把私钥改成 0644,
再 docker compose up -d --force-recreate sshd:
docker inspect 容器状态 | 日志 | |
|---|---|---|
| 修复前 | running 0 | [bootstrap] keypolicy 未通过(rc=9) → Server listening on 0.0.0.0 port 2222. |
| 修复后 | exited 9 | [bootstrap] FATAL: 私钥保管策略未满足(keypolicy rc=9),拒绝启动 sshd |
非属主读取(tools 容器内):
# 问题态
$ docker exec 3day-89-tools sh -c "su watcher -c 'head -c 30 /srv/keys/deployer_id_ed25519'"
-----BEGIN OPENSSH PRIVATE KEY
# 修复后
head: cannot open '/srv/keys/deployer_id_ed25519' for reading: Permission denied
七、可复现命令(最短路径)
git clone -b 2026-09-29-0800 https://cnb.cool/zhenxijs/3dayTest/3day-test.git
cd 3day-test/scenario/2026-09-29-0800
# ── 有 docker ──
docker compose down -v && docker compose up -d --build
bash ops/verify-host.sh # 问题态:PASS=12 FAIL=3 / RESULT: BAD
docker compose exec -T tools /srv/ops/repro.sh # 问题态:RESULT: BAD
bash ops/apply_fix.sh --apply
docker compose down -v && docker compose up -d --build
bash ops/verify-host.sh # 参考解:PASS=15 FAIL=0 / RESULT: OK
bash ops/apply_fix.sh --restore
# ── 回看问题态(分支 HEAD 已含修复)──
git stash push -- app/ && docker compose down -v && docker compose up -d --build
bash ops/verify-host.sh # 问题态:PASS=12 FAIL=3 / RESULT: BAD
docker compose exec -T tools /srv/ops/repro.sh # 问题态:RESULT: BAD
git stash pop && docker compose down -v && docker compose up -d --build
bash ops/verify-host.sh # 修复后:PASS=15 FAIL=0 / RESULT: OK
docker compose exec -T tools /srv/ops/repro.sh # 修复后:RESULT: OK
# ── 无 docker 也行:直接验闸门口径(脚本本体不依赖容器)──
D=/tmp/kpcheck && rm -rf $D && mkdir -p $D
for n in deployer_id_ed25519 deployer_id_rsa hop2_id_ed25519 hop2_id_rsa; do echo k > $D/$n; done
chmod 644 $D/*
DEPLOY_KEY_DIR=$D KEEP_PRIVATE_KEYS_UNREADABLE=1 bash app/keypolicy.sh; echo "EXIT=$?"
# 期望:VIOLATION ×4 + EXIT=9
八、踩坑清单(11 条)
- 【本轮最大坑】"顺手修好权限"反而让闸门再也测不出来。
第一版修改删掉
mode-b后,对 4 把私钥无条件chmod 600兜底。 现象:verify-host.sh只从PASS=12 FAIL=3走到PASS=14 FAIL=1,B3 恒红, 容器仍running 0(期望exited 9)。原因:无条件chmod 600在启动时把 「卷上被人改回 0644」的坏现场静默抹平,闸门永远拿不到坏输入。 → 权限修复要落在"新建那一刻",发现不合规交给闸门大声拒绝。 bash -c "…cmd || echo $?"会把$?提前展开。 外层 shell 先展开成0, 看起来像「闸门 rc=0」,足以得出与事实相反的结论。改用单引号包住整条命令。stat -c %a在带 setuid/setgid/sticky 时输出 4 位,而 bash 的8#只吃 3 位。chmod 4755 f→stat -c %a f=4755,$(( 8#4755 ))直接报arithmetic expression: expecting EOF。稳妥写法v=$(( 8#${mode: -3} ))先剥特殊位。 这也解释了第四条踩坑的来源。- 「按字符取位」在 4 位 mode 下会错位 —— 而参考解正是这么写的。
8#${mode:1:1}固定取第 2 个字符,4600(setuid+rw-------) 会被取成"组位=6"→ 误判为可读; 而位权式(v>>3)&7对4600判「不可读」是正确的。 对 3 位 mode 两者等价(穷举 512 种 0 处不同),差异只在 4 位时暴露。 → 别照着"看起来更直观"的写法改,先穷举验证。 tools默认 root,root 绕过 mode 位。 权限类断言必须用非 root 用户(镜像里备了watcher)。 另外su watcher -c head ...读失败时内容走 stderr,判定要用 「输出里是否包含BEGIN OPENSSH PRIVATE KEY」,而不是「输出是否为空」。stat -c %a八进制字符串的多种解析写法极易写错、也极易写出无关注释。 看起来都在「解释 bug」的写法,其实只有部分等价,必须用可执行的对照实验证伪 —— 但样本必须覆盖整个取值空间。我第一次只挑 8 个好看的 mode 就下结论,踩了第 8 条的坑。docker restart≠--force-recreate,--build才是换代码。app/是COPY app/ /srv/app/进镜像的,改完宿主源码后restart/--force-recreate跑的都还是旧镜像。 更隐蔽的是verify.sh的 B3 故意在卷上把私钥改坏再重建, 若keygen里留着「无条件chmod 600」,坏现场会被静默抹平,B3 永远测不出闸门死活。env_file只对声明了它的 service 生效。tools拿不到KEEP_PRIVATE_KEYS_UNREADABLE时,keypolicy.sh走的是私钥可读性约束未开启,跳过检查(仅做审计)+exit 0—— 「静默跳过」而不是「报错」。排查权限问题前先确认被测容器真的拿到了契约键。- 「随手挑几个样本」比「不验证」更危险 —— 反例必须靠穷举。
我第一次举的反例 mode=604 就是错的:
(604/10)%10=0、604%10=4,oth&4=4 非 0, 照样判 VIOLATION(实跑VIOLATION×4 +EXIT=9),根本没被漏掉。 而且 bash 有隐式八进制:mode是字符串,裸用变量名进$(( ))时前导0触发八进制解析, 所以(mode/10)%10不是「按字符取第 2 位」,8#${mode:1:1}才是。 穷举 512 个三位 mode 的最终口径(与v=$(( 8#$mode ))+右移位 对比):
| 写法 | 判定不一致 | 其中漏检 | 举例 |
|---|---|---|---|
8#${mode:1:1} 按字符取位 | 0 / 512 | 0 | 真等价,可互换 |
(mode/10)%10、mode%10 十进制取位 | 20 / 512 | 14 | 014、024、034 |
v=$(( 10#$mode )) 整体十进制再位运算 | 164 / 512 | 82 | 016、064、136 |
教训:说「某个反例会出错」时,样本量要覆盖整个取值空间; 只挑几个「看起来典型」的值,很容易把「碰巧也判对」当成「会被漏掉」。
- 「文档改对了」≠「注释也改对了」—— 纠正结论时要扫干净所有分发副本。
我在第一、二轮把 604 反例从 SOLUTION.md / 经验文档 / 索引条目里删了,
却漏了
app/keypolicy.sh、solution/keypolicy.sh.fixed、solution/keypolicy.sh.problem三处注释(后两者还会被apply_fix.sh拷进app/继续分发)。 结果同一要点在不同文件里给出互相矛盾的结论 —— 这恰恰是本题一直在警告的「注释里写的根因 ≠ 代码的根因」。 另外,stat -c %a不补前导 0(chmod 0014→"14"、chmod 0004→"4"), 所以「三位补零」换来的漏检结论不能直接搬到生产语境: 对真实stat输出,(mode/10)%10与正解结果一致,真正会放过644的是整体十进制写法。 教训:写「某写法会错」时,要把「在什么输入形态下会错」一并写清; 纠正一个结论,要用grep -rn 关键词扫全部副本(含注释、含将被拷走的模板文件)后再收工。 - 「文档里的实测数字」必须能被第三方一键复现。 早期一版是在无 docker 的环境里写的,只能静态推演,却把推演值写成了"实测记录"。 本轮环境有 docker,已把所有数字换成可直接粘贴的原始输出,并写明环境版本。 → 凡是写"实测"二字,就要能被别人一条命令复现出来。
九、同类影响面
- 任何「有判定、没有动作」的准入控制:
if ! check; then echo ...; fi后照常放行; - 任何把子脚本退出码只写进日志、不进
exit的entrypoint/supervisor/ 启动包装器; - 云上「安全组规则存在但没绑到实例」「策略已配置但没 attach」;
- CI 里的
allow_failure: true、|| true、set +e把质量门变成告示牌; - 文件权限类自检脚本中「新建时设对」与「发现时修正」不分(后者会让安全控制退化成摆设)。
十、一句话结论
「配置里写了严格校验」≠「严格校验在执行」(sshd -T 里 strictmodes=yes 千真万确,
可它管的是服务端 ~/.ssh/authorized_keys,本场景的漏洞不归它管);
「闸门打了 VIOLATION」≠「闸门拦住了」(退出码没有被用起来时,日志再刺眼也拦不住 exec sshd)。
外加本轮实跑坐实的第三条:「注释里写的根因」≠「代码的根因」 ——
本题那份注释描述的缺陷根本不存在(穷举 512 种 mode 0 处不一致),
实测证伪后应当如实更正,而不是改代码去迎合注释。
第四条(本轮踩得最疼):「顺手加的兜底修正」可能是最隐蔽的破坏 ——
启动时无条件 chmod 600 会把坏现场抹平,让安全闸门永远拿不到坏输入,
"修好权限"反而使"闸门真会拦"这条断言永久为红。
最终结果:ops/verify-host.sh PASS=12 FAIL=3 / RESULT: BAD → PASS=15 FAIL=0 / RESULT: OK;
ops/repro.sh RESULT: BAD → RESULT: OK;干净重建可重复;仅改动 app/ 三个文件。
