Призраки в коммитах: как я заработал $64 000 на удаленных файлах в Git
- четверг, 12 июня 2025 г. в 00:00:08
TL;DR: я построил систему, которая клонирует и сканирует тысячи публичных GitHub-репозиториев — и находит в них утекшие секреты.
В каждом репозитории я восстанавливал удаленные файлы, находил недостижимые объекты, распаковывал .pack-файлы и находил API-ключи, активные токены и учетки. А когда сообщил компаниям об утечках, заработал более $64 000 на баг-баунти.
Меня зовут Шарон Бризинов. Я занимаюсь исследованием низкоуровневых эксплойтов в устройствах OT/IoT и время от времени охочусь за уязвимостями в рамках баг-баунти.
Многие багхантеры сканируют репозитории GitHub в поисках случайно засвеченных учетных данных. Я решил копнуть глубже: восстанавливать секреты из файлов, которые авторы считали удаленными. Разработчики часто забывают: если что-то попало в Git, оно остается в истории — даже если из рабочей директории всё подчистили.
Чтобы проверить гипотезу, я просканировал десятки тысяч корпоративных репозиториев, анализируя их историю коммитов в поисках конфиденциальных данных. Результаты впечатлили: я обнаружил множество удаленных файлов с API-ключами, логинами, паролями и даже действующими токенами сессий.
Ниже расскажу, как собирал репозитории, писал скрипты, находил секреты и отправлял отчеты об их утечке.
Для начала советую прочитать статью How Git Internally Works — она ясно и просто объясняет внутреннее устройство Git.
Git — это распределенная система управления версиями, которая отслеживает изменения в файлах и позволяет разработчикам совместно работать над проектами. Она сохраняет полную историю изменений, позволяя при необходимости возвращаться к предыдущим состояниям, создавать ветки и объединять изменения. По сути Git устроен как файловая система с адресацией по содержимому, в которой каждая версия файла хранится в репозитории как уникальный объект.
Git отслеживает всё — файлы, папки, коммиты — в виде объектов, каждый из которых идентифицируется хэшем SHA-1 или SHA-256 (в зависимости от конфигурации). Существует четыре типа объектов:
Блоб (blob, binary large object) — объект с содержимым файла.
Дерево (tree) — отражает структуру каталогов.
Коммит (commit) — снэпшот + метаданные.
Тэг (tag) — аннотированная метка.
Блоб — это объект, в котором 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
.
Иногда в репозитории появляются недостижимые объекты (dangling objects) — валидные коммиты, блобы, деревья или теги, на которые больше не ссылается ни одна ветка, тег, stash или reflog. Обычно они возникают при переписывании истории — например, при использовании команд git commit --amend
, rebase
, reset
или при удалении ветки. Хотя такие объекты уже не входят в активную историю, Git по умолчанию хранит их еще в течение двух недель, чтобы их можно было восстановить. Найти такие объекты можно с помощью команды git fsck --dangling
.
Каждый коммит в 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 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: отдельный для основной разработки, отдельный для 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;
удаление репозиториев.
Я использовал 10 серверов: часть из них — облачные машины (например, EC2), часть — VPS, и даже пара физических устройств с Raspberry Pi. Я проследил, чтобы на каждом узле было достаточно свободного пространства — не менее 120 ГБ. Затем разбил список компаний на десять блоков и распределил их между серверами.
Для получения списка репозиториев отдельно взятой компании на 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;
В рамках этого шага удаленные файлы восстанавливались с помощью методов, которые описал выше.
Теперь нужно было просканировать восстановленные файлы на наличие активных секретов. Здесь мне помог 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-файлы и потоки были слишком большими для корректной обработки, а иногда — перемешаны и упакованы с использованием разных форматов, из-за чего инструменты не справлялись со сканированием в сыром виде. Извлечение максимально возможного количества файлов значительно повышало мои шансы обнаружить утекшие секреты.
Как только 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? Просто привычный мне способ получения уведомлений. Конечно, под эту задачу можно было сделать отдельный бэкенд с базой данных, но к чему эти напряги? :)
После обработки репозиторий удалялся, чтобы освободить место, и скрипт переходил к следующему.
Что же мне удалось найти? Сотни секретов, которые утекли — и при этом оставались актуальными. Впрочем, помимо секретов, которые реально использовались в 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 сохраняет всю историю, и если восстановить старые состояния, эти секреты по-прежнему можно найти.
В ряде случаев разработчики не использовали .gitignore и случайно коммитили двоичные файлы — например, .pyc (скомпилированные Python-файлы), которые содержали секреты. Эти файлы позже удалялись, но в истории они оставались.
Были и случаи, когда в коммит попадали скрытые файлы (например, .env) или архивы .zip, содержащие такие файлы. Даже после удаления этих артефактов восстановление было возможно, и секреты оказывались уязвимыми.
В одном примере команда случайно закоммитила секреты в свой репозиторий. Позже они применили инструменты для переписывания истории, чтобы удалить следы, но в .pack-файле всё еще сохранялась ссылка на объект. Я смог восстановить этот секрет и уведомил разработчиков, чтобы они устранили проблему до её эксплуатации.
Большинство утекших секретов было найдено в двоичных файлах, которые закоммитили в репозиторий, а позже удалили. Эти файлы обычно были сгенерированы компиляторами или автоматизированными процессами. Самый частый пример — .pyc-файлы, в которых Python-интерпретатор сохраняет байт-код. Эти файлы часто попадают в коммит случайно.
Другой распространенный случай — файлы отладки .pdb, создаваемые компиляторами. Они тоже содержат чувствительные данные и нередко оказываются в истории репозитория.
Проект выдался не просто полезным, а чертовски увлекательным. Массовое локальное клонирование репозиториев GitHub и восстановление удаленных файлов оказалось эффективной (и весьма прибыльной) тактикой для охоты на утекшие секреты.
Заодно я основательно прокачался в анатомии Git и даже создал в процессе несколько собственных инструментов. Один из них — HexShare — открыт для всех желающих.
А дальше — больше. Отправил отчеты, получил фидбек, помог улучшить безопасность нескольким крупным компаниям. И в довесок заработал $64 350 на баг-баунти ;)