朕喜技术(ZhenxiJS)
2026.09.29
1 分钟阅读

SSH 私钥被 chmod 644 却能登录成功?—— 权限闸门两处失效(keygen 放开私钥权限 / bootstrap 吞掉退出码)

关键词:SSH 私钥权限 0644、Permissions 0644 for 'xxx' are too open、私钥权限太开放、 ssh 免密登录突然失败、StrictModes、sshd -T 生效配置、Include 覆盖 sshd_config、 stat -c %a 八进制字符串、bash 8# 只接受 3 位八进制、setuid 4 位 mode、chmod 644、 sshd 拒绝启动、退出码未传导、安全闸门失效、keypolicy、entrypoint 吞掉退出码、 root 绕过 mode 位、su watcher、docker restart vs --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
600OKOKOK
640VIOLATIONVIOLATIONVIOLATION
604VIOLATIONVIOLATIONVIOLATION
644VIOLATIONVIOLATIONVIOLATION
660VIOLATIONVIOLATIONVIOLATION
666VIOLATIONVIOLATIONVIOLATION
755VIOLATIONVIOLATIONVIOLATION
777VIOLATIONVIOLATIONVIOLATION

前两列完全一致 —— 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 / 5120—— 真正等价,可互相替换
(mode/10)%10、mode%10 十进制取位20 / 51214014、024、034
v=$(( 10#$mode )) 整体十进制再位运算164 / 51282016、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 条)

  1. 【本轮最大坑】"顺手修好权限"反而让闸门再也测不出来。 第一版修改删掉 mode-b 后,对 4 把私钥无条件 chmod 600 兜底。 现象:verify-host.sh 只从 PASS=12 FAIL=3 走到 PASS=14 FAIL=1,B3 恒红, 容器仍 running 0(期望 exited 9)。原因:无条件 chmod 600 在启动时把 「卷上被人改回 0644」的坏现场静默抹平,闸门永远拿不到坏输入。 → 权限修复要落在"新建那一刻",发现不合规交给闸门大声拒绝。
  2. bash -c "…cmd || echo $?" 会把 $? 提前展开。 外层 shell 先展开成 0, 看起来像「闸门 rc=0」,足以得出与事实相反的结论。改用单引号包住整条命令。
  3. 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. 「按字符取位」在 4 位 mode 下会错位 —— 而参考解正是这么写的。8#${mode:1:1} 固定取第 2 个字符,4600(setuid+rw-------) 会被取成"组位=6"→ 误判为可读; 而位权式 (v>>3)&7 对 4600 判「不可读」是正确的。 对 3 位 mode 两者等价(穷举 512 种 0 处不同),差异只在 4 位时暴露。 → 别照着"看起来更直观"的写法改,先穷举验证。
  5. tools 默认 root,root 绕过 mode 位。 权限类断言必须用非 root 用户(镜像里备了 watcher)。 另外 su watcher -c head ... 读失败时内容走 stderr,判定要用 「输出里是否包含 BEGIN OPENSSH PRIVATE KEY」,而不是「输出是否为空」。
  6. stat -c %a 八进制字符串的多种解析写法极易写错、也极易写出无关注释。 看起来都在「解释 bug」的写法,其实只有部分等价,必须用可执行的对照实验证伪 —— 但样本必须覆盖整个取值空间。我第一次只挑 8 个好看的 mode 就下结论,踩了第 8 条的坑。
  7. docker restart ≠ --force-recreate,--build 才是换代码。app/ 是 COPY app/ /srv/app/ 进镜像的,改完宿主源码后 restart/--force-recreate 跑的都还是旧镜像。 更隐蔽的是 verify.sh 的 B3 故意在卷上把私钥改坏再重建, 若 keygen 里留着「无条件 chmod 600」,坏现场会被静默抹平,B3 永远测不出闸门死活。
  8. env_file 只对声明了它的 service 生效。tools 拿不到 KEEP_PRIVATE_KEYS_UNREADABLE 时,keypolicy.sh 走的是 私钥可读性约束未开启,跳过检查(仅做审计) + exit 0 —— 「静默跳过」而不是「报错」。排查权限问题前先确认被测容器真的拿到了契约键。
  9. 「随手挑几个样本」比「不验证」更危险 —— 反例必须靠穷举。 我第一次举的反例 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 / 5120真等价,可互换
(mode/10)%10、mode%10 十进制取位20 / 51214014、024、034
v=$(( 10#$mode )) 整体十进制再位运算164 / 51282016、064、136

教训:说「某个反例会出错」时,样本量要覆盖整个取值空间; 只挑几个「看起来典型」的值,很容易把「碰巧也判对」当成「会被漏掉」。

  1. 「文档改对了」≠「注释也改对了」—— 纠正结论时要扫干净所有分发副本。 我在第一、二轮把 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 关键词 扫全部副本(含注释、含将被拷走的模板文件)后再收工。
  2. 「文档里的实测数字」必须能被第三方一键复现。 早期一版是在无 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/ 三个文件。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-29-0800-chmod-644-ssh

分享这篇文章