先复现:加了 sudo 仍然 Permission denied
sudo echo "127.0.0.1 api.local" >> /etc/hosts
终端返回:
bash: /etc/hosts: Permission denied
注意报错前缀是 bash,不是 sudo。这一行信息已经把责任方交代清楚了:拒绝访问发生在 shell 尝试打开 /etc/hosts 的时候,此时 sudo 还没被启动。把同样的内容改用 sudo tee -a /etc/hosts 写入就一切正常,说明 sudo 本身没坏,问题出在 >> 上。
根因:重定向由当前 shell 完成
在 Linux 上执行一条带重定向的命令,顺序大致是这样:
- shell 解析命令行,识别出
>>和后面的文件名; - shell 调用
open(),以当前用户身份打开/etc/hosts,拿到文件描述符; - shell
fork()出子进程,把这个描述符复制到子进程的标准输出; - 子进程
exec()执行sudo,sudo 校验策略后再exec()真正的echo。
第 2 步就已经决定了成败。/etc/hosts 属主是 root、权限 644,普通用户没有写权限,open() 直接返回 EACCES,第 3、4 步根本不会发生。sudo 的能力边界在于它启动的那个进程——它能把该进程的 uid 换成 0,但无法追溯修改父 shell 在此前已经完成的系统调用。
一句话概括:sudo 改变的是命令进程的身份,不改变 shell 进程的身份,而重定向是 shell 干的活。
管道里的同类问题:先问“谁在访问受保护资源”
管道本身不落盘,但它把判断拆成了两半,容易看漏。
# 左边 echo 以普通用户运行,右边 tee 以 root 运行
echo "127.0.0.1 api.local" | sudo tee -a /etc/hosts
# 左边 cat 需要读权限,右边 tee 只往普通目录写
sudo cat /etc/shadow | tee /tmp/shadow.copy
# 读端提权了,写端没有
sudo cat /etc/shadow > /root/shadow.copy
判断方法只有一条:列出命令里每个真正打开文件的进程,逐个确认它有没有权限。> 和 >> 归属 shell,tee 归属管道右侧的进程,cat 归属左侧进程。给错的进程加 sudo,当然不起作用。
路径一:用 tee 承接写入
tee 从标准输入读,同时往标准输出和指定文件写。把它放在管道右端并加 sudo,打开文件的动作就由 root 身份的 tee 完成。
echo "127.0.0.1 api.local" | sudo tee -a /etc/hosts > /dev/null
逐段拆开看:
echo由当前用户运行,只往管道里写,不碰磁盘;- 管道由 shell 创建,不涉及文件权限;
sudo tee -a /etc/hosts中,tee 以 root 身份打开文件并追加;- 末尾的
> /dev/null由当前 shell 执行,/dev/null对所有用户可写,不会报错。
末尾的 > /dev/null 不能省。tee 会把写入内容同时送到标准输出,不加就把整段配置回显在终端里,写进脚本时尤其碍事。
写多行内容用 heredoc,分隔符加上引号可以阻止变量展开:
sudo tee /etc/sysctl.d/99-tuning.conf > /dev/null <<'EOF'
vm.swappiness = 10
net.core.somaxconn = 1024
EOF
两条注意:
- 不带
-a的sudo tee与>等价,会截断原文件,改配置前先备份; - 由 tee 新建的文件属主是 root,权限取决于 tee 运行时的 umask。需要固定权限时,用
install -m 644创建,或在写入后补一次chmod。
路径二:把整段操作交给 root shell
另一种写法是让 sudo 启动一个 shell,重定向就由这个 root shell 来做:
sudo sh -c 'echo "127.0.0.1 api.local" >> /etc/hosts'
这里的 >> 在单引号内,当前 shell 不处理,而是原样传给 sudo 启动的 sh;sh 的 uid 是 0,打开 /etc/hosts 自然成功。
这条路径的坑集中在引号上:
# 错误:$HOME 被当前 shell 提前展开成普通用户的家目录
sudo sh -c "echo $HOME > /etc/marker"
# 正确:单引号阻止提前展开,由 root shell 求值
sudo sh -c 'echo $HOME > /etc/marker'
如果确实需要把当前 shell 的变量带进去,用位置参数传递,而不是把整串命令塞进双引号:
target=/etc/myapp.conf
sudo sh -c 'printf "%s\n" "$1" > "$2"' _ "$value" "$target"
更简单的办法是让内容从标准输入进入,变量在管道左侧展开:
printf 'listen = %s\n' "$port" | sudo tee /etc/myapp.conf > /dev/null
需要多条命令顺序执行时,sh -c 比 tee 省事:
sudo sh -c 'cp /etc/hosts /etc/hosts.bak && sed -i "/api.local/d" /etc/hosts'
代价是提权范围从单个命令扩大到了整段脚本,任何一处写错都以 root 执行。
两条路径的对比
| 维度 | sudo tee 路径 |
sudo sh -c 路径 |
|---|---|---|
| 提权范围 | 只有 tee 一个进程 | 整个子 shell 及其启动的所有命令 |
| 变量展开 | 在当前 shell 展开,行为直观 | 默认在 root shell 展开,需坚持单引号 |
| sudoers 精细化 | 可只授权 tee 对特定路径 | 授权 sh -c 等于放开任意命令 |
| 多命令协作 | 需多次 tee 或额外脚本 | 一条命令内即可完成 |
| 输出控制 | 内容会回显,需重定向到 /dev/null | 由脚本自身控制 |
补充一点审计上的差异:sudo 日志记录的是实际被执行的那条命令。tee 方案里记录的是 tee /etc/hosts,管道左侧产生内容的那条命令不会进入 sudo 记录;sh -c 方案里整串字符串会进日志,但可读性差。两种都不构成完整的变更审计。这一点取决于本机 sudoers 中 log_output、syslog 等选项的配置,默认配置下不要指望它提供内容级别的记录。
顺带澄清几个相邻的误区
sudo cd /root 不生效。 cd 是 shell 内建命令,不是可执行文件,sudo 找不到可以 exec 的目标。要切换目录请用 sudo -i 或 sudo -s 进入 root shell,或者改用 sudo ls /root 这类真正的外部命令。
sudo echo $PATH 打印的不是 root 的 PATH。 $PATH 在 sudo 启动之前就被当前 shell 展开了,echo 只是把这串字符串打出来。想看 root 的环境用 sudo printenv PATH。
后台任务的重定向同样归属 shell。 sudo cmd > /var/log/x.log & 里的 > 仍由当前 shell 完成,& 只是把整个作业放到后台运行,不改变权限归属。
不要用“把用户加进某个组”来绕过。 为了写一个文件把账号塞进 disk、adm 之类的组,等于扩大了这个账号的全部权限,收益远小于风险。正确做法是收紧目标文件的权限,或用 sudoers 精确授权一条命令。
一段可复用的排查顺序
- 看报错前缀。
bash:、zsh:开头说明是 shell 打开文件失败,在前面追加 sudo 无效;程序名开头才是程序自身报错。 - 找出命令里所有会打开文件的进程:重定向目标、管道左右两端、命令内部的写操作。
- 判断每个进程运行时是什么身份,只给真正需要的那一个加 sudo。
- 单条写入优先用
| sudo tee,追加时加-a;需要多条命令或复杂引号时用sudo sh -c,并坚持用单引号。 - 修改系统配置前先备份,写完用
sudo cat或stat确认属主与权限符合预期。
把“谁在什么身份下打开文件”这一条想清楚,这类问题基本不会出现第二次。