Бэкдор в системных файлах 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 waitingCPU:  2.6% user,  0.0% nice, 97.3% system,  0.0% interrupt,  0.0% idleMem: 379M Active, 5860M Inact, 21G Wired, 104K Buf, 20G FreeARC: 18G Total, 1732M MFU, 16G MRU, 41M Anon, 203M Header, 198M Other     17G Compressed, 99G Uncompressed, 5.84:1 RatioSwap: 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-cgi81716 root        125    0  9028K  1400K CPU7     7 348.4H  74.61% php-cgi90499 root        126    0  9028K  1416K CPU9     9 348.8H  73.91% php-cgi55209 root         59    0  9028K  1428K RUN      9 348.1H  73.65% php-cgi94044 root         59    0  9028K  1404K RUN     18 348.0H  72.70% php-cgi11272 root        125    0  9028K   956K CPU12   12  34:14  72.63% php-cgi59994 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 NAME8544 php-cgi           text v r r-------   -       - -   /usr/local/bin/php-cgi8544 php-cgi            cwd v d r-------   -       - -   /usr/local/bin8544 php-cgi           root v d r-------   -       - -   /8544 php-cgi              0 s - rw------  11       0 UDS 0 0 /var/lib/php/tmp/php-fastcgi.socket-48544 php-cgi              1 v c rw---n--  32       0 -   /dev/null8544 php-cgi              2 v c rw---n--  31       0 -   /dev/null8544 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-cgiSHELL=/bin/shPATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbinREQUESTS_CA_BUNDLE=/usr/local/etc/ssl/cert.pem#minute hour    mday    month   wday    command1       *       *       *       *       (/usr/local/sbin/configctl -d syslog archive) > /dev/null*/4     *       *       *       *       (/usr/local/sbin/ping_hosts.sh) > /dev/null0       22      *       *       *       (/usr/local/sbin/configctl -d firmware changelog cron) > /dev/null0,15,30,45      *       *       *       *       (/sbin/pfctl -qt 'virusprot' -T expire '3600') > /dev/null0,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/null0       1       *       *       *       (/usr/local/sbin/configctl -d system remote backup 3600) > /dev/null*       *       *       *       *       (/usr/local/opnsense/scripts/nginx/ngx_autoblock.php) > /dev/null1       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/shPATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbinREQUESTS_CA_BUNDLE=/usr/local/etc/ssl/cert.pem#minute hour    mday    month   wday    command1       *       *       *       *       (/usr/local/sbin/configctl -d syslog archive) > /dev/null*/4     *       *       *       *       (/usr/local/sbin/ping_hosts.sh) > /dev/null0       22      *       *       *       (/usr/local/sbin/configctl -d firmware changelog cron) > /dev/null0,15,30,45      *       *       *       *       (/sbin/pfctl -qt 'virusprot' -T expire '3600') > /dev/null0,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/null0       1       *       *       *       (/usr/local/sbin/configctl -d system remote backup 3600) > /dev/null*       *       *       *       *       (/usr/local/opnsense/scripts/nginx/ngx_autoblock.php) > /dev/null1       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 --allChecking all packages:  42%opnsense-26.7.1_1: checksum mismatch for /usr/local/etc/inc/system.incChecking 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 --allChecking 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. Своевременное обновление. В нашем случае отставание версии на пару релизов не сыграло большой роли, но обновляться всё равно необходимо вовремя.

Инцидент закрыт, бэкдор удалён, система обновлена. Кто и как его установил — мы так и не узнали. Буду рад, если эта статья поможет кому‑то решить похожую проблему.

ссылка на оригинал статьи https://habr.com/ru/articles/1086052/