crontab 权限收口实战:cron.allow、沙箱与限频

为什么要把用户的 crontab 关进笼子

绝大多数个人站长的服务器上,crontab 是一个「谁登录谁说了算」的文件。只要拿到一个能登录的账号,无论是被暴力破解的弱口令、泄漏的部署密钥,还是某个 Web 应用里藏着的 webshell,攻击者做的第三件事往往就是 crontab -e:加一条每分钟执行一次的下载器,把自己从临时立足点变成常驻后门。它比写 systemd service 更隐蔽(很多人根本不看 crontab 列表),比改 .bashrc 更稳定(不依赖用户登录),而且不需要 root。

cron 本身有两个「天生」的坑,很多站长甚至不知道自己踩了:

  • cron 不读你的 PATH 和 alias。 cron 启动任务时用的是极简环境,PATH 通常只有 /usr/bin:/bin。你在终端里能跑的 php、node、composer,在 cron 里就是 command not found。于是站长们养成了在脚本里塞满绝对路径、或者在 crontab 顶部写一行 PATH=/usr/local/bin:... 的习惯——而这行 PATH 一旦被攻击者利用,就变成了「任意目录优先执行」的垫脚石。
  • cron 的执行者身份就是写这条任务的人。 www-data 的 crontab 里跑的东西,天然拥有 www-data 对网站根目录的全部读写权。一个正常的清理临时文件脚本,和一个死循环 find . -name "*.php" | xargs grep -l eval | xargs sed,在 cron 眼里没有区别。

这篇文章要解决的不是「怎么给单个脚本加锁」,而是把 crontab 当作一个需要最小权限、需要审计、需要限频的系统资源来治理。做法全部基于 Debian/Ubuntu 上现成的 cron 配置项与 Linux 内核能力,不引入额外服务,适合单机 VPS。

先把现状摸清楚:这台机器上到底有多少个 crontab

治理的第一步是盘点。crontab 有四类来源,很多站长只记得自己 crontab -e 的那一个:

# 1) 系统级任务(按 run-parts 规则跑 /etc/cron.daily 等目录)
ls -l /etc/crontab /etc/cron.d/

# 2) 所有用户的个人 crontab(root 执行可看全部)
ls -l /var/spool/cron/crontabs/

# 3) systemd timer 形式的「伪 cron」
systemctl list-timers --all

# 4) 应用自带的调度:Typecho/WordPress 的伪 cron、宝塔面板任务、docker 容器内的 cron
grep -rl "wp-cron\|typecho" /var/www --include=*.php 2>/dev/null | head

盘点的关键不是「列出来」,而是给每一条任务建立归属。实践中建议维护一张表:来源、执行用户、执行频率、脚本绝对路径、脚本的所有者与权限、最后一次成功执行时间。第四列最容易出问题——很多任务的脚本路径指向 /home/www/xxx.sh,而这个文件是 777 或者属主是 www-data,等于任何能写网站目录的人都能改这个脚本的内容,然后等 cron 以 root 身份执行它。这是提权链里最经典的一环。

# 一次性扫描:找出所有被 cron 引用的脚本,并检查它们的权限
grep -hoE '(/[a-zA-Z0-9_./-]+\.(sh|py|php))' /etc/crontab /etc/cron.d/* \
  /var/spool/cron/crontabs/* 2>/dev/null | sort -u | while read -r f; do
    [ -f "$f" ] && stat -c '%A %U:%G %n' "$f"
done

输出里凡是出现 rwxrwxrwx、属主为 www-data 却被 root 调用的、或者位于网站可用写目录(如 /var/www/*/uploads)的,全部列入待整改清单。

第一道锁:cron.allow 白名单,而不是 cron.deny 黑名单

Debian 的 cron 在启动用户任务时会检查 /etc/cron.allow 与 /etc/cron.deny。规则很简单:如果 cron.allow 存在,则只有文件里列出的用户才被允许使用 crontab,cron.deny 被完全忽略。如果两个文件都不存在,则所有人都可以用(这是很多发行版的默认状态)。

# /etc/cron.allow —— 只放行真正需要定时任务的账号
root
# 如果确实有业务账号需要跑任务,逐个加进来,不要用 @all
# 权限必须是 600 或 640,且属主 root,否则 cron 会报 WRONG FILE OWNER
chown root:root /etc/cron.allow
chmod 600 /etc/cron.allow
# 删掉 deny 文件,避免两套规则并存造成误判
rm -f /etc/cron.deny

这一步的效果是:webshell 以 www-data 身份跑起来之后,crontab -e 会直接得到 You (www-data) are not allowed to use this program (crontab)。它没法再用最省事的方式做持久化,只能退而求其次去改 systemd unit 或写 /etc/cron.d——而这两个都需要 root,攻击面收窄了一大截。

要注意两个副作用:其一,某些面板(宝塔、1Panel)会用特定用户跑任务,加白名单前先确认这些用户;其二,cron.allow 只约束 crontab 命令本身,不约束 /etc/cron.d 与 /etc/crontab(那是 root 文件系统权限说话的),所以真正防护要靠下面的目录加固。

第二道锁:把系统级 cron 目录变成只读

/etc/cron.d/*、/etc/cron.{hourly,daily,weekly,monthly}/* 里的文件只要属主是 root、权限合法,就会被执行。攻击者拿到 root 之后会往这里塞后门;更糟的是,某些「临时高权限」场景(比如一个以 root 跑的备份脚本被注入了命令)也可能被用来落地文件。除了保证 /etc 本身的权限(755 root:root),建议再加两种手段:

# 1) 给目录加不可变属性(注意:会阻止正常安装软件,改动前先记录)
chattr +i /etc/cron.d /etc/cron.daily /etc/cron.hourly

# 需要新增任务时先解锁,改完再上锁
chattr -i /etc/cron.d && echo "任务内容" > /etc/cron.d/xxx && chattr +i /etc/cron.d

# 2) 用 auditd 记录对 cron 目录的任何写操作(不改权限,只留痕)
apt-get install -y auditd
cat >> /etc/audit/rules.d/cron.rules <<'EOF'
-w /etc/crontab -p wa -k cron_mod
-w /etc/cron.d/ -p wa -k cron_mod
-w /var/spool/cron/ -p wa -k cron_mod
-w /etc/cron.allow -p wa -k cron_mod
augenrules --load
auditctl -l | grep cron_mod

chattr +i 对根目录不是万能钥匙(chattr -i 本身也能取消),它的价值在于把「静默落地」变成「必须显式解锁」——一次正常运维不会在半夜三点解锁 cron 目录。而 auditd 的价值在于事后取证:ausearch -k cron_mod -i 能告诉你哪条命令、哪个 PID、哪个用户改了哪个文件。两者配合,既能发现,也能追责。

第三道锁:给每个定时脚本套一层最小权限的沙箱

如果你的服务器上确实有需要以较高权限运行的定时任务(备份、日志清理、证书续期),那正确的姿势不是让它直接以 root 在 cron 里裸跑,而是让 cron 只负责「触发」,真正的执行体用 systemd 的沙箱能力隔离。cron 可以只写一行:

# /etc/cron.d/backup-trigger
0 3 * * * root /usr/bin/systemctl start --no-block backup.service

而真正干活的 unit 把权限收窄到「只能碰它该碰的目录」:

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly site backup
After=network-online.target

[Service]
Type=oneshot
User=backup
Group=backup
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/site-backup.sh
# 文件系统沙箱
ProtectSystem=strict
ReadWritePaths=/var/backups/site
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
# 内核与网络能力收口
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# 只允许写、不允许执行下载来的东西
MemoryDenyWriteExecute=yes
LimitNOFILE=4096
TimeoutStartSec=1800

这一层是整套方案里收益最大的。ProtectSystem=strict 让进程除了 ReadWritePaths 列出的目录外,看到的整个文件系统都是只读的;NoNewPrivileges=yes 掐死了 setuid 提权;MemoryDenyWriteExecute=yes 让「下载一个二进制到内存里执行」这类挖矿后门无法运行。即使备份脚本里被注入了 curl | bash,它也写不进 /etc,也执行不了内存马。

第四道锁:任务的执行必须有限频与单实例保护

cron 最容易被忽视的破坏力不是「被植入后门」,而是「自己把自己拖垮」。一条每 5 分钟跑一次的任务,如果某次因为数据库锁而跑了 40 分钟,那么在 cron 眼里它每一轮都会重新启动一个新的实例,几分钟后你就会看到几十个同名字进程、内存打满、OOM Killer 开始随机杀进程——而它杀掉的往往是你最宝贵的 MySQL。

最低成本的做法是用 flock 把任务串行化,这是 util-linux 自带的,无需安装:

# 前一个实例还没退出时,新实例立刻放弃(-n 非阻塞)
*/5 * * * * root /usr/bin/flock -n /var/lock/site-sync.lock /usr/local/bin/sync.sh

# 需要「排队但不重叠」时用 -w 等待有限时间
0 * * * * root /usr/bin/flock -w 600 /var/lock/report.lock /usr/local/bin/report.sh

把锁文件放在 /var/lock 而不是 /tmp,因为多数发行版在重启后会清理 /tmp,且 /tmp 往往带 noexec/nodev。另外注意:flock 锁的是文件描述符,脚本内部再起子进程时锁不会自动继承,所以要把 flock 写在整条命令的最外层,而不是在脚本内部包住某一段。

同时给任务加上超时与资源上限,避免单次任务失控:

*/10 * * * * root /usr/bin/timeout -k 30 540 /usr/bin/nice -n 10 /usr/bin/ionice -c3 /usr/local/bin/scan.sh >> /var/log/scan.log 2>&1

这里三个参数各有分工:timeout -k 30 540 表示 540 秒后发 SIGTERM,再等 30 秒不退就 SIGKILL;nice -n 10 降低 CPU 优先级;ionice -c3 把磁盘 IO 降到 idle 级,保证它不会和前台 Web 请求抢盘。这三件小事是防止「定时任务把网站拖慢」的性价比最高的组合。

第五道锁:让 cron 的输出有处可去,且不撑爆磁盘

cron 的默认行为是把任务的所有 stdout/stderr 收集起来,通过本地 MTA 邮件发给任务属主。如果服务器没装 MTA,这些输出就在 /var/mail 或者干脆丢失;如果装了,一个小内存 VPS 上 /var/mail/root 涨到几个 G 是常有的事。两种都不可接受。

# 方案 A:统一重定向到日志目录,并交给 logrotate 管
*/5 * * * * root /usr/local/bin/task.sh >> /var/log/cron-tasks/task.log 2>&1

# 方案 B:只在失败时留痕(成功时静默),适合高频任务
*/5 * * * * root /usr/local/bin/task.sh > /dev/null 2>> /var/log/cron-tasks/task.err

# 方案 C:通过 MAILTO 控制收件人,彻底关掉本机邮件
MAILTO=""

配套的 logrotate 片段(放在 /etc/logrotate.d/cron-tasks):

/var/log/cron-tasks/*.log /var/log/cron-tasks/*.err {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 root adm
    maxsize 100M
}

还有一个隐蔽的坑:如果任务本身会读标准输入,而 cron 的环境里 stdin 是一个空管道,某些交互式脚本会立刻退出或者行为异常。稳妥的写法是在每条任务末尾显式加 < /dev/null,这是很多老运维的肌肉记忆,值得保留。

验证与日常巡检:怎么确认这套东西真的在生效

配置完不能只看「任务还在跑」。至少要做四组验证:

# 1) 白名单生效:用一个非白名单账号尝试
su -s /bin/sh www-data -c 'crontab -l'   # 应报 not allowed

# 2) 沙箱生效:看 unit 的资源与文件系统视图
systemd-analyze security backup.service | tail -20   # 关注 exposure 分数

# 3) 审计规则命中
ausearch -k cron_mod -ts today -i | tail -20

# 4) 任务是否真的在跑(日志时间戳比 crontab 更可信)
ls -lt /var/log/cron-tasks/ | head
journalctl -u cron --since "1 hour ago" | grep -E "CMD|error"

巡检清单建议做成每周一次的三分钟流程:看 /var/spool/cron/crontabs/ 的 mtime 是否有人动过、看所有 cron 引用的脚本权限有无变化(stat 对比基线)、看 systemctl list-timers 有没有不认识的条目、看 /var/log/cron-tasks 里有没有新的报错。把这四项写进一个小脚本,让它在每天早上 8 点把差异发到你手机上,成本几乎为零,但能第一时间发现「被动手了」和「任务悄悄挂了」这两类问题。

小结

crontab 在防御体系里长期被当成「配置」而不是「权限」,这是很多个人站长服务器的真实短板。五道锁各自的职责可以这样记:cron.allow 挡住没有资格的账号,目录加固挡住静默落地,systemd 沙箱挡住任务本身被利用,flock + timeout 挡住任务自己失控,日志重定向挡住磁盘被撑爆。它们都是系统自带能力,不需要额外服务,也不会拖慢服务器。真正需要的只是把「定时任务」当成一个需要最小权限和审计对象的系统资源来对待——这件事做一次,往后每一批新增任务都按同一套模板走,收益会持续累积。

Last modification:October 19th, 2026 at 10:23 pm

Leave a Comment