Контейнер новый, код старый: почему инцидент вернулся через три месяца

Есть в инфобезе сценарий, который повторяется с завидным постоянством. Инцидент вроде бы закрыли, отчитались, выдохнули — а через пару недель выясняется, что атакующий никуда не уходил. Он просто переждал. Именно так и произошло здесь, и именно поэтому эта история стоит того, чтобы её разобрать не по верхам, а по‑настоящему.
Два сайта — appeal и new портал на 1С‑Битрикс — несли один и тот же веб‑шелл WebShellOrb (WSO). В первый раз инцидент обнаружили по логам Nginx: с одного адреса шли POST‑запросы к /assets/images/access.php и следом GET‑запросы к /assets/images/<hash>index.php, причём реферером выступал сам шелл. Дальше началось самое неприятное. Логи хранились всего месяц, и следов сканирования в них не оказалось. Точку входа пришлось искать догадками — и вывод получился такой: раз следов нет, значит, проникновение было раньше, за пределами окна хранения логов. Вывод разумный, но он же и закрыл поиск: раз данных нет, искать дальше негде.
Было принято решение проверить агенты 1С‑Битрикс — записей с подозрительным кодом там не оказалось. На этом основании решили, что закрепления нет, и перезапустили контейнер. Казалось бы, что ещё нужно. Через некоторое время злоумышленник снова был внутри. Инцидент растянулся почти на три месяца, и повторная компрометация случилась уже после того, как были приняты меры.
И вот тут очень легко ошибиться в выводах. Соблазнительно сказать: «ну, значит, меры сделали плохо». Но это было бы неправдой. Меры сделали так, как обычно делают, — и именно поэтому они не сработали. Начали копать с админами, оказалось, что в файловой системе, где ресурсы, написанные на 1С Битрикс, appeal являлся самописным сайтом из прошлого проекта и его просто не перенесли на CMS. Тогда у меня и возникла идея посмотреть, что у него «под капотом». В итоге меры не трогали корень и уязвимость осталась в исходном коде. Инфраструктура — общая файловая система, общий системный пользователь, секрет в конфиге — сохранила и доступ, и закрепление. Давайте по порядку.
Атака шла поэтапно. Сначала — эксплуатация уязвимости на appeal. Затем успешный вход через /api/auth/login и массовая выгрузка данных через get‑list за один запрос. Такой симбиоз должен был насторожить ещё тогда. Отдельно фиксируется обращение к POST /api/appeal/get‑report — через него и была проведена SQL‑инъекция. Дальше — первое обращение к шеллу в /assets/images/ на appeal и почти сразу появление того же шелла на втором сайте. Спустя несколько часов — загрузка ещё двух шеллов index.php для закрепления. Повторная активность от системного пользователя спустя недели. Так мы узнали, что «пересоздать контейнер» — не то же самое, что «вылечить проблему».
Теперь посмотрим, на чём именно держалась цепочка. Слабых мест в самописном сайте несколько, и каждое дало атакующему конкретный шаг — не абстрактную «уязвимость», а именно шаг вперёд.
Первое — SQL‑инъекция в AppealModel::getReportData() в AppealModel.php. Пользовательские данные попадают в SQL без параметризации, а флаг $isSkipArgs = true вызывает skipArgs(), который отключает защиту.

Второе — обход аутентификации, так как для login и logout JWT‑токен просто не выполняется. Мы видим это на скриншоте:

Третье — утечка хешей паролей в User.php. Список полей включает password, а getRow(5) всегда отдаёт пользователя с ID = 5, так что хеши фактически доступны через API.

Четвёртое — JWT‑секрет в открытом виде: ключ лежит в конфиге обычной строкой, и кто получил доступ к коду, получил и ключ. Каждое из этих мест по отдельности выглядит как «ну, бывает». Вместе они складываются в открытую дверь.

Дальше всё разворачивается по знакомому сценарию: инъекция, хеши паролей, вход по JWT, выгрузка данных, загрузка шелла в /assets/images/ на appeal, копирование шелла на new через общую файловую систему и ещё два шелла для закрепления.
После развертывания контейнера админы успокоились и сайты продолжили функционировать. Теперь мы подошли к самому интересному: «Почему же меры не помогли?». Вот ряд причин:
Первая: смена пароля не закрывает вход. JWT‑секрет лежит в конфиге, а Identity.php пропускает login/logout без проверки токена. Даже после смены пароля администратора злоумышленник, знающий секрет, подделывает токены. Если секрет не менялся, смена пароля вообще не влияет на уже выданные JWT — вход остаётся открытым в обход пароля. Звучит почти как издевательство, но это ровно то, что написано в коде.
Вторая: общая инфраструктура сохраняет закрепление. Оба сайта работают под одним пользователем <пользователь> и в общей файловой системе. Шелл с appeal копируется в каталог new через общую ФС, и переразвёртывание одного контейнера не затрагивает общий том и соседний сайт. Один вылечили — второй остался носителем.
Третья: учётные данные остаются доступными. User::getList() возвращает поле password, getRow(5) всегда отдаёт пользователя с ID = 5. Пока методы не исправлены, хеши можно извлечь повторно и снова подобрать пароль — смена пароля не устраняет канал утечки. Это как менять замок, оставив ключ под ковриком.
Четвертая: закрепление многослойно. За инцидент загружено несколько шеллов, повторные обращения фиксировались спустя недели. Переразвёртывание не удаляет файлы из общей ФС и не проверяет cron и systemd — точки закрепления переживают «пересоздание» окружения.
Если сложить эти пять причин вместе, то «и ежу понятно», что именно поэтому инцидент воспроизводился после, казалось бы, радикальных действий. Первичная мера «пересоздать и сменить пароль = закрыть инцидент» ломается именно здесь: она предполагает, что состояние атаки живёт в контейнере и в пароле, а на самом деле оно живёт в коде, в общем томе и в конфиге.
Вывод простой и неприятный. Инцидент закрывается не тогда, когда пересоздано окружение, а тогда, когда устранены условия эксплуатации. В этом случае — параметризация SQL, отказ от skipArgs(), проверка прав при вызове методов модели, валидация $ROUTE['action'], вынос JWT‑секрета в переменные окружения, удаление password из getList() и getRow(5), разграничение пользователей и файловых систем для сайтов. Пока хоть одно условие остается, «пересоздать и сменить пароль» не решит проблему с незваным гостем. И это, пожалуй, главный урок нашего инцидента: иногда самые очевидные меры реагирования лечат симптом, а не болезнь, и тогда злоумышленник возвращается — тихо, спустя недели, как будто никакой реакции и не было.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.