Криптомайнер на Gitea: 70% нагрузки CPU, RCE в self-hosted Gitea вместо GitLab и как я закрыл уязвимость

Я CTO и full‑stack в веб‑студии. Работаем командой, отдельного SRE нет, поэтому, инфраструктура на мне. Проектов за время работы накопилось довольно много и есть некая сборная солянка, с которыми приходиться взаимодействовать наиболее активно. Часть из них на PHP, часть на React, часть старых сайтов которые живут на shared-хостингах. Ничего космического. Обычная рабочая инфраструктура, где Git должен просто работать.
Но в какой-то момент GitLab стал периодически отваливаться: сетевые ограничения, фильтры, то непонятные блокировки.
Было принято решение сделать небольшой самохостинг (self-hosted) сервер для студии.
Не «enterprise-платформу мечты», а простой внутренний control plane, так сказать - управляющий слой:
Internet → Caddy → Gitea → PostgreSQLВ планах: поднять Gitea, перевезти туда проекты, добавить Gitea Actions Runner, позже подключить Registry, ещё позже научиться поднимать временные стенды в Yandex Cloud.
Архитектуру специально сделал простой: Docker, отдельные compose-проекты, Caddy как reverse proxy, PostgreSQL в отдельном контейнере, минимум лишних сервисов.
Всё работало. Gitea принимал HTTPS через Caddy, репозитории создавались. Потом появились обычные рабочие задачи, и настройка инфраструктуры на несколько недель уехала вниз списка приоритетов, а сам сервер продолжал работать.
Теперь к самому инциденту
На рабочую почту приходит письмо от хостинга.
На VPS в течение продолжительного времени держалась загрузка CPU выше 70%. Из-за этого хостинг временно ограничил доступные процессорные ресурсы.

Я полез смотреть, что именно там так увлечённо считает процессор. Никакой полезной работы на 70% CPU я ему не поручал.
Через некоторое время вопрос «кто съел CPU?» превратился в мой первый полноценный incident response с реальным RCE, Git hooks, неизвестным репозиторием и цепочкой загрузки крипто-майнера. Причём активная часть атаки заняла около 11 секунд.
Короткая версия произошедшего
На момент атаки сервер выглядел примерно так:
Internet
↓ Caddy
↓ Gitea
PostgreSQL28 июля 2026 года для Gitea был опубликован критический security advisory GHSA-rcr6-4jqh-j84m, связанный с CVE-2026-60004.
Уязвимость затрагивала diffpatch API и позволяла добиться выполнения Git hook с содержимым, контролируемым пользователем репозитория. Для исправления Gitea выпустила версию 1.27.1.
В моей конфигурации регистрация была открыта, поэтому цепочка получилась очень короткой:
сканер → регистрация нового пользователя → создание собственного репозитория → write-доступ к нему → POST /diffpatch → Git hook → RCE → loader → miner-like dropperАтакующему хватило собственного репозитория. Доступ к существующим рабочим проектам для запуска этой цепочки ему не требовался.
Что было открыто на сервере
На момент атаки стояла Gitea 1.24.7. Из важных настроек:
DISABLE_REGISTRATION = falseREGISTER_EMAIL_CONFIRM = falseENABLE_OPENID_SIGNUP = trueREQUIRE_SIGNIN_VIEW = falseREGISTER_EMAIL_CONFIRM = false
ENABLE_OPENID_SIGNUP = true
REQUIRE_SIGNIN_VIEW = false
Капча также была выключена.
В актуальной Configuration Cheat Sheet Gitea параметр DISABLE_REGISTRATION отвечает за возможность самостоятельной регистрации пользователей, а REGISTER_EMAIL_CONFIRM — за подтверждение регистрации по почте. В документации оба параметра описаны с дефолтными значениями false.
Сам факт открытой регистрации здесь важен именно в связке с уязвимостью. Новый пользователь мог зарегистрироваться, создать собственный репозиторий и получить необходимые права записи внутри него. Gitea SSH наружу при этом опубликован не был. Вектор атаки шёл через HTTPS.
Ещё один момент, который позже оказался важнее, чем я первоначально думал: контейнер Gitea имел исходящий доступ в интернет.
11 секунд до RCE
Самая интересная часть нашлась в docker logs gitea.
Если убрать второстепенные запросы, вся активная фаза выглядит так:
14:25:16 GET /user/sign_up → 14:25:16 POST /user/sign_up → 14:25:17 создан приватный repository → 14:25:18 GET /api/v1/repos/.../branches/main → 14:25:19 первый POST /diffpatch → 14:25:20 второй POST /diffpatch → 14:25:23 создана ветка rce-proof → 14:25:24 в /tmp появляется новый файл → 14:25:27 GET .../raw/proof?ref=rce-proofТо есть от открытия формы регистрации до получения proof прошло около 11 секунд.
Зарегистрированный пользователь после создания аккаунта больше обычным образом не логинился. Последовательность запросов, скорость и название созданного poc-* репозитория хорошо укладываются в автоматизированный массовый exploit.
Сервер никто часами руками не исследовал, а автомат нашёл подходящую комбинацию:
публичный Gitea + подходящая версия + возможность получить write в repository → запуск цепочкиВот эта часть лично мне запомнилась больше самого майнера.
Иногда кажется, что маленький технический сервер никому особо не интересен. Для автоматического сканирования размер компании вообще отсутствует как критерий. Есть fingerprint сервиса. Есть версия. Есть рабочий exploit. Этого достаточно.
Как обычный diffpatch дошёл до shell-кода
Теперь самое техническое. В Gitea есть API endpoint:
POST /api/v1/repos/{owner}/{repo}/diffpatchОн применяется к содержимому репозитория.
Официальный advisory Gitea описывает сценарий, при котором специально подготовленный diffpatch в сочетании с поведением git apply позволяет разместить Git hook в hooks/post-index-change, после чего Git выполняет его.
Если упростить механику до схемы:
diffpatch → git apply → three-way fallback → add/add collision → hooks/post-index-change → выполнение hookВ моём случае эта цепочка подтверждалась уже не теорией из advisory, а артефактами на диске. В созданном атакующим репозитории появилась ветка:
rce-proofВ ней лежал файл proof со следующим содержимым:
uid=1000(git) gid=1000(git) groups=1000(git),1000(git)То есть команда id реально выполнилась внутри контейнера Gitea от пользователя git.
Зачем вообще писать proof в Git-ветку
Вот эта часть PoC мне даже понравилась инженерно. Для подтверждения RCE часто используют какой-либо callback: HTTP, DNS или отдельное соединение. Здесь автор цепочки пошёл другим путём. Hook выполнял id, брал stdout, создавал из него Git object, собирал commit и двигал ref:
id → stdout → Git object → commit → refs/heads/rce-proofПосле этого сканер просто обращался к обычному endpoint Gitea и забирал файл из ветки rce-proof. То есть proof ехал обратно через сам Git. Удобно для автоматизации:
эксплойт сработал → появился ref → прочитали proof → следующий хостВ логах как раз виден запрос к raw/proof через несколько секунд после запуска цепочки, но proof был только первой задачей hook. Вторая уже интереснее.
Stage 0: Git hook
Восстановленный hooks/post-index-change выполнял две независимые задачи.
Первая:
id → Git object → commit → rce-proofВторая запускалась в фоне:
base64 → shell → загрузка следующего stageПолный hook здесь приводить смысла мало. В incident-файле он сохранён целиком, включая исходный base64 и технические артефакты.
Для самой истории важнее другое: после подтверждения RCE сервер начал скачивать следующую полезную нагрузку.
Stage 1: скачай чем получится
После декодирования первой нагрузки там оказался универсальный shell-loader. И вот он уже был написан максимально прагматично. Чтобы скачать следующий stage, последовательно пробовались:
curl → wget → python3 urllib → perl HTTP::Tiny → perl IO::Socket::INETПричина понятна. Автор payload заранее не знает, куда попадёт код. Сегодня это полноценный VPS. Завтра минимальный Docker image. Послезавтра CI runner. На одном сервере есть curl, на другом только wget, где-то лежит Python, а где-то внезапно ещё жив Perl.
В общем, к инфраструктуре "жертвы" автор подошёл с уважением: если скачать payload можно «хоть чем-нибудь», он постарается найти это «чем-нибудь».
В нашем контейнере нужные инструменты были, плюс контейнер имел исходящий интернет. Этого оказалось достаточно для продолжения цепочки.
После разбора именно этот момент заставил меня серьёзнее посмотреть на outbound traffic из application containers.
До этого сетевой вопрос обычно звучал так: кто может подключиться к сервису?
После инцидента появился второй: куда сможет подключиться сам сервис, если внутри него уже выполняется чужой код?
Stage 2: от RCE к крипто-майнеру
Следующий stage я получил отдельно и разбирал как текст. Он уже выглядел как типичный майнероподобный dropper, и сценарий его работы был довольно характерным:
очищал
LD_PRELOADиLD_LIBRARY_PATH;искал процессы с высокой загрузкой CPU;
пытался убивать конкурирующие процессы;
выбирал payload под архитектуру;
искал writable directory;
скачивал бинарник под случайным восьмисимвольным именем;
запускал его;
удалял файл после запуска.
Особенно характерно выглядела борьба за CPU. Dropper находил процессы с высокой загрузкой, проверял их имена и пытался освободить ресурсы для собственной нагрузки. Свои файлы он как раз создавал под случайными восьмисимвольными именами.
В /tmp контейнера сохранился след:
/tmp/XXNpKFfD (deleted)Он появился в ту же секунду, когда отработал hook. Имя совпадало с форматом, который использовал разобранный dropper. Именно поэтому дальше я использую формулировку крипто-майнер / майнероподобный dropper с одной важной оговоркой.
Сами Stage 3 binaries /1, /2, /3 я не запускал и полноценный анализ их содержимого не проводил. Поэтому у меня нет подтверждённого mining pool, wallet, семейства майнера и конкретного оператора.
Зато поведение Stage 2 видно достаточно хорошо: борьба за CPU, архитектурные payload, случайные имена процессов и запуск отдельного бинарника. И здесь история возвращается к самому началу. К письму про CPU.
Значит, крипто-майнер и съел 70% процессора?
Очень хочется поставить здесь красивый знак = :
письмо про CPU → майнерНо в incident response такая уверенность должна опираться на наблюдение самого процесса в момент нагрузки. У меня сохранилась другая доказательная цепочка:
высокая нагрузка → начало проверки → чужой аккаунт → чужой repository → diffpatch → Git hook → RCE proof → loader → miner-like dropperПоэтому высокая загрузка CPU стала сигналом, после которого началось расследование, а сам факт RCE и дальнейшая цепочка уже подтверждались логами, Git objects и файловыми артефактами. Мне такая формулировка кажется ценнее красивого вывода, который нельзя доказать "до последней цифры".
SSH тут оказался вообще в стороне
Дальше я пошёл по довольно очевидному пути и полез проверять SSH logs. После подобных историй это, пожалуй, первое место, куда хочется посмотреть.
На сервер постоянно приходили типовые попытки по root, admin, test и другим именам - обычный интернет-фон.
При этом в собранных логах не было успешного SSH-входа с IP, который использовался в цепочке Gitea. В authorized_keys новых ключей тоже не появилось, а Gitea SSH наружу вообще не пробрасывался.
По совокупности артефактов рабочим вектором этого инцидента был HTTPS → Gitea → diffpatch → Git hook. Это важное различие ещё и с точки зрения последствий: удалённое выполнение команд происходило внутри контейнера Gitea от пользователя git. Root shell на VPS по найденным данным атакующий не получил.
Что здесь реально дала контейнеризация
Gitea работал в Docker, поэтому после RCE код оказался внутри его контейнера. Контейнер не был privileged, а после перезапусков процесс с майнероподобной нагрузкой не пережил restart. Следов persistence через cron, systemd или новые SSH keys в ходе разбора не обнаружилось.
Контейнеризация в этом случае сократила blast radius.
В документации Docker по безопасности namespaces и cgroups как раз описываются как базовые механизмы изоляции процессов и ресурсов контейнеров.
При этом контейнер по-прежнему имел доступ к тому, что было доступно процессу Gitea:
конфигурации приложения;
подключённым volumes;
сетевым ресурсам;
внутренним credentials;
исходящему интернету.
Поэтому после RCE я воспринимал контейнер уже как скомпрометированную application environment и действовал соответственно.
Что я поменял после разбора
Самое бесполезное решение здесь - ограничиться блокировкой одного IP: сегодня он один, завтра будет другой.
Банить их по одному можно долго. Интернет, судя по моим логам, желающих подкидывает исправно.После инцидента я сделал несколько системных изменений.
Обновил Gitea
Версия:
1.24.7 → 1.27.2Для CVE-2026-60004 исправление опубликовано начиная с 1.27.1.
Закрыл регистрацию
DISABLE_REGISTRATION = trueДля этого сервера публичная самостоятельная регистрация просто не требовалась.
Убрал лишние способы signup
OpenID signup/signin также пересмотрел в соответствии с реальным сценарием использования.
Ротировал секреты
После RCE были сменены:
пароль администратора;
пароль PostgreSQL;
внутренние tokens/secrets.
Если процесс приложения получил выполнение чужого кода, разумнее считать доступные этому процессу секреты потенциально скомпрометированными и проводить ротацию.
Переделал Docker-сети
Gitea убрал из лишней публичной сети и оставил там, где ему действительно требовалось находиться.
Закрыл egress
Вот это изменение я бы сейчас поставил почти на один уровень с обновлением. Контейнеру Gitea больше не нужен произвольный доступ во внешний интернет. В исходной схеме после RCE приложение автоматически получало возможность скачать Stage 1 и Stage 2, поэтому теперь исходящие подключения ограничены. Даже если приложение снова окажется скомпрометировано, следующая часть цепочки уже должна упереться в сетевую политику.
Ограничил trusted proxies
Настройки reverse proxy также сузил до реальной сети между Caddy и Gitea, чтобы доверие распространялось только на тот участок инфраструктуры, где оно действительно необходимо.
Добавил firewall rules
IP, участвовавшие в конкретном инциденте, тоже заблокировал. Это уже скорее дополнительная санитарная мера поверх основных изменений: обновления Gitea, закрытой регистрации, ограничения egress и пересмотра сетевой конфигурации.
Что я теперь проверил бы на любом публичном Gitea
После этой истории мой минимальный список выглядит довольно коротко.
Версия. Сначала проверяю security advisories и актуальную ветку. Для этого кейса первоисточник: официальный security advisory Gitea.
Регистрация. Если пользовательская регистрация проекту не нужна — закрываю её. Основные параметры собраны в Configuration Cheat Sheet Gitea.
Права нового пользователя. Смотрю, что пользователь может создать сразу после регистрации и какие действия доступны внутри собственного repository.
Исходящие подключения. Проверяю, действительно ли application container нужен полный outbound Internet.
Volumes. Изучаю, что именно смонтировано внутрь контейнера и с какими правами.
Secrets. Проверяю, какие credentials доступны процессу приложения после RCE.
Сети. Оставляю контейнер только в тех Docker networks, которые ему реально нужны.
Логи. Храню их достаточно долго, чтобы, к примеру, даже через неделю можно было восстановить последовательность запросов.
Последний пункт кажется скучным ровно до первого нормального инцидента требующего реакции и разбор.
В моём случае именно логи позволили собрать историю почти по секундам.
Итого, что получается:
Я хотел получить небольшой self-hosted Git вместо GitLab, а в итоге получил собственный incident response с Gitea, diffpatch, Git hook, RCE proof, loader и майнероподобным dropper.
Самое показательное для меня здесь даже не наличие CVE. Между первым запросом к /user/sign_up и получением proof прошло около 11 секунд. Автоматическому сканеру не требовалось знать, чей это сервер, сколько у нас проектов и насколько важна инфраструктура. Он увидел подходящий сервис и отработал цепочку.
После этой истории любой публичный сервис у меня начинается с трёх вопросов:
Какую версию я выставляю в интернет?
Кто может получить внутри неё права?
Куда приложение сможет подключиться само, если эти права однажды превратятся в выполнение кода.
Пожалуй, ради этих трёх вопросов и стоило разобрать инцидент до конца.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.