TL;DR: я построил систему, которая клонирует и сканирует тысячи публичных GitHub-репозиториев — и находит в них утекшие секреты.

В каждом репозитории я восстанавливал удаленные файлы, находил непривязанные («висячие») объекты, распаковывал .pack-файлы и находил API-ключи, активные токены и учетки. А когда сообщил компаниям об утечках, заработал более $64 000 на баг-баунти.

С чего все началось

Меня зовут Шарон Бризинов. Я занимаюсь исследованием низкоуровневых эксплойтов в устройствах OT/IoT и время от времени охочусь за уязвимостями в рамках баг-баунти.

Многие багхантеры сканируют репозитории GitHub в поисках случайно засвеченных учетных данных. Я решил копнуть глубже: восстанавливать секреты из файлов, которые авторы считали удаленными. Разработчики часто забывают: если что-то попало в Git, оно остается в истории — даже если из рабочей директории всё подчистили.

Чтобы проверить гипотезу, я просканировал десятки тысяч корпоративных репозиториев, анализируя их историю коммитов в поисках конфиденциальных данных. Результаты впечатлили: я обнаружил множество удаленных файлов с API-ключами, лог��нами, паролями и даже действующими токенами сессий. 

Ниже расскажу, как собирал репозитории, писал скрипты, находил секреты и отправлял отчеты об их утечке.

Внутреннее устройство Git

Для начала советую прочитать статью How Git Internally Works — она ясно и просто объясняет внутреннее устройство Git. 

Git — это распределенная система управления версиями, которая отслеживает изменения в файлах и позволяет разработчикам совместно работать над проектами. Она сохраняет полную историю изменений, позволяя при необходимости возвращаться к предыдущим состояниям, создавать ветки и объединять изменения. По сути Git устроен как файловая система с адресацией по содержимому, в которой каждая версия файла хранится в репозитории как уникальный объект.

Git отслеживает всё — файлы, папки, коммиты — в виде объектов, каждый из которых идентифицируется хэшем SHA-1 или SHA-256 (в зависимости от конфигурации). Существует четыре типа объектов:

  • Блоб (blob, binary large object) — объект с содержимым файла.

  • Дерево (tree) — отражает структуру каталогов.

  • Коммит (commit) — снэпшот + метаданные.

  • Тэг (tag) — аннотированная метка.

Блобы и .pack-файлы

Блоб — это объект, в котором Git хранит содержимое файла. Он не содержит информации об имени файла или его расположении — только сами данные.

Когда Git впервые сохраняет объект, он записывает его как несвязанный объект (loose object), примерно в таком виде:

.git/objects/ab/cdef1234567890...

Здесь ab — это первые два символа SHA-хэша, а cdef1234567890… — его продолжение. Данные сжимаются с помощью алгоритма zlib и представляют собой содержимое одного файла. 

Для экономии пространства и повышения производительности Git упаковывает несвязанные объекты в .pack-файлы. По умолчанию это происходит, когда число loose-объектов достигает 6700.

.git/objects/pack/pack-<hash>.pack

.pack-файлы имеют сложную и очень интересную структуру. К счастью, чтобы извлечь их содержимое, нам не обязательно понимать детали формата — достаточно воспользоваться командойgit-unpack-objects.

.pack-файлы и блобы можно опознать по сигнатурам: 50 41 43 4B — для начала .pack-файла; 78 01 — для начала zlib-сжатого блоба
.pack-файлы и блобы можно опознать по сигнатурам: 50 41 43 4B — для начала .pack-файла; 78 01 — для начала zlib-сжатого блоба

Иногда в репозитории появляются «висячие» объекты (dangling objects) — валидные коммиты, блобы, деревья или теги, на которые больше не ссылается ни одна ветка, тег, stash или reflog. Обычно они возникают при переписывании истории — например, при использовании команд git commit --amend, rebase, reset или при удалении ветки. Хотя такие объекты уже не входят в активную историю, Git по умолчанию хранит их еще в течение двух недель, чтобы их можно б��ло восстановить. Найти такие объекты можно с помощью команды git fsck --dangling.

Как устроены коммиты в Git

Каждый коммит в Git представляет собой снэпшот репозитория в определенный момент времени. Коммиты неизменяемы, они идентифицируются по хэшу SHA-1/SHA-256. 

Коммит содержит:

  • Ссылку на объект дерева, описывающий структуру файлов.

  • Указатели на родительские коммиты, формирующие граф с историей изменений.

  • Метаданные, в том числе имя автора, временную метку и сообщение коммита.

Благодаря дельта-сжатию Git эффективно хранит коммиты: записывает только изменения, а не полные копии файлов.

Взаимосвязь между коммитом, деревом и блобом
Взаимосвязь между коммитом, деревом и блобом

Почему удаленные файлы не исчезают

Когда файл удаляется при помощи git rm или просто перемещается из рабочей директории и изменения коммитятся, он исчезает из текущего снэпшота, но все равно продолжает храниться в истории репозитория. Это происходит по двум причинам:

  • Коммиты в Git неизменны. После создания каждый коммит и все связанные с ним объекты сохраняются в .git/objects и продолжают существовать, даже если на них больше не ссылаются ни одна ветка, ни один тег. Объекты, на которые ничто не ссылается («висячие»), не удаляются сразу — обычно они хранятся около двух недель, прежде чем будут удалены системой по сборке мусора. 

  • Ссылки (refs) удерживают объекты от удаления. Git хранит ссылки в head, тэгах и удаленных репозиториях. Даже если в более позднем коммите файл был удален, старые коммиты все еще содержат его.

Чтобы действительно удалить файл из истории, нужно переписать историю либо с помощью инструментов вроде git filter-branch и git-filter-repo, либо вручную через rebase с последующей сборкой мусора (с prune) — это нужно для удаления «висячих» объектов. Но если репозиторий публичный, файл уже могли скопировать или клонировать, и тогда важно как можно скорее отозвать все API-ключи, токены, сессионные идентификаторы и другие секреты.

Чтобы лучше понять, как Git обрабатывает файлы и каталоги, я напис��л небольшой инструмент, визуализирующий изменения в структуре репозитория: какие объекты создаются, какие удаляются. Это была, конечно, избыточная затея для этого проекта — но в духе «вайб-кодинга» я справился за пять минут, так что почему бы и нет. 

Вайбкодим небольшую платформу для визуализации изменений в снэпшотах репозитория Git
Вайбкодим небольшую платформу для визуализации изменений в снэпшотах репозитория Git

Главный вопрос: как нам получить все удаленные файлы?

  • Можно восстановить удаленные файлы, сравнивая родительские и дочерние коммиты с помощью git diff.

  • Распаковать все файлы .pack с помощью git unpack-objects < .git/objects/pack/pack-<SHA>.pack.

  • Найти «висячие» объекты через git fsck — full — unreachable — dangling.

Чтобы собрать все удаленные файлы, я прошел по каждому коммиту и сравнил его с родительским (git diff). Если в списке были файлы со статусом D (удален), я восстанавливал их через git show и сохранял на диск. 

Конечно, это не самый лучший и эффективный способ, но для моих целей он сработал. Вот небольшой proof-of-concept-скрипт, выполняющий эту задачу:

#!/bin/bash

# cd в клонированный репозиторий
mkdir -p "__ANALYSIS/del"
# Извлекаем все коммиты и обрабатываем каждый
git rev-list --all | while read -r commit; do
    echo "Processing commit: $commit"

    # Получаем родительский коммит
    parent_commit=$(git log --pretty=format:"%P" -n 1 "$commit")
    if [ -z "$parent_commit" ]; then
        continue
    fi
    parent_commit=$(echo "$parent_commit" | awk '{print $1}')

    # Получаем diff коммита
    git diff --name-status "$parent_commit" "$commit" | while read -r file_status file; do
        # Заменяем / на _ для имен файлов в binary_files_dir
        safe_file_name=$(echo "$file" | sed 's/\//_/g')

        # Обрабатываем удаленные файлы
        if [ "$file_status" = "D" ]; then
                # Обрабатываем двоичные файлы
                echo "Binary file deleted: $file" | tee -a "__ANALYSIS/del.log"
                echo "Saving to __ANALYSIS/del/${commit}___${safe_file_name}"
                git show "$parent_commit:$file" > "__ANALYSIS/del/${safe_file_name}"
        fi
    done
done

А вот однострочник, который я использовал для извлечения всех «висячих» блобов:

mkdir -p unreachable_blobs && git fsck --unreachable --dangling --no-reflogs --full -  | grep 'unreachable blob' | awk '{print $3}' | while read h; do git cat-file -p "$h" > "unreachable_blobs/$h.blob"; done

Поиск целей

Теперь, когда PoC-скрипт по восстановлению удаленных файлов был готов, нужно было собрать как можно больше релевантных GitHub-репозиториев. В нашем случае «релевантные» = принадлежащие компаниям, которые участвуют в баг-баунти. 

Первым делом я составил список компаний, участвующих в публичных и приватных программах.

Где искать информацию о публичных программах

Кроме того, я решил изучить GitHub-аккаунты компаний, у которых есть хотя бы один репозиторий с 5000+ звезд. Для этого я воспользовался таким однострочником:

for page in {1..100}; do gh api "search/repositories?q=stars:>5000&sort=stars&order=desc&per_page=50&page=$page" --jq '.items[].full_name'; done | cut -d '/' -f 1

Упорядочиваем аккаунты

Я собрал огромный список с названиями компаний и сохранил его в файл companies.txt. Теперь нужно было найти их публичные аккаунты на Github. Можно было придумать что-то умное, но я выбрал самый ленивый способ — использовать ИИ. Просто отправлял нейросетям наименования компаний с просьбой найти GitHub-аккаунты, связанные с той или иной организацией. Несколько аккаунтов ИИ выдумал, но в целом сработало неплохо.

Промт: «У меня есть задача для тебя. Я покажу список компаний, а ты должен будешь поискать в интернете все GitHub-аккаунты, связанные с этими компаниями — официальные, аффилированные, опенсорсные и связанные с сообществом. Пожалуйста, верни только URL-ссылки на GitHub в виде аккуратного маркированного списка»
Промт: «У меня есть задача для тебя. Я покажу список компаний, а ты должен будешь поискать в интернете все GitHub-аккаунты, связанные с этими компаниями — официальные, аффилированные, опенсорсные и связанные с сообществом. Пожалуйста, верни только URL-ссылки на GitHub в виде аккуратного маркированного списка»

Еще я довольно быстро заметил, что у многие компании держат по несколько аккаунтов на GitHub: отдельный для основной разработки, отдельный для QA и так далее. С этого момента я искал аккаунты по ключевым словам вроде: lab, research, test, qa, samples, hq, community.

Идем дальше

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

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

На этом этапе я также экспериментировал с графами, отображающими связи между репозиториями
На этом этапе я также экспериментировал с графами, отображающими связи между репозиториями

В итоге я собрал несколько тысяч корпоративных GitHub-аккаунтов. Пора было переходить к технической части. 

Собираем систему по поиску секретов

Сама система была довольно простой: я клонировал проекты всех компаний, восстанавливал удаленные файлы и искал в них активные (сохранившие актуальность) секреты.

Упрощенный псевдокод:

- foreach company in companies:
    - foreach repo in comapny.repos:
        - restore all deleted files
        - foreach file in files:
            - collect secrets
            - foreach secret in secrets:
                - is secret active?
                    - notify via Telegram bot

Весь процесс состоял из нескольких этапов: 

  • подготовка машин;

  • клонирование репозиториев;

  • восстановление удаленных файлов; 

  • поиск секретов;

  • отправка уведомления о найденных секретах мне в Telegram;

  • удаление репозиториев. 

1. Подготовка машин

Я использовал 10 серверов: часть из них — облачные машины (например, EC2), часть — VPS, и даже пара физических устройств с Raspberry Pi. Я проследил, чтобы на каждом узле было достаточно свободного пространства — не менее 120 ГБ. Затем разбил список компаний на десять блоков и распределил их между серверами.

2. Клонирование репозиториев

Для получения списка репозиториев отдельно взятой компании на GitHub я использовал CLI-инструмент gh:

for REPO_NAME in $(gh repo list $ORG_NAME -L 1000 --json name --jq '.[].name');
do
    FULL_REPO_URL="https://github.com/$ORG_NAME/$REPO_NAME.git"
    git clone "$FULL_REPO_URL" "$REPO_NAME"
done;

3. Восстановление файлов

В рамках этого шага удаленные файлы восстанавливались с помощью методов, которые описал выше

4. Поиск секретов

Теперь нужно было просканировать восстановленные файлы на наличие активных секретов. Здесь мне помог TruffleHog — мощный инструмент для поиска секретов, который глубоко проверяет содержимое репозитория. 

TruffleHog поддерживает более 800 типов ключей и умеет верифицировать найденные секреты, чтобы отсеять ложные срабатывания. Помимо этого он способен обнаруживать данные в base64 и некоторых архивных форматах.

Я запускал Trufflehog с флагом only-verified, чтобы сохранять только те секреты, которые прошли проверку и с высокой вероятностью сохранили актуальность. Еще использовал аргумент filesystem, чтобы просканировать диск и найти восстановленные файлы:

trufflehog filesystem --only-verified --print-avg-detector-time --include-detectors="all" ./ > secrets.txt

Одним из ключевых преимуществ использования TruffleHog для локальных клонов было то, что он сканирует директорию .git. Благодаря тому, что он умеет распаковывать и анализировать потоки, сжатые через zlib, большинство несвязанных объектов автоматически оказывались в области видимости без лишней возни. TruffleHog также анализировал .pack-файлы — и они несколько раз приятно меня удивили.

Вы спросите: если TruffleHog умеет распаковывать и сканировать объекты Git, зачем тогда вручную восстанавливать удаленные файлы? Ответ прост — это значительно повышало эффективность нахождения секретов. В некоторых случаях .pack-файлы и потоки были слишком большими для корректной обработки, а иногда — перемешаны и упакованы с использованием разных форматов, из-за чего инструменты не справлялись со сканированием в сыром виде. Извлечение максимально возможного количества файлов значительно повышало мои шансы обнаружить утекшие секреты.

5. Уведомления о верифицированных секретах

Как только TruffleHog находил активный секрет, я получал уведомление в Telegram:

curl -F chat_id="XXXXXXXXXXXXX" \
     -F document=@"$ORG_NAME.$REPO_NAME.secrets.txt" \
     -F caption="New secerts - $ORG_NAME - $REPO_NAME" \
     'https://api.telegram.org/botXXXXXXXXXXXXX:XXXXXXXXXXXXX/sendDocument'

Почему Telegram? Просто привычный мне способ получения уведомлений. Конечно, под эту задачу можно было сделать отдельный бэкенд с базой данных, но к чему эти напряги? :)

6. Очистка

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

Находки и вознаграждение

Что же мне удалось найти? Сотни секретов, которые утекли — и при этом оставались актуальными. Впрочем, помимо секретов, которые реально использовались в production-среде, попадалась и всякая ерунда — например, тестовые аккаунты и canary-токены.

Самые «вкусные» секреты

Ниже — список самых ценных токенов и ключей, которые мне удалось обнаружить, а также примерные суммы вознаграждений. 

  • Токены GCP/AWS — $5000–$15000 🔥🔥🔥

  • Токены Slack — $3000–$10000 🔥🔥

  • Токены Github — $5000–$10000 🔥🔥

  • Токены OpenAPI — $500–$2000 🔥

  • Токены HuggingFace — $500–$2000 🔥

  • Токены Algolia Admin — $300–$1000 🔥

  • Учетные данные SMTP — $500–$1000 🔥

  • Токены и сессии разработчиков конкретных платформ — $500–$2000 🔥

Некоторые из найденных секретов открывали доступ к чувствительной информации, включая персональные данные
Некоторые из найденных секретов открывали доступ к чувствительной информации, включая персональные данные

Менее интересные находки

Несмотря на большое количество действительно ценных токенов, многие из них оказались бесполезными: они принадлежали тестовым аккаунтам или были canary-токенами (ловушками для злоумышленников). Такие ловушки генерируются с помощью сервисов вроде CanaryTokens: при срабатывании токена владельцу даже приходит уведомление на почту. Это удобный и эффективный способ отслеживания утечек.

Другие виды секретов-пустышек: 

  • Тестовые приватные ключи для Github. Многие проекты содержали приватные ключи, связанные с тестовыми пользователями — например, aaron1234567890123. Такие ключи встречались в сотнях репозиториев.

  • Одноразовые аккаунты. Аккаунты, используемые для read-only или для обхода ограничений частоты запросов.

  • Открытые токены API Web3. Сотни проектов Web3 содержали открытые ключи, предназначенные для обращения к блокчейну — чтобы получить, например, информацию о транзакциях или курсах криптовалют. Чаще всего встречались токены Infura и Alchemy.

  • Фронтенд-ключи API: некоторые сервисы (например, Algolia) предполагают, что ключ будет использоваться во фронтенде. Обычно такие ключи имеют только права на чтение. Проблема возникает, когда разработчик случайно использует токен с правами администратора вместо безопасного ключа для поиска.

Одноразовые аккаунты и тестовые приватные ключи меня не интересовали
Одноразовые аккаунты и тестовые приватные ключи меня не интересовали

Почему секреты вообще утекали?

Проанализировав десятки критических случаев, я пришел к выводу, что секреты чаще всего утекали по одной из трех причин: непонимание работы Git, незнание состава файлов, попадающих в коммит, и чрезмерная вера в инструменты переписывания истории. 

Нехватка знаний о Git

Некоторые разработчики просто не знали, как Git на самом деле сохраняет данные. Они могли ��лучайно закоммитить незашифрованные токены, секреты или учетные данные. Осознав ошибку, они удаляли файл или вырезали секрет вручную — но не отзывали его. Git сохраняет всю историю, и если восстановить старые состояния, эти секреты по-прежнему можно найти. 

Непонимание состава коммита 

В ряде случаев разработчики не использовали .gitignore и случайно коммитили двоичные файлы — например, .pyc (скомпилированные Python-файлы), которые содержали секреты. Эти файлы позже удалялись, но в истории они оставались.

Были и случаи, когда в коммит попадали скрытые файлы (например, .env) или архивы .zip, содержащие такие файлы. Даже после удаления этих артефактов восстановление было возможно, и секреты оказывались уязвимыми.

Слепое доверие инструментам переписывания истории

В одном примере команда случайно закоммитила секреты в свой репозиторий. Позже они применили инструменты для переписывания истории, чтобы удалить следы, но в .pack-файле всё еще сохранялась ссылка на объект. Я смог восстановить этот секрет и уведомил разработчиков, чтобы они устранили проблему до её эксплуатации.

Главная причина утечек — удаленные двоичные файлы
Главная причина утечек — удаленные двоичные файлы

Большинство утекших секретов было найдено в двоичных файлах, которые закоммитили в репозиторий, а позже удалили. Эти файлы обычно были сгенерированы компиляторами или автоматизированными процессами. Самый частый пример — .pyc-файлы, в которых Python-интерпретатор сохраняет байт-код. Эти файлы часто попадают в коммит случайно. 

Другой распространенный случай — файлы отладки .pdb, создаваемые компиляторами. Они тоже содержат чувствительные данные и нередко оказываются в истории репозитория.

Подводим итог

Проект выдался не просто полезным, а чертовски увлекательным. Массовое локальное клонирование репозиториев GitHub и восстановление удаленных файлов оказалось эффективной (и весьма прибыльной) тактикой для охоты на утекшие секреты. 

Заодно я основательно прокачался в анатомии Git и даже создал в процессе несколько собственных инструментов. Один из них — HexShare — открыт для всех желающих. 

А дальше — больше. Отправил отчеты, получил фидбек, помог улучшить безопасность нескольким крупным компаниям. И в довесок заработал $64 350 на баг-баунти ;)