diff --git a/lib/msf/core/exploit/remote/kerberos/client.rb b/lib/msf/core/exploit/remote/kerberos/client.rb index d6c9d7a8a8827..c141766f4d1c0 100644 --- a/lib/msf/core/exploit/remote/kerberos/client.rb +++ b/lib/msf/core/exploit/remote/kerberos/client.rb @@ -1,6 +1,7 @@ # -*- coding: binary -*- require 'msf/core/opt_timedelta' +require 'rex/proto/gss/kerberos_token' require 'rex/proto/kerberos/kerberos_logger_subscriber' module Msf @@ -20,9 +21,9 @@ module Client include Msf::Exploit::Remote::CertificateTrace # https://datatracker.ietf.org/doc/html/rfc4121#section-4.1 - TOK_ID_KRB_AP_REQ = "\x01\x00" - TOK_ID_KRB_AP_REP = "\x02\x00" - TOK_ID_KRB_ERROR = "\x03\x00" + TOK_ID_KRB_AP_REQ = Rex::Proto::Gss::KerberosToken::TOK_ID_KRB_AP_REQ + TOK_ID_KRB_AP_REP = Rex::Proto::Gss::KerberosToken::TOK_ID_KRB_AP_REP + TOK_ID_KRB_ERROR = Rex::Proto::Gss::KerberosToken::TOK_ID_KRB_ERROR # https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.2 NEG_TOKEN_ACCEPT_COMPLETED = 0 @@ -48,7 +49,7 @@ def initialize(info = {}) register_advanced_options( [ OptTimedelta.new('KrbClockSkew', [true, 'Adjust Kerberos client clock by this offset (e.g. 90s, -5m, 1h)', '0s']), - OptEnum.new('KerberosTicketTrace', [false, 'Kerberos ticket trace mode', 'off', %w[off metadata ticket full]]), + OptEnum.new('KerberosTicketTrace', [false, 'Kerberos protocol trace mode', 'off', %w[off metadata ticket full]]), OptString.new('KerberosTicketTraceColors', [false, 'Kerberos request and response colors for KerberosTicketTrace (unset to disable)', 'red/blu']) ], self.class ) diff --git a/lib/msf/core/exploit/remote/kerberos/client/ap_request.rb b/lib/msf/core/exploit/remote/kerberos/client/ap_request.rb index 23440eeeaad62..7f5807e87b1c9 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/ap_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/ap_request.rb @@ -1,10 +1,13 @@ # -*- coding: binary -*- +require 'rex/proto/gss/kerberos_token' + module Msf class Exploit class Remote module Kerberos module Client + # Helpers for constructing and wrapping Kerberos AP-REQ messages. module ApRequest # https://datatracker.ietf.org/doc/html/rfc4120#section-5.5.1 AP_USE_SESSION_KEY = 0x40000000 @@ -28,33 +31,23 @@ def build_service_ap_request(opts = {}) ap_req end - def encode_gss_kerberos_ap_request(ap_request_asn1) - ap_request_mech = wrap_pseudo_asn1( - ::Rex::Proto::Gss::OID_KERBEROS_5, - TOK_ID_KRB_AP_REQ + ap_request_asn1.to_der + # @param ap_request_asn1 [OpenSSL::ASN1::ASN1Data] the ASN.1 AP-REQ + # @param ap_request_der [String, nil] a pre-encoded AP-REQ + # @return [String] a GSS-Kerberos token + # @see https://datatracker.ietf.org/doc/html/rfc1964#section-1.1.1 + def encode_gss_kerberos_ap_request(ap_request_asn1, ap_request_der: nil) + Rex::Proto::Gss::KerberosToken.build_gss_ap_req( + ap_request_der || ap_request_asn1.to_der ) end - # @param ap_request_asn1 [Object] The ASN1 KRB_AP_REQ as defined in https://datatracker.ietf.org/doc/html/rfc1964#section-1.1.1 - # @return [String] SPNEGO GSS Blob - def encode_gss_spnego_ap_request(ap_request_asn1) - ap_request_mech = encode_gss_kerberos_ap_request(ap_request_asn1) - - 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(ap_request_mech) - ], 2, :CONTEXT_SPECIFIC) - ]) - ], 0, :CONTEXT_SPECIFIC) - ], 0, :APPLICATION).to_der + # @param ap_request_asn1 [OpenSSL::ASN1::ASN1Data] the ASN.1 KRB_AP_REQ defined in RFC 1964 section 1.1.1 + # @param ap_request_mech [String, nil] a pre-encoded GSS-Kerberos AP-REQ token + # @return [String] a SPNEGO GSS blob + # @see https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.1 + def encode_gss_spnego_ap_request(ap_request_asn1, ap_request_mech: nil) + ap_request_mech ||= encode_gss_kerberos_ap_request(ap_request_asn1) + Rex::Proto::Gss::KerberosToken.build_spnego_init(ap_request_mech) end end end diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb index d1cf484370f83..dae681827adcb 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -288,20 +288,43 @@ def authenticate(options = {}) auth_context = authenticate_via_krb5_ccache_credential_tgt(auth_context[:credential], options) end - service_ap_request = auth_context.delete(:service_ap_request) + service_ap_request = auth_context[:service_ap_request] + trace_context = service_authentication_trace_context(auth_context, options) + kerberos_trace_subscriber.on_ap_req(trace_context) kerberos_trace_subscriber.on_request(service_ap_request) ap_request_asn1 = service_ap_request.to_asn1 + ap_request_der = ap_request_asn1.to_der + gss_kerberos_token = encode_gss_kerberos_ap_request(ap_request_asn1, ap_request_der: ap_request_der) + kerberos_trace_subscriber.on_gss_token( + trace_context.merge( + token: gss_kerberos_token, + ap_req_der: ap_request_der, + direction: 'outbound' + ) + ) mechanism = options.fetch(:mechanism) { self.mechanism } if mechanism == Rex::Proto::Gss::Mechanism::SPNEGO - security_blob = encode_gss_spnego_ap_request(ap_request_asn1) + security_blob = encode_gss_spnego_ap_request(ap_request_asn1, ap_request_mech: gss_kerberos_token) + kerberos_trace_subscriber.on_spnego_token( + trace_context.merge( + token: security_blob, + gss_token: gss_kerberos_token, + ap_req_der: ap_request_der, + direction: 'outbound' + ) + ) elsif mechanism == Rex::Proto::Gss::Mechanism::KERBEROS - security_blob = encode_gss_kerberos_ap_request(ap_request_asn1) + security_blob = gss_kerberos_token else raise RuntimeError, "Unknown GSS mechanism: #{mechanism}" end auth_context[:security_blob] = security_blob + auth_context.delete(:service_ap_request) + auth_context.delete(:authenticator) + auth_context.delete(:trace_credential) + auth_context.delete(:delegated_credentials) auth_context end @@ -316,38 +339,111 @@ def get_message_encryptor(key, client_sequence_number, server_sequence_number, u end def parse_gss_init_response(token, session_key) + response_trace_emitted = false mech_id, encapsulated_token = unwrap_pseudo_asn1(token) if mech_id.value == Rex::Proto::Gss::OID_KERBEROS_5.value - tok_id = encapsulated_token[0,2] + tok_id = encapsulated_token[0, 2] data = encapsulated_token[2, encapsulated_token.length] case tok_id when TOK_ID_KRB_AP_REP ap_rep = Rex::Proto::Kerberos::Model::ApRep.decode(data) - print_good("#{peer} - Received AP-REQ. Extracting session key...") - - raise ::Rex::Proto::Kerberos::Model::Error::KerberosError, 'Mismatching etypes' if session_key.type != ap_rep.enc_part.etype + print_good("#{peer} - Received AP-REP. Extracting session key...") + + if session_key.type != ap_rep.enc_part.etype + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + message: ap_rep, + session_key: session_key, + parse_failure: 'unable to decrypt AP-REP encrypted part: mismatching etypes', + mutual_authentication: mutual_authentication_trace_result(false) + ) + response_trace_emitted = true + raise ::Rex::Proto::Kerberos::Model::Error::KerberosError, 'Mismatching etypes' + end decrypted = ap_rep.decrypt_enc_part(session_key.value) + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + message: ap_rep, + decrypted_part: decrypted, + session_key: session_key, + mutual_authentication: mutual_authentication_trace_result(true) + ) + response_trace_emitted = true - result = { + { ap_rep_subkey: decrypted.subkey, server_sequence_number: decrypted.sequence_number, etype: ap_rep.enc_part.etype } when TOK_ID_KRB_ERROR krb_err = Rex::Proto::Kerberos::Model::KrbError.decode(data) + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + message: krb_err, + session_key: session_key, + mutual_authentication: mutual_authentication_trace_result(false) + ) + response_trace_emitted = true kerberos_trace_subscriber.on_response(krb_err) print_error("#{peer} - Received KRB-ERR.") raise ::Rex::Proto::Kerberos::Model::Error::KerberosError.new(res: krb_err) else + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + parse_failure: "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: unknown token id #{tok_id.inspect}", + mutual_authentication: mutual_authentication_trace_result(false) + ) + response_trace_emitted = true raise ::Rex::Proto::Kerberos::Model::Error::KerberosError, "Unknown token id: #{tok_id.inspect}" end else + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + parse_failure: "Parsing mechtype #{mech_id.value} not supported", + mutual_authentication: mutual_authentication_trace_result(false) + ) + response_trace_emitted = true raise ::NotImplementedError, "Parsing mechtype #{mech_id.value} not supported" end - + rescue ::Rex::Proto::Kerberos::Model::Error::KerberosError => e + unless response_trace_emitted + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + session_key: session_key, + parse_failure: "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: #{e.class}: #{e.message}", + mutual_authentication: mutual_authentication_trace_result(false) + ) + end + raise + rescue ::NotImplementedError + raise + rescue StandardError => e + unless response_trace_emitted + trace_response_token( + token: token, + token_type: 'GSS-Kerberos', + carrier: 'GSS init response', + session_key: session_key, + parse_failure: "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: #{e.class}: #{e.message}", + mutual_authentication: mutual_authentication_trace_result(false) + ) + end + raise end # @param security_blob [String] SPNEGO GSS Blob @@ -360,17 +456,45 @@ def validate_response!(security_blob, accept_incomplete: false) neg_result = ::RubySMB::Gss.asn1dig(gss_api, 0, 0, 0)&.value.to_i supported_neg = ::RubySMB::Gss.asn1dig(gss_api, 0, 1, 0)&.value rescue OpenSSL::ASN1::ASN1Error - raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError.new('Invalid GSS Response') + trace_response_token( + token: security_blob, + token_type: 'SPNEGO NegTokenResp', + carrier: 'GSS/SPNEGO response', + parse_failure: 'unable to parse response token as SPNEGO NegTokenResp', + mutual_authentication: mutual_authentication_trace_result(false) + ) + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Invalid GSS Response' end - is_success = (neg_result == NEG_TOKEN_ACCEPT_COMPLETED || (accept_incomplete && neg_result == NEG_TOKEN_ACCEPT_INCOMPLETE)) && - supported_neg == ::Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + is_kerberos = supported_neg == ::Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + is_complete = neg_result == NEG_TOKEN_ACCEPT_COMPLETED && is_kerberos + is_incomplete = accept_incomplete && neg_result == NEG_TOKEN_ACCEPT_INCOMPLETE && is_kerberos + is_success = is_complete || is_incomplete + + trace_response_token( + token: security_blob, + token_type: 'SPNEGO NegTokenResp', + carrier: 'GSS/SPNEGO response', + mutual_authentication: mutual_authentication_trace_result(is_complete, incomplete: is_incomplete) + ) - raise ::Rex::Proto::Kerberos::Model::Error::KerberosError.new('Failed to negotiate Kerberos GSS') unless is_success + raise ::Rex::Proto::Kerberos::Model::Error::KerberosError, 'Failed to negotiate Kerberos GSS' unless is_success is_success end + # Records how a GSS/SPNEGO token is transported by an application protocol. + # Trace failures must not interrupt authentication. + # + # @param metadata [Hash] structured carrier metadata + # @return [nil] + def trace_protocol_carrier(metadata) + kerberos_trace_subscriber.on_protocol_carrier(service_trace_context.merge(metadata)) + rescue StandardError => e + vprint_warning("Unable to trace Kerberos protocol carrier: #{e.class}: #{e.message}") + nil + end + def build_spn(options = {}) nil end @@ -801,6 +925,96 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, private + def service_authentication_trace_context(auth_context, options) + ap_req = auth_context[:service_ap_request] + authenticator = auth_context[:authenticator] + ticket = ap_req&.ticket + sname = options[:sname] || ticket&.sname + + service_trace_context.merge( + ap_req: ap_req, + authenticator: authenticator, + credential: auth_context.fetch(:trace_credential) { auth_context[:credential] }, + ticket: ticket, + session_key: auth_context[:session_key], + sequence_number: auth_context[:client_sequence_number], + client_principal: client_principal_for_trace(authenticator), + service_principal: service_principal_for_trace(sname, ticket&.realm), + spn: sname&.to_s, + realm: ticket&.realm || realm&.upcase, + mutual_auth: mutual_auth, + use_session_key: authenticator&.subkey.nil?, + use_subkey: !authenticator&.subkey.nil?, + replay_detection: use_gss_checksum ? 'requested by GSS checksum flags' : nil, + channel_binding: channel_binding_trace(options[:gss_channel_binding]), + delegated_credentials: auth_context[:delegated_credentials] + ).compact + end + + def service_trace_context + { + realm: realm&.upcase, + mutual_auth: mutual_auth, + service_principal: service_principal_for_trace(build_spn, realm&.upcase), + host: hostname || host + }.compact + rescue StandardError + { + realm: realm&.upcase, + mutual_auth: mutual_auth, + host: hostname || host + }.compact + end + + def trace_response_token(metadata) + kerberos_trace_subscriber.on_response_token( + service_trace_context.merge( + { + direction: 'inbound' + }.merge(metadata) + ) + ) + rescue StandardError => e + vprint_warning("Unable to trace Kerberos response token: #{e.class}: #{e.message}") + nil + end + + def mutual_authentication_trace_result(success, incomplete: false) + return nil unless mutual_auth + return 'incomplete' if incomplete + + success ? 'success' : 'failed' + end + + def client_principal_for_trace(authenticator) + cname = authenticator&.cname + crealm = authenticator&.crealm || realm&.upcase + return "#{username}@#{crealm}" if cname.nil? && username + + principal_with_realm(cname, crealm) + end + + def service_principal_for_trace(principal, principal_realm) + principal_with_realm(principal, principal_realm || realm&.upcase) + end + + def principal_with_realm(principal, principal_realm) + name = principal&.to_s + return nil if name.nil? + + principal_realm ? "#{name}@#{principal_realm}" : name + end + + def channel_binding_trace(channel_binding) + return { present: false } unless channel_binding + + token = channel_binding.channel_binding_token if channel_binding.respond_to?(:channel_binding_token) + { + present: true, + length: token&.bytesize + }.compact + end + # Authenticate with a ticket-granting-service (TGS). This method will not contact the KDC and can not request a # delegation ticket. # @@ -835,7 +1049,7 @@ def authenticate_via_krb5_ccache_credential_tgs(credential, options = {}) checksum = build_gss_ap_req_checksum_value(options: options) if use_gss_checksum sequence_number = rand(1 << 32) - service_ap_request = build_service_ap_request( + service_ap_request_options = { session_key: tgs_auth_key, checksum: checksum, ticket: tgs_ticket, @@ -850,12 +1064,26 @@ def authenticate_via_krb5_ccache_credential_tgs(credential, options = {}) ] ), sequence_number: sequence_number + } + authenticator = build_authenticator( + service_ap_request_options.merge( + subkey: nil, + authenticator_enc_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::AP_REQ_AUTHENTICATOR + ) ) + service_ap_request = build_service_ap_request(service_ap_request_options.merge(authenticator: authenticator)) { service_ap_request: service_ap_request, + authenticator: authenticator, session_key: tgs_auth_key, - client_sequence_number: sequence_number + client_sequence_number: sequence_number, + trace_credential: credential, + delegated_credentials: { + mode: send_delegated_creds, + requested: do_delegation == true, + present: false + } } end @@ -897,6 +1125,8 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {}) sname, tgs_options ) + # Delegation may replace `credential` below, but the AP-REQ continues to use this service ticket. + service_credential = credential case send_delegated_creds when Delegation::ALWAYS @@ -932,7 +1162,7 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {}) end sequence_number = rand(1 << 32) - service_ap_request = build_service_ap_request( + service_ap_request_options = { session_key: tgs_auth.key, checksum: checksum, ticket: tgs_ticket, @@ -948,13 +1178,27 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {}) ), sequence_number: sequence_number, subkey_type: ticket.enc_part.etype # The AP-REP will come back with this same type of subkey + } + authenticator = build_authenticator( + service_ap_request_options.merge( + subkey: nil, + authenticator_enc_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::AP_REQ_AUTHENTICATOR + ) ) + service_ap_request = build_service_ap_request(service_ap_request_options.merge(authenticator: authenticator)) { service_ap_request: service_ap_request, + authenticator: authenticator, session_key: tgs_auth.key, client_sequence_number: sequence_number, - credential: credential + credential: credential, + trace_credential: service_credential, + delegated_credentials: { + mode: send_delegated_creds, + requested: do_delegation == true, + present: !delegated_tgs_ticket.nil? + } } end diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/options.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/options.rb index 6f7ee354d98f9..e3faf2b0314d7 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/options.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/options.rb @@ -46,7 +46,7 @@ def kerberos_auth_options(protocol:, auth_methods:) ), Msf::OptEnum.new( 'KerberosTicketTrace', - [false, 'Kerberos ticket trace mode', 'off', %w[off metadata ticket full]], + [false, 'Kerberos protocol trace mode', 'off', %w[off metadata ticket full]], conditions: option_conditions ), Msf::OptString.new( diff --git a/lib/msf/core/exploit/remote/smb/client/kerberos_authentication.rb b/lib/msf/core/exploit/remote/smb/client/kerberos_authentication.rb index 76cd76f364f7b..5ce5a043f8158 100644 --- a/lib/msf/core/exploit/remote/smb/client/kerberos_authentication.rb +++ b/lib/msf/core/exploit/remote/smb/client/kerberos_authentication.rb @@ -46,8 +46,24 @@ def smb1_authenticate @application_key = @session_key = kerberos_result[:session_key].value[0...16] + @kerberos_authenticator.trace_protocol_carrier( + protocol: 'SMB', + direction: 'request', + label: 'SMB Session Setup', + carrier: 'SMB1 Session Setup security blob', + field_name: 'security_blob', + token: kerberos_result[:security_blob] + ) raw_kerberos_response = smb1_kerberos_authenticate(kerberos_result[:security_blob]) response = smb1_session_setup_response(raw_kerberos_response) + @kerberos_authenticator.trace_protocol_carrier( + protocol: 'SMB', + direction: 'response', + label: 'SMB Session Setup', + carrier: 'SMB1 Session Setup security blob', + field_name: 'security_blob', + token: response.data_block.security_blob + ) @kerberos_authenticator.validate_response!(response.data_block.security_blob) response_code = response.status_code @@ -98,8 +114,24 @@ def smb2_authenticate kerberos_result = @kerberos_authenticator.authenticate + @kerberos_authenticator.trace_protocol_carrier( + protocol: 'SMB', + direction: 'request', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'security_blob', + token: kerberos_result[:security_blob] + ) raw_kerberos_response = smb2_kerberos_authenticate(kerberos_result[:security_blob]) response = smb2_session_setup_response(raw_kerberos_response) + @kerberos_authenticator.trace_protocol_carrier( + protocol: 'SMB', + direction: 'response', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'buffer', + token: response.buffer + ) @kerberos_authenticator.validate_response!(response.buffer) @session_id = response.smb2_header.session_id diff --git a/lib/rex/proto/gss.rb b/lib/rex/proto/gss.rb index 0f91825fa9d2d..315912700ed38 100644 --- a/lib/rex/proto/gss.rb +++ b/lib/rex/proto/gss.rb @@ -1,4 +1,8 @@ # -*- coding: binary -*- +# frozen_string_literal: true + +require 'openssl' +require 'rex/proto' module Rex::Proto::Gss OID_SPNEGO = OpenSSL::ASN1::ObjectId.new('1.3.6.1.5.5.2') diff --git a/lib/rex/proto/gss/asn1.rb b/lib/rex/proto/gss/asn1.rb index 0763dbad21c32..15e31315dffd9 100644 --- a/lib/rex/proto/gss/asn1.rb +++ b/lib/rex/proto/gss/asn1.rb @@ -1,3 +1,9 @@ +# frozen_string_literal: true + +require 'openssl' +require 'rex/proto' + +# Helpers for the pseudo-ASN.1 framing used by GSS mechanism tokens. module Rex::Proto::Gss::Asn1 # # GSS has some "pseudo-asn1" to wrap up tokens. This function parses that wrapping, extracts @@ -5,18 +11,22 @@ module Rex::Proto::Gss::Asn1 def unwrap_pseudo_asn1(token) start_of_token = nil mech_id = nil - # This bit is pseudo-ASN1 - we parse up until the OID, then take note of where we got up + # This bit is pseudo-ASN1 - we parse up until the OID, then take note of where we got up # to, and continue parsing from there. - OpenSSL::ASN1.traverse(token) do | depth, offset, header_len, length, constructed, tag_class, tag| - component = token[offset, header_len+length] + OpenSSL::ASN1.traverse(token) do |depth, offset, header_len, length, _constructed, tag_class, tag| + component = token[offset, header_len + length] if depth == 1 && tag_class == :UNIVERSAL && tag == 6 mech_id = OpenSSL::ASN1.decode(component) - start_of_token = offset+header_len+length + start_of_token = offset + header_len + length break end end - [mech_id, token[start_of_token, token.length - start_of_token]] + unless mech_id && start_of_token + raise OpenSSL::ASN1::ASN1Error, 'GSS token does not contain a top-level mechanism OID' + end + + [mech_id, token.byteslice(start_of_token, token.bytesize - start_of_token)] end def wrap_pseudo_asn1(mech_id, token) diff --git a/lib/rex/proto/gss/kerberos_token.rb b/lib/rex/proto/gss/kerberos_token.rb new file mode 100644 index 0000000000000..371b12a11e1ba --- /dev/null +++ b/lib/rex/proto/gss/kerberos_token.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require 'rex/proto/gss' +require 'rex/proto/gss/asn1' +require 'rex/proto/gss/spnego_neg_token_init' +require 'rex/proto/gss/spnego_neg_token_targ' + +module Rex + module Proto + module Gss + # Represents the RFC 1964 framing around an opaque Kerberos protocol + # message and provides the RFC 4178 SPNEGO operations needed to carry it. + # + # The Kerberos message payload is deliberately not decoded. In particular, + # AP-REQ bytes can be extracted and rebuilt without changing the encrypted + # ticket or authenticator. + class KerberosToken + extend Rex::Proto::Gss::Asn1 + + # Raised when a GSS or SPNEGO token cannot be parsed or does not satisfy + # the requested Kerberos token constraints. + class ParseError < StandardError; end + + # RFC 1964 section 1.1 token identifiers. + # @see https://datatracker.ietf.org/doc/html/rfc1964#section-1.1 + TOK_ID_KRB_AP_REQ = "\x01\x00".b + TOK_ID_KRB_AP_REP = "\x02\x00".b + TOK_ID_KRB_ERROR = "\x03\x00".b + + KERBEROS_MECHANISM_OIDS = [ + Rex::Proto::Gss::OID_KERBEROS_5.value, + Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + ].freeze + + # @return [String] the GSS mechanism OID + attr_reader :mechanism_oid + + # @return [String] the two-byte RFC 1964 token identifier + attr_reader :token_id + + # @return [String] the opaque Kerberos message payload + attr_reader :payload + + # @param mechanism_oid [String] + # @param token_id [String] + # @param payload [String] + def initialize(mechanism_oid:, token_id:, payload:) + @mechanism_oid = mechanism_oid + @token_id = token_id + @payload = payload + end + + # Parses a bare GSS-Kerberos token and validates its mechanism OID. + # + # @param token [String, #to_binary_s] + # @return [KerberosToken] + # @raise [ParseError] if the token is malformed or is not a supported + # Kerberos mechanism + def self.parse(token) + blob = binary_string(token) + mechanism, encapsulated_token = unwrap_pseudo_asn1(blob) + parse_unwrapped(mechanism, encapsulated_token) + rescue ParseError + raise + rescue OpenSSL::ASN1::ASN1Error, TypeError => e + raise ParseError, "unable to parse GSS-Kerberos token: #{e.message}" + end + + # Parses an RFC 4178 SPNEGO NegTokenInit. + # + # @param token [String, #to_binary_s] + # @return [Hash] SPNEGO mechanism and optimistic-token metadata + # @raise [ParseError] if the token is malformed or does not use the + # SPNEGO mechanism OID + def self.parse_spnego_init(token) + spnego = Rex::Proto::Gss::SpnegoNegTokenInit.parse(binary_string(token)) + mechanism_oid = spnego[:gssapi][:oid].value + unless mechanism_oid == Rex::Proto::Gss::OID_SPNEGO.value + raise ParseError, "unsupported GSS mechanism OID #{mechanism_oid.inspect}" + end + + mech_types = [] + index = 0 + while (mech_type = spnego.mech_type_list[index]) + mech_types << mech_type.value + index += 1 + end + + neg_token_init = spnego[:gssapi][:neg_token_init] + { + mechanism_oid: mechanism_oid, + mech_types: mech_types, + preferred_mech: mech_types.first, + req_flags: neg_token_init[:context_flags]&.value, + mech_token: spnego.mech_token, + mech_list_mic: neg_token_init[:mech_list_mic]&.value + }.compact + rescue ParseError + raise + rescue RASN1::ASN1Error, TypeError => e + raise ParseError, "unable to parse SPNEGO NegTokenInit: #{e.message}" + end + + # Parses an RFC 4178 SPNEGO NegTokenResp. + # + # @param token [String, #to_binary_s] + # @return [Hash] SPNEGO negotiation result and response-token metadata + # @raise [ParseError] if the token is malformed + def self.parse_spnego_response(token) + spnego = Rex::Proto::Gss::SpnegoNegTokenTarg.parse(binary_string(token)) + { + neg_state: spnego_neg_state_name(spnego.neg_result), + supported_mech: spnego.supported_mech, + response_token: spnego.response_token, + mech_list_mic: spnego.mech_list_mic + }.compact + rescue ParseError + raise + rescue RASN1::ASN1Error, TypeError => e + raise ParseError, "unable to parse SPNEGO NegTokenResp: #{e.message}" + end + + # Extracts an opaque AP-REQ from a bare GSS-Kerberos token or an RFC + # 4178 SPNEGO NegTokenInit. + # + # @param token [String, #to_binary_s] + # @return [String] the byte-identical AP-REQ payload + # @raise [ParseError] if the token is not a Kerberos AP-REQ + def self.extract_ap_req(token) + blob = binary_string(token) + mechanism, encapsulated_token = unwrap_pseudo_asn1(blob) + + kerberos_token = if mechanism.value == Rex::Proto::Gss::OID_SPNEGO.value + spnego = parse_spnego_init(blob) + mech_token = spnego[:mech_token] + raise ParseError, 'SPNEGO NegTokenInit does not contain a mechanism token' if mech_token.nil? + + begin + parse(mech_token) + rescue ParseError => e + raise ParseError, "SPNEGO mechanism token is not a valid Kerberos token: #{e.message}" + end + else + parse_unwrapped(mechanism, encapsulated_token) + end + + unless kerberos_token.ap_req? + raise ParseError, "GSS-Kerberos token is not an AP-REQ (token ID #{kerberos_token.token_id_hex})" + end + if kerberos_token.payload.empty? + raise ParseError, 'GSS-Kerberos AP-REQ payload is empty' + end + + kerberos_token.payload + rescue ParseError + raise + rescue OpenSSL::ASN1::ASN1Error, TypeError => e + raise ParseError, "unable to extract Kerberos AP-REQ: #{e.message}" + end + + # Attempts to extract an AP-REQ without raising for an unsupported or + # malformed token. This is useful when dispatching between GSS + # mechanisms, such as Kerberos and NTLM. + # + # @param token [String, #to_binary_s] + # @return [String, nil] the opaque AP-REQ payload, or nil + def self.try_extract_ap_req(token) + extract_ap_req(token) + rescue ParseError + nil + end + + # Tests whether a GSS or SPNEGO token contains a Kerberos AP-REQ. + # + # @param token [String, #to_binary_s] + # @return [Boolean] + def self.kerberos_ap_req?(token) + !try_extract_ap_req(token).nil? + end + + # Builds an RFC 1964 GSS-Kerberos token containing an opaque AP-REQ. + # + # @param ap_req_der [String, #to_binary_s] encoded AP-REQ bytes + # @param mechanism_oid [OpenSSL::ASN1::ObjectId, String] the Kerberos + # mechanism OID placed in the GSS wrapper + # @return [String] + def self.build_gss_ap_req(ap_req_der, mechanism_oid: Rex::Proto::Gss::OID_KERBEROS_5) + mechanism = asn1_object_id(mechanism_oid) + validate_kerberos_mechanism!(mechanism.value) + ap_req_der = required_binary_string(ap_req_der, 'AP-REQ') + wrap_pseudo_asn1(mechanism, TOK_ID_KRB_AP_REQ + ap_req_der) + end + + # Builds an RFC 4178 SPNEGO NegTokenInit around an existing mechanism + # token. + # + # @param mech_token [String, #to_binary_s] + # @param mech_types [Array] ordered + # initiator mechanism preferences + # @return [String] + def self.build_spnego_init(mech_token, mech_types: [Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5]) + encoded_mech_types = mech_types.map { |mechanism| asn1_object_id(mechanism) } + if encoded_mech_types.empty? + raise ParseError, 'SPNEGO NegTokenInit requires at least one mechanism type' + end + + mech_token = required_binary_string(mech_token, 'SPNEGO mechanism 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(encoded_mech_types) + ], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::OctetString.new(mech_token) + ], 2, :CONTEXT_SPECIFIC) + ]) + ], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION).to_der + end + + # Builds an RFC 4178 SPNEGO NegTokenInit containing a GSS-Kerberos + # AP-REQ. The AP-REQ payload is never decoded or modified. + # + # @param ap_req_der [String, #to_binary_s] + # @return [String] + def self.build_spnego_ap_req(ap_req_der) + build_spnego_init(build_gss_ap_req(ap_req_der)) + end + + # Coerces protocol binary fields into binary strings. + # + # @param value [String, #to_binary_s, #to_s] + # @return [String, nil] + # @raise [ParseError] if the value cannot be represented as bytes + def self.binary_string(value) + return nil if value.nil? + + result = if value.is_a?(String) + value + elsif value.respond_to?(:to_binary_s) + value.to_binary_s + elsif value.respond_to?(:bytesize) && value.respond_to?(:to_s) + value.to_s + end + + unless result.is_a?(String) + raise ParseError, "value of type #{value.class} cannot be converted to a binary string" + end + + result.b + end + + # @return [String] hexadecimal token identifier + def token_id_hex + token_id.unpack1('H*') + end + + # @return [String] readable Kerberos token type + def token_type + case token_id + when TOK_ID_KRB_AP_REQ + 'AP-REQ' + when TOK_ID_KRB_AP_REP + 'AP-REP' + when TOK_ID_KRB_ERROR + 'KRB-ERROR' + else + "UNKNOWN (#{token_id_hex})" + end + end + + # @return [Boolean] whether this token contains an AP-REQ + def ap_req? + token_id == TOK_ID_KRB_AP_REQ + end + + class << self + private + + def parse_unwrapped(mechanism, encapsulated_token) + unless mechanism.respond_to?(:value) + raise ParseError, 'GSS token does not contain a mechanism OID' + end + + mechanism_oid = mechanism.value + validate_kerberos_mechanism!(mechanism_oid) + if encapsulated_token.nil? || encapsulated_token.bytesize < 2 + raise ParseError, 'GSS-Kerberos token does not contain a two-byte token ID' + end + + new( + mechanism_oid: mechanism_oid, + token_id: encapsulated_token.byteslice(0, 2), + payload: encapsulated_token.byteslice(2, encapsulated_token.bytesize - 2) + ) + end + + def validate_kerberos_mechanism!(mechanism_oid) + return if KERBEROS_MECHANISM_OIDS.include?(mechanism_oid) + + raise ParseError, "unsupported Kerberos mechanism OID #{mechanism_oid.inspect}" + end + + def asn1_object_id(value) + return value if value.is_a?(OpenSSL::ASN1::ObjectId) + + OpenSSL::ASN1::ObjectId.new(value.to_s) + rescue OpenSSL::ASN1::ASN1Error, TypeError => e + raise ParseError, "invalid mechanism OID: #{e.message}" + end + + def required_binary_string(value, label) + value = binary_string(value) + raise ParseError, "#{label} must not be empty" if value.nil? || value.empty? + + value + end + + def spnego_neg_state_name(value) + return nil if value.nil? + return value if Rex::Proto::Gss::SpnegoNegTokenTarg::NEG_RESULTS.key?(value) + + Rex::Proto::Gss::SpnegoNegTokenTarg::NEG_RESULTS.invert.fetch(value, "unknown (#{value})") + end + end + end + end + end +end diff --git a/lib/rex/proto/http/client.rb b/lib/rex/proto/http/client.rb index 6c2548975e63a..174c012e3c695 100644 --- a/lib/rex/proto/http/client.rb +++ b/lib/rex/proto/http/client.rb @@ -427,12 +427,25 @@ def kerberos_auth(opts = {}, mechanism: Rex::Proto::Gss::Mechanism::KERBEROS) # Separate options for the auth requests auth_opts = opts.clone auth_opts['headers'] = opts['headers'].clone + scheme = nil case mechanism when Rex::Proto::Gss::Mechanism::KERBEROS - auth_opts['headers']['Authorization'] = "Kerberos #{gss_data_b64}" + scheme = 'Kerberos' + auth_opts['headers']['Authorization'] = "#{scheme} #{gss_data_b64}" when Rex::Proto::Gss::Mechanism::SPNEGO - auth_opts['headers']['Authorization'] = "Negotiate #{gss_data_b64}" + scheme = 'Negotiate' + auth_opts['headers']['Authorization'] = "#{scheme} #{gss_data_b64}" end + kerberos_authenticator.trace_protocol_carrier( + protocol: 'HTTP', + direction: 'request', + label: 'HTTP Authorization', + carrier: 'Authorization header', + header_name: 'Authorization', + scheme: scheme, + base64_token: gss_data_b64, + token: gss_data + ) if auth_opts['no_body_for_auth'] auth_opts.delete('data') @@ -449,10 +462,22 @@ def kerberos_auth(opts = {}, mechanism: Rex::Proto::Gss::Mechanism::KERBEROS) end # Get the challenge and craft the response - response = resp.headers['WWW-Authenticate'].scan(/Kerberos ([A-Z0-9\x2b\x2f=]+)/ni).flatten[0] - return resp unless response + response_match = resp.headers['WWW-Authenticate'].to_s.match(/(Kerberos|Negotiate)\s+([A-Z0-9\x2b\x2f=]+)/ni) + return resp unless response_match + response_scheme = response_match[1] + response = response_match[2] decoded = Rex::Text.decode_base64(response) + kerberos_authenticator.trace_protocol_carrier( + protocol: 'HTTP', + direction: 'response', + label: 'HTTP WWW-Authenticate', + carrier: 'WWW-Authenticate header', + header_name: 'WWW-Authenticate', + scheme: response_scheme, + base64_token: response, + token: decoded + ) mutual_auth_result = kerberos_authenticator.parse_gss_init_response(decoded, auth_result[:session_key]) self.krb_encryptor = kerberos_authenticator.get_message_encryptor(mutual_auth_result[:ap_rep_subkey], auth_result[:client_sequence_number], diff --git a/lib/rex/proto/kerberos/gss_token_parser.rb b/lib/rex/proto/kerberos/gss_token_parser.rb new file mode 100644 index 0000000000000..610455b4acd4a --- /dev/null +++ b/lib/rex/proto/kerberos/gss_token_parser.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rex/proto/gss/kerberos_token' + +module Rex + module Proto + module Kerberos + # Parses GSS-Kerberos and SPNEGO tokens for trace presentation. + class GssTokenParser + # Parses the GSS-Kerberos framing defined by RFC 1964 section 1.1. + # + # @param token [String, #to_binary_s] + # @return [Hash] + # @see https://datatracker.ietf.org/doc/html/rfc1964#section-1.1 + def parse_kerberos(token) + parsed = Rex::Proto::Gss::KerberosToken.parse(token) + + compact_hash( + mechanism_oid: parsed.mechanism_oid, + token_id: parsed.token_id_hex, + inner_token_type: parsed.token_type, + ap_req_payload: parsed.ap_req? ? parsed.payload : nil, + response_payload: parsed.ap_req? ? nil : parsed.payload + ) + rescue Rex::Proto::Gss::KerberosToken::ParseError => e + { parse_failure: "unable to parse token as GSS-Kerberos: #{e.class}: #{e.message}" } + end + + # Parses a SPNEGO NegTokenInit token defined by RFC 4178 section 4.2.1. + # + # @param token [String, #to_binary_s] + # @return [Hash] + # @see https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.1 + def parse_spnego_init(token) + Rex::Proto::Gss::KerberosToken.parse_spnego_init(token) + rescue Rex::Proto::Gss::KerberosToken::ParseError => e + { parse_failure: "unable to parse token as SPNEGO NegTokenInit: #{e.class}: #{e.message}" } + end + + # Parses a SPNEGO NegTokenResp token defined by RFC 4178 section 4.2.2. + # + # @param token [String, #to_binary_s] + # @return [Hash] + # @see https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.2 + def parse_spnego_response(token) + Rex::Proto::Gss::KerberosToken.parse_spnego_response(token) + rescue Rex::Proto::Gss::KerberosToken::ParseError => e + { parse_failure: "unable to parse response token as SPNEGO NegTokenResp: #{e.class}: #{e.message}" } + end + + # Coerces string-like protocol fields to binary strings. + # + # @param value [Object] + # @return [String, nil, Object] + def binary_string(value) + Rex::Proto::Gss::KerberosToken.binary_string(value) + end + + private + + def compact_hash(hash) + hash.compact + end + end + end + end +end diff --git a/lib/rex/proto/kerberos/kerberos_logger_subscriber.rb b/lib/rex/proto/kerberos/kerberos_logger_subscriber.rb index 563428e5dc139..84716ca39bffc 100644 --- a/lib/rex/proto/kerberos/kerberos_logger_subscriber.rb +++ b/lib/rex/proto/kerberos/kerberos_logger_subscriber.rb @@ -1,17 +1,15 @@ -# -*- coding: binary -*- +# frozen_string_literal: true -require 'set' -require 'time' -require 'rex/proto/kerberos/model' -require 'rex/proto/kerberos/crypto' -require 'rex/proto/kerberos/kerberos_subscriber' -require 'rex/proto/kerberos/kerberos_readable_text_presenter' require 'rex/proto/kerberos/credential_cache/krb5_ccache_presenter' +require 'rex/proto/kerberos/kerberos_readable_text_presenter' +require 'rex/proto/kerberos/kerberos_subscriber' +require 'rex/proto/kerberos/kerberos_trace_serializer' +require 'rex/proto/kerberos/service_authentication_trace_presenter' module Rex module Proto module Kerberos - # Logs Kerberos requests/responses + # Logs Kerberos requests, responses, credentials, and service-authentication trace events. class KerberosLoggerSubscriber < KerberosSubscriber TRACE_MODE_OFF = 'off' TRACE_MODE_METADATA = 'metadata' @@ -24,6 +22,7 @@ class KerberosLoggerSubscriber < KerberosSubscriber TRACE_MODE_FULL ].freeze + # @param logger [#print_line, #datastore] def initialize(logger:) super() raise 'Incompatible logger' unless logger.respond_to?(:print_line) && logger.respond_to?(:datastore) @@ -63,6 +62,52 @@ def on_credential(credential, source: nil) @logger.print_line(format_credential_for_trace_mode(credential)) end + # (see Rex::Proto::Kerberos::KerberosSubscriber#on_ap_req) + def on_ap_req(metadata) + return unless service_authentication_trace_enabled? + + if trace_mode == TRACE_MODE_TICKET + credential = metadata && metadata[:credential] + return if credential.nil? + + print_credential_header('AP-REQ Selected Service Ticket') + @logger.print_line(format_credential_for_trace_mode(credential)) + return + end + + print_event('Kerberos AP-REQ', service_authentication_presenter.present_ap_req(metadata || {}), direction: :request) + end + + # (see Rex::Proto::Kerberos::KerberosSubscriber#on_gss_token) + def on_gss_token(metadata) + return unless token_trace_enabled? + + print_event('Kerberos GSS Token', service_authentication_presenter.present_gss_token(metadata || {}), direction: :request) + end + + # (see Rex::Proto::Kerberos::KerberosSubscriber#on_spnego_token) + def on_spnego_token(metadata) + return unless token_trace_enabled? + + print_event('Kerberos SPNEGO Token', service_authentication_presenter.present_spnego_token(metadata || {}), direction: :request) + end + + # (see Rex::Proto::Kerberos::KerberosSubscriber#on_response_token) + def on_response_token(metadata) + return unless token_trace_enabled? + + print_event('Kerberos Response Token', service_authentication_presenter.present_response_token(metadata || {}), direction: :response) + end + + # (see Rex::Proto::Kerberos::KerberosSubscriber#on_protocol_carrier) + def on_protocol_carrier(metadata) + return unless token_trace_enabled? + + title = metadata && metadata[:label] ? "Kerberos Carrier: #{metadata[:label]}" : 'Kerberos Carrier' + direction = metadata && metadata[:direction].to_s == 'response' ? :response : :request + print_event(title, service_authentication_presenter.present_protocol_carrier(metadata || {}), direction: direction) + end + private def message_trace_enabled? @@ -73,6 +118,14 @@ def credential_trace_enabled? [TRACE_MODE_METADATA, TRACE_MODE_TICKET, TRACE_MODE_FULL].include?(trace_mode) end + def service_authentication_trace_enabled? + [TRACE_MODE_METADATA, TRACE_MODE_TICKET, TRACE_MODE_FULL].include?(trace_mode) + end + + def token_trace_enabled? + [TRACE_MODE_METADATA, TRACE_MODE_FULL].include?(trace_mode) + end + def trace_mode configured_trace_mode = @logger.datastore['KerberosTicketTrace'] return TRACE_MODE_FULL if configured_trace_mode == true @@ -84,7 +137,6 @@ def trace_mode def trace_colors configured_trace_colors = @logger.datastore['KerberosTicketTraceColors'] - # Keep HttpTrace-compatible default formatting: request/response color pair. trace_colors = blank_value?(configured_trace_colors) ? 'red/blu' : configured_trace_colors trace_colors += '/' if trace_colors.count('/') == 0 trace_colors.gsub('/', ' / ').split('/').map do |color| @@ -104,28 +156,22 @@ def print_credential_header(source) @logger.print_line('#' * 20) end + def print_event(title, body, direction:) + request_color, response_color = trace_colors + color = direction == :response ? response_color : request_color + + @logger.print_line('#' * 20) + @logger.print_line("# #{title}") + @logger.print_line('#' * 20) + @logger.print_line("%clr#{color}#{body}%clr") + end + def message_type_name(message) msg_type = message.msg_type if message.respond_to?(:msg_type) - case msg_type - when Rex::Proto::Kerberos::Model::AS_REQ - 'AS-REQ' - when Rex::Proto::Kerberos::Model::AS_REP - 'AS-REP' - when Rex::Proto::Kerberos::Model::TGS_REQ - 'TGS-REQ' - when Rex::Proto::Kerberos::Model::TGS_REP - 'TGS-REP' - when Rex::Proto::Kerberos::Model::AP_REQ - 'AP-REQ' - when Rex::Proto::Kerberos::Model::AP_REP - 'AP-REP' - when Rex::Proto::Kerberos::Model::KRB_ERROR - 'KRB-ERROR' - when nil - 'UNKNOWN' - else - "UNKNOWN (#{msg_type})" - end + return 'UNKNOWN' if msg_type.nil? + + name = trace_serializer.message_type_name(msg_type) + name == 'UNKNOWN' ? "UNKNOWN (#{msg_type})" : name end def format_message_for_trace_mode(message) @@ -136,10 +182,8 @@ def format_message(message, redact_binary:) return 'null' if message.nil? if message.respond_to?(:attributes) - serialized_message = serialize_element(message, redact_binary: redact_binary) - readable_text_presenter.present(serialized_message) + readable_text_presenter.present(trace_serializer.serialize(message, redact_binary: redact_binary)) else - # Fall back for non-model objects. message.to_s end rescue StandardError => e @@ -152,152 +196,6 @@ def format_credential_for_trace_mode(credential) "Credential presenter error: #{e.class}: #{e.message}" end - def serialize_element(element, redact_binary:) - element.attributes.each_with_object({}) do |attribute, output| - value = element.public_send(attribute) - next if value.nil? - - output[serialized_attribute_key(element, attribute)] = serialize_value(value, element: element, attribute: attribute.to_sym, redact_binary: redact_binary) - end - end - - def serialized_attribute_key(element, attribute) - if attribute == :options && element.is_a?(Rex::Proto::Kerberos::Model::ApReq) - 'ap_options' - elsif attribute == :options && element.is_a?(Rex::Proto::Kerberos::Model::KdcRequestBody) - 'kdc_options' - else - attribute.to_s - end - end - - def serialize_value(value, redact_binary:, element: nil, attribute: nil) - if value.respond_to?(:attributes) - # Recursively serialize nested Kerberos model objects. - serialize_element(value, redact_binary: redact_binary) - elsif kerberos_error_code?(value) - # Normalize ErrorCode-like objects to a compact structured form. - { - 'name' => value.name, - 'value' => value.value, - 'description' => value.description - } - else - serialize_scalar_value(value, element: element, attribute: attribute, redact_binary: redact_binary) - end - end - - def serialize_scalar_value(value, redact_binary:, element: nil, attribute: nil) - case value - when Array - value.map { |entry| serialize_value(entry, element: element, attribute: attribute, redact_binary: redact_binary) } - when Set - value.to_a.map { |entry| serialize_value(entry, element: element, attribute: attribute, redact_binary: redact_binary) } - when Hash - value.each_with_object({}) do |(key, entry), output| - output[key.to_s] = serialize_value(entry, redact_binary: redact_binary) - end - when Rex::Proto::Kerberos::Model::KerberosFlags - { - 'value' => value.to_i, - 'flags' => value.enabled_flag_names.map(&:to_s) - } - when Time - value.utc.iso8601 - when String - serialize_string(value, redact_binary: redact_binary) - when Symbol - value.to_s - when Integer - serialize_enum_value(value, element: element, attribute: attribute) || value - when Float, TrueClass, FalseClass, NilClass - value - else - value.to_s - end - end - - def serialize_enum_value(value, element:, attribute:) - enum_name = case attribute - when :msg_type - message_type_name_for_value(value) - when :type - enum_type_name(value, element) - when :etype - enum_etype_name(value) - when :name_type - enum_name_type_name(value, element) - end - return nil if enum_name.nil? - - "#{value} (#{enum_name})" - end - - def message_type_name_for_value(msg_type) - case msg_type - when Rex::Proto::Kerberos::Model::AS_REQ - 'AS-REQ' - when Rex::Proto::Kerberos::Model::AS_REP - 'AS-REP' - when Rex::Proto::Kerberos::Model::TGS_REQ - 'TGS-REQ' - when Rex::Proto::Kerberos::Model::TGS_REP - 'TGS-REP' - when Rex::Proto::Kerberos::Model::AP_REQ - 'AP-REQ' - when Rex::Proto::Kerberos::Model::AP_REP - 'AP-REP' - when Rex::Proto::Kerberos::Model::KRB_ERROR - 'KRB-ERROR' - else - 'UNKNOWN' - end - end - - def enum_type_name(value, element) - if element.is_a?(Rex::Proto::Kerberos::Model::PreAuthDataEntry) - const_name_for_value(Rex::Proto::Kerberos::Model::PreAuthType, value) - elsif element.is_a?(Rex::Proto::Kerberos::Model::EncryptionKey) - enum_etype_name(value) - end - end - - def enum_etype_name(value) - Rex::Proto::Kerberos::Crypto::Encryption.const_name(value) || 'UNKNOWN' - end - - def enum_name_type_name(value, element) - return nil unless element.is_a?(Rex::Proto::Kerberos::Model::PrincipalName) - - const_name_for_value(Rex::Proto::Kerberos::Model::NameType, value) - end - - def const_name_for_value(mod, value) - mod.constants.each do |const_name| - return const_name.to_s if mod.const_get(const_name) == value - rescue StandardError - next - end - - 'UNKNOWN' - end - - def kerberos_error_code?(value) - value.respond_to?(:name) && value.respond_to?(:value) && value.respond_to?(:description) - end - - def serialize_string(value, redact_binary:) - return value if printable_string?(value) - - return "[binary #{value.bytesize} bytes]" if redact_binary - - "[binary #{value.bytesize} bytes: #{value.unpack1('H*')}]" - end - - def present_time(time) - time.localtime.to_s - end - def ticket_presenter @ticket_presenter ||= Rex::Proto::Kerberos::CredentialCache::Krb5CcachePresenter.new(nil) end @@ -306,15 +204,20 @@ def readable_text_presenter @readable_text_presenter ||= Rex::Proto::Kerberos::KerberosReadableTextPresenter.new end - def printable_string?(value) - utf8_value = value.dup.force_encoding(::Encoding::UTF_8) - utf8_value.valid_encoding? && utf8_value.match?(/\A[[:print:]\r\n\t ]*\z/) - rescue ::Encoding::CompatibilityError - false + def trace_serializer + @trace_serializer ||= Rex::Proto::Kerberos::KerberosTraceSerializer.new + end + + def service_authentication_presenter + @service_authentication_presenters ||= {} + @service_authentication_presenters[trace_mode] ||= Rex::Proto::Kerberos::ServiceAuthenticationTracePresenter.new( + trace_mode: trace_mode, + serializer: trace_serializer, + readable_text_presenter: readable_text_presenter + ) end def blank_value?(value) - # Avoid depending on ActiveSupport's `blank?` for this Rex-level helper. return true if value.nil? || value == false return value.strip.empty? if value.respond_to?(:strip) return value.empty? if value.respond_to?(:empty?) diff --git a/lib/rex/proto/kerberos/kerberos_readable_text_presenter.rb b/lib/rex/proto/kerberos/kerberos_readable_text_presenter.rb index 0316b125b8e65..69471dfc47756 100644 --- a/lib/rex/proto/kerberos/kerberos_readable_text_presenter.rb +++ b/lib/rex/proto/kerberos/kerberos_readable_text_presenter.rb @@ -30,7 +30,51 @@ class KerberosReadableTextPresenter 'ticket' => 'Ticket', 'tkt_vno' => 'Ticket Version Number', 'kvno' => 'Key Version Number', - 'flags' => 'Flags' + 'flags' => 'Flags', + 'spn' => 'SPN', + 'ap_req' => 'AP-REQ', + 'ap_req_message' => 'AP-REQ Message', + 'gss_context' => 'GSS Context', + 'wrapper_type' => 'Wrapper Type', + 'mechanism_oid' => 'Mechanism OID', + 'inner_token_type' => 'Inner Token Type', + 'ap_req_payload_length' => 'AP-REQ Payload Length', + 'ap_req_payload_matches' => 'AP-REQ Payload Matches', + 'final_token_length' => 'Final Token Length', + 'mech_types' => 'Mech Types', + 'selected_mech' => 'Selected Mech', + 'preferred_mech' => 'Preferred Mech', + 'optimistic_mech_token' => 'Optimistic Mech Token', + 'mech_token_length' => 'Mech Token Length', + 'req_flags' => 'Request Flags', + 'mech_list_mic' => 'Mech List MIC', + 'inner_ap_req' => 'Inner AP-REQ', + 'neg_state' => 'Neg State', + 'supported_mech' => 'Supported Mech', + 'response_token_length' => 'Response Token Length', + 'inner_response' => 'Inner Response', + 'parse_failure' => 'Parse Failure', + 'selected_service_ticket' => 'Selected Service Ticket', + 'mutual_authentication' => 'Mutual Authentication', + 'key_usage' => 'Key Usage', + 'ticket_etype' => 'Ticket etype', + 'ticket_length' => 'Ticket Length', + 'ticket_times' => 'Ticket Times', + 'decrypted_authenticator' => 'Decrypted Authenticator', + 'decrypted_part' => 'Decrypted Part', + 'ap_rep_message' => 'AP-REP Message', + 'krb_error_message' => 'KRB-ERROR Message', + 'error_text' => 'Error Text', + 'error_data' => 'Error Data', + 'server_principal' => 'Server Principal', + 'server_time' => 'Server Time', + 'server_microseconds' => 'Server Microseconds', + 'client_time' => 'Client Time', + 'client_microseconds' => 'Client Microseconds', + 'base64_token_length' => 'Base64 Token Length', + 'decoded_token_length' => 'Decoded Token Length', + 'blob_length' => 'Blob Length', + 'token_summary' => 'Token Summary' }.freeze def present(serialized_message) diff --git a/lib/rex/proto/kerberos/kerberos_subscriber.rb b/lib/rex/proto/kerberos/kerberos_subscriber.rb index aba78608fad26..d5fbed6f55dd1 100644 --- a/lib/rex/proto/kerberos/kerberos_subscriber.rb +++ b/lib/rex/proto/kerberos/kerberos_subscriber.rb @@ -20,6 +20,31 @@ def on_response(response) def on_credential(credential, source: nil) nil end + + # @param metadata [Hash] Structured AP-REQ construction trace context. + def on_ap_req(metadata) + nil + end + + # @param metadata [Hash] Structured GSS-Kerberos token wrapping trace context. + def on_gss_token(metadata) + nil + end + + # @param metadata [Hash] Structured SPNEGO token wrapping trace context. + def on_spnego_token(metadata) + nil + end + + # @param metadata [Hash] Structured service-authentication response token trace context. + def on_response_token(metadata) + nil + end + + # @param metadata [Hash] Structured protocol carrier trace context. + def on_protocol_carrier(metadata) + nil + end end end end diff --git a/lib/rex/proto/kerberos/kerberos_trace_serializer.rb b/lib/rex/proto/kerberos/kerberos_trace_serializer.rb new file mode 100644 index 0000000000000..96d9d627609bb --- /dev/null +++ b/lib/rex/proto/kerberos/kerberos_trace_serializer.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'set' +require 'time' +require 'rex/proto/kerberos/crypto' +require 'rex/proto/kerberos/model' + +module Rex + module Proto + module Kerberos + # Serializes Kerberos models into trace-friendly primitive values. + class KerberosTraceSerializer + # @param element [Object] a Kerberos model object + # @param redact_binary [Boolean] whether binary values should omit their contents + # @return [Hash] + def serialize(element, redact_binary:) + element.attributes.each_with_object({}) do |attribute, output| + value = element.public_send(attribute) + next if value.nil? + + output[serialized_attribute_key(element, attribute)] = serialize_value( + value, + element: element, + attribute: attribute.to_sym, + redact_binary: redact_binary + ) + end + end + + # @param value [Object] + # @param redact_binary [Boolean] + # @param element [Object, nil] + # @param attribute [Symbol, nil] + # @return [Object] + def serialize_value(value, redact_binary:, element: nil, attribute: nil) + if value.respond_to?(:attributes) + serialize(value, redact_binary: redact_binary) + elsif kerberos_error_code?(value) + { + 'name' => value.name, + 'value' => value.value, + 'description' => value.description + } + else + serialize_scalar_value(value, element: element, attribute: attribute, redact_binary: redact_binary) + end + end + + # @param msg_type [Integer] + # @return [String] + def message_type_name(msg_type) + case msg_type + when Rex::Proto::Kerberos::Model::AS_REQ + 'AS-REQ' + when Rex::Proto::Kerberos::Model::AS_REP + 'AS-REP' + when Rex::Proto::Kerberos::Model::TGS_REQ + 'TGS-REQ' + when Rex::Proto::Kerberos::Model::TGS_REP + 'TGS-REP' + when Rex::Proto::Kerberos::Model::AP_REQ + 'AP-REQ' + when Rex::Proto::Kerberos::Model::AP_REP + 'AP-REP' + when Rex::Proto::Kerberos::Model::KRB_ERROR + 'KRB-ERROR' + else + 'UNKNOWN' + end + end + + # @param value [Integer] + # @return [String] + def encryption_type_name(value) + Rex::Proto::Kerberos::Crypto::Encryption.const_name(value) || 'UNKNOWN' + end + + private + + def serialized_attribute_key(element, attribute) + if attribute == :options && element.is_a?(Rex::Proto::Kerberos::Model::ApReq) + 'ap_options' + elsif attribute == :options && element.is_a?(Rex::Proto::Kerberos::Model::KdcRequestBody) + 'kdc_options' + else + attribute.to_s + end + end + + def serialize_scalar_value(value, redact_binary:, element: nil, attribute: nil) + case value + when Array + value.map { |entry| serialize_value(entry, element: element, attribute: attribute, redact_binary: redact_binary) } + when Set + value.to_a.map { |entry| serialize_value(entry, element: element, attribute: attribute, redact_binary: redact_binary) } + when Hash + value.each_with_object({}) do |(key, entry), output| + output[key.to_s] = serialize_value(entry, redact_binary: redact_binary) + end + when Rex::Proto::Kerberos::Model::KerberosFlags + { + 'value' => value.to_i, + 'flags' => value.enabled_flag_names.map(&:to_s) + } + when Time + value.utc.iso8601 + when String + serialize_string(value, redact_binary: redact_binary) + when Symbol + value.to_s + when Integer + serialize_enum_value(value, element: element, attribute: attribute) || value + when Float, TrueClass, FalseClass, NilClass + value + else + value.to_s + end + end + + def serialize_enum_value(value, element:, attribute:) + enum_name = case attribute + when :msg_type + message_type_name(value) + when :type + enum_type_name(value, element) + when :etype + encryption_type_name(value) + when :name_type + enum_name_type_name(value, element) + end + return nil if enum_name.nil? + + "#{value} (#{enum_name})" + end + + def enum_type_name(value, element) + if element.is_a?(Rex::Proto::Kerberos::Model::PreAuthDataEntry) + const_name_for_value(Rex::Proto::Kerberos::Model::PreAuthType, value) + elsif element.is_a?(Rex::Proto::Kerberos::Model::EncryptionKey) + encryption_type_name(value) + end + end + + def enum_name_type_name(value, element) + return nil unless element.is_a?(Rex::Proto::Kerberos::Model::PrincipalName) + + const_name_for_value(Rex::Proto::Kerberos::Model::NameType, value) + end + + def const_name_for_value(mod, value) + mod.constants.each do |const_name| + return const_name.to_s if mod.const_get(const_name) == value + rescue StandardError + next + end + + 'UNKNOWN' + end + + def kerberos_error_code?(value) + value.respond_to?(:name) && value.respond_to?(:value) && value.respond_to?(:description) + end + + def serialize_string(value, redact_binary:) + return value if printable_string?(value) + return "[binary #{value.bytesize} bytes]" if redact_binary + + "[binary #{value.bytesize} bytes: #{value.unpack1('H*')}]" + end + + def printable_string?(value) + utf8_value = value.dup.force_encoding(::Encoding::UTF_8) + utf8_value.valid_encoding? && utf8_value.match?(/\A[[:print:]\r\n\t ]*\z/) + rescue ::Encoding::CompatibilityError + false + end + end + end + end +end diff --git a/lib/rex/proto/kerberos/service_authentication_trace_presenter.rb b/lib/rex/proto/kerberos/service_authentication_trace_presenter.rb new file mode 100644 index 0000000000000..92883f3b8dbcc --- /dev/null +++ b/lib/rex/proto/kerberos/service_authentication_trace_presenter.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +require 'rex/proto/gss' +require 'rex/proto/kerberos/gss_token_parser' +require 'rex/proto/kerberos/kerberos_readable_text_presenter' +require 'rex/proto/kerberos/kerberos_trace_serializer' + +module Rex + module Proto + module Kerberos + # Presents Kerberos service-authentication, GSS/SPNEGO, and carrier trace events. + class ServiceAuthenticationTracePresenter + TRACE_MODE_FULL = 'full' + + # @param trace_mode [String] + # @param token_parser [Rex::Proto::Kerberos::GssTokenParser] + # @param serializer [Rex::Proto::Kerberos::KerberosTraceSerializer] + # @param readable_text_presenter [Rex::Proto::Kerberos::KerberosReadableTextPresenter] + def initialize( + trace_mode:, + token_parser: GssTokenParser.new, + serializer: KerberosTraceSerializer.new, + readable_text_presenter: KerberosReadableTextPresenter.new + ) + @trace_mode = trace_mode + @token_parser = token_parser + @serializer = serializer + @readable_text_presenter = readable_text_presenter + end + + # @param metadata [Hash] + # @return [String] + def present_ap_req(metadata) + present_hash(ap_req_trace_hash(metadata, full: full_trace?)) + rescue StandardError => e + "AP-REQ trace rendering error: #{e.class}: #{e.message}" + end + + # @param metadata [Hash] + # @return [String] + def present_gss_token(metadata) + present_hash(gss_token_trace_hash(metadata)) + rescue StandardError => e + "GSS token trace rendering error: #{e.class}: #{e.message}" + end + + # @param metadata [Hash] + # @return [String] + def present_spnego_token(metadata) + present_hash(spnego_token_trace_hash(metadata)) + rescue StandardError => e + "SPNEGO token trace rendering error: #{e.class}: #{e.message}" + end + + # @param metadata [Hash] + # @return [String] + def present_response_token(metadata) + present_hash(response_token_trace_hash(metadata)) + rescue StandardError => e + "Response token trace rendering error: #{e.class}: #{e.message}" + end + + # @param metadata [Hash] + # @return [String] + def present_protocol_carrier(metadata) + present_hash(protocol_carrier_trace_hash(metadata)) + rescue StandardError => e + "Protocol carrier trace rendering error: #{e.class}: #{e.message}" + end + + private + + def present_hash(hash) + @readable_text_presenter.present(serialize_trace_value(hash, redact_binary: !full_trace?)) + end + + def full_trace? + @trace_mode == TRACE_MODE_FULL + end + + def ap_req_trace_hash(metadata, full:) + ap_req = metadata[:ap_req] + authenticator = metadata[:authenticator] + credential = metadata[:credential] + ticket = metadata[:ticket] || ap_req&.ticket + encrypted_authenticator = ap_req&.authenticator + + hash = { + 'ap_req' => compact_hash( + 'message_type' => format_message_type_value(ap_req&.msg_type || Rex::Proto::Kerberos::Model::AP_REQ), + 'pvno' => ap_req&.pvno, + 'ap_options' => format_ap_options(ap_req&.options), + 'mutual_authentication' => metadata[:mutual_auth] ? 'requested' : 'not requested', + 'key_usage' => metadata[:use_subkey] ? 'authenticator subkey' : 'service ticket session key' + ), + 'principal' => compact_hash( + 'client_principal' => metadata[:client_principal] || principal_with_realm(authenticator&.cname, authenticator&.crealm), + 'service_principal' => metadata[:service_principal] || principal_with_realm(ticket&.sname, ticket&.realm), + 'spn' => metadata[:spn] || principal_to_s(ticket&.sname), + 'realm' => metadata[:realm] || ticket&.realm || authenticator&.crealm + ), + 'selected_service_ticket' => compact_hash( + 'server' => credential&.server&.to_s || principal_with_realm(ticket&.sname, ticket&.realm), + 'client' => credential&.client&.to_s || metadata[:client_principal], + 'ticket_etype' => format_encryption_type(ticket&.enc_part&.etype || metadata[:ticket_encryption_type]), + 'ticket_flags' => format_flags(metadata[:ticket_flags] || credential&.ticket_flags), + 'ticket_length' => credential&.ticket&.length || encoded_length(ticket), + 'times' => ticket_times_hash(metadata, credential) + ), + 'authenticator' => compact_hash( + 'encryption_type' => format_encryption_type(encrypted_authenticator&.etype || metadata[:session_key]&.type), + 'checksum_type' => authenticator&.checksum&.type, + 'checksum' => binary_summary(authenticator&.checksum&.checksum), + 'subkey' => encryption_key_summary(authenticator&.subkey), + 'sequence_number' => authenticator&.sequence_number || metadata[:sequence_number] + ), + 'gss_context' => compact_hash( + 'replay_detection' => metadata[:replay_detection], + 'channel_binding' => metadata[:channel_binding], + 'delegated_credentials' => metadata[:delegated_credentials] + ) + } + + if full + hash['ap_req_message'] = @serializer.serialize(ap_req, redact_binary: false) if ap_req + hash['decrypted_authenticator'] = @serializer.serialize(authenticator, redact_binary: false) if authenticator + hash['session_key'] = encryption_key_summary(metadata[:session_key], redact_binary: false) if metadata[:session_key] + end + + compact_nested_hash(hash) + end + + def gss_token_trace_hash(metadata) + token = @token_parser.binary_string(metadata[:token]) + parsed = @token_parser.parse_kerberos(token) + ap_req_der = metadata[:ap_req_der] + ap_req_payload = parsed[:ap_req_payload] + + compact_nested_hash( + 'wrapper_type' => 'GSS-Kerberos', + 'direction' => metadata[:direction] || 'outbound', + 'mechanism_oid' => parsed[:mechanism_oid] || metadata[:mechanism_oid] || Rex::Proto::Gss::OID_KERBEROS_5.value, + 'inner_token_type' => parsed[:inner_token_type] || metadata[:inner_token_type], + 'ap_req_payload_length' => (ap_req_payload || ap_req_der)&.bytesize, + 'final_token_length' => token&.bytesize, + 'ap_req_payload_matches' => ap_req_der && ap_req_payload ? ap_req_der == ap_req_payload : nil, + 'token' => binary_summary(token), + 'service_principal' => metadata[:service_principal], + 'realm' => metadata[:realm], + 'mutual_authentication' => metadata[:mutual_auth] ? 'requested' : 'not requested', + 'parse_failure' => parsed[:parse_failure] + ) + end + + def spnego_token_trace_hash(metadata) + token = @token_parser.binary_string(metadata[:token]) + parsed = @token_parser.parse_spnego_init(token) + mech_token = parsed[:mech_token] + inner = mech_token ? gss_token_trace_hash(metadata.merge(token: mech_token, direction: metadata[:direction])) : nil + + compact_nested_hash( + 'wrapper_type' => 'SPNEGO NegTokenInit', + 'direction' => metadata[:direction] || 'outbound', + 'mechanism_oid' => Rex::Proto::Gss::OID_SPNEGO.value, + 'mech_types' => parsed[:mech_types], + 'preferred_mech' => parsed[:preferred_mech], + 'optimistic_mech_token' => !mech_token.nil?, + 'mech_token_length' => mech_token&.bytesize, + 'req_flags' => parsed[:req_flags], + 'mech_list_mic' => binary_summary(parsed[:mech_list_mic]), + 'final_token_length' => token&.bytesize, + 'token' => binary_summary(token), + 'inner_ap_req' => inner, + 'service_principal' => metadata[:service_principal], + 'realm' => metadata[:realm], + 'mutual_authentication' => metadata[:mutual_auth] ? 'requested' : 'not requested', + 'parse_failure' => parsed[:parse_failure] + ) + end + + def response_token_trace_hash(metadata) + token = @token_parser.binary_string(metadata[:token]) + token_type = metadata[:token_type] + parsed = if metadata[:parse_failure] + if metadata[:message] + kerberos_response_message_hash(metadata[:message], metadata[:decrypted_part], full: full_trace?).merge( + 'parse_failure' => metadata[:parse_failure] + ) + else + { parse_failure: metadata[:parse_failure] } + end + elsif token_type == 'SPNEGO NegTokenResp' + spnego_response_trace_hash(token) + elsif metadata[:message] + kerberos_response_message_hash(metadata[:message], metadata[:decrypted_part], full: full_trace?) + else + gss_response_trace_hash(token, metadata[:session_key]) + end + + compact_nested_hash( + 'wrapper_type' => token_type, + 'direction' => metadata[:direction] || 'inbound', + 'carrier' => metadata[:carrier], + 'token_length' => token&.bytesize, + 'token' => binary_summary(token), + 'response' => parsed, + 'service_principal' => metadata[:service_principal], + 'realm' => metadata[:realm], + 'mutual_authentication' => metadata[:mutual_authentication] + ) + end + + def protocol_carrier_trace_hash(metadata) + token = @token_parser.binary_string(metadata[:token]) + parsed = token ? carrier_token_summary(token) : nil + + compact_nested_hash( + 'protocol' => metadata[:protocol], + 'direction' => metadata[:direction], + 'carrier' => metadata[:carrier] || metadata[:label], + 'header_name' => metadata[:header_name], + 'scheme' => metadata[:scheme], + 'field_name' => metadata[:field_name], + 'host' => metadata[:host], + 'port' => metadata[:port], + 'service_principal' => metadata[:service_principal], + 'base64_token_length' => metadata[:base64_token]&.length, + 'decoded_token_length' => metadata[:base64_token] ? token&.bytesize : nil, + 'blob_length' => token&.bytesize || metadata[:blob_length], + 'token' => binary_summary(token), + 'token_summary' => parsed + ) + end + + def spnego_response_trace_hash(token) + parsed = @token_parser.parse_spnego_response(token) + return { 'parse_failure' => parsed[:parse_failure] } if parsed[:parse_failure] + + response_token = parsed[:response_token] + compact_nested_hash( + 'wrapper_type' => 'SPNEGO NegTokenResp', + 'neg_state' => parsed[:neg_state], + 'supported_mech' => parsed[:supported_mech], + 'response_token_length' => response_token&.bytesize, + 'mech_list_mic' => binary_summary(parsed[:mech_list_mic]), + 'inner_response' => response_token ? gss_response_trace_hash(response_token, nil) : nil + ) + end + + def gss_response_trace_hash(token, session_key) + parsed = @token_parser.parse_kerberos(token) + if parsed[:parse_failure] + return { + 'parse_failure' => "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: #{parsed[:parse_failure]}" + } + end + + case parsed[:inner_token_type] + when 'AP-REP' + ap_rep = Rex::Proto::Kerberos::Model::ApRep.decode(parsed[:response_payload]) + decrypted_part = decrypt_ap_rep_for_trace(ap_rep, session_key) + kerberos_response_message_hash(ap_rep, decrypted_part, full: full_trace?) + when 'KRB-ERROR' + krb_error = Rex::Proto::Kerberos::Model::KrbError.decode(parsed[:response_payload]) + kerberos_response_message_hash(krb_error, nil, full: full_trace?) + else + { + 'parse_failure' => "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: unknown token id #{parsed[:token_id].inspect}", + 'mechanism_oid' => parsed[:mechanism_oid], + 'token_id' => parsed[:token_id] + } + end + rescue StandardError => e + { 'parse_failure' => "unable to parse response token as AP-REP/KRB-ERROR/SPNEGO: #{e.class}: #{e.message}" } + end + + def kerberos_response_message_hash(message, decrypted_part, full:) + case message + when Rex::Proto::Kerberos::Model::ApRep + hash = { + 'message_type' => format_message_type_value(message.msg_type), + 'encrypted_part' => compact_hash( + 'etype' => format_encryption_type(message.enc_part&.etype), + 'cipher' => binary_summary(message.enc_part&.cipher) + ), + 'decrypted_part' => decrypted_ap_rep_part_hash(decrypted_part) + } + hash['ap_rep_message'] = @serializer.serialize(message, redact_binary: false) if full + compact_nested_hash(hash) + when Rex::Proto::Kerberos::Model::KrbError + hash = { + 'message_type' => format_message_type_value(message.msg_type), + 'error_code' => @serializer.serialize_value(message.error_code, redact_binary: !full_trace?), + 'realm' => message.realm, + 'server_principal' => principal_with_realm(message.sname, message.realm), + 'error_text' => message.etext, + 'error_data' => binary_summary(message.e_data), + 'server_time' => message.stime, + 'server_microseconds' => message.susec, + 'client_time' => message.ctime, + 'client_microseconds' => message.cusec + } + hash['krb_error_message'] = @serializer.serialize(message, redact_binary: false) if full + compact_nested_hash(hash) + else + { 'message' => message.to_s } + end + end + + def decrypted_ap_rep_part_hash(decrypted_part) + return nil if decrypted_part.nil? + return { 'parse_failure' => decrypted_part[:parse_failure] } if decrypted_part.is_a?(Hash) && decrypted_part[:parse_failure] + + compact_hash( + 'subkey' => encryption_key_summary(decrypted_part.subkey), + 'sequence_number' => decrypted_part.sequence_number, + 'client_time' => decrypted_part.ctime, + 'client_microseconds' => decrypted_part.cusec + ) + end + + def decrypt_ap_rep_for_trace(ap_rep, session_key) + return nil if session_key.nil? + return { parse_failure: 'unable to decrypt AP-REP without matching session key etype' } if session_key.type != ap_rep.enc_part.etype + + ap_rep.decrypt_enc_part(session_key.value) + rescue StandardError => e + { parse_failure: "unable to decrypt AP-REP encrypted part: #{e.class}: #{e.message}" } + end + + def carrier_token_summary(token) + spnego_init = @token_parser.parse_spnego_init(token) + return compact_hash('token_type' => 'SPNEGO NegTokenInit', 'details' => spnego_init) unless spnego_init[:parse_failure] + + spnego_response = spnego_response_trace_hash(token) + return compact_hash('token_type' => 'SPNEGO NegTokenResp', 'details' => spnego_response) unless spnego_response['parse_failure'] + + gss_token = @token_parser.parse_kerberos(token) + unless gss_token[:parse_failure] + gss_details = gss_token.each_with_object({}) do |(key, value), output| + output[key] = value unless %i[ap_req_payload response_payload].include?(key) + end + return compact_hash('token_type' => 'GSS-Kerberos', 'details' => gss_details) + end + + compact_hash('parse_failure' => 'unable to parse carrier token as GSS-Kerberos/SPNEGO') + end + + def ticket_times_hash(metadata, credential) + explicit_times = metadata[:ticket_times] + return explicit_times if explicit_times + return nil if credential.nil? + + compact_hash( + 'auth_time' => credential.authtime, + 'start_time' => credential.starttime, + 'end_time' => credential.endtime, + 'renew_till' => credential.renew_till + ) + end + + def encryption_key_summary(key, redact_binary: !full_trace?) + return nil if key.nil? + + compact_hash( + 'type' => format_encryption_type(key.type), + 'value' => redact_binary ? binary_summary(key.value, redact_binary: true) : format_key_material(key.value) + ) + end + + def binary_summary(value, redact_binary: !full_trace?) + value = @token_parser.binary_string(value) + return nil if value.nil? + return value unless value.is_a?(String) + return "[binary #{value.bytesize} bytes]" if redact_binary + + "[binary #{value.bytesize} bytes: #{value.unpack1('H*')}]" + end + + def encoded_length(element) + return nil if element.nil? + + element.encode.bytesize + rescue StandardError + nil + end + + def format_message_type_value(msg_type) + return nil if msg_type.nil? + + "#{msg_type} (#{@serializer.message_type_name(msg_type)})" + end + + def format_ap_options(options) + return nil if options.nil? + + flags = [] + flags << 'AP_USE_SESSION_KEY' if (options & 0x40000000) != 0 + flags << 'AP_MUTUAL_REQUIRED' if (options & 0x20000000) != 0 + "0x#{options.to_i.to_s(16).rjust(8, '0')} (#{flags.join(', ')})" + end + + def principal_with_realm(principal, realm) + name = principal_to_s(principal) + return nil if name.nil? + + realm ? "#{name}@#{realm}" : name + end + + def principal_to_s(principal) + return nil if principal.nil? + + principal.to_s + end + + def compact_nested_hash(value) + case value + when Hash + value.each_with_object({}) do |(key, entry), output| + compacted = compact_nested_hash(entry) + next if compacted.nil? + next if compacted.respond_to?(:empty?) && compacted.empty? + + output[key] = compacted + end + when Array + value.map { |entry| compact_nested_hash(entry) }.compact + else + value + end + end + + def serialize_trace_value(value, redact_binary:) + case value + when Hash + value.each_with_object({}) do |(key, entry), output| + output[key.to_s] = serialize_trace_value(entry, redact_binary: redact_binary) + end + when Array + value.map { |entry| serialize_trace_value(entry, redact_binary: redact_binary) } + else + @serializer.serialize_value(value, redact_binary: redact_binary) + end + end + + def compact_hash(hash) + hash.compact + end + + def format_flags(flags) + return nil if flags.nil? + + flags_value = flags.to_i + flag_names = if flags.respond_to?(:enabled_flag_names) + flags.enabled_flag_names + else + Rex::Proto::Kerberos::Model::TicketFlags.new(flags_value).enabled_flag_names + end + "0x#{flags_value.to_s(16).rjust(8, '0')} (#{flag_names.join(', ')})" + end + + def format_encryption_type(encryption_type) + return nil if encryption_type.nil? + + "#{encryption_type} (#{@serializer.encryption_type_name(encryption_type)})" + end + + def format_key_material(key_material) + return nil if blank_value?(key_material) + + key_material.unpack1('H*') + end + + def blank_value?(value) + return true if value.nil? || value == false + return value.strip.empty? if value.respond_to?(:strip) + return value.empty? if value.respond_to?(:empty?) + + false + end + end + end + end +end diff --git a/lib/rex/proto/ldap/auth_adapter/rex_kerberos.rb b/lib/rex/proto/ldap/auth_adapter/rex_kerberos.rb index 7500a36e0f0cb..21efd9110731a 100644 --- a/lib/rex/proto/ldap/auth_adapter/rex_kerberos.rb +++ b/lib/rex/proto/ldap/auth_adapter/rex_kerberos.rb @@ -26,6 +26,14 @@ def bind(auth) kerberos_result = kerberos_authenticator.authenticate(options) initial_credential = kerberos_result[:security_blob] + kerberos_authenticator.trace_protocol_carrier( + protocol: 'LDAP', + direction: 'request', + label: 'LDAP SASL Bind', + carrier: 'SASL bind request credential', + field_name: 'initial_credential', + token: initial_credential + ) result = Net::LDAP::AuthAdapter::Sasl.new(@connection).bind( method: :sasl, @@ -33,6 +41,15 @@ def bind(auth) initial_credential: initial_credential, challenge_response: true ) + server_sasl_creds = result.result[:serverSaslCreds] if result.respond_to?(:result) && result.result + kerberos_authenticator.trace_protocol_carrier( + protocol: 'LDAP', + direction: 'response', + label: 'LDAP serverSaslCreds', + carrier: 'SASL bind response serverSaslCreds', + field_name: 'serverSaslCreds', + token: server_sasl_creds + ) if auth[:sign_and_seal] encryptor = Encryptor.new(kerberos_authenticator) diff --git a/lib/rex/proto/mssql/client.rb b/lib/rex/proto/mssql/client.rb index f5bcf8dffec7d..0952fe6d5a9d5 100644 --- a/lib/rex/proto/mssql/client.rb +++ b/lib/rex/proto/mssql/client.rb @@ -408,6 +408,15 @@ def login_kerberos(user, pass, db, domain_name) ) pkt_body.sspi = kerberos_result[:security_blob].bytes + kerberos_authenticator.trace_protocol_carrier( + protocol: 'MSSQL', + direction: 'request', + label: 'MSSQL SSPI', + carrier: 'TDS7 Login SSPI security blob', + field_name: 'sspi', + port: rport, + token: kerberos_result[:security_blob] + ) pkt_hdr.packet_length += pkt_body.num_bytes pkt = pkt_hdr.to_binary_s + pkt_body.to_binary_s @@ -415,6 +424,15 @@ def login_kerberos(user, pass, db, domain_name) @mstds_channel.starttls if tdsencryption == true resp = mssql_send_recv(pkt) + kerberos_authenticator.trace_protocol_carrier( + protocol: 'MSSQL', + direction: 'response', + label: 'MSSQL SSPI', + carrier: 'TDS login response', + field_name: 'tds_response', + port: rport, + blob_length: resp&.bytesize + ) info = {:errors => []} info = mssql_parse_reply(resp, info) diff --git a/spec/lib/msf/base/serializer/readable_text_spec.rb b/spec/lib/msf/base/serializer/readable_text_spec.rb index 07bebcda1128f..9041550ea5de5 100644 --- a/spec/lib/msf/base/serializer/readable_text_spec.rb +++ b/spec/lib/msf/base/serializer/readable_text_spec.rb @@ -291,7 +291,7 @@ def initialize Name Current Setting Required Description ---- --------------- -------- ----------- DomainControllerRhost no The resolvable rhost for the Domain Controller - KerberosTicketTrace off no Kerberos ticket trace mode (Accepted: off, metadata, ticket, full) + KerberosTicketTrace off no Kerberos protocol trace mode (Accepted: off, metadata, ticket, full) KerberosTicketTraceColors red/blu no Kerberos request and response colors for KerberosTicketTrace (unset to disable) KrbClockSkew 0s yes Adjust Kerberos client clock by this offset (e.g. 90s, -5m, 1h) Winrm::Krb5Ccname no The ccache file to use for kerberos authentication @@ -353,7 +353,7 @@ def initialize Name Current Setting Required Description ---- --------------- -------- ----------- DomainControllerRhost no The resolvable rhost for the Domain Controller - KerberosTicketTrace off no Kerberos ticket trace mode (Accepted: off, metadata, ticket, full) + KerberosTicketTrace off no Kerberos protocol trace mode (Accepted: off, metadata, ticket, full) KerberosTicketTraceColors red/blu no Kerberos request and response colors for KerberosTicketTrace (unset to disable) KrbClockSkew 0s yes Adjust Kerberos client clock by this offset (e.g. 90s, -5m, 1h) Winrm::Krb5Ccname no The ccache file to use for kerberos authentication diff --git a/spec/lib/msf/core/exploit/remote/kerberos/service_authenticator/base_spec.rb b/spec/lib/msf/core/exploit/remote/kerberos/service_authenticator/base_spec.rb index 9ba11408a0a20..638e8f5061580 100644 --- a/spec/lib/msf/core/exploit/remote/kerberos/service_authenticator/base_spec.rb +++ b/spec/lib/msf/core/exploit/remote/kerberos/service_authenticator/base_spec.rb @@ -118,6 +118,265 @@ def fixture(name) expect { subject.validate_response!(response) }.to raise_error(::Rex::Proto::Kerberos::Model::Error::KerberosError, /Failed to negotiate/) end end + + context 'when mutual authentication tracing is enabled' do + let(:params) do + default_params.merge(mutual_auth: true) + end + let(:trace_subscriber) do + instance_double(Rex::Proto::Kerberos::KerberosSubscriber, on_response_token: nil) + end + let(:success_response) do + "\xa1\x14\x30\x12\xa0\x03\x0a\x01\x00\xa1\x0b\x06\x09\x2a\x86\x48" \ + "\x82\xf7\x12\x01\x02\x02" + end + let(:failure_response) do + "\xa1\x81\x89\x30\x81\x86\xa0\x03\x0a\x01\x01\xa1\x0b\x06\x09\x2a" \ + "\x86\x48\x82\xf7\x12\x01\x02\x02\xa2\x72\x04\x70\x60\x6e\x06\x09" \ + "\x2a\x86\x48\x86\xf7\x12\x01\x02\x02\x03\x00\x7e\x5f\x30\x5d\xa0" \ + "\x03\x02\x01\x05\xa1\x03\x02\x01\x1e\xa4\x11\x18\x0f\x32\x30\x32" \ + "\x32\x30\x36\x32\x39\x32\x33\x32\x32\x31\x31\x5a\xa5\x05\x02\x03" \ + "\x0e\x45\xd0\xa6\x03\x02\x01\x3c\xa9\x0c\x1b\x0a\x41\x44\x46\x33" \ + "\x2e\x4c\x4f\x43\x41\x4c\xaa\x11\x30\x0f\xa0\x03\x02\x01\x01\xa1" \ + "\x08\x30\x06\x1b\x04\x64\x63\x33\x24\xac\x11\x04\x0f\x30\x0d\xa1" \ + "\x03\x02\x01\x01\xa2\x06\x04\x04\x6d\x00\x00\xc0" + end + + before do + allow(subject).to receive(:kerberos_trace_subscriber).and_return(trace_subscriber) + end + + it 'traces a successful negotiation as successful mutual authentication' do + expect(trace_subscriber).to receive(:on_response_token).with( + hash_including(mutual_authentication: 'success') + ) + + expect(subject.validate_response!(success_response)).to be true + end + + it 'traces a rejected negotiation as failed mutual authentication' do + expect(trace_subscriber).to receive(:on_response_token).with( + hash_including(mutual_authentication: 'failed') + ) + + expect { subject.validate_response!(failure_response) }.to raise_error( + ::Rex::Proto::Kerberos::Model::Error::KerberosError, + /Failed to negotiate/ + ) + end + + it 'does not trace an accepted incomplete negotiation as successful mutual authentication' do + expect(trace_subscriber).to receive(:on_response_token).with( + hash_including(mutual_authentication: 'incomplete') + ) + + expect(subject.validate_response!(failure_response, accept_incomplete: true)).to be true + end + end + end + + describe '#authenticate KerberosTicketTrace events' do + let(:params) do + default_params.merge( + realm: 'example.local', + username: 'fake_user', + mutual_auth: true, + use_gss_checksum: true, + send_delegated_creds: Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base::Delegation::NEVER + ) + end + let(:credential) do + subject.send(:load_credential_from_file, fixture(:fake_user_example_local_forged_silver)).fetch(:credential) + end + let(:trace_subscriber) do + instance_double( + Rex::Proto::Kerberos::KerberosSubscriber, + on_ap_req: nil, + on_request: nil, + on_gss_token: nil, + on_spnego_token: nil + ) + end + + before do + allow(subject).to receive(:kerberos_trace_subscriber).and_return(trace_subscriber) + end + + it 'traces AP-REQ construction and GSS/SPNEGO wrapping' do + expect(trace_subscriber).to receive(:on_ap_req) do |metadata| + expect(metadata[:ap_req]).to be_a(Rex::Proto::Kerberos::Model::ApReq) + expect(metadata[:authenticator]).to be_a(Rex::Proto::Kerberos::Model::Authenticator) + expect(metadata[:authenticator].checksum.type).to eq(Rex::Proto::Gss::KRB_AP_REQ_CHKSUM_TYPE) + expect(metadata[:credential]).to eq(credential) + expect(metadata[:mutual_auth]).to be(true) + expect(metadata[:use_session_key]).to be(true) + expect(metadata[:service_principal]).to include('MSSqlSvc') + end + expect(trace_subscriber).to receive(:on_request).with(instance_of(Rex::Proto::Kerberos::Model::ApReq)) + expect(trace_subscriber).to receive(:on_gss_token) do |metadata| + expect(metadata[:token]).to start_with("\x60".b) + expect(metadata[:ap_req_der]).to eq(metadata[:ap_req].encode) + expect(metadata[:mutual_auth]).to be(true) + end + expect(trace_subscriber).to receive(:on_spnego_token) do |metadata| + expect(metadata[:token]).to start_with("\x60".b) + expect(metadata[:gss_token]).to start_with("\x60".b) + expect(metadata[:service_principal]).to include('MSSqlSvc') + end + + result = subject.authenticate(credential: credential, mechanism: Rex::Proto::Gss::Mechanism::SPNEGO) + + expect(result[:security_blob]).to start_with("\x60".b) + expect(result[:session_key]).to be_a(Rex::Proto::Kerberos::Model::EncryptionKey) + expect(result).not_to include(:service_ap_request) + expect(result).not_to include(:authenticator) + expect(result).not_to include(:trace_credential) + end + end + + describe '#authenticate_via_krb5_ccache_credential_tgt trace credential selection' do + let(:params) do + default_params.merge( + realm: 'example.local', + username: 'fake_user', + send_delegated_creds: Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base::Delegation::ALWAYS + ) + end + let(:input_credential) do + subject.send(:load_credential_from_file, fixture(:fake_user_example_local_forged_silver)).fetch(:credential) + end + let(:service_credential) { double('service credential') } + let(:delegated_credential) { double('delegated credential') } + let(:service_ticket) do + Rex::Proto::Kerberos::Model::Ticket.decode(input_credential.ticket.value) + end + let(:service_session_key) do + Rex::Proto::Kerberos::Model::EncryptionKey.new( + type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + value: 'S' * 32 + ) + end + let(:service_tgs_auth) { double('service TGS decrypted part', key: service_session_key) } + let(:delegated_tgs_auth) { double('delegation TGS decrypted part') } + let(:authenticator) do + instance_double( + Rex::Proto::Kerberos::Model::Authenticator, + cname: nil, + crealm: nil, + subkey: nil + ) + end + let(:service_ap_request) do + instance_double(Rex::Proto::Kerberos::Model::ApReq, ticket: service_ticket) + end + let(:service_name) do + Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, + name_string: ['cifs', 'fileserver.example.local'] + ) + end + + before do + allow(subject).to receive(:request_service_ticket).and_return( + [service_ticket, service_tgs_auth, service_credential] + ) + allow(subject).to receive(:request_delegation_ticket).and_return( + [service_ticket, delegated_tgs_auth, delegated_credential] + ) + allow(subject).to receive(:build_authenticator).and_return(authenticator) + allow(subject).to receive(:build_service_ap_request).and_return(service_ap_request) + end + + it 'preserves the original delegated credential return while tracing the AP-REQ service ticket' do + auth_context = subject.send( + :authenticate_via_krb5_ccache_credential_tgt, + input_credential, + sname: service_name + ) + trace_context = subject.send( + :service_authentication_trace_context, + auth_context, + sname: service_name + ) + + expect(auth_context[:credential]).to eq(delegated_credential) + expect(auth_context[:trace_credential]).to eq(service_credential) + expect(trace_context[:credential]).to eq(service_credential) + end + end + + describe '#parse_gss_init_response' do + let(:trace_subscriber) do + instance_double(Rex::Proto::Kerberos::KerberosSubscriber, on_response_token: nil) + end + let(:session_key) do + Rex::Proto::Kerberos::Model::EncryptionKey.new( + type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + value: 'A' * 32 + ) + end + + before do + allow(subject).to receive(:kerberos_trace_subscriber).and_return(trace_subscriber) + end + + it 'traces an invalid response once before preserving the parsing error' do + expect(trace_subscriber).to receive(:on_response_token).once.with( + hash_including( + direction: 'inbound', + token: 'not-asn1', + token_type: 'GSS-Kerberos', + parse_failure: match(/unable to parse response token/) + ) + ) + + expect { subject.parse_gss_init_response('not-asn1', session_key) }.to raise_error(StandardError) + end + + it 'traces a Kerberos decoding error once before preserving it' do + token = OpenSSL::ASN1::ASN1Data.new( + [ + Rex::Proto::Gss::OID_KERBEROS_5, + "\x02\x00not-an-ap-rep".b + ], + 0, + :APPLICATION + ).to_der + allow(Rex::Proto::Kerberos::Model::ApRep).to receive(:decode).and_raise( + ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, + 'invalid AP-REP' + ) + + expect(trace_subscriber).to receive(:on_response_token).once.with( + hash_including( + direction: 'inbound', + token: token, + token_type: 'GSS-Kerberos', + parse_failure: match(/unable to parse response token.*KerberosDecodingError/) + ) + ) + + expect { subject.parse_gss_init_response(token, session_key) }.to raise_error( + ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError + ) + end + end + + describe '#trace_protocol_carrier' do + let(:trace_subscriber) do + instance_double(Rex::Proto::Kerberos::KerberosSubscriber) + end + + before do + allow(subject).to receive(:kerberos_trace_subscriber).and_return(trace_subscriber) + allow(trace_subscriber).to receive(:on_protocol_carrier).and_raise(RuntimeError, 'trace failed') + allow(framework_module).to receive(:vprint_warning) + end + + it 'does not allow trace failures to interrupt authentication protocols' do + expect(subject.trace_protocol_carrier(protocol: 'SMB')).to be_nil + expect(framework_module).to have_received(:vprint_warning).with('Unable to trace Kerberos protocol carrier: RuntimeError: trace failed') + end end describe '#load_credential_from_file' do diff --git a/spec/lib/msf/core/exploit/remote/smb/client/kerberos_authentication_spec.rb b/spec/lib/msf/core/exploit/remote/smb/client/kerberos_authentication_spec.rb index 19856537f1002..ce7380357e7dc 100644 --- a/spec/lib/msf/core/exploit/remote/smb/client/kerberos_authentication_spec.rb +++ b/spec/lib/msf/core/exploit/remote/smb/client/kerberos_authentication_spec.rb @@ -95,5 +95,100 @@ end end end + + describe '#smb1_authenticate' do + let(:smb1) { true } + let(:smb2) { false } + let(:smb3) { false } + + it 'traces the request and response SMB1 session setup carriers' do + request_token = 'request-token' + response_token = 'response-token' + kerberos_result = { + security_blob: request_token, + session_key: double('session key', value: 'K' * 32) + } + data_block = double('SMB1 data block', security_blob: response_token, native_os: 'Windows', native_lan_man: 'Windows') + smb_header = double('SMB1 header', uid: 123) + response = double( + 'SMB1 session setup response', + data_block: data_block, + smb_header: smb_header, + status_code: WindowsError::NTStatus::STATUS_SUCCESS + ) + + allow(mock_authenticator).to receive(:authenticate).and_return(kerberos_result) + allow(mock_authenticator).to receive(:trace_protocol_carrier) + allow(mock_authenticator).to receive(:validate_response!) + allow(subject).to receive(:smb1_kerberos_authenticate).with(request_token).and_return('raw-response') + allow(subject).to receive(:smb1_session_setup_response).with('raw-response').and_return(response) + + expect(subject.smb1_authenticate).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(mock_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'SMB', + direction: 'request', + label: 'SMB Session Setup', + carrier: 'SMB1 Session Setup security blob', + field_name: 'security_blob', + token: request_token + ) + expect(mock_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'SMB', + direction: 'response', + label: 'SMB Session Setup', + carrier: 'SMB1 Session Setup security blob', + field_name: 'security_blob', + token: response_token + ) + end + end + + describe '#smb2_authenticate' do + let(:smb1) { false } + let(:smb2) { true } + let(:smb3) { false } + + it 'traces the request and response SMB2 session setup carriers' do + request_token = 'request-token' + response_token = 'response-token' + kerberos_result = { + security_blob: request_token, + session_key: double('session key', value: 'K' * 32) + } + smb2_header = double('SMB2 header', session_id: 123) + session_flags = double('SMB2 session flags', guest: 0, encrypt_data: 0) + response = double( + 'SMB2 session setup response', + buffer: response_token, + session_flags: session_flags, + smb2_header: smb2_header, + status_code: WindowsError::NTStatus::STATUS_SUCCESS + ) + + allow(mock_authenticator).to receive(:authenticate).and_return(kerberos_result) + allow(mock_authenticator).to receive(:trace_protocol_carrier) + allow(mock_authenticator).to receive(:validate_response!) + allow(subject).to receive(:smb2_kerberos_authenticate).with(request_token).and_return('raw-response') + allow(subject).to receive(:smb2_session_setup_response).with('raw-response').and_return(response) + + expect(subject.smb2_authenticate).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(mock_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'SMB', + direction: 'request', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'security_blob', + token: request_token + ) + expect(mock_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'SMB', + direction: 'response', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'buffer', + token: response_token + ) + end + end end end diff --git a/spec/lib/rex/proto/gss/asn1_spec.rb b/spec/lib/rex/proto/gss/asn1_spec.rb new file mode 100644 index 0000000000000..48f138f492a13 --- /dev/null +++ b/spec/lib/rex/proto/gss/asn1_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/gss/asn1' + +RSpec.describe Rex::Proto::Gss::Asn1 do + subject(:asn1_helper) do + Class.new do + include Rex::Proto::Gss::Asn1 + end.new + end + + describe '#unwrap_pseudo_asn1' do + it 'returns the mechanism OID and opaque token bytes' do + payload = "\x01\x00opaque-token".b + wrapped = asn1_helper.wrap_pseudo_asn1(Rex::Proto::Gss::OID_KERBEROS_5, payload) + + mechanism, token = asn1_helper.unwrap_pseudo_asn1(wrapped) + + expect(mechanism.value).to eq(Rex::Proto::Gss::OID_KERBEROS_5.value) + expect(token).to eq(payload) + end + + it 'raises an ASN.1 error instead of TypeError when no top-level mechanism OID is present' do + neg_token_response = OpenSSL::ASN1::ASN1Data.new( + [OpenSSL::ASN1::Sequence.new([])], + 1, + :CONTEXT_SPECIFIC + ).to_der + + expect { asn1_helper.unwrap_pseudo_asn1(neg_token_response) }.to raise_error( + OpenSSL::ASN1::ASN1Error, + /does not contain a top-level mechanism OID/ + ) + end + + it 'preserves the ASN.1 error for malformed input' do + expect { asn1_helper.unwrap_pseudo_asn1('not-asn1') }.to raise_error(OpenSSL::ASN1::ASN1Error) + end + end +end diff --git a/spec/lib/rex/proto/gss/kerberos_token_spec.rb b/spec/lib/rex/proto/gss/kerberos_token_spec.rb new file mode 100644 index 0000000000000..fe803248b2416 --- /dev/null +++ b/spec/lib/rex/proto/gss/kerberos_token_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/gss/kerberos_token' + +RSpec.describe Rex::Proto::Gss::KerberosToken do + let(:ap_req_der) do + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::OctetString.new('AP-REQ-PAYLOAD') + ]).to_der + end + let(:ap_rep_der) do + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::OctetString.new('AP-REP-PAYLOAD') + ]).to_der + end + let(:gss_ap_req) do + described_class.build_gss_ap_req(ap_req_der) + end + let(:spnego_ap_req) do + described_class.build_spnego_ap_req(ap_req_der) + end + let(:spnego_response) do + "\xa1\x14\x30\x12\xa0\x03\x0a\x01\x00\xa1\x0b\x06\x09\x2a\x86\x48\x82\xf7\x12\x01\x02\x02".b + end + + describe '.parse' do + it 'parses the standard Kerberos mechanism and preserves the opaque AP-REQ payload' do + token = described_class.parse(gss_ap_req) + + expect(token.mechanism_oid).to eq(Rex::Proto::Gss::OID_KERBEROS_5.value) + expect(token.token_id).to eq(described_class::TOK_ID_KRB_AP_REQ) + expect(token.token_id_hex).to eq('0100') + expect(token.token_type).to eq('AP-REQ') + expect(token.payload).to eq(ap_req_der) + expect(token).to be_ap_req + end + + it 'accepts the Microsoft Kerberos mechanism OID' do + gss_token = described_class.build_gss_ap_req( + ap_req_der, + mechanism_oid: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5 + ) + + expect(described_class.parse(gss_token).mechanism_oid).to eq( + Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + ) + end + + it 'identifies AP-REP and unknown token IDs without decoding their payloads' do + ap_rep = described_class.parse( + gss_token(described_class::TOK_ID_KRB_AP_REP, ap_rep_der) + ) + unknown = described_class.parse(gss_token("\xff\xff".b, 'unknown')) + + expect(ap_rep.token_type).to eq('AP-REP') + expect(ap_rep.payload).to eq(ap_rep_der) + expect(unknown.token_type).to eq('UNKNOWN (ffff)') + expect(unknown.payload).to eq('unknown') + end + + it 'rejects a non-Kerberos mechanism OID' do + token = gss_token( + described_class::TOK_ID_KRB_AP_REQ, + ap_req_der, + mechanism_oid: OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10') + ) + + expect { described_class.parse(token) }.to raise_error( + described_class::ParseError, + /unsupported Kerberos mechanism OID/ + ) + end + + it 'normalizes malformed input into ParseError' do + expect { described_class.parse('not-asn1') }.to raise_error( + described_class::ParseError, + /unable to parse GSS-Kerberos token/ + ) + end + + it 'rejects a GSS-Kerberos token without a complete token ID' do + token = gss_token("\x01".b, '') + + expect { described_class.parse(token) }.to raise_error( + described_class::ParseError, + /does not contain a two-byte token ID/ + ) + end + end + + describe '.parse_spnego_init' do + it 'returns the ordered mechanism preferences and optimistic mechanism token' do + parsed = described_class.parse_spnego_init(spnego_ap_req) + + expect(parsed).to include( + mechanism_oid: Rex::Proto::Gss::OID_SPNEGO.value, + mech_types: [Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value], + preferred_mech: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value, + mech_token: gss_ap_req + ) + expect(parsed).not_to include(:selected_mech) + end + + it 'preserves the initiator mechanism preference order' do + ntlm_oid = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10') + token = described_class.build_spnego_init( + gss_ap_req, + mech_types: [ntlm_oid, Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5] + ) + + expect(described_class.parse_spnego_init(token)).to include( + mech_types: [ntlm_oid.value, Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value], + preferred_mech: ntlm_oid.value + ) + end + + it 'normalizes malformed input into ParseError' do + expect { described_class.parse_spnego_init('not-spnego') }.to raise_error( + described_class::ParseError, + /unable to parse SPNEGO NegTokenInit/ + ) + end + end + + describe '.parse_spnego_response' do + it 'returns the negotiation state and selected mechanism' do + expect(described_class.parse_spnego_response(spnego_response)).to include( + neg_state: Rex::Proto::Gss::SpnegoNegTokenTarg::ACCEPT_COMPLETED, + supported_mech: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + ) + end + + it 'normalizes malformed input into ParseError' do + expect { described_class.parse_spnego_response('not-spnego') }.to raise_error( + described_class::ParseError, + /unable to parse SPNEGO NegTokenResp/ + ) + end + end + + describe '.extract_ap_req' do + it 'extracts a byte-identical AP-REQ from bare GSS and SPNEGO tokens' do + expect(described_class.extract_ap_req(gss_ap_req)).to eq(ap_req_der) + expect(described_class.extract_ap_req(spnego_ap_req)).to eq(ap_req_der) + end + + it 'rejects a Kerberos token that is not an AP-REQ' do + token = gss_token(described_class::TOK_ID_KRB_AP_REP, ap_rep_der) + + expect { described_class.extract_ap_req(token) }.to raise_error( + described_class::ParseError, + /is not an AP-REQ/ + ) + end + + it 'rejects a SPNEGO token containing a non-Kerberos mechanism token' do + token = described_class.build_spnego_init("NTLMSSP\x00\x01".b) + + expect { described_class.extract_ap_req(token) }.to raise_error( + described_class::ParseError, + /SPNEGO mechanism token is not a valid Kerberos token/ + ) + end + + it 'rejects a SPNEGO NegTokenResp without leaking TypeError' do + expect { described_class.extract_ap_req(spnego_response) }.to raise_error( + described_class::ParseError, + /unable to extract Kerberos AP-REQ/ + ) + end + + it 'rejects an empty AP-REQ payload' do + token = gss_token(described_class::TOK_ID_KRB_AP_REQ, '') + + expect { described_class.extract_ap_req(token) }.to raise_error( + described_class::ParseError, + /AP-REQ payload is empty/ + ) + end + end + + describe '.try_extract_ap_req' do + it 'returns the AP-REQ for a supported token' do + expect(described_class.try_extract_ap_req(spnego_ap_req)).to eq(ap_req_der) + end + + it 'returns nil for malformed input and other GSS mechanisms' do + ntlm = described_class.build_spnego_init("NTLMSSP\x00\x01".b) + + expect(described_class.try_extract_ap_req('not-asn1')).to be_nil + expect(described_class.try_extract_ap_req(ntlm)).to be_nil + expect(described_class.try_extract_ap_req(spnego_response)).to be_nil + end + end + + describe '.kerberos_ap_req?' do + it 'distinguishes Kerberos AP-REQ tokens from other input' do + expect(described_class.kerberos_ap_req?(gss_ap_req)).to be(true) + expect(described_class.kerberos_ap_req?(spnego_ap_req)).to be(true) + expect(described_class.kerberos_ap_req?('not-asn1')).to be(false) + end + end + + describe 'AP-REQ builders' do + it 'round-trips AP-REQ bytes through the GSS-Kerberos builder' do + expect(described_class.parse(gss_ap_req).payload).to eq(ap_req_der) + end + + it 'round-trips AP-REQ bytes through the SPNEGO builder' do + expect(described_class.extract_ap_req(spnego_ap_req)).to eq(ap_req_der) + end + + it 'rejects an empty SPNEGO mechanism list' do + expect { described_class.build_spnego_init(gss_ap_req, mech_types: []) }.to raise_error( + described_class::ParseError, + /requires at least one mechanism type/ + ) + end + + it 'rejects empty AP-REQ and mechanism-token inputs' do + expect { described_class.build_gss_ap_req(nil) }.to raise_error( + described_class::ParseError, + /AP-REQ must not be empty/ + ) + expect { described_class.build_spnego_init('') }.to raise_error( + described_class::ParseError, + /SPNEGO mechanism token must not be empty/ + ) + end + end + + describe '.binary_string' do + it 'coerces protocol binary objects' do + value = double('binary value', to_binary_s: ap_req_der) + + expect(described_class.binary_string(value)).to eq(ap_req_der) + end + + it 'rejects values that cannot be represented as bytes' do + expect { described_class.binary_string(Object.new) }.to raise_error( + described_class::ParseError, + /cannot be converted to a binary string/ + ) + end + end + + def gss_token(token_id, payload, mechanism_oid: Rex::Proto::Gss::OID_KERBEROS_5) + OpenSSL::ASN1::ASN1Data.new( + [mechanism_oid, token_id + payload], + 0, + :APPLICATION + ).to_der + end +end diff --git a/spec/lib/rex/proto/http/client_spec.rb b/spec/lib/rex/proto/http/client_spec.rb index ccfee1c73daf6..a23610f2fa76a 100644 --- a/spec/lib/rex/proto/http/client_spec.rb +++ b/spec/lib/rex/proto/http/client_spec.rb @@ -66,6 +66,62 @@ def excuse_needs_auth expect(req).to be_a_kind_of Rex::Proto::Http::ClientRequest end + describe '#kerberos_auth' do + let(:kerberos_authenticator) do + instance_double(Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base) + end + + subject(:cli) do + Rex::Proto::Http::Client.new(ip, kerberos_authenticator: kerberos_authenticator) + end + + it 'traces the request and response HTTP authentication carriers' do + request_token = 'request-token' + response_token = 'response-token' + encoded_request_token = Rex::Text.encode_base64(request_token) + encoded_response_token = Rex::Text.encode_base64(response_token) + response = Rex::Proto::Http::Response.new(200, 'OK') + response.headers['WWW-Authenticate'] = "Negotiate #{encoded_response_token}" + auth_result = { + security_blob: request_token, + session_key: 'session-key', + client_sequence_number: 1 + } + + allow(kerberos_authenticator).to receive(:authenticate).and_return(auth_result) + allow(kerberos_authenticator).to receive(:trace_protocol_carrier) + allow(kerberos_authenticator).to receive(:parse_gss_init_response).and_return( + ap_rep_subkey: 'subkey', + server_sequence_number: 2 + ) + allow(kerberos_authenticator).to receive(:get_message_encryptor).and_return('encryptor') + allow(cli).to receive(:request_cgi).and_return(double('HTTP request')) + allow(cli).to receive(:_send_recv).and_return(response) + + expect(cli.kerberos_auth({ 'headers' => {} }, mechanism: Rex::Proto::Gss::Mechanism::SPNEGO)).to eq(response) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'HTTP', + direction: 'request', + label: 'HTTP Authorization', + carrier: 'Authorization header', + header_name: 'Authorization', + scheme: 'Negotiate', + base64_token: encoded_request_token, + token: request_token + ) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'HTTP', + direction: 'response', + label: 'HTTP WWW-Authenticate', + carrier: 'WWW-Authenticate header', + header_name: 'WWW-Authenticate', + scheme: 'Negotiate', + base64_token: encoded_response_token, + token: response_token + ) + end + end + context "with authorization" do subject(:cli) do cli = Rex::Proto::Http::Client.new(ip) diff --git a/spec/lib/rex/proto/kerberos/gss_token_parser_spec.rb b/spec/lib/rex/proto/kerberos/gss_token_parser_spec.rb new file mode 100644 index 0000000000000..b83b0c747cb74 --- /dev/null +++ b/spec/lib/rex/proto/kerberos/gss_token_parser_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/kerberos/gss_token_parser' + +RSpec.describe Rex::Proto::Kerberos::GssTokenParser do + subject(:parser) { described_class.new } + + let(:ap_req_der) { "\x6e\x03\x02\x01\x05".b } + let(:gss_token) { build_gss_kerberos_token(ap_req_der) } + let(:spnego_init) { build_spnego_neg_token_init(gss_token) } + let(:spnego_response) do + "\xa1\x14\x30\x12\xa0\x03\x0a\x01\x00\xa1\x0b\x06\x09\x2a\x86\x48\x82\xf7\x12\x01\x02\x02".b + end + + describe '#parse_kerberos' do + it 'extracts the mechanism, token type, and AP-REQ payload' do + result = parser.parse_kerberos(gss_token) + + expect(result).to include( + mechanism_oid: Rex::Proto::Gss::OID_KERBEROS_5.value, + token_id: '0100', + inner_token_type: 'AP-REQ', + ap_req_payload: ap_req_der + ) + end + + it 'returns a parse failure instead of raising for malformed input' do + expect(parser.parse_kerberos('not-asn1')).to include(parse_failure: match(/GSS-Kerberos/)) + end + + it 'accepts the Microsoft Kerberos mechanism OID' do + token = Rex::Proto::Gss::KerberosToken.build_gss_ap_req( + ap_req_der, + mechanism_oid: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5 + ) + + expect(parser.parse_kerberos(token)).to include( + mechanism_oid: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value, + inner_token_type: 'AP-REQ' + ) + end + + it 'turns unsupported mechanisms into a trace parse failure' do + token = OpenSSL::ASN1::ASN1Data.new( + [ + OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10'), + "\x01\x00".b + ap_req_der + ], + 0, + :APPLICATION + ).to_der + + expect(parser.parse_kerberos(token)).to include( + parse_failure: match(/unsupported Kerberos mechanism OID/) + ) + end + + it 'does not hide unexpected programming errors' do + allow(Rex::Proto::Gss::KerberosToken).to receive(:parse).and_raise(RuntimeError, 'unexpected bug') + + expect { parser.parse_kerberos(gss_token) }.to raise_error(RuntimeError, 'unexpected bug') + end + end + + describe '#parse_spnego_init' do + it 'extracts the offered mechanism and optimistic mechanism token' do + result = parser.parse_spnego_init(spnego_init) + + expect(result).to include( + mech_types: [Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value], + preferred_mech: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value, + mech_token: gss_token + ) + end + end + + describe '#parse_spnego_response' do + it 'extracts the negotiation state and selected mechanism' do + expect(parser.parse_spnego_response(spnego_response)).to include( + neg_state: Rex::Proto::Gss::SpnegoNegTokenTarg::ACCEPT_COMPLETED, + supported_mech: Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5.value + ) + end + end + + describe '#binary_string' do + it 'coerces protocol binary objects' do + token = double('binary token', to_binary_s: gss_token) + + expect(parser.binary_string(token)).to eq(gss_token) + end + end + + def build_gss_kerberos_token(payload) + OpenSSL::ASN1::ASN1Data.new( + [Rex::Proto::Gss::OID_KERBEROS_5, "\x01\x00".b + payload], + 0, + :APPLICATION + ).to_der + end + + def build_spnego_neg_token_init(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(token)], + 2, + :CONTEXT_SPECIFIC + ) + ] + ) + ], + 0, + :CONTEXT_SPECIFIC + ) + ], + 0, + :APPLICATION + ).to_der + end +end diff --git a/spec/lib/rex/proto/kerberos/kerberos_logger_subscriber_spec.rb b/spec/lib/rex/proto/kerberos/kerberos_logger_subscriber_spec.rb index 30ce86c67dd71..18a14025fe864 100644 --- a/spec/lib/rex/proto/kerberos/kerberos_logger_subscriber_spec.rb +++ b/spec/lib/rex/proto/kerberos/kerberos_logger_subscriber_spec.rb @@ -921,6 +921,250 @@ end end + # -- Service authentication trace events ----------------------------------- + + describe 'service authentication trace events' do + let(:trace_mode) { 'metadata' } + let(:ap_req_metadata) { build_ap_req_trace_metadata } + let(:ap_req_der) { ap_req_metadata[:ap_req].encode } + let(:gss_token) { build_gss_kerberos_token(ap_req_der) } + let(:spnego_token) { build_spnego_neg_token_init(gss_token) } + + before do + capture_logging(logger) + end + + describe '#on_ap_req' do + it 'prints AP-REQ construction metadata without expanding binary fields' do + subscriber.on_ap_req(ap_req_metadata) + + expect(output_text).to include('# Kerberos AP-REQ') + expect(output_text).to include('Message Type: 14 (AP-REQ)') + expect(output_text).to include('Mutual Authentication: requested') + expect(output_text).to include('Service Principal: cifs/fileserver.example.local@EXAMPLE.LOCAL') + expect(output_text).to include('Ticket etype: 18 (AES256)') + expect(output_text).to include('Checksum Type: 32771') + expect(output_text).to include('Checksum: [binary 24 bytes]') + expect(output_text).not_to include('000000000000000000000000000000000000000000000000') + end + + context 'when KerberosTicketTrace is full' do + let(:trace_mode) { 'full' } + + it 'prints full AP-REQ and decrypted authenticator details' do + subscriber.on_ap_req(ap_req_metadata) + + expect(output_text).to include('AP-REQ Message:') + expect(output_text).to include('Decrypted Authenticator:') + expect(output_text).to include('Checksum: [binary 24 bytes: 000000000000000000000000000000000000000000000000]') + expect(output_text).to include('Session Key:') + expect(output_text).to include('1111111111111111111111111111111111111111111111111111111111111111') + end + end + + context 'when KerberosTicketTrace is ticket' do + let(:trace_mode) { 'ticket' } + let(:credential) { double('credential') } + let(:presenter) do + instance_double( + Rex::Proto::Kerberos::CredentialCache::Krb5CcachePresenter, + present_credentials: 'selected credential output' + ) + end + + before do + allow(Rex::Proto::Kerberos::CredentialCache::Krb5CcachePresenter).to receive(:new).with(nil).and_return(presenter) + end + + it 'prints only the selected credential' do + subscriber.on_ap_req(ap_req_metadata.merge(credential: credential)) + + expect(output_text).to eq( + <<~EOF.rstrip + #################### + # Kerberos Credential: AP-REQ Selected Service Ticket + #################### + selected credential output + EOF + ) + end + end + + context 'when KerberosTicketTrace is off' do + let(:trace_mode) { 'off' } + + it 'does not print anything' do + subscriber.on_ap_req(ap_req_metadata) + + expect(output_text).to be_nil + end + end + end + + describe '#on_gss_token' do + it 'prints GSS-Kerberos wrapping metadata' do + subscriber.on_gss_token(ap_req_metadata.merge(token: gss_token, ap_req_der: ap_req_der, direction: 'outbound')) + + expect(output_text).to include('# Kerberos GSS Token') + expect(output_text).to include('Wrapper Type: GSS-Kerberos') + expect(output_text).to include('Mechanism OID: 1.2.840.113554.1.2.2') + expect(output_text).to include('Inner Token Type: AP-REQ') + expect(output_text).to include('AP-REQ Payload Matches: true') + expect(output_text).to include("AP-REQ Payload Length: #{ap_req_der.bytesize}") + expect(output_text).to include('Token: [binary') + end + + context 'when KerberosTicketTrace is ticket' do + let(:trace_mode) { 'ticket' } + + it 'does not print wrapper tokens' do + subscriber.on_gss_token(ap_req_metadata.merge(token: gss_token, ap_req_der: ap_req_der)) + + expect(output_text).to be_nil + end + end + end + + describe '#on_spnego_token' do + it 'prints SPNEGO NegTokenInit wrapping metadata' do + subscriber.on_spnego_token(ap_req_metadata.merge(token: spnego_token, ap_req_der: ap_req_der, direction: 'outbound')) + + expect(output_text).to include('# Kerberos SPNEGO Token') + expect(output_text).to include('Wrapper Type: SPNEGO NegTokenInit') + expect(output_text).to include('Mechanism OID: 1.3.6.1.5.5.2') + expect(output_text).to include('Mech Types:') + expect(output_text).to include('1.2.840.48018.1.2.2') + expect(output_text).to include('Optimistic Mech Token: true') + expect(output_text).to include("Mech Token Length: #{gss_token.bytesize}") + expect(output_text).to include('Inner AP-REQ:') + end + end + + describe '#on_response_token' do + it 'prints AP-REP response token details' do + subscriber.on_response_token( + ap_req_metadata.merge( + token_type: 'GSS-Kerberos', + message: build_ap_rep_message, + decrypted_part: build_enc_ap_rep_part, + token: gss_token, + mutual_authentication: 'success' + ) + ) + + expect(output_text).to include('# Kerberos Response Token') + expect(output_text).to include('Message Type: 15 (AP-REP)') + expect(output_text).to include('Encryption Type: 18 (AES256)') + expect(output_text).to include('Mutual Authentication: success') + expect(output_text).to include('Sequence Number: 1234') + expect(output_text).to include('Subkey:') + end + + it 'prints KRB-ERROR response token details' do + subscriber.on_response_token(ap_req_metadata.merge(token_type: 'GSS-Kerberos', message: build_krb_error_message, token: gss_token)) + + expect(output_text).to include('Message Type: 30 (KRB-ERROR)') + expect(output_text).to include('Name: KRB_AP_ERR_MODIFIED') + expect(output_text).to include('Value: 41') + expect(output_text).to include('Server Principal: cifs/fileserver.example.local@EXAMPLE.LOCAL') + expect(output_text).to include('Error Data: [binary 4 bytes]') + expect(output_text).not_to include('Mutual Authentication: success') + end + + it 'prints parse failure details for unparseable response tokens' do + subscriber.on_response_token(token_type: 'GSS-Kerberos', token: 'not-asn1', carrier: 'HTTP WWW-Authenticate') + + expect(output_text).to include('Parse Failure: unable to parse response token as AP-REP/KRB-ERROR/SPNEGO') + expect(output_text).to include('Token Length: 8') + expect(output_text).to include('Carrier: HTTP WWW-Authenticate') + end + end + + describe '#on_protocol_carrier' do + it 'prints HTTP Authorization carrier metadata and token summary' do + subscriber.on_protocol_carrier( + protocol: 'HTTP', + direction: 'request', + label: 'HTTP Authorization', + carrier: 'Authorization header', + header_name: 'Authorization', + scheme: 'Negotiate', + base64_token: Rex::Text.encode_base64(spnego_token), + token: spnego_token, + service_principal: 'http/www.example.local@EXAMPLE.LOCAL' + ) + + expect(output_text).to include('# Kerberos Carrier: HTTP Authorization') + expect(output_text).to include('Header Name: Authorization') + expect(output_text).to include('Scheme: Negotiate') + expect(output_text).to include("Decoded Token Length: #{spnego_token.bytesize}") + expect(output_text).to include('Token Type: SPNEGO NegTokenInit') + end + + [ + { + protocol: 'SMB', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'security_blob' + }, + { + protocol: 'MSSQL', + label: 'MSSQL SSPI', + carrier: 'TDS7 Login SSPI security blob', + field_name: 'sspi' + }, + { + protocol: 'LDAP', + label: 'LDAP serverSaslCreds', + carrier: 'SASL bind response serverSaslCreds', + field_name: 'serverSaslCreds' + } + ].each do |carrier_context| + it "prints #{carrier_context[:label]} carrier metadata" do + subscriber.on_protocol_carrier( + carrier_context.merge( + direction: 'request', + token: spnego_token, + service_principal: 'cifs/fileserver.example.local@EXAMPLE.LOCAL' + ) + ) + + expect(output_text).to include("# Kerberos Carrier: #{carrier_context[:label]}") + expect(output_text).to include("Protocol: #{carrier_context[:protocol]}") + expect(output_text).to include("Carrier: #{carrier_context[:carrier]}") + expect(output_text).to include("Field Name: #{carrier_context[:field_name]}") + expect(output_text).to include("Blob Length: #{spnego_token.bytesize}") + expect(output_text).to include('Token Type: SPNEGO NegTokenInit') + end + end + + context 'when a carrier token is a string-like binary object' do + let(:trace_mode) { 'full' } + let(:spnego_response_token) { build_spnego_neg_token_resp } + + it 'renders the token as a binary summary and preserves SPNEGO negState' do + subscriber.on_protocol_carrier( + protocol: 'SMB', + direction: 'response', + label: 'SMB Session Setup', + carrier: 'SMB2 Session Setup security blob', + field_name: 'buffer', + token: binary_token_object(spnego_response_token), + service_principal: 'cifs/dc01.msflab.test@MSFLAB.TEST' + ) + + expect(output_text).to include('# Kerberos Carrier: SMB Session Setup') + expect(output_text).to include('Blob Length: 22') + expect(output_text).to include("Token: [binary 22 bytes: #{spnego_response_token.unpack1('H*')}]") + expect(output_text).to include('Token Type: SPNEGO NegTokenResp') + expect(output_text).to include('Neg State: accept-completed') + expect(output_text).not_to include('Neg State: unknown (accept-completed)') + end + end + end + end + def expected_trace_output(direction:, message_name:, color_prefix:, body:) [ '####################', @@ -951,6 +1195,175 @@ def build_kerberos_message(msg_type:, fields: {}) **payload ) end + + def build_ap_req_trace_metadata + service_name = Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, + name_string: ['cifs', 'fileserver.example.local'] + ) + client_name = Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: Rex::Proto::Kerberos::Model::NameType::NT_PRINCIPAL, + name_string: ['Administrator'] + ) + ticket = Rex::Proto::Kerberos::Model::Ticket.new( + tkt_vno: 5, + realm: 'EXAMPLE.LOCAL', + sname: service_name, + enc_part: Rex::Proto::Kerberos::Model::EncryptedData.new( + etype: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + kvno: 2, + cipher: "\x22".b * 32 + ) + ) + checksum = Rex::Proto::Kerberos::Model::Checksum.new( + type: Rex::Proto::Gss::KRB_AP_REQ_CHKSUM_TYPE, + checksum: "\x00".b * 24 + ) + authenticator = Rex::Proto::Kerberos::Model::Authenticator.new( + vno: 5, + crealm: 'EXAMPLE.LOCAL', + cname: client_name, + checksum: checksum, + cusec: 123_456, + ctime: Time.utc(2026, 3, 7, 12, 0, 0), + enc_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::AP_REQ_AUTHENTICATOR, + sequence_number: 1234 + ) + ap_req = Rex::Proto::Kerberos::Model::ApReq.new( + pvno: 5, + msg_type: Rex::Proto::Kerberos::Model::AP_REQ, + options: 0x20000000, + ticket: ticket, + authenticator: Rex::Proto::Kerberos::Model::EncryptedData.new( + etype: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + cipher: "\x33".b * 32 + ) + ) + session_key = Rex::Proto::Kerberos::Model::EncryptionKey.new( + type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + value: "\x11".b * 32 + ) + + { + ap_req: ap_req, + authenticator: authenticator, + ticket: ticket, + session_key: session_key, + client_principal: 'Administrator@EXAMPLE.LOCAL', + service_principal: 'cifs/fileserver.example.local@EXAMPLE.LOCAL', + spn: 'cifs/fileserver.example.local', + realm: 'EXAMPLE.LOCAL', + mutual_auth: true, + use_session_key: true, + use_subkey: false, + ticket_flags: Rex::Proto::Kerberos::Model::TicketFlags.from_flags( + [ + Rex::Proto::Kerberos::Model::TicketFlags::FORWARDABLE, + Rex::Proto::Kerberos::Model::TicketFlags::RENEWABLE + ] + ), + replay_detection: 'requested by GSS checksum flags', + channel_binding: { present: false }, + delegated_credentials: { mode: 'never', requested: false, present: false } + } + end + + def build_gss_kerberos_token(ap_req_der) + OpenSSL::ASN1::ASN1Data.new( + [ + Rex::Proto::Gss::OID_KERBEROS_5, + "\x01\x00".b + ap_req_der + ], + 0, + :APPLICATION + ).to_der + end + + def build_spnego_neg_token_init(gss_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(gss_token) + ], + 2, + :CONTEXT_SPECIFIC + ) + ] + ) + ], + 0, + :CONTEXT_SPECIFIC + ) + ], + 0, + :APPLICATION + ).to_der + end + + def build_spnego_neg_token_resp + "\xa1\x14\x30\x12\xa0\x03\x0a\x01\x00\xa1\x0b\x06\x09\x2a\x86\x48\x82\xf7\x12\x01\x02\x02".b + end + + def binary_token_object(token) + double('binary token', to_binary_s: token, bytesize: token.bytesize, to_s: token) + end + + def build_ap_rep_message + Rex::Proto::Kerberos::Model::ApRep.new( + pvno: 5, + msg_type: Rex::Proto::Kerberos::Model::AP_REP, + enc_part: Rex::Proto::Kerberos::Model::EncryptedData.new( + etype: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + cipher: "\x44".b * 32 + ) + ) + end + + def build_enc_ap_rep_part + Rex::Proto::Kerberos::Model::EncApRepPart.new( + ctime: Time.utc(2026, 3, 7, 12, 0, 0), + cusec: 123_456, + subkey: Rex::Proto::Kerberos::Model::EncryptionKey.new( + type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, + value: "\x55".b * 32 + ), + sequence_number: 1234 + ) + end + + def build_krb_error_message + Rex::Proto::Kerberos::Model::KrbError.new( + pvno: 5, + msg_type: Rex::Proto::Kerberos::Model::KRB_ERROR, + stime: Time.utc(2026, 3, 7, 12, 0, 0), + susec: 123_456, + error_code: Rex::Proto::Kerberos::Model::Error::ErrorCodes::KRB_AP_ERR_MODIFIED, + realm: 'EXAMPLE.LOCAL', + sname: Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, + name_string: ['cifs', 'fileserver.example.local'] + ), + etext: 'Message stream modified', + e_data: "\x60\x00\x00\xc0".b + ) + end end # rubocop:enable Metrics/BlockLength diff --git a/spec/lib/rex/proto/kerberos/kerberos_trace_serializer_spec.rb b/spec/lib/rex/proto/kerberos/kerberos_trace_serializer_spec.rb new file mode 100644 index 0000000000000..862af88a31d58 --- /dev/null +++ b/spec/lib/rex/proto/kerberos/kerberos_trace_serializer_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/kerberos/kerberos_trace_serializer' + +RSpec.describe Rex::Proto::Kerberos::KerberosTraceSerializer do + subject(:serializer) { described_class.new } + + describe '#serialize' do + it 'serializes Kerberos message types and redacts binary values' do + message = double( + 'Kerberos message', + attributes: %i[msg_type payload], + msg_type: Rex::Proto::Kerberos::Model::AP_REQ, + payload: "\x00\xff".b + ) + + expect(serializer.serialize(message, redact_binary: true)).to eq( + 'msg_type' => '14 (AP-REQ)', + 'payload' => '[binary 2 bytes]' + ) + end + + it 'includes binary contents when redaction is disabled' do + message = double( + 'Kerberos message', + attributes: [:payload], + payload: "\x00\xff".b + ) + + expect(serializer.serialize(message, redact_binary: false)).to eq( + 'payload' => '[binary 2 bytes: 00ff]' + ) + end + end + + describe '#encryption_type_name' do + it 'returns UNKNOWN for unsupported encryption types' do + expect(serializer.encryption_type_name(-1)).to eq('UNKNOWN') + end + end +end diff --git a/spec/lib/rex/proto/kerberos/service_authentication_trace_presenter_spec.rb b/spec/lib/rex/proto/kerberos/service_authentication_trace_presenter_spec.rb new file mode 100644 index 0000000000000..5c6bdc53a2dfe --- /dev/null +++ b/spec/lib/rex/proto/kerberos/service_authentication_trace_presenter_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/kerberos/service_authentication_trace_presenter' + +RSpec.describe Rex::Proto::Kerberos::ServiceAuthenticationTracePresenter do + subject(:presenter) { described_class.new(trace_mode: trace_mode) } + + let(:trace_mode) { 'metadata' } + let(:ap_req_der) { "\x6e\x03\x02\x01\x05".b } + let(:gss_token) do + OpenSSL::ASN1::ASN1Data.new( + [Rex::Proto::Gss::OID_KERBEROS_5, "\x01\x00".b + ap_req_der], + 0, + :APPLICATION + ).to_der + end + + describe '#present_ap_req' do + it 'presents service-authentication metadata without a logger dependency' do + output = presenter.present_ap_req( + service_principal: 'cifs/server.example@EXAMPLE', + realm: 'EXAMPLE', + mutual_auth: true, + use_subkey: false + ) + + expect(output).to include('Message Type: 14 (AP-REQ)') + expect(output).to include('Service Principal: cifs/server.example@EXAMPLE') + expect(output).to include('Mutual Authentication: requested') + end + end + + describe '#present_gss_token' do + it 'presents the parsed GSS-Kerberos wrapper' do + output = presenter.present_gss_token(token: gss_token, ap_req_der: ap_req_der) + + expect(output).to include('Wrapper Type: GSS-Kerberos') + expect(output).to include('Inner Token Type: AP-REQ') + expect(output).to include('AP-REQ Payload Matches: true') + end + end + + describe '#present_spnego_token' do + it 'turns parser failures into trace output' do + output = presenter.present_spnego_token(token: 'not-spnego') + + expect(output).to include('Wrapper Type: SPNEGO NegTokenInit') + expect(output).to include('Parse Failure: unable to parse token as SPNEGO NegTokenInit') + end + + it 'labels the initiator first mechanism as preferred rather than selected' do + output = presenter.present_spnego_token( + 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 + ) + ] + ) + ], + 0, + :CONTEXT_SPECIFIC + ) + ], + 0, + :APPLICATION + ).to_der + ) + + expect(output).to include('Preferred Mech: 1.2.840.48018.1.2.2') + expect(output).not_to include('Selected Mech:') + end + end + + describe '#present_response_token' do + it 'presents malformed inbound tokens without raising' do + output = presenter.present_response_token(token_type: 'GSS-Kerberos', token: 'not-asn1') + + expect(output).to include('Direction: inbound') + expect(output).to include('Parse Failure: unable to parse response token as AP-REP/KRB-ERROR/SPNEGO') + end + + it 'does not treat a requested mutual-auth exchange as successful without a result' do + output = presenter.present_response_token( + token_type: 'GSS-Kerberos', + token: 'not-asn1', + mutual_auth: true + ) + + expect(output).not_to include('Mutual Authentication: success') + end + + it 'presents an explicit mutual-auth failure result' do + output = presenter.present_response_token( + token_type: 'GSS-Kerberos', + token: 'not-asn1', + mutual_auth: true, + mutual_authentication: 'failed' + ) + + expect(output).to include('Mutual Authentication: failed') + expect(output).not_to include('Mutual Authentication: success') + end + end + + describe '#present_protocol_carrier' do + it 'presents application-protocol carrier metadata' do + output = presenter.present_protocol_carrier( + protocol: 'HTTP', + direction: 'request', + carrier: 'Authorization header', + header_name: 'Authorization', + token: gss_token + ) + + expect(output).to include('Protocol: HTTP') + expect(output).to include('Carrier: Authorization header') + expect(output).to include('Token Type: GSS-Kerberos') + end + + it 'redacts printable token contents in metadata mode' do + output = presenter.present_protocol_carrier(protocol: 'MSSQL', token: 'printable-secret') + + expect(output).to include('Token: [binary 16 bytes]') + expect(output).not_to include('printable-secret') + end + end + + context 'when trace mode is full' do + let(:trace_mode) { 'full' } + + it 'includes binary token contents' do + expect(presenter.present_gss_token(token: gss_token)).to include(gss_token.unpack1('H*')) + end + end +end diff --git a/spec/lib/rex/proto/ldap/auth_adapter/rex_kerberos_spec.rb b/spec/lib/rex/proto/ldap/auth_adapter/rex_kerberos_spec.rb new file mode 100644 index 0000000000000..7dcbbd4bb758a --- /dev/null +++ b/spec/lib/rex/proto/ldap/auth_adapter/rex_kerberos_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/proto/ldap/auth_adapter/rex_kerberos' + +RSpec.describe Rex::Proto::LDAP::AuthAdapter::RexKerberos do + let(:connection) { double('LDAP connection', socket: Object.new) } + let(:kerberos_authenticator) do + instance_double(Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base) + end + let(:sasl_adapter) { instance_double(Net::LDAP::AuthAdapter::Sasl) } + + subject(:adapter) { described_class.new(connection) } + + describe '#bind' do + it 'traces the request and response LDAP SASL carriers' do + request_token = 'request-token' + response_token = 'response-token' + result = double('LDAP bind result', result: { serverSaslCreds: response_token }) + + allow(kerberos_authenticator).to receive(:authenticate).with({}).and_return(security_blob: request_token) + allow(kerberos_authenticator).to receive(:trace_protocol_carrier) + allow(Net::LDAP::AuthAdapter::Sasl).to receive(:new).with(connection).and_return(sasl_adapter) + allow(sasl_adapter).to receive(:bind).and_return(result) + + expect(adapter.bind(kerberos_authenticator: kerberos_authenticator, sign_and_seal: false)).to eq(result) + expect(sasl_adapter).to have_received(:bind).with( + method: :sasl, + mechanism: 'GSS-SPNEGO', + initial_credential: request_token, + challenge_response: true + ) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'LDAP', + direction: 'request', + label: 'LDAP SASL Bind', + carrier: 'SASL bind request credential', + field_name: 'initial_credential', + token: request_token + ) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'LDAP', + direction: 'response', + label: 'LDAP serverSaslCreds', + carrier: 'SASL bind response serverSaslCreds', + field_name: 'serverSaslCreds', + token: response_token + ) + end + end +end diff --git a/spec/lib/rex/proto/mssql/client_spec.rb b/spec/lib/rex/proto/mssql/client_spec.rb index 048b6644ef856..2cb939623361a 100644 --- a/spec/lib/rex/proto/mssql/client_spec.rb +++ b/spec/lib/rex/proto/mssql/client_spec.rb @@ -81,4 +81,46 @@ expect(result).to eq({ version: '16.0.1000', encryption: 2 }) end end + + describe '#login_kerberos' do + let(:kerberos_authenticator) do + instance_double(Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::MSSQL) + end + + before do + subject.instance_variable_set(:@hostname, 'sql.example.test') + allow(Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::MSSQL).to receive(:new).and_return(kerberos_authenticator) + allow(subject).to receive(:mssql_prelogin).and_return(version: '16.0.1000') + end + + it 'traces the request and response MSSQL SSPI carriers' do + request_token = 'request-token' + response_blob = 'response-blob' + + allow(kerberos_authenticator).to receive(:authenticate).and_return(security_blob: request_token) + allow(kerberos_authenticator).to receive(:trace_protocol_carrier) + allow(subject).to receive(:mssql_send_recv).and_return(response_blob) + allow(subject).to receive(:mssql_parse_reply).and_return(login_ack: true) + + expect(subject.send(:login_kerberos, 'user', 'password', db_name, 'EXAMPLE.TEST')).to be(true) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'MSSQL', + direction: 'request', + label: 'MSSQL SSPI', + carrier: 'TDS7 Login SSPI security blob', + field_name: 'sspi', + port: port, + token: request_token + ) + expect(kerberos_authenticator).to have_received(:trace_protocol_carrier).with( + protocol: 'MSSQL', + direction: 'response', + label: 'MSSQL SSPI', + carrier: 'TDS login response', + field_name: 'tds_response', + port: port, + blob_length: response_blob.bytesize + ) + end + end end