Апокриф 06

Тамперинг загрузчика и Timestomping

Тамперинг загрузчика и Timestomping

Предыдущая тема заканчивалась на systemd: точка закрепления жила в конфигурации служб и переживала перезагрузку, но всё ещё находилась внутри обычной файловой системы. Защитник, который проверяет /etc, /usr и список юнитов, такую точку рано или поздно находит. Следующий шаг — уйти ниже: изменить код, который выполняется до того, как корневая файловая система вообще смонтирована, и подделать следы, по которым эту правку можно обнаружить.

Загрузка Linux — это цепочка звеньев, каждое из которых передаёт управление следующему: прошивка UEFI, загрузчик shim, загрузчик GRUB, ядро, initramfs, init в корневой файловой системе, PID 1. На каждом переходе есть файл, который можно изменить, и есть механизм, который это изменение способен поймать. Тема разбирает и то, и другое: как устроен переход, что именно проверяется подписью, а что нет, и как изменение initramfs переживает обновление пакетов, потому что initramfs собирается заново при каждом обновлении ядра — в том числе с участием пользовательских хуков.

Вторая половина темы — временные метки. Любая правка файла меняет его метаданные, и метаданные — это тоже след. В ext4 у inode четыре времени, и три из них пользователь может менять, а четвёртое — нет; понимание того, какое именно и почему, отделяет аккуратную работу от работы, которая выдаёт себя одним stat. Разбирается и то, как метку всё-таки подделывают напрямую через debugfs, и что при этом остаётся на диске.

Третья часть — журналы. Записи о входе, о запуске служб и о событиях ядра хранятся в разных форматах, и каждый формат чистится по-своему. Здесь важно не «как удалить лог», а «что остаётся после удаления»: порядковые номера, запечатанные записи, отметки на удалённом сервере, следы в журнале файловой системы.

Все примеры проверены на реальной системе там, где это возможно без root: распаковка initramfs, сборка собственного образа и его загрузка в QEMU, подделка меток в образе ext4 через debugfs, разбор wtmp. Команды, требующие администратора (GRUB, update-initramfs, auditd), приведены как примеры для собственной виртуальной машины; вывод для них помечен. Общая модель загрузки и структур ядра описана в литературе 78.

Цепочка загрузки и точки вмешательства в неё показаны на рисунке 1.

Цепочка загрузки Linux и точки вмешательства
Рисунок 1. UEFI → shim → GRUB → ядро → initramfs → PID 1: что проверяется подписью и где можно изменить код

Цепочка загрузки и её звенья

UEFI, shim и GRUB

Прошивка UEFI читает загрузочную запись и запускает EFI-приложение. В системах с Secure Boot это приложение должно быть подписано ключом, которому доверяет прошивка. Обычно таким приложением является shim — небольшой загрузчик, подписанный ключом Microsoft и потому проходящий проверку на большинстве машин. shim содержит собственный список доверенных ключей (MOK, Machine Owner Key) и передаёт управление следующему звену — как правило, GRUB.

GRUB читает конфигурацию, показывает меню, загружает ядро и initramfs. Его конфигурация на Ubuntu собирается из /etc/default/grub и скриптов /etc/grub.d/ командой grub-mkconfig, которая пишет /boot/grub/grub.cfg. В grub.cfg каждый пункт меню — это две строки: linux с путём к ядру и параметрами, и initrd с путём к образу initramfs. Пример с рабочей машины:

linux	/vmlinuz-linux-zen root=UUID=27ae04c6-97fd-43ab-a885-dadc5ed8aa67 rw rootflags=subvol=@ loglevel=3 quiet
initrd	/intel-ucode.img /initramfs-linux-zen.img

Здесь важны три вещи. root=UUID=… сообщает ядру, какую файловую систему монтировать как корень. initrd может перечислять несколько образов — они склеиваются в один initramfs, и файлы из более позднего образа перекрывают одноимённые из раннего. Параметры после root= передаются ядру и дальше в init; именно через них включаются отладочные режимы.

Править grub.cfg напрямую бессмысленно: при следующем update-grub он будет перезаписан. Сама раскладка /boot, порядок пунктов меню и роль загрузчика в администрировании описаны в руководстве по системному администрированию 3. Правка делается в /etc/default/grub — там же, где живут параметры по умолчанию:

# /etc/default/grub
GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
GRUB_CMDLINE_LINUX=""

GRUB_CMDLINE_LINUX_DEFAULT попадает в обычные пункты меню, GRUB_CMDLINE_LINUX — во все пункты, включая recovery. После правки — sudo update-grub. Это штатный путь; он же — самый заметный, потому что меняет файл, который проверяется глазами и контрольной суммой.

Параметры ядра как переключатель режима

Ядро разбирает параметры командной строки до запуска первого процесса. Несколько параметров меняют то, что будет исполнено:

ПараметрДействиеГде применяется
init=/bin/shвместо /sbin/init запустить указанную программупосле монтирования корня
rdinit=/bin/shвместо /init в initramfs запустить указанную программудо монтирования корня
systemd.unit=rescue.targetsystemd стартует в аварийном режимепосле монтирования корня
single, s, 1то же, что rescue.targetпосле монтирования корня
rd.breakостановиться в initramfs перед монтированием корняdracut (RHEL, Fedora)
break=premountостановиться в initramfs на указанной стадииinitramfs-tools (Debian, Ubuntu)

Список параметров, которые понимает конкретная система, стоит сверять с man 7 bootparam и с документацией набора инструментов 1.

Разница между init= и rdinit= принципиальна. init= применяется, когда корневая файловая система уже смонтирована и ядро ищет программу в ней. rdinit= применяется к initramfs: ядро распаковывает образ в ramfs и запускает /init оттуда, не имея никакого корня. Именно rdinit= позволяет получить управление до того, как что-либо из диска будет прочитано.

rd.break и break=premount — это не параметры ядра в строгом смысле, а инструкции скрипту /init внутри initramfs: разные наборы инструментов понимают разные слова. На Ubuntu с initramfs-tools работает break=premount; на RHEL с dracut — rd.break. Попытка использовать rd.break на Ubuntu приведёт к тому, что параметр просто проигнорируется.

Проверить, с какими параметрами загружена текущая система, можно без перезагрузки:

cat /proc/cmdline
BOOT_IMAGE=/vmlinuz-linux-zen root=UUID=27ae04c6-97fd-43ab-a885-dadc5ed8aa67 rw rootflags=subvol=@ loglevel=3 quiet

initramfs: формат и момент выполнения

initramfs — это cpio-архив (формат newc), обычно сжатый gzip, xz, zstd или lz4. Ядро распаковывает его в tmpfs/ramfs и запускает /init. Формат newc описан в документации ядра 8; заголовок каждого файла — 110 байт ASCII с hex-полями: магия 070701, inode, режим, uid, gid, число ссылок, mtime, размер, номера устройств, длина имени, контрольная сумма. После заголовка идёт имя, выровненное до четырёх байт, затем данные, тоже выровненные.

Образ может быть склейкой нескольких архивов. На рабочей машине файл /boot/initramfs-linux-zen.img начинается с несжатого cpio с микрокодом процессора, затем идёт выравнивание нулями, затем сжатый zstd основной архив:

file /boot/initramfs-linux-zen.img
/boot/initramfs-linux-zen.img: ASCII cpio archive (SVR4 with no CRC)
xxd -s 138301440 -l 16 /boot/initramfs-linux-zen.img
083e5000: 28b5 2ffd 0458 545c 062e e408 2720 4c10  (./..XT\....' L.

Байты 28 b5 2f fd — магия zstd. Это значит, что простой cpio -it покажет только первый архив и остановится на маркере TRAILER!!!:

cpio -it < /boot/initramfs-linux-zen.img | head
bin
early_cpio
kernel/
kernel/x86/
kernel/x86/microcode/
kernel/x86/microcode/GenuineIntel.bin
lib
lib64

Именно поэтому для работы с initramfs нужны инструменты, понимающие склейку: lsinitramfs и unmkinitramfs из initramfs-tools (Ubuntu), lsinitcpio и unmkinitcpio из mkinitcpio (Arch), lsinitrd из dracut. Каждый из них знает, где заканчивается один архив и начинается следующий, и умеет разжимать каждый по отдельности.

Устройство образа и порядок выполнения показаны на рисунке 2.

Устройство initramfs и порядок выполнения
Рисунок 2. Склейка cpio-архивов и стадии выполнения до монтирования корня

Изменение initramfs

Распаковка и сборка

На Ubuntu образ лежит в /boot/initrd.img-<версия> и собирается командой update-initramfs. Посмотреть содержимое и распаковать:

lsinitramfs /boot/initrd.img-$(uname -r) | head -20
sudo unmkinitramfs /boot/initrd.img-$(uname -r) /tmp/initrd-unpacked

unmkinitramfs создаёт каталог с подкаталогами early, main и, если есть, early2 — по числу архивов в склейке. Дальше образ собирается обратно тем же способом, каким его собирает система: cpio поверх каталога и сжатие. Ручная сборка полезна для понимания, но для правки штатного образа используется update-initramfs, потому что он добавляет в образ модули ядра и скрипты, без которых система не загрузится.

# Ручная сборка минимального образа из каталога
cd /tmp/initrd-unpacked/main
find . -print0 | cpio --null -o --format=newc --quiet | gzip -9 > /tmp/custom.img

Порядок файлов в cpio важен: /init должен существовать, а все каталоги, в которые будут монтироваться файловые системы, — тоже. Ядро не создаёт их автоматически.

Свой /init и что он видит

Простейшая демонстрация того, что initramfs исполняется до монтирования корня, — собственный статически собранный /init, который печатает маркер и выключает машину. Ниже он приведён целиком; это тот же код, который использовался для проверки вывода в этом разделе.

/* demo-init.c — минимальный init для initramfs */
#include <stdio.h>
#include <unistd.h>
#include <sys/mount.h>
#include <sys/reboot.h>

int main(void) {
    mount("proc", "/proc", "proc", 0, "");
    printf("[demo] initramfs: PID 1 running before root mount\n");
    printf("[demo] uid=%d, /proc mounted, no root filesystem\n", getuid());
    fflush(stdout);
    sync();
    reboot(RB_POWER_OFF);
    return 0;
}

Сборка и упаковка:

gcc -static -O2 -o demo-init demo-init.c
mkdir -p ir/{dev,proc,sys}
cp demo-init ir/init
cd ir && find . -print0 | cpio --null -o --format=newc --quiet | gzip -9 > ../demo.img

Запуск в QEMU с ядром рабочей системы:

qemu-system-x86_64 -kernel /boot/vmlinuz-linux-zen -initrd demo.img \
  -append "console=ttyS0 rdinit=/init panic=-1" \
  -nographic -no-reboot -m 256
Booting from ROM...
[    0.916070] virt/tdx: TDX not supported by the host platform
[demo] initramfs: PID 1 running before root mount
[demo] uid=0, /proc mounted, no root filesystem
[    1.815820] reboot: Power down

Строка [demo] напечатана процессом, который стал PID 1 до того, как ядро узнало что-либо о корневой файловой системе. Это и есть точка, в которой работает бэкдор уровня initramfs: код исполняется с правами root, без systemd, без журнала systemd, без профилей AppArmor, примонтированных из корня.

Отдельный формат — UKI (Unified Kernel Image): ядро, initramfs и командная строка упакованы в один PE-файл, который загружается как EFI-приложение напрямую, без GRUB. В этом случае initramfs собирается и подписывается вместе с ядром, а его правка ломает подпись. systemd-stub, который загружает такой образ, умеет измерять компоненты в TPM и передаёт их хеши в PCR; изменение любого компонента видно при аттестации 2.

Практический вариант того же приёма на штатном образе — заменить /init или добавить скрипт, который выполняется до него. В initramfs-tools /init — это shell-скрипт, который последовательно запускает скрипты из /scripts по стадиям: init-top, init-premount, local-top, local-block, local-premount, local-bottom, init-bottom. Свой скрипт в /etc/initramfs-tools/scripts/init-premount/ попадёт в образ при следующей сборке и выполнится до монтирования корня.

Хук, срабатывающий по параметру ядра

Скрипт в initramfs получает параметры ядра через /proc/cmdline и может решать, вмешиваться или нет. Это делает правку условной: на обычной загрузке система ведёт себя как обычно, а нужное поведение включается только при конкретном параметре.

#!/bin/sh
# /etc/initramfs-tools/scripts/init-premount/zz-probe
set -eu

case "$(cat /proc/cmdline)" in
    *probe=1*)
        # Выполняется только при загрузке с параметром probe=1.
        printf 'probe: initramfs stage, uid=%s\n' "$(id -u)" >> /dev/kmsg
        # Здесь может быть запуск агента, чтение диска или монтирование
        # корня в обход обычной логики.
        ;;
esac

Сборка образа с хуком:

sudo install -m 0755 zz-probe /etc/initramfs-tools/scripts/init-premount/zz-probe
sudo update-initramfs -u -k all

Чтобы хук переживал обновления ядра, его кладут не в /etc/initramfs-tools/scripts, а в пакетный хук, который вызывается при каждой сборке initramfs. В Debian/Ubuntu это /etc/kernel/postinst.d/ — каталог скриптов, которые ядро запускает после установки:

# /etc/kernel/postinst.d/zz-repack
#!/bin/sh
set -eu
# Аргумент — версия устанавливаемого ядра.
install -m 0755 /usr/local/lib/probe /etc/initramfs-tools/scripts/init-premount/zz-probe
update-initramfs -u -k "$1"

Такой хук вызывается при каждом обновлении ядра и заново добавляет скрипт в свежесобранный образ. Пользовательские пакеты при этом не меняются, dpkg -V молчит, а journalctl не содержит ничего, потому что initramfs работает до systemd.

Почему это переживает обновления и что его ломает

Механизм живучести прост: initramfs — не пакетный файл, а результат сборки. Пакетный менеджер обновляет ядро и вызывает update-initramfs, а тот собирает образ из содержимого /etc/initramfs-tools и установленных модулей. Всё, что лежит в этих каталогах, попадает в новый образ автоматически. Поэтому правка «в образе» исчезнет после обновления, а правка «в хуке» — нет.

Границы приёма:

  • Secure Boot не проверяет initramfs. shim проверяет GRUB, GRUB проверяет ядро, но образ initramfs по умолчанию не подписан и не проверяется. Поэтому тамперинг initramfs проходит на системе с включённым Secure Boot. Это не дыра в Secure Boot, а его задокументированная область ответственности: он защищает цепочку до ядра, а содержимое initramfs — уже нет.
  • IMA appraisal ломает приём. Если включена целостная оценка IMA (ima_appraise=enforce) и политика требует подписи или хеша для исполняемых файлов, неподписанный /init или скрипт не выполнится. IMA работает на уровне отдельных файлов, а не образа целиком.
  • Подписанный initramfs ломает приём. Некоторые дистрибутивы и hardened-сборки подписывают initramfs и проверяют подпись в загрузчике или через systemd-stub/UKI. В этом случае любая правка образа обнаруживается до его выполнения.
  • Измерение в TPM ломает незаметность. Если загрузчик измеряет загруженные образы в PCR, изменение initramfs меняет хеш и видно в tpm2_pcrread и в аттестации. Приём продолжает работать, но перестаёт быть незаметным.
  • lockdown в режиме confidentiality ограничивает /dev/mem, kexec и загрузку неподписанных модулей; на саму распаковку initramfs это не влияет, но сужает набор действий, которые из него можно выполнить.
  • update-initramfs требует root. Без прав администратора изменить системный образ нельзя; остаётся собственный образ, загруженный вручную через QEMU или через отдельную загрузочную запись.

Временные метки

Четыре времени inode

В ext4 у каждого inode есть четыре отметки времени. Они хранятся как секунды Unix плюс наносекунды в дополнительных полях inode; для больших значений используется отдельное поле с старшими битами.

ПолеЧто означаетКто меняет
atimeвремя последнего доступа к содержимомуядро при read, execve, readdir
mtimeвремя последнего изменения содержимогоядро при write, truncate
ctimeвремя последнего изменения inode (метаданных)ядро при chmod, chown, link, rename, write
btime (crtime)время создания inodeядро при создании, хранится в extra inode

stat показывает первые три, statx — все четыре:

stat /etc/hostname
statx /etc/hostname 2>/dev/null || python3 -c 'import os; print(os.stat("/etc/hostname").st_birthtime)'

На файловых системах, где btime не поддерживается, statx вернёт флаг STATX_BTIME снятым, и поле будет пустым. На ext4 с включённой опцией metadata_csum и достаточно новым ядром btime доступно.

Ключевое свойство: atime и mtime пользователь может выставить произвольно, ctime — нет. Системный вызов utimensat(2) принимает массив из двух значений — для atime и mtime; попытка передать что-то для ctime невозможна, потому что поля для неё в аргументах нет. Ядро обновляет ctime само при любом изменении inode, включая сам вызов utimensat 4.

touch -d '2020-01-01 00:00:00' secret.txt
stat -c 'atime=%x mtime=%y ctime=%z' secret.txt
atime=2020-01-01 00:00:00.000000000 +0300 mtime=2020-01-01 00:00:00.000000000 +0300 ctime=2026-10-19 17:40:12.184637219 +0300

mtime подделан, ctime — сегодняшний. Это и есть основной признак timestomping: ctime новее, чем mtime, хотя содержимое якобы не менялось два года. Схема с четырьмя полями и аномалией показана на рисунке 3.

Временные метки inode и обнаружение timestomping
Рисунок 3. Четыре времени inode: подделка mtime оставляет ctime свежим

Подделка через debugfs

Обойти ограничение можно, только записав inode напрямую в блочное устройство, минуя ядро. debugfs — отладочная утилита ext2/ext3/ext4, которая умеет читать и писать inode на несмонтированной файловой системе. Она не является «взломом»: это штатный инструмент восстановления, и работает он с правами root.

Проверка на образе ext4, созданном в файле (реальный вывод):

truncate -s 8M t.img
mkfs.ext4 -q -F t.img
debugfs -w -R "write /etc/hostname secret.txt" t.img
debugfs -R "stat /secret.txt" t.img | grep -E 'ctime|mtime|atime|crtime'
 ctime: 0x6ad62ada:00000000 -- Mon Oct 19 17:36:10 2026
 atime: 0x6ad62ada:00000000 -- Mon Oct 19 17:36:10 2026
 mtime: 0x6ad62ada:00000000 -- Mon Oct 19 17:36:10 2026
crtime: 0x6ad62ada:00000000 -- Mon Oct 19 17:36:10 2026

Теперь подделка всех трёх доступных полей:

debugfs -w -R "set_inode_field /secret.txt mtime 202001010000" t.img
debugfs -w -R "set_inode_field /secret.txt atime 202001010000" t.img
debugfs -w -R "set_inode_field /secret.txt ctime 202001010000" t.img
debugfs -R "stat /secret.txt" t.img | grep -E 'ctime|mtime|atime|crtime'
 ctime: 0x5e0be100:00000000 -- Wed Jan  1 03:00:00 2020
 atime: 0x5e0be100:00000000 -- Wed Jan  1 03:00:00 2020
 mtime: 0x5e0be100:00000000 -- Wed Jan  1 03:00:00 2020
crtime: 0x6ad62ada:00000000 -- Mon Oct 19 17:36:10 2026

ctime подделан, crtime — нет: время создания осталось исходным. Значение 202001010000 debugfs разобрал как UTC и записал 0x5e0be100, что соответствует 00:00 UTC 1 января 2020 года, то есть 03:00 по московскому времени. Это важно помнить: touch и debugfs могут интерпретировать строку времени по-разному.

Чтобы подделать и crtime, нужна отдельная команда:

debugfs -w -R "set_inode_field /secret.txt crtime 202001010000" t.img

Но у этого действия есть последствия, которых нет у touch. Во-первых, debugfs пишет в образ напрямую, и если файловая система смонтирована, ядро об этом не знает: кэш inode остаётся старым, а следующая запись в файл перезапишет подделанные поля. Работать надо на несмонтированном образе или на размонтированной файловой системе. Во-вторых, debugfs обновляет контрольные суммы inode, но не переписывает журнал: старые значения остаются в журнале ext4 и могут быть найдены при logdump. В-третьих, сама запись в блочное устройство оставляет след в истории команд, в auditd, а на системах с dm-verity — просто не пройдёт.

Что остаётся после подделки

Даже идеально подделанные atime, mtime, ctime и crtime не делают файл невидимым. Остаются:

  • журнал ext4: старые значения inode и записи о выделении блоков;
  • btime соседних файлов: если файл создан в каталоге, где все остальные файлы имеют дату установки системы, а он — 2020 год, это аномалия сама по себе;
  • размер и права: подделка меток не меняет size, mode, uid;
  • содержимое: хеш файла сравнивается с манифестом пакета (dpkg -V, rpm -Va) или с базой AIDE;
  • ctime каталога: создание и удаление файла меняет mtime и ctime каталога, и эти метки подделать сложнее, потому что их много;
  • резервные копии и снапшоты: если система делает снапшоты, старая версия файла сохраняется.

Отсюда практический вывод для обнаружения: сравнивать надо не одну метку с другой, а файл с его окружением и с эталоном. ctime > mtime — быстрый фильтр, но не доказательство; отсутствие аномалии — тоже не доказательство чистоты.

Очистка журналов

wtmp, btmp, utmp, lastlog

Записи о сессиях ведутся в бинарных файлах со структурой struct utmp. Каждая запись — фиксированный набор полей: тип записи (ut_type), PID, идентификатор терминала, имя пользователя, имя хоста, время (ut_tv). Типы, которые важны для разбора: USER_PROCESS (7) — вход, DEAD_PROCESS (8) — завершение, BOOT_TIME (2) — загрузка, RUN_LVL (1) — смена уровня выполнения.

Файлы:

ФайлЧто содержитКто читает
/var/log/wtmpуспешные входы и выходы, загрузкиlast, last -F
/var/log/btmpнеудачные попытки входаlastb
/var/run/utmpактивные сессии (в памяти)who, w
/var/log/lastlogпоследний вход каждого пользователяlastlog

Просмотр и правка — через utmpdump, который превращает бинарный файл в текст и обратно. Реальный вывод:

utmpdump /var/log/wtmp | tail -4
[7] [09531] [ts/5] [noviui  ] [pts/5       ] [:1                  ] [0.0.0.0        ] [2026-10-19T12:22:21,410925+00:00]
[8] [03166] [ts/5] [noviui  ] [pts/5       ] [                    ] [0.0.0.0        ] [2026-10-19T12:27:35,045929+00:00]
[7] [15068] [ts/5] [noviui  ] [pts/5       ] [:1                  ] [0.0.0.0        ] [2026-10-19T12:49:01,139258+00:00]
[7] [23101] [ts/6] [noviui  ] [pts/6       ] [:1                  ] [0.0.0.0        ] [2026-10-19T13:21:37,396514+00:00]

Поля читаются слева направо: тип, PID, терминал, пользователь, tty, хост, адрес, время в ISO 8601 с микросекундами. Удалить конкретную сессию можно, отфильтровав текстовое представление и загрузив его обратно:

utmpdump /var/log/wtmp > /tmp/wtmp.txt
grep -v 'noviui' /tmp/wtmp.txt > /tmp/wtmp-clean.txt
utmpdump -r /tmp/wtmp-clean.txt > /var/log/wtmp

Обратная операция (-r) читает текст и пишет бинарный файл. Формат полей и типы записей описаны в utmp(5) 1. Работает она только на размонтированном или корректно открытом файле; на живой системе wtmp постоянно дописывается, и правка «на месте» может быть перезаписана следующей записью. Кроме того, удаление записи не удаляет её из резервных копий, из journalctl (там свои записи о сессиях) и с удалённого сервера, если журналы туда отправляются.

journald, запечатанные записи и --verify

systemd-journald хранит журнал в бинарном формате в /var/log/journal/<machine-id>/ (или в /run/log/journal, если постоянное хранилище отключено). Записи состоят из полей: _PID, _UID, _COMM, _EXE, _SYSTEMD_UNIT, MESSAGE, __REALTIME_TIMESTAMP, __SEQNUM. Поле __SEQNUM — порядковый номер записи; он монотонно растёт и позволяет обнаружить пропуск.

Проверка целостности:

journalctl --verify
journalctl --verify --file /var/log/journal/*/system.journal

--verify проверяет контрольные суммы и структуру файлов; формат журнала и его поля описаны в документации systemd 2. Он не обнаружит аккуратно удалённый файл целиком, но обнаружит повреждение или обрыв записи. Если включено запечатывание (Seal=yes в /etc/systemd/journald.conf), каждая запись содержит HMAC, и подделка требует ключа; ключи создаются командой journalctl --setup-keys и хранятся отдельно. Проверка запечатанного журнала:

journalctl --verify --verify-key=$(cat /var/lib/systemd/journal-verify-key 2>/dev/null)

На практике запечатывание включают редко, потому что оно требует хранения ключа вне машины. Более распространённая защита — отправка журнала на внешний сервер:

# на клиенте, /etc/systemd/journald.conf
[Journal]
Storage=persistent
ForwardToSyslog=no
# на сервере
sudo systemctl enable --now systemd-journal-remote.socket

С удалённым журналом локальная очистка не имеет смысла: записи уже ушли по сети. Обнаружить отправку можно по открытому порту 19532/tcp, по конфигурации systemd-journal-upload, по исходящим соединениям в ss и по юнитам systemd-journal-upload.service.

Что остаётся после очистки

  • Порядковые номера. Пропуск __SEQNUM в локальном журнале виден при экспорте (journalctl -o export).
  • Размер и даты файлов. Удаление старых журналов меняет mtime каталога; journalctl --disk-usage показывает непропорционально малый объём.
  • wtmp и journalctl вместе. Записи о сессии есть в двух местах; чистка одного оставляет другое.
  • auth.log/secure. Если rsyslog пишет текстовые логи параллельно journald, запись остаётся там.
  • auditd. События аудита пишутся отдельно и переживают чистку journald, если аудит настроен.
  • Удалённый сервер. Самое надёжное хранилище — то, к которому у атакующего нет доступа.

Обнаружение и защита

Инвентаризация загрузки

Проверка начинается с чтения того, что реально загружено, и сравнения с эталоном:

cat /proc/cmdline
ls -la /boot
sha256sum /boot/vmlinuz-* /boot/initrd.img-* 2>/dev/null
lsinitramfs /boot/initrd.img-$(uname -r) | grep -E 'scripts|hooks|init$'
sudo grep -R "probe=\|rdinit=\|init=" /etc/default/grub /etc/grub.d/ 2>/dev/null

lsinitramfs с фильтром показывает, какие скрипты попали в образ. Если в списке есть файл, которого нет в пакете (dpkg -S не находит), это повод распаковать образ и прочитать его.

Сравнение с эталоном — единственный способ поймать аккуратную правку. Эталоном может быть хеш, снятый при установке, база AIDE/Tripwire или манифест пакета:

sudo aide --check
sudo debsums -s linux-image-$(uname -r) 2>/dev/null
sudo dpkg -V

debsums и dpkg -V проверяют файлы пакетов, но не проверяют initramfs: он не является файлом пакета. Поэтому хеш /boot/initrd.img-* надо хранить отдельно, а лучше — на другой машине.

Признаки timestomping

Быстрый фильтр по аномалии меток:

find / -xdev -type f -newermt '2020-01-01' ! -newermt '2020-01-02' -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head

Файлы, у которых mtime старый, а ctime свежий, ищутся скриптом:

#!/usr/bin/env python3
"""Ищет файлы, у которых ctime заметно новее mtime."""
import os
import sys

root = sys.argv[1] if len(sys.argv) > 1 else "."
threshold = 86400 * 30

for dirpath, dirnames, filenames in os.walk(root):
    dirnames[:] = [d for d in dirnames if d not in (".git", "proc", "sys", "dev")]
    for name in filenames:
        path = os.path.join(dirpath, name)
        try:
            st = os.lstat(path)
        except OSError:
            continue
        if st.st_ctime - st.st_mtime > threshold:
            print(f"{st.st_ctime - st.st_mtime:>10.0f} {path}")

Скрипт не доказывает подделку: ctime обновляется при chmod, chown, переименовании и смене прав, поэтому расхождение может быть штатным. Он даёт список для ручной проверки.

Полный разбор образа без монтирования — задача криминалистических инструментов: sleuthkit (fls, istat) показывает inode и списки каталогов, debugfs — поля конкретного inode. Порядок действий при таком разборе — сначала снять копию образа, потом анализировать копию, сохраняя исходник нетронутым 5.

На несмонтированном образе метки читаются через debugfs:

sudo debugfs -R "stat /path/in/image" /dev/sdX1
sudo debugfs -R "logdump -a" /dev/sdX1 | grep -i 'ctime\|mtime'

Оба класса приёмов — изменение кода до загрузки ОС и подделка следов — описаны в модели MITRE ATT&CK как техники T1542 (Pre-OS Boot) и T1070.006 (Indicator Removal: Timestomp) 6.

Матрица «приём — признак — контрмера»

ПриёмПризнакКонтрмера
Правка /etc/default/grubmtime файла, параметры в /proc/cmdlineAIDE на /etc, auditd -w /etc/default/grub -p wa
Правка grub.cfgхеш файла, лишние пункты менюupdate-grub из конфигурационного менеджера, хеш в эталоне
Хук в /etc/initramfs-tools/scriptsфайл вне пакета, dpkg -S не находитauditd -w, сверка lsinitramfs с эталоном
Хук в /etc/kernel/postinst.dфайл вне пакета, вызов update-initramfsаудит каталога, контроль dpkg -V
Подмена /init в образехеш /boot/initrd.img-* изменилсяхеш вне машины, подпись образа, IMA
Загрузка с rdinit=/bin/sh/proc/cmdline в работающей системежурнал загрузки, TPM PCR, auditd на kexec
Подделка mtime через touchctime > mtimeAIDE, find -newermt, сверка с манифестом
Подделка через debugfscrtime не совпадает с окружением, журнал ext4logdump, sleuthkit, анализ на копии образа
Чистка wtmpпропуск записей, расхождение с journalctlотправка журналов на сервер, auditd
Чистка journald__SEQNUM, --verify, размер журналаSeal=yes, systemd-journal-remote, auditd

Что ломает приём целиком

  • Secure Boot с подписанным initramfs и UKI — образ проверяется до выполнения.
  • IMA appraisal в режиме enforce — неподписанные файлы не исполняются.
  • Измеряемая загрузка (TPM PCR 9/12) — изменение образа видно при аттестации.
  • dm-verity на корне — прямой доступ к блочному устройству не даёт изменить данные незаметно.
  • Только чтение /boot — смонтированный read-only раздел не даёт пересобрать образ без перемонтирования.
  • Отсутствие root — все приёмы темы требуют прав администратора на своей машине.

Итоги темы

Загрузка Linux — последовательность доверия, а не монолит. Прошивка доверяет подписи shim, shim — подписи GRUB, GRUB — подписи ядра; initramfs в эту цепочку по умолчанию не входит. Именно поэтому изменение initramfs — самый долгоживущий приём темы: он исполняется до systemd, до журналов, до профилей безопасности и переживает обновления пакетов, если правка сделана не в образе, а в хуке, который этот образ собирает.

Вторая половина темы — про следы. В ext4 четыре времени, и пользовательские вызовы меняют только два; ctime и btime обновляет ядро. Прямая запись в inode через debugfs обходит это ограничение, но оставляет журнал, несоответствие crtime окружению и запись в аудите. Очистка журналов убирает записи из одного места и не убирает из другого: wtmp, journald, auth.log, auditd и удалённый сервер хранят одно и то же событие независимо.

Практический вывод: обнаружение строится не на одной метке и не на одном журнале, а на сравнении нескольких независимых источников. Хеш /boot вне машины, эталон AIDE, порядковые номера journald, crtime рядом с соседними файлами — каждый источник по отдельности слаб, вместе они дают картину.

Следующая тема опускается ещё ниже — в ядро и в сеть: как скрыть процесс от ps, а соединение — от ss, и почему user-mode сокрытие всегда оставляет щель, которую видно прямым системным вызовом.

Литература

  1. The Linux man-pages project. — https://man7.org/linux/man-pages/
  2. systemd. Документация systemd.unit, systemd.service, systemd.timer, journalctl. — https://www.freedesktop.org/software/systemd/man/
  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. NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.
  6. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/
  7. Bovet D. P., Cesati M. Understanding the Linux Kernel. 3rd ed. — O'Reilly Media, 2005.
  8. Linux Kernel Documentation. — https://docs.kernel.org/