一次常规的服务账户改密,passwd 回显“成功”,可这个账户再也没登进去。问题不在 PAM、不在键盘布局、也不在锁定策略——在双引号:$ 后面那串名字被 shell 当成变量,展开成了空串。下面是完整复盘。
| 项目 | 内容 |
|---|---|
| 案例类型 | 变更事故(口令变更 · 人为操作) |
| 发生时间 | 2026-11-05 |
| 系统环境 | CentOS 7(RHEL 系),root 执行 passwd --stdin |
| 涉及账户 | svc-ops(服务账户,示例已脱敏) |
| 影响 | 该账户 8 分钟内无法登录;有带外通道,未造成业务中断 |
| 定级 | P2(可自行推导恢复) |
| 根因 | shell 双引号内的 $变量 被参数展开,口令被静默截短 |
| 关键词 | 参数展开 · passwd --stdin · 口令保管 · 变更验证 |
本案例口令全部为虚构示例:意图口令
Sv9@kL$DbPass7#nW,实际生效口令Sv9@kL#nW。
一、现象
改密命令回显”成功”,但用原样口令登录被拒;再用”看起来完整”的口令试几次仍然被拒。
[root@node01 ~]# echo "Sv9@kL$DbPass7#nW" | passwd --stdin svc-ops
Changing password for user svc-ops.
passwd: all authentication tokens updated successfully.
[root@node01 ~]# ssh svc-ops@10.20.30.40
svc-ops@10.20.30.40's password: # 照抄 Sv9@kL$DbPass7#nW
Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password).
典型误判顺序:先怀疑键盘布局 / 输入法 / CapsLock → 再怀疑 PAM 拒绝弱口令 → 再怀疑 pam_tally2 锁号 → 都没问题,于是卡住。
二、时间线
| 时间 | 动作 | 结果 |
|---|---|---|
| T+0 | echo "...$..." | passwd --stdin svc-ops |
回显成功 |
| T+1 | SSH 登录(原口令) | Permission denied |
| T+3 | 排除 CapsLock / 输入法 / 全角 | 无效 |
| T+5 | 查 secure 日志、pam_tally2 |
无锁定、无拒绝记录 |
| T+6 | 按”双引号变量展开”规则反推候选 | 得到 Sv9@kL#nW |
| T+8 | 用候选口令登录 | 成功,定位完成 |
三、最小复现
$ bash -c 'echo "Sv9@kL$DbPass7#nW"'
Sv9@kL#nW
只要 DbPass7 这个变量不存在,它就被替换成空串——命令不报错,口令静默变短。
对照一下三种引号的差别:
$ bash -c 'echo "Sv9@kL$DbPass7#nW"' # 双引号:展开 → Sv9@kL#nW
$ bash -c "echo 'Sv9@kL\$DbPass7#nW'" # 单引号:原样 → Sv9@kL$DbPass7#nW
$ printf '%s\n' 'Sv9@kL$DbPass7#nW' # printf + 单引号:原样(推荐)
四、根因
4.1 规则
POSIX 规定:双引号内仍保留特殊含义的只有 $、`、\ 和 " 本身(交互式 bash 还有 ! 历史展开)。其余字符——包括 #、%、@、空格——在双引号里都是字面量。
$ 后面紧跟名字时,shell 取最长的合法标识符作为变量名,合法字符是 [A-Za-z0-9_]:
"Sv9@kL" + "$DbPass7" + "#nW"
└── 变量名取到 D b P a s s 7 结束(下一个字符是 #)
DbPass7 未定义 → 展开为空串 → 实际口令 Sv9@kL#nW。
4.2 边界在哪
| 意图口令 | 变量名 | 原因 | 实际生效 |
|---|---|---|---|
Sv9@kL$DbPass7#nW |
DbPass7 |
# 不属于名字 |
Sv9@kL#nW |
Sv9@kL$Db-Pass7#nW |
Db |
- 不属于名字 |
Sv9@kL-Pass7#nW |
Sv9@kL${DbPass7}#nW |
DbPass7 |
花括号明确边界 | Sv9@kL#nW |
4.3 为什么没人发现
- 回显是成功:
passwd --stdin拿到一行就改,改的正是它收到的那行,所以必然成功。 set -x帮不上忙:调试输出打印的是展开后的 argv,看到的就是echo Sv9@kL#nW,反而掩盖了原意。要看原样文本得用set -v(打印读入的原始行)或命令审计日志。- 残留口令仍然”像密码”:
Sv9@kL#nW有#、@、大小写混排,肉眼不像残缺。
五、处置路径(按代价从低到高)
① 按规则反推候选 → 直接试登录(本案例走的是这条,8 分钟恢复)
② 还有 root 会话(tmux / 其他终端)→ passwd svc-ops 交互式重设
③ 只有 SSH 且 root 被禁 → 云控制台 VNC、IPMI、串口 console
④ 彻底进不去 → 单用户模式(rd.break)改 root 再 passwd;或挂 rescue 盘
反推候选的算法很简单:把 $ 后面那串 [A-Za-z0-9_] 整段删掉,剩下的就是实际口令。变量越多,候选越多,但仍是有限枚举。
六、验证:用 shadow 的盐值反查
恢复前想确认”到底改成了什么”,不必反复试登录,可以用 /etc/shadow 里的盐重新算一遍(需 root):
# 1. 取出哈希
[root@node01 ~]# awk -F: '$1=="svc-ops"{print $2}' /etc/shadow
$6$abcd1234$q.3Bzdf7UWVY5.fpDKH9Bz6ZxlVMENwQntib/0zPHR.L1f0Kc0W1GwY7.vxWtwdQlITZY/mHqYNaYPfRx2j7D.
# 2. 用候选口令 + 同一盐值重算,逐字比对
[root@node01 ~]# openssl passwd -6 -salt abcd1234 'Sv9@kL#nW'
$6$abcd1234$q.3Bzdf7UWVY5.fpDKH9Bz6ZxlVMENwQntib/0zPHR.L1f0Kc0W1GwY7.vxWtwdQlITZY/mHqYNaYPfRx2j7D.
前缀决定算法:$6$ → openssl passwd -6(sha512,CentOS 7 默认),$5$ → -5,$1$ → -1。哈希以 ! 或 * 开头表示账户被锁,不能当口令判断依据。
七、正确写法对照
| 写法 | 是否安全 | 说明 |
|---|---|---|
echo "…$X…" | passwd --stdin u |
❌ | 本案例;$X 被展开 |
echo '…$X…' | passwd --stdin u |
✅ | 单引号内不展开 |
printf '%s\n' '…$X…' | passwd --stdin u |
✅ 推荐 | 不受 -n/-e/反斜杠影响 |
printf 'u:%s\n' '…$X…' | chpasswd |
✅ 推荐 | 免二次输入,适合批量;口令内不能含 : |
passwd u 交互式 |
✅ 首选 | 全程不经 shell,人少时最稳 |
cat <<'EOF' | passwd --stdin u |
✅ | 引号版 heredoc 不展开 |
| 从 600 权限文件读 | ✅ | 用于批量,用完 shred -u |
八、相邻陷阱清单
| 陷阱 | 表现 | 规避 |
|---|---|---|
反引号 ` 在双引号内 |
被当命令替换执行 | 口令含反引号必须单引号 |
! 历史展开(交互 bash) |
event not found,或命令被改写 |
单引号;或 set +H |
\ 在双引号内 |
对 $、`、"、\ 仍具转义作用 |
单引号 |
chpasswd 遇 : |
字段错位,用户名/口令被切分 | 口令禁用 :,或改用 passwd --stdin |
| Debian/Ubuntu | 无 passwd --stdin |
用 chpasswd |
| Windows 编辑过的脚本 | 行尾 \r 混入口令 |
dos2unix 或 tr -d '\r' |
sh(dash)里的 echo |
会解释 -n、\ |
一律 printf |
九、预防清单
P0
- 改密命令中禁止出现”双引号 +
$“的组合;脚本里口令一律走单引号 /printf/ 引号版 heredoc。 - 改密后必须做一次真实登录验证(
su - u -c true,或从其他主机 SSH),不能只看passwd的回显。
P1
- 改密脚本加
--dry-run:只打印”将要写入的字符串长度 + 命中的字符集”,不打印明文,长度不符即中止。 - 变更记录只记口令指纹(如
sha256sum前 8 位),不落明文,便于事后确认”到底改了哪个版本”。 - 生产改密走双人复核;口令临时值放密码保管箱,不走聊天工具。
P2
- 定期核查带外通道(VNC / IPMI / 串口)可用性,避免”只能靠密码”。
- 服务账户至少保留一条密钥登录通道,口令变更失败时不至于彻底失联。
十、自测题
1.(单选)echo "ab$Cd9x#ef" | passwd --stdin u,实际写入的口令是?
A. ab$Cd9x#ef B. ab9x#ef C. ab#ef D. abCd9x#ef
2.(判断)双引号里的 # 会被当作注释,注释之后的内容被丢弃。
3.(填空)echo "a$B-c$D9" 中,第一个被展开的变量名是 ______。
4.(单选)在 bash 中查看”变量展开前的原始行”,应使用?
A. set -x B. set -v C. set -e D. set -u
5.(场景)chpasswd 批量改密时报 line 3: missing new password,最可能的原因是什么?
6.(场景)账户登录失败,/etc/shadow 里哈希为 $6$Xk9p$…,且以 ! 开头。这说明什么?能否据此判断口令被改错过?
参考答案
- C。
$Cd9x是完整变量名(#终止名字),未定义则空,剩ab#ef。 - 错。双引号内
#只是普通字符,不构成注释。 B(-不是名字字符,名字在B处结束);第二次是D9。- B。
-v打印读入的原始行;-x打印的是展开后的 argv,正好掩盖问题。 - 口令里含
:,chpasswd按用户名:口令切分时字段错位;也可能该行含空白/CRLF。 !前缀表示账户被passwd -l或 PAM 锁定,不能据此判断口令是否被改错;需先解锁再验证口令。
一句话总结:双引号替 shell 留了一个后门——$ 后面那串名字被整段换成空,口令”改成功了”,但改成了另一个值;口令进 shell,只用单引号。
