Бэкдор в системных файлах OPNsense: как расследовали внедрение и что из этого вышло

Хочу поделиться разбором инцидента, который заставил меня вместо привычного GUI окунуться в файловую систему OPNsense. Я расскажу, как с виду легитимный, но подозрительный процесс привёл к обнаружению бэкдора в системном файле, почему расследование зашло в тупик и какие выводы мы из него вынесли. Всё по существу.
TL;DR: В OPNsense обнаружен процесс php‑cgi, пожирающий 100% CPU. Оказался бэкдором, вшитым в системный файл system.inc через cron. Источник внедрения не установлен — помешал общий root‑пароль и отсутствие внешнего логирования. Выводы — в конце.
В один из рабочих дней я столкнулся с неприятным симптомом: веб‑интерфейс нашего межсетевого экрана OPNsense начал жутко тормозить. На тот момент (13.09.2026) на шлюзе была установлена версия OPNsense 26.7.1_1 (последней сборкой тогда была 26.7.4). Причина: утилизация процессора на OPNsense уперлась в потолок, при этом процессорное время тратилось по большей части на системные процессы.
Первая мысль — банальный баг в ПО. Ведь системный процесс обслуживания веб‑интерфейса (OPNsense построен на PHP) не должен утилизировать все ресурсы процессора, лишая ИБ‑задачи вычислительных ресурсов.
last pid: 89763; load averages: 29.55, 27.45, 26.24 up 36+22:54:07 11:23:05
1125 threads: 58 running, 1012 sleeping, 55 waiting
CPU: 2.6% user, 0.0% nice, 97.3% system, 0.0% interrupt, 0.0% idle
Mem: 379M Active, 5860M Inact, 21G Wired, 104K Buf, 20G Free
ARC: 18G Total, 1732M MFU, 16G MRU, 41M Anon, 203M Header, 198M Other
17G Compressed, 99G Uncompressed, 5.84:1 Ratio
Swap: 8192M Total, 8192M Free
PID USERNAME PRI NICE SIZE RES STATE C TIME WCPU COMMAND
8544 root 125 0 9028K 1400K CPU23 23 349.4H 75.04% php-cgi
81716 root 125 0 9028K 1400K CPU7 7 348.4H 74.61% php-cgi
90499 root 126 0 9028K 1416K CPU9 9 348.8H 73.91% php-cgi
55209 root 59 0 9028K 1428K RUN 9 348.1H 73.65% php-cgi
94044 root 59 0 9028K 1404K RUN 18 348.0H 72.70% php-cgi
11272 root 125 0 9028K 956K CPU12 12 34:14 72.63% php-cgi
59994 root 59 0 9028K 1392K RUN 18 348.8H 72.58% php-cgi
...Анатомия аномалии
Виновником аномалии оказался процесс php‑cgi. У него обнаружился ряд странных особенностей: полное отсутствие аргументов командной строки и единственный открытый дескриптор — Unix‑сокет (никаких внешних сетевых соединений).
$ procstat files 8544
PID COMM FD T V FLAGS REF OFFSET PRO NAME
8544 php-cgi text v r r------- - - - /usr/local/bin/php-cgi
8544 php-cgi cwd v d r------- - - - /usr/local/bin
8544 php-cgi root v d r------- - - - /
8544 php-cgi 0 s - rw------ 11 0 UDS 0 0 /var/lib/php/tmp/php-fastcgi.socket-4
8544 php-cgi 1 v c rw---n-- 32 0 - /dev/null
8544 php-cgi 2 v c rw---n-- 31 0 - /dev/null
8544 php-cgi 3 v r rw------ 6 0 - -
8544 php-cgi 4 ? - -------- 2 0 - -Поскольку процесс явно мешал работе и выглядел странно, я попытался завершить его принудительно (killall -9 php-cgi). Однако процессы автоматически создавались заново. Первое подозрение пало на cron — и оно подтвердилось.
# or /usr/local/etc/cron.d and follow the same format as
# /etc/crontab, see the crontab(5) manual page.
@reboot sleep 5; /usr/local/sbin/php-cgi
SHELL=/bin/sh
PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
REQUESTS_CA_BUNDLE=/usr/local/etc/ssl/cert.pem
#minute hour mday month wday command
1 * * * * (/usr/local/sbin/configctl -d syslog archive) > /dev/null
*/4 * * * * (/usr/local/sbin/ping_hosts.sh) > /dev/null
0 22 * * * (/usr/local/sbin/configctl -d firmware changelog cron) > /dev/null
0,15,30,45 * * * * (/sbin/pfctl -qt 'virusprot' -T expire '3600') > /dev/null
0,15,30,45 * * * * (/sbin/pfctl -qt 'sshlockout' -T expire '3600') > /dev/null
* * * * * (/usr/local/bin/flock -n -E 0 -o /tmp/updaterrd.lock /usr/local/opnsense/scripts/health/updaterrd.php) > /dev/null
0 1 * * * (/usr/local/sbin/configctl -d system remote backup 3600) > /dev/null
* * * * * (/usr/local/opnsense/scripts/nginx/ngx_autoblock.php) > /dev/null
1 3 1 * * (/usr/local/sbin/configctl -d filter schedule bogons) > /dev/null
* * * * * (/usr/local/bin/flock -n -E 0 -o /tmp/filter_update_tables.lock /usr/local/opnsense/scripts/filter/update_tables.py --quick) > /dev/nullПоиск источника заражения
В нашей инфраструктуре кроме основного OPNsense есть и резервный. Они развёрнуты в кластере, имеют одинаковые наборы и версии пакетов. На втором OPNsense не был запущен этот процесс и отсутствовала команда php‑cgi в cron. Если бы это была легитимная строка из штатного обновления, она была бы и на втором узле.
# or /usr/local/etc/cron.d and follow the same format as
# /etc/crontab, see the crontab(5) manual page.
SHELL=/bin/sh
PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
REQUESTS_CA_BUNDLE=/usr/local/etc/ssl/cert.pem
#minute hour mday month wday command
1 * * * * (/usr/local/sbin/configctl -d syslog archive) > /dev/null
*/4 * * * * (/usr/local/sbin/ping_hosts.sh) > /dev/null
0 22 * * * (/usr/local/sbin/configctl -d firmware changelog cron) > /dev/null
0,15,30,45 * * * * (/sbin/pfctl -qt 'virusprot' -T expire '3600') > /dev/null
0,15,30,45 * * * * (/sbin/pfctl -qt 'sshlockout' -T expire '3600') > /dev/null
* * * * * (/usr/local/bin/flock -n -E 0 -o /tmp/updaterrd.lock /usr/local/opnsense/scripts/health/updaterrd.php) > /dev/null
0 1 * * * (/usr/local/sbin/configctl -d system remote backup 3600) > /dev/null
* * * * * (/usr/local/opnsense/scripts/nginx/ngx_autoblock.php) > /dev/null
1 3 1 * * (/usr/local/sbin/configctl -d filter schedule bogons) > /dev/null
* * * * * (/usr/local/bin/flock -n -E 0 -o /tmp/filter_update_tables.lock /usr/local/opnsense/scripts/filter/update_tables.py --quick) > /dev/nullВ работе с cron OPNsense имеет особенность. Он сам наполняет cron на основе пользовательской конфигурации (/usr/local/etc/cron.d или из GUI) и своих собственных, системных задач (для обновления сигнатур, базы GeoIP...). OPNsense не сохраняет изменения, внесённые через crontab ‑e. Каким путём команда оказалась в cron?
Директории с пользовательскими настройками были пусты, в GUI также ничего похожего замечено не было. Поиск совпадений по всем файлам дал подсказку: php‑cgi запускается через cron и попадает в него из системного файла /usr/local/etc/inc/system.inc, который ни один пользователь не менял руками.
$crontab_contents = "# DO NOT EDIT THIS FILE -- OPNsense auto-generated file\n";
$crontab_contents .= "#\n";
$crontab_contents .= "# User-defined crontab files can be loaded via /etc/cron.d\n";
$crontab_contents .= "# or /usr/local/etc/cron.d and follow the same format as\n";
$crontab_contents .= "# /etc/crontab, see the crontab(5) manual page.\n";
$crontab_contents .= "@reboot sleep 5; /usr/local/sbin/php-cgi\n";
$crontab_contents .= "SHELL=/bin/sh\n";
$crontab_contents .= "PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin\n";
$crontab_contents .= "REQUESTS_CA_BUNDLE=/usr/local/etc/ssl/cert.pem\n";
$crontab_contents .= "#minute\thour\tmday\tmonth\twday\tcommand\n";Анализ угроз и локализация
После экспресс‑анализа ситуации и общения с коллегами сформировались две теории: мы имеем дело либо с майнером (из‑за большой нагрузки на процессор), либо с бэкдором (пусть и через socket). Первый вариант отпадал из‑за того, что не было никакого открытого соединения с внешним сервером, также нагрузка шла на System, а не на User CPU. Второй сценарий выглядел гораздо реалистичнее: даже в таком изолированном виде php‑cgi мог использоваться атакующими в качестве локального бэкдора для выполнения произвольного кода с правами root.
Я понял, что ситуация критичная, и принял решение:
Заблокировать весь трафик, исходящий от самого OPNsense в интернет;

Проверить целостность всех пакетов OPNsense на соответствие хешам из официального репозитория;
$ pkg check --checksums --all
Checking all packages: 42%
opnsense-26.7.1_1: checksum mismatch for /usr/local/etc/inc/system.inc
Checking all packages: 100%Исправить руками системный файл и удалить из него php‑cgi;
$ sed -i '' '1146d' /usr/local/etc/inc/system.incПерезагрузить службу cron и убрать оставшиеся процессы php‑cgi из системы.
$ configctl cron restart
$ killall -9 php-cgiРасследование
Проверка целостности после исправления показала, что все файлы пакетов целы.
$ pkg check --checksums --all
Checking all packages: 100%Тем не менее, злоумышленник мог так же подменить генерируемые из шаблонов и пользовательские конфиги. pkg check это проверить не может.
Проверка логов также ничего не дала (/var/log/, dmesg, auth.log), так как root‑пароль знали все, кому было разрешено заходить на OPNsense под своей учётной записью. В таком случае любой вход в систему даже под root‑пользователем выглядит легитимно. К тому же злоумышленник мог запросто почистить за собой логи.
GUI и SSH на OPNsense не были открыты «наружу» в интернет, а правила исключали возможность взлома не сотрудниками компании.
Длительное наблюдение за логами трафика, исходящего от OPNsense, показало, что кроме NTP и DNS запросов наружу ничего не летит.
Можно было бы провести дальнейшее расследование и выяснить, как именно появился бэкдор, кто им пользовался, но для этого не хватало опыта и времени.
Расследование зашло в тупик, но инцидент заставил нас пересмотреть подход к безопасности инфраструктуры.
Выводы для себя
1. Коллективный root. Использование общего root‑пароля внутри команды — огромная брешь для ИБ. Невозможно определить, кто и когда внёс изменение. Доступ должен быть строго персонализирован, а вход под root — запрещён. OPNsense мы поставили не так давно и решили, что на всякий случай доступ должен быть у всех.
2. Внешнее логирование. Логам на самом OPNsense верить нельзя — при получении root‑прав атакующий может их удалить. Необходима постоянная репликация логов на удалённый сервер.
3. Контроль целостности файлов. В данном случае pkg check --checksums --all успешно обнаружил модификацию system.inc. Проблема в другом: эта проверка запускается вручную, а не постоянно, и проверяет не все файлы. Для своевременного обнаружения вмешательства нужны системы контроля целостности файлов (FIM) — например, Syscheck в Wazuh, которые отслеживают изменения автоматически и оповещают в реальном времени.
4. Своевременное обновление. В нашем случае отставание версии на пару релизов не сыграло большой роли, но обновляться всё равно необходимо вовремя.
Инцидент закрыт, бэкдор удалён, система обновлена. Кто и как его установил — мы так и не узнали. Буду рад, если эта статья поможет кому‑то решить похожую проблему.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.