From 1f2d8c6e4f6323533e8f17e679c9bc1d2c40d8bd Mon Sep 17 00:00:00 2001 From: cyan <1570302455@qq.com> Date: Mon, 27 Jul 2026 17:48:02 +0800 Subject: [PATCH 1/2] Add shared Kerberos GSS and SPNEGO token handling --- .../core/exploit/remote/kerberos/client.rb | 7 +- .../remote/kerberos/client/ap_request.rb | 41 +-- lib/rex/proto/gss.rb | 4 + lib/rex/proto/gss/asn1.rb | 20 +- lib/rex/proto/gss/kerberos_token.rb | 336 ++++++++++++++++++ spec/lib/rex/proto/gss/asn1_spec.rb | 41 +++ spec/lib/rex/proto/gss/kerberos_token_spec.rb | 292 +++++++++++++++ 7 files changed, 709 insertions(+), 32 deletions(-) create mode 100644 lib/rex/proto/gss/kerberos_token.rb create mode 100644 spec/lib/rex/proto/gss/asn1_spec.rb create mode 100644 spec/lib/rex/proto/gss/kerberos_token_spec.rb diff --git a/lib/msf/core/exploit/remote/kerberos/client.rb b/lib/msf/core/exploit/remote/kerberos/client.rb index d6c9d7a8a8827..85af93d18a6f8 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 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/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..cc6193704cd5e --- /dev/null +++ b/lib/rex/proto/gss/kerberos_token.rb @@ -0,0 +1,336 @@ +# 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 + if mech_types.empty? + raise ParseError, 'SPNEGO NegTokenInit requires at least one mechanism type' + 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? + raise ParseError, 'SPNEGO mechanism token must not be empty' if mech_token.empty? + + 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/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..322a676b57d1f --- /dev/null +++ b/spec/lib/rex/proto/gss/kerberos_token_spec.rb @@ -0,0 +1,292 @@ +# 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 'rejects an empty mechanism list' do + token = spnego_init_token(mech_types: [], mech_token: gss_ap_req) + + expect { described_class.parse_spnego_init(token) }.to raise_error( + described_class::ParseError, + /requires at least one mechanism type/ + ) + 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 an empty SPNEGO mechanism token' do + token = spnego_init_token( + mech_types: [Rex::Proto::Gss::OID_MICROSOFT_KERBEROS_5], + mech_token: '' + ) + + expect { described_class.extract_ap_req(token) }.to raise_error( + described_class::ParseError, + /SPNEGO mechanism token must not be empty/ + ) + 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 + + def spnego_init_token(mech_types:, mech_token:) + OpenSSL::ASN1::ASN1Data.new([ + Rex::Proto::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new(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 +end From ccea14eba3d0eaa6bedd3c7155eb633bc9528559 Mon Sep 17 00:00:00 2001 From: jheysel-r7 Date: Mon, 17 Aug 2026 18:08:39 -0700 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Brendan --- lib/rex/proto/gss/kerberos_token.rb | 4 ++-- spec/lib/rex/proto/gss/kerberos_token_spec.rb | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/rex/proto/gss/kerberos_token.rb b/lib/rex/proto/gss/kerberos_token.rb index cc6193704cd5e..f8257f08c1521 100644 --- a/lib/rex/proto/gss/kerberos_token.rb +++ b/lib/rex/proto/gss/kerberos_token.rb @@ -100,7 +100,7 @@ def self.parse_spnego_init(token) }.compact rescue ParseError raise - rescue RASN1::ASN1Error, TypeError => e + rescue RASN1::Error, TypeError => e raise ParseError, "unable to parse SPNEGO NegTokenInit: #{e.message}" end @@ -119,7 +119,7 @@ def self.parse_spnego_response(token) }.compact rescue ParseError raise - rescue RASN1::ASN1Error, TypeError => e + rescue RASN1::Error, TypeError => e raise ParseError, "unable to parse SPNEGO NegTokenResp: #{e.message}" end diff --git a/spec/lib/rex/proto/gss/kerberos_token_spec.rb b/spec/lib/rex/proto/gss/kerberos_token_spec.rb index 322a676b57d1f..1455493676f5a 100644 --- a/spec/lib/rex/proto/gss/kerberos_token_spec.rb +++ b/spec/lib/rex/proto/gss/kerberos_token_spec.rb @@ -146,6 +146,22 @@ /unable to parse SPNEGO NegTokenResp/ ) end + it 'normalizes an out-of-range neg_result into ParseError instead of leaking RASN1::EnumeratedError' do + token = OpenSSL::ASN1::ASN1Data.new( + [ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Enumerated.new(99)], 0, :CONTEXT_SPECIFIC) + ]) + ], + 1, + :CONTEXT_SPECIFIC + ).to_der + + expect { described_class.parse_spnego_response(token) }.to raise_error( + described_class::ParseError, + /unable to parse SPNEGO NegTokenResp/ + ) + end end describe '.extract_ap_req' do