你写了一段"自动写配置"的脚本:用一条带密码的 sudo 去提权,再用 heredoc 把一段配置内容写进目标文件。跑完看着很成功,可检查目标文件时,里面平白多出一行莫名其妙的字符串;更糟的是,这份配置被解析成了"对全网段开放",等于把你的密码暴露给了整个局域网。
现象
- 执行完脚本后,目标配置文件里多出一行奇怪的字符串——看起来正是你的 root 密码;
- 该配置随后被解析成一条"对全网段开放"的导出项,生效后对局域网敞开了;
- 脚本本身没有报错,退出码正常,所以你以为"成功了"。
这类事故的可怕之处在于:它不报错,而且立刻生效。 密码被当成业务数据写进了对外的配置文件。
根因
根因是 stdin(标准输入)被两个东西抢着读,而它们互不知情。
你用的 sudo 封装通常是这种形式——用管道把密码喂给 sudo -S:
echo "$PASS" | sudo -S some_command
-S 的含义是"从标准输入读密码"。所以这条命令的 stdin 已经被密码占用了。
当这个封装再叠加一个 heredoc(用来提供要写入的文件内容)时,问题就来了:
echo "$PASS" | sudo -S bash -c "cat <<EOF > /etc/exports
/srv/data *(rw,sync)
EOF"
heredoc 的内容本意是"要写进文件的数据",但它同样是以 stdin 的形式喂给 bash -c 里的 cat 的。于是同一条管道里,stdin 上同时排着两样东西:一个是 echo 出来的密码,一个是 heredoc 的内容。
结果就是:cat 读到的第一个"行"其实是密码(因为它在管道最前面),它把密码当成了 heredoc 的第一行内容,写进了 /etc/exports。而真正的配置内容可能错位、缺失,或者把密码单独顶成了一条导出项——那条导出项往往形如"某网段可读写",一旦生效,就是对全网开放。
一句话机制:stdin 是独占资源。 管道和 heredoc 都想从它取数据,先到先得,另一方拿到的是错的东西。而 -S 又恰好把密码放在了这股竞争里。
解决
有四条纪律,按优先级排:
① 绝不给"管道喂密码的 sudo"再叠加 heredoc 或 stdin 重定向。 这是最根本的一条——只要不让 stdin 被两方争抢,事故就不会发生。
② 要写 root 文件,先在临时目录用普通权限拼好内容,再 sudo 拷贝过去。 推荐流程:
# 1. 在临时目录(普通权限即可)把内容写好
cat > /tmp/exports.new <<'EOF'
/srv/data <内网网段>(rw,sync)
EOF
# 2. 用 sudo 把它安装到目标位置(cp 只搬运文件,不占用 stdin 传内容)
echo "$PASS" | sudo -S -p '' cp /tmp/exports.new /etc/exports
# 3. 收尾
rm -f /tmp/exports.new
这里 cp 的输入是"文件路径参数",内容来自文件本身,不经过 stdin,所以与 -S 读密码不会冲突。
③ 改完必须验证真实生效的配置,而不是你以为写进去的内容。 对 NFS 导出这类配置,用它的专用查看命令:
sudo exportfs -v # 查看实际生效的导出项
showmount -e <IP> # 从客户端侧确认导出列表
这两条命令会告诉你内核/服务实际认了什么,而不是文件里"看起来"写了什么。写文件和生效是两件事,必须验证后者。
④ 出事后,先把被污染的版本备份留证,再重写并重新加载。 别急着覆盖——备份一份现场,方便排查是哪一步写错的:
sudo cp /etc/exports /etc/exports.broken.bak
然后重写正确内容、重新加载服务(如 sudo exportfs -ra),最后再次用 exportfs -v 确认。
延伸与预防
这条坑的普适教训是:stdin 是独占资源,两个东西同时想读它,一定有一方拿到错的东西。 凡是"管道 + heredoc + 需要 stdin 的命令"凑在一起的写法,都要警惕这场输入竞争。
几条可复用的原则:
- 提权写文件,优先"先拼内容、再搬运",而不是"边提权边用 heredoc 生成"。
- 改完配置永远要看"生效视图",别只看文件内容——
exportfs -v、nginx -T、ss -tlnp这类命令才是真相。 - 远端无 tty 时,
echo '<密码>' | sudo -S -p '' 命令是常用手法,但正因它占用 stdin,绝不能和 heredoc 混用。
一句话:别让两样数据去抢同一个输入通道。