MAATRIX / Блог / Майнер прячется от top: почему процесс невидим и как его достать

Майнер прячется от top: почему процесс невидим и как его достать

MAATRIX

Вентиляторы у хостера явно не просто так гудят, load average держится под потолком сутками — а вы открываете top, htop, ps aux --sort=-%cpu и видите десяток процессов, каждый из которых ест по 1-2%. Арифметика не сходится: где-то есть процесс, который жрёт ресурсы, но не показывается ни в одном стандартном инструменте. Это не глюк мониторинга — часть вредоносного ПО, включая майнеры криптовалюты, умеет прятать себя от ps/top либо на уровне библиотек, либо прямо на уровне ядра. Разберём, как заподозрить такую подмену по цифрам, а не по ощущениям, какими способами вытащить скрытый процесс на свет и в какой момент обычная диагностика становится бессмысленной и пора грузиться с чистого образа.

Как заподозрить: расхождение между суммарной загрузкой CPU и суммой процессов

Первый и самый надёжный сигнал — не «сервер тормозит», а конкретное числовое расхождение. Верхняя строка top (%Cpu(s): us, sy, ni, id, wa, hi, si, st) считается ядром независимо от того, что top потом покажет в списке процессов ниже. Если id (idle) низкий, а сумма %CPU по всем видимым строкам далека от 100 - id, разница — это то, что съедено процессом, которого вы не видите.

Проверить руками:

top -bn1 | awk '/^%Cpu/{print; next} NR>7 && $9 ~ /^[0-9.]+$/{sum+=$9} END{print "Сумма %CPU видимых процессов:", sum}'
vmstat 1 5
mpstat -P ALL 1 5

Смотрите на id за 5-10 секунд усреднения, а не в моменте — майнеры почти всегда держат нагрузку ровно, без всплесков. Если idle стабильно низкий, а сумма %CPU процессов на многоядерном сервере (поделите на число ядер: nproc) даёт условные 20-30%, а не 70-90% — гэп в 40-50 процентных пунктов объяснить нечем, кроме скрытого процесса.

Два ложных срабатывания, которые стоит исключить сразу:

  • Steal time. На виртуальном сервере высокий %st означает, что гипервизор не отдаёт вам процессор, а не то, что у вас что-то спрятано — это отдельная история, разобранная в статье про steal time и соседей по железу.
  • iowait и легитимные фоновые задачи. Прежде чем подозревать руткит, стоит пройти базовый чек-лист поиска прожорливого процесса — иногда виновник просто не туда отсортирован или скрыт контейнером. Общий план разбора нагрузки — в статье «высокая нагрузка на процессор: как найти причину».

Если оба варианта исключены, а расхождение стабильно воспроизводится на нескольких снятиях подряд — переходите к следующим разделам.

LD_PRELOAD: как процесс исчезает из ps и top, оставаясь в памяти

Самый частый и самый простой в реализации способ спрятать процесс — не трогать ядро вообще, а подменить библиотечные вызовы, которыми ps, top и ls /proc читают список процессов. Динамический линковщик Linux перед запуском любого динамически слинкованного бинарника читает переменную LD_PRELOAD или файл /etc/ld.so.preload и подгружает указанные там .so-библиотеки с приоритетом выше системной libc. Вредоносная библиотека переопределяет функции вроде readdir()/readdir64()/opendir() (а иногда и чтение /proc/net/tcp для сокрытия сетевых соединений) так, чтобы запись про нужный PID или имя процесса просто выпадала из результата.

Ключевая деталь: подмена происходит не в ядре, а в пользовательском пространстве, на уровне конкретного вызова библиотеки. Ядро честно отдаёт директорию /proc целиком — врёт именно библиотека, через которую ps/top/ls её читают.

Проверка:

cat /etc/ld.so.preload
ls -la /etc/ld.so.preload
find / -xdev -name '*.so' -newer /etc/passwd -mtime -30 2>/dev/null
grep -r LD_PRELOAD /etc/environment /etc/profile.d/ /etc/systemd/system/*.service 2>/dev/null

Пустой /etc/ld.so.preload не гарантирует чистоту — LD_PRELOAD можно выставить точечно, для конкретного демона, через Environment=LD_PRELOAD=... в systemd unit.

Слабое место этой техники для атакующего — она работает только против динамически слинкованных бинарников. Статически собранный инструмент (или busybox) не обращается к ld.so и не видит LD_PRELOAD вообще, потому что делает системные вызовы напрямую:

file /bin/busybox
ldd /bin/busybox   # "not a dynamic executable" — хороший знак
busybox ps aux

Если busybox ps aux показывает лишний процесс — LD_PRELOAD-подмена подтверждена, и это относительно легко лечится: удаляете точку внедрения (/etc/ld.so.preload, юнит systemd, сам .so-файл), потом убиваете процесс и точку его персистентности.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Подмена /proc на уровне ядра: когда врёт уже не библиотека

Если статический busybox тоже не видит подозрительный процесс — дело серьёзнее LD_PRELOAD. Это означает, что подмена происходит внутри самого ядра: загружаемый модуль (LKM-руткит) перехватывает системный вызов getdents/getdents64 или коллбэк iterate_shared виртуальной файловой системы /proc, и тогда фильтрация касается абсолютно любого процесса, читающего директорию — динамического, статического, только что скомпилированного вами на месте.

Более новый вариант той же идеи — использование eBPF-программ, прицепленных к нужным syscall'ам или трейспоинтам, без загрузки классического .ko-модуля. Это усложняет обнаружение через lsmod, потому что формально никакого «нового модуля» может и не быть.

Косвенные признаки:

lsmod | wc -l
ls /sys/module | wc -l
diff <(lsmod | awk 'NR>1{print $1}' | sort) <(ls /sys/module | sort)
dmesg | tail -100 | grep -iE 'module|taint'
cat /proc/sys/kernel/tainted

Некоторые руткиты удаляют себя из связного списка, который читает lsmod, но забывают почистить /sys/module/<имя>/ — расхождение между этими источниками проверяйте в первую очередь. Ненулевой /proc/sys/kernel/tainted тоже повод присмотреться, хотя его «пачкают» и легитимные сторонние модули (проприетарные драйверы, ZFS), так что это не приговор сам по себе.

Если в системе есть bpftool, стоит посмотреть на активные программы:

bpftool prog list
bpftool prog show | grep -iE 'getdents|proc'

Учтите честно: легитимные security-агенты и системы трассировки тоже вешают eBPF-программы на похожие точки — сам факт наличия программы не доказательство компрометации, важен контекст.

Практика: сравниваем /proc напрямую с ps и добираемся до unhide

Прямое сравнение списка PID из /proc со списком из ps — рабочий метод, но с оговоркой: если ls/ps оба динамически слинкованы и оба подпадают под один и тот же LD_PRELOAD-хук, они солгут одинаково, и diff ничего не покажет. Поэтому одну сторону сравнения нужно брать из инструмента, который не проходит через подменённую libc:

busybox ls /proc | grep -E '^[0-9]+$' | sort -n > /tmp/proc_pids.txt
ps -eo pid --no-headers | sort -n > /tmp/ps_pids.txt
comm -23 /tmp/proc_pids.txt /tmp/ps_pids.txt

Результат этой команды даёт диагностическую развилку:

  • Если busybox видит PID, которых нет в ps — подтверждена LD_PRELOAD-подмена (раздел выше), лечится относительно просто.
  • Если busybox тоже не видит ничего лишнего, а расхождение по CPU из первого раздела всё равно есть — сокрытие происходит на уровне ядра, и листинг директории /proc в принципе не поможет, потому что лжёт сам источник данных, а не инструмент чтения.

Для второго случая нужны методы, которые не зависят от перечисления директории. Здесь пригождается unhide — набор утилит, специально написанный для такой задачи:

apt install unhide -y    # Debian/Ubuntu
unhide proc              # сверяет /proc с ps/ls/lsof несколькими способами
unhide sys                # опрашивает PID через syscall'ы (getpgid, sched_getaffinity и т.п.), а не через листинг
unhide brute              # комбинирует оба подхода по всему диапазону PID

Режим sys полезнее всего против ядерных руткитов: вместо чтения директории он перебирает PID и дёргает системные вызовы, которые ничего не «листают», а просто спрашивают ядро про конкретный номер процесса. Чтобы спрятать процесс и от такой проверки, пришлось бы перехватывать десяток syscall'ов индивидуально вместо одного getdents — на порядок больше работы, поэтому многие реальные образцы этого не делают.

Если unhide недоступен, тот же принцип воспроизводится вручную через kill -0 — сигнал 0 ничего не отправляет, а только проверяет, существует ли процесс:

for pid in $(seq 1 32768); do
  kill -0 "$pid" 2>/dev/null && [ ! -e "/proc/$pid" ] && echo "возможно скрыт: $pid"
done

Запускайте под root — иначе kill -0 не пройдёт проверку прав для чужих процессов, и результат будет ложноотрицательным. Метод медленный, но именно поэтому его сложно обмануть частичным хуком.

Более широкий чек-лист компрометации — не только скрытые процессы, но и сеть, cron, целостность бинарников — собран в статье «скрытые процессы на сервере: как найти»; используйте её как общий каркас, а этот материал — как углублённый разбор именно механизма невидимости.

Статистика планировщика ядра: то, что сложнее подделать

Даже если процесс не виден ни в одном листинге, он физически исполняется на процессоре — а значит, где-то в учёте планировщика ядра его время всё равно фигурирует, просто не в виде удобной строчки с именем.

/proc/schedstat даёт агрегированные по каждому CPU счётчики (время выполнения задач, время ожидания в очереди, число переключений контекста). Это монотонные счётчики, поэтому смотреть нужно дельту между двумя снятиями:

cat /proc/schedstat > /tmp/sched1
sleep 10
cat /proc/schedstat > /tmp/sched2
diff /tmp/sched1 /tmp/sched2

Резкий рост «времени выполнения» на ядре, которое по сумме видимых процессов должно почти простаивать — ещё одно подтверждение расхождения из первого раздела, уже привязанное к конкретному core, что помогает понять, сидит ли скрытый процесс на одном ядре (частый случай для майнеров без настроенного affinity) или мигрирует.

Если у вас уже включён BSD-style process accounting, у вас есть кое-что посерьёзнее снапшотов — постоянный журнал, который ядро пишет само при завершении каждого процесса, независимо от ps/top:

apt install acct -y
accton /var/log/account/pacct
lastcomm | head -50
sa -m

lastcomm/sa показывают историю запусков с именем команды, пользователем и CPU-временем — запись создаётся ядром в момент выхода процесса, а не читается через /proc, поэтому простая LD_PRELOAD-подмена на неё не влияет. Минус — он полезен только с момента включения, задним числом ничего не восстановить. Включайте сразу после первого подозрения и не выключайте — для периодически прячущегося процесса это часто самый прямой источник правды.

Когда пора грузиться с live-образа для чистой диагностики

Если дошли до этого раздела и ни busybox, ни unhide sys, ни диагностика планировщика не дают ясности, а lsmod/sys/module показывают расхождение — доверять любому инструменту внутри работающей системы больше нельзя. Вы не знаете, насколько глубоко перехвачены системные вызовы, а значит не можете быть уверены даже в результатах только что описанных проверок.

Важная оговорка: перезагрузка убивает сам процесс, который вы ловите. Если есть время разобраться подробно, перед ребутом зафиксируйте что успеете — вывод unhide brute, /proc/<pid>/status, /proc/<pid>/maps, попытку прочитать /proc/<pid>/exe, активные соединения через ss/tcpdump. Если время критично — быстрее и правильнее сразу изолировать сервер от сети через панель хостера или файрвол гипервизора, не дожидаясь идеальной криминалистики.

Дальше — рескью-режим:

# у большинства провайдеров VPS/выделенных серверов есть live/rescue-режим в панели
mount -o ro /dev/vda1 /mnt/target

Принципиально важно: диск монтируется read-only, а проверка идёт инструментами из доверенного live-окружения, а не бинарниками с заражённого диска — даже chroot-переход не спасает, если внутри вы всё равно исполняете скомпрометированный /bin/ps:

debsums --root /mnt/target -c
# или для RHEL-based
rpm --root /mnt/target -Va | sort
find /mnt/target/lib/modules -name '*.ko*' -newer /mnt/target/etc/hostname
cat /mnt/target/etc/ld.so.preload 2>/dev/null

Критерии, по которым пора переходить именно на этот шаг, а не продолжать копать живьём:

  • расхождение CPU из первого раздела воспроизводится, но статический busybox и unhide sys ничего не находят — сокрытие явно ядерное;
  • lsmod и /sys/module расходятся, либо видны незнакомые eBPF-программы без внятного объяснения;
  • журнал acct/lastcomm выглядит подозрительно пустым в моменты, когда нагрузка была явно высокой — то есть под сомнением уже и сама подсистема учёта.

После подтверждения компрометации на уровне ядра лечить систему «на месте» обычно бессмысленно — слишком много неизвестных о том, что и как глубоко изменено. Практичный путь — поднять окружение заново из чистого образа, перенеся только проверенные данные, а не бинарники. Как построить этот процесс и не наступить на те же грабли — в статьях «майнер на сервере: как он попал и как его выкорчевать» и «incident response plan: что делать при взломе».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если ps и top ничего не показывают, а нагрузка есть — это точно руткит?

Не обязательно. Сначала исключите steal time (для VPS) и iowait, потом посчитайте расхождение между idle из /proc/stat и суммой %CPU видимых процессов за несколько снятий подряд. Если гэп стабильный и большой — переходите к проверке LD_PRELOAD и статическим инструментам.

Почему diff между /proc и выводом ps иногда ничего не находит, хотя процесс точно скрыт?

Потому что оба инструмента могли пройти через один и тот же перехваченный библиотечный вызов и солгать одинаково. Сравнение имеет смысл, только если один источник статически слинкован (busybox) или использует принципиально другой путь получения данных — syscall-перебор через unhide sys, а не листинг директории.

Как быстро понять, LD_PRELOAD это или подмена на уровне ядра?

Запустите busybox ps aux рядом с обычным ps aux. Если busybox видит лишний процесс — это LD_PRELOAD, лечится вычисткой /etc/ld.so.preload и точки персистентности. Если оба инструмента слепы одинаково — ищите дальше на уровне модулей ядра и eBPF.

Стоит ли сразу включать process accounting (acct), не дожидаясь подозрений?

Разумно для серверов с чувствительной нагрузкой — накладные расходы минимальны, а исторический журнал, независимый от ps/top, потом экономит время именно в такой ситуации. Но он бесполезен задним числом: если инцидент уже случился, а acct включаете только сейчас, прошлого не восстановить.

Можно ли обойтись без ребута и live-образа, если процесс явно ядерный?

Технически можно углубиться дальше (анализ памяти, форензик-инструменты), но это требует опыта и времени, которых обычно нет в разгар инцидента. Практичнее изолировать сервер от сети, снять снапшот диска для анализа не спеша и переустановить систему с нуля.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →