Короткий ответ

Root-cron, который вызывает world-writable скрипт или backup.sh из PATH без абсолютного пути — готовый PATH hijack. Зафиксируйте PATH=/usr/sbin:/usr/bin:/bin, в crontab только /usr/local/sbin/backup.sh, режим 750 root:root (или группа бэкапа без write для other). /etc/cron.d и spool не writable для admin без нужды. Не чините сбой job через chmod 777. Хост host.example (10.0.20.10).

Симптомы и как отличить

  • Скрипт в /opt/scripts rwxrwxrwx, cron root каждую ночь;
  • в crontab python backup.py без пути;
  • /etc/cron.d принадлежит ubuntu:ubuntu;
  • MAILTO молчит, hijack уже идёт.
МестоКоманда просмотра
user crontabcrontab -u admin -l
/etc/crontabполе user
/etc/cron.d/*run-parts правила имён
systemd timerне cron

Отличие от «cron не запускается»: там unit/PATH/mail. Здесь безопасность живого cron. Права деревьев: 777. Секреты в скрипте: секреты.

Возможные причины

  1. Деплой копирует job в /opt с umask 000.
  2. PATH=. или /home/admin/bin первым.
  3. NFS no_root_squash + writable share.
  4. wget https://…/install.sh | bash в weekly.
  5. Пользовательский crontab с sudo NOPASSWD.
  6. World-writable /etc/cron.d после «восстановления из tar».

Диагностика

systemctl status cron --no-pager
sudo ls -la /etc/crontab /etc/cron.d /etc/cron.daily /var/spool/cron/crontabs
sudo crontab -l
sudo crontab -u admin -l

Права целей:

sudo grep -Rvh '^#' /etc/crontab /etc/cron.d/* 2>/dev/null | awk 'NF'
# для каждой команды: type/stat
namei -l /usr/local/sbin/backup.sh
stat /usr/local/sbin/backup.sh
sudo find /etc/cron.d /etc/cron.daily /usr/local/sbin -perm -0002 -o -perm -0020 2>/dev/null

PATH в файлах:

sudo grep -n PATH /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/* 2>/dev/null

SUID рядом: SUID.

Решение

Сценарий A. Скрипт writable

sudo chown root:root /usr/local/sbin/backup.sh
sudo chmod 750 /usr/local/sbin/backup.sh

Каталог /usr/local/sbin755 root. Other не пишет. Если правит группа ops750 root:ops и без write для other.

В crontab абсолютный путь:

PATH=/usr/sbin:/usr/bin:/bin
0 2 * * * root /usr/local/sbin/backup.sh

Не backup.sh, не cd /opt && ./backup.sh если /opt writable.

Сценарий B. PATH hijack

В скрипте:

#!/bin/bash
set -eu
PATH=/usr/sbin:/usr/bin:/bin
export PATH
/usr/bin/tar ...

Каждая внешняя команда — с полным путём или после безопасного PATH. Не вызывайте python без /usr/bin/python3.

Сценарий C. Ограничить кто имеет crontab

echo 'admin' | sudo tee /etc/cron.allow
sudo chmod 640 /etc/cron.allow

При наличии cron.allow остальные не ставят crontab (кроме root). cron.deny — другая модель, не смешивайте в голове: читайте man crontab вашей Ubuntu.

Сценарий D. Секреты и wget

Ключи БД — EnvironmentFile 640, не в скрипте 755 в git. Уберите | bash из cron: пакет/артефакт с checksum.

systemd timer + User=backup лучше root-cron, если не нужен raw disk: сервис User= по духу тот же принцип.

Широкий sudo из cron: sudo.

Проверка «кто может подменить job». Кроме mode файла смотрите владельца каждого компонента namei -l: если /opt 777, файл 750 бесполезен. То же для симлинков: cron следует по ссылке; writable symlink в /etc/cron.d = запись чужого задания.

/etc/cron.hourly и cron.daily: скрипты пакетов + ваши. Не кладите туда файлы с точкой в имени и не копируйте .bak. run-parts молча пропустит. Для безопасности важнее: эти каталоги 755 root:root. Если admin туда пишет без sudo — сузьте.

systemd timer как замена: User=backup, ReadWritePaths= на каталог бэкапа, без shell PATH. Старый cron удалите, чтобы не было двух запускающих с разным uid.

Логирование: MAILTO=admin@host.example или явный >> /var/log/backup.log 2>&1 с файлом 640. World-writable лог позволит подменить вывод и скрыть hijack. Не chmod 777 лог «чтобы видеть».

Интерактивный crontab -e под root на проде оставляет spool root без git. Лучше файл в /etc/cron.d/backup из ansible с mode 644 root:root (содержимое читают, не пишут). 644 на cron.d файле ок, если каталог не writable; содержимое без секретов.

Тест PATH hijack на копии: положите /tmp/tar executable в PATH до системного — в безопасном crontab с абсолютным /usr/bin/tar не вызовется. Если вызовется — PATH всё ещё дырявый.

На 24.04 пользовательский crontab лежит в том же spool; crontab -e создаёт файл mode 600. Не «выровняйте права» chmod 666, чтобы ansible читал без sudo. Ansible должен становиться root. Дублирование job в /etc/cron.d и user crontab — два PATH, два hijack-вектора; оставьте одно место.

Прогоните run-parts --test /etc/cron.daily и сверните список с ожидаемым: лишний скрипт с 777 виден здесь же.

Отдельно: anacron и /etc/cron.daily/apt-compat не должны быть world-writable. Пакетный файл 755 root. Если видите 777 — инцидент, не «чтобы apt заработал». Для таймеров apt смотрите systemd, не чините daily chmod.

EDITOR=vim crontab -e создаёт spool с umask пользователя: проверьте stat /var/spool/cron/crontabs/root — не 666. Дефолт обычно 600.

Как проверить, что проблема устранена

stat -c '%a %U:%G %n' /usr/local/sbin/backup.sh /etc/cron.d /var/spool/cron/crontabs
sudo find /etc/cron.d /usr/local/sbin -perm -0002
sudo crontab -l | grep PATH
sudo journalctl -t CRON --since 'today' | tail

Скрипт не world-writable. Job отрабатывает (тестовый run от того же user). nobody не может дописать файл. cron.service active.

Если не помогло

  • Job не стартует после 750: владелец не тот, что в поле user crontab.
  • anacron vs hourly — читайте, какой планировщик.
  • systemd timer дублирует cron — два запуска, не hijack.
  • AppArmor DENIED на /usr/local/sbin — профиль, не 777.
  • NFS: права на сервере экспорта.

Профилактика

  • CI: запрет 0002 на cron-targets.
  • Скрипты только из пакета/ansible с mode.
  • Инвентарь crontab в git (без паролей).
  • Baseline.

FAQ

Точка в PATH

Никогда в cron. Даже в конце опасна при пустых компонентах ::.

/etc/cron.daily и точки в имени

run-parts пропускает имена с точкой. Это доступность, не hijack, но «тихий» бэкап.

Нужен ли cron.allow если только root crontab

Да как слой. Пользовательские crontab — лишняя поверхность.

wget | sh один раз вручную

Тот же риск. Верифицируйте подпись/хеш.

Можно ли chmod 777 «на минуту»?

Минута с cron каждую минуту = окно. Нет.