diff --git a/lib/ruby_smb/gss/provider/multi.rb b/lib/ruby_smb/gss/provider/multi.rb index 5584ad7d..ceabd2fe 100644 --- a/lib/ruby_smb/gss/provider/multi.rb +++ b/lib/ruby_smb/gss/provider/multi.rb @@ -56,10 +56,19 @@ def allow_guests end class Authenticator < Authenticator::Base + # The derivation strings MS-NLMP uses to turn the NTLM exported session key into the + # one-way signing and sealing keys for the client-to-server direction. The mechListMIC + # is signed with the client-to-server signing key (seq=0), optionally RC4-whitened with + # the sealing key when NEGOTIATE_KEY_EXCHANGE was agreed. + NTLM_C2S_SIGNING_CONSTANT = "session key to client-to-server signing key magic constant\0".b.freeze + NTLM_C2S_SEALING_CONSTANT = "session key to client-to-server sealing key magic constant\0".b.freeze + def initialize(provider, server_client) # built lazily, so a provider that is advertised but never selected is never instantiated @authenticators = {} @selected = nil + @mech_list_der = nil + @mech_mismatch_fired = false super end @@ -67,6 +76,8 @@ def reset! super @authenticators&.each_value(&:reset!) @selected = nil + @mech_list_der = nil + @mech_mismatch_fired = false end def process(request_buffer=nil) @@ -81,15 +92,39 @@ def process(request_buffer=nil) end if negotiation_init?(gss_api) - # a NegTokenInit names the mechanism the client chose, so this is where routing is decided - mech_type = Gss.asn1dig(gss_api, 1, 0, 0, 0, 0) - authenticator = authenticator_for(mech_type) - if authenticator.nil? - logger.warn("Client selected an unsupported GSS mechanism (#{mech_type&.oid || 'unknown'})") + # a NegTokenInit carries the client's full mechTypeList. Server preference wins the + # routing: the server picks its most-preferred advertised mechanism that the client + # also offers, independent of the client's own ordering. This prevents a client (or + # an on-path attacker rewriting the mechTypeList before signing is in effect) from + # forcing the server to a weaker sub-provider by listing it first. + client_oids = client_mech_oids(gss_api) + if client_oids.empty? + logger.warn('NegTokenInit carried no mechTypeList') return end - @selected = authenticator + # Remember the raw DER of the mechTypeList SEQUENCE for later mechListMIC + # verification. Both sides compute GSS_GetMIC over these same bytes, so any on-path + # change to the list between the client and the server will produce a mismatched MIC. + @mech_list_der = Gss.asn1dig(gss_api, 1, 0, 0, 0)&.to_der + + chosen_mech = @provider.mech_types.find { |m| client_oids.include?(m.value) } + if chosen_mech.nil? + logger.warn("Client offered no mechanism the server supports (client_oids=#{client_oids})") + return + end + + @selected = authenticator_for(chosen_mech) + + # if the client listed a different mechanism first, its optimistic mechToken is for + # the wrong mechanism. RFC 4178 section 4.2.2 says to reply with a NegTokenResp + # carrying accept-incomplete and supportedMech so the client resends a token for the + # mechanism the server selected + if client_oids.first != chosen_mech.value + @mech_mismatch_fired = true + logger.info("SPNEGO: client listed #{client_oids.first} first; server prefers #{chosen_mech.value}, requesting a token for it") + return Result.new(build_accept_incomplete(chosen_mech), WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + end elsif @selected.nil? # a NegTokenResp carries no mechanism OID, so it can only be interpreted as a continuation of a # negotiation that has already selected one @@ -97,7 +132,35 @@ def process(request_buffer=nil) return end - @selected.process(request_buffer) + result = @selected.process(request_buffer) + + # Verify the client's mechListMIC on the leg that completes authentication. The MIC + # proves the client's own view of the mechTypeList matches what the server saw, so an + # on-path attacker who dropped or reordered OIDs between the two cannot pass this + # check without the negotiated mechanism's session key. + # + # Microsoft's [MS-SPNG] section 3.2.5.5 requires the client to carry a mechListMIC + # whenever the mechanism the server selected is not the one the client listed first, + # which is the exact code path we take when accept-incomplete fired above. For the + # happy path, the MIC is optional, and we verify when present but do not require it. + if result.is_a?(Result) && result.nt_status == WindowsError::NTStatus::STATUS_SUCCESS && @selected.is_a?(RubySMB::Gss::Provider::NTLM::Authenticator) + session_key = @selected.session_key + mic = extract_mech_list_mic(gss_api) + if mic.nil? + if @mech_mismatch_fired + logger.warn('mechListMIC is required on the mismatch path (MS-SPNG 3.2.5.5) but was not present; rejecting') + return Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + # optimistic path: MIC is OPTIONAL per RFC 4178, nothing more to do + elsif !verify_ntlm_mech_list_mic(mic, session_key) + logger.warn('mechListMIC verification failed; rejecting authentication') + return Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + else + logger.info('mechListMIC verified against the mechTypeList the server observed') + end + end + + result end # The session key belongs to whichever mechanism actually authenticated the client. @@ -122,6 +185,86 @@ def authenticator_for(mech_type) @authenticators[provider] ||= provider.new_authenticator(@server_client) end + + # The OIDs the client listed in the NegTokenInit mechTypeList, in the client's own order. + # + # The ASN.1 path mirrors the one NTLM uses to reach a single mechTypeList entry + # (gss_api, 1, 0, 0, 0, 0): one level less reaches the Sequence that holds every entry. + def client_mech_oids(gss_api) + seq = Gss.asn1dig(gss_api, 1, 0, 0, 0) + return [] unless seq.respond_to?(:value) && seq.value.is_a?(Array) + + seq.value.map { |item| item.respond_to?(:value) ? item.value : nil }.compact + end + + # A NegTokenResp carrying negResult = accept-incomplete and supportedMech, per RFC 4178 + # section 4.2.2, used to request a mechToken for the mechanism the server selected when + # the client's optimistic mechToken was for a different mechanism. + def build_accept_incomplete(supported_mech) + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Enumerated.new(OpenSSL::BN.new(1))], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([supported_mech], 1, :CONTEXT_SPECIFIC) + ]) + ], 1, :CONTEXT_SPECIFIC).to_der + end + + # Pull the mechListMIC octet string out of the client's NegTokenResp. The NegTokenInit + # path also allows a mechListMIC at tag [3] but is unusual, so both shapes are handled. + def extract_mech_list_mic(gss_api) + seq = case gss_api&.tag_class + when :APPLICATION then Gss.asn1dig(gss_api, 1, 0) + when :CONTEXT_SPECIFIC then Gss.asn1dig(gss_api, 0) + end + return nil unless seq.respond_to?(:value) && seq.value.is_a?(Array) + + seq.value.each do |element| + next unless element.respond_to?(:tag) && element.tag == 3 && element.tag_class == :CONTEXT_SPECIFIC + inner = element.value.is_a?(Array) ? element.value[0] : nil + return inner&.value + end + nil + end + + # Verify an NTLM-generated mechListMIC. The MIC is the 16-byte MS-NLMP 3.4.4.2 signature: + # version 1 (4 bytes LE), 8 bytes of HMAC-MD5 keyed by the client-to-server signing key + # over the concatenation of the sequence number and the mechTypeList DER bytes, and the + # sequence number itself (4 bytes LE). When NEGOTIATE_KEY_EXCHANGE was agreed, the HMAC + # bytes are additionally RC4-whitened under the client-to-server sealing key. + def verify_ntlm_mech_list_mic(mic, session_key) + return false unless @mech_list_der && session_key && mic.respond_to?(:bytesize) && mic.bytesize == 16 + + version = mic.byteslice(0, 4) + checksum = mic.byteslice(4, 8) + seqnum = mic.byteslice(12, 4) + return false unless version == "\x01\x00\x00\x00".b + + sign_key = OpenSSL::Digest::MD5.digest(session_key + NTLM_C2S_SIGNING_CONSTANT) + expected = OpenSSL::HMAC.digest(OpenSSL::Digest::MD5.new, sign_key, seqnum + @mech_list_der).byteslice(0, 8) + + # Try the straight comparison first (the client did not negotiate key exchange, or the + # sealing step was skipped). If that fails and RC4 is available, apply the whitening + # NEGOTIATE_KEY_EXCHANGE adds and compare again. One of the two matches when the MIC + # was produced by a client that holds the same session key. + return true if OpenSSL.secure_compare(expected, checksum) + + begin + seal_key = OpenSSL::Digest::MD5.digest(session_key + NTLM_C2S_SEALING_CONSTANT) + rc4 = OpenSSL::Cipher.new('rc4') + rc4.encrypt + rc4.key = seal_key + whitened = rc4.update(expected) + rc4.final + OpenSSL.secure_compare(whitened, checksum) + rescue OpenSSL::Cipher::CipherError + # OpenSSL 3 with the legacy provider off has no RC4; a MIC signed under + # NEGOTIATE_KEY_EXCHANGE cannot be verified here, so treat it as a mismatch + # rather than silently accepting. + false + end + rescue => e + logger.error("mechListMIC verification raised #{e.class}: #{e.message}") + false + end end end end diff --git a/spec/lib/ruby_smb/gss/provider/multi_spec.rb b/spec/lib/ruby_smb/gss/provider/multi_spec.rb index 5b444d18..bcda0802 100644 --- a/spec/lib/ruby_smb/gss/provider/multi_spec.rb +++ b/spec/lib/ruby_smb/gss/provider/multi_spec.rb @@ -109,6 +109,55 @@ it 'returns nil for a malformed request' do expect(authenticator.process('not asn1 at all')).to be_nil end + + # Server preference is [Kerberos, NTLM] (other_provider ordered first in the subject). + # Each row names a client-supplied mechTypeList and the outcome the server should produce. + context 'when a client offers more than one mechanism' do + it '[Kerberos, NTLM] routes to Kerberos directly (first-mech matches server preference)' do + expect(other_authenticator).to receive(:process) + authenticator.process(gss_init_list([RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_NTLMSSP])) + end + + it '[NTLM, Kerberos] rejects the client ordering and replies accept-incomplete for Kerberos' do + expect(other_authenticator).not_to receive(:process) + result = authenticator.process(gss_init_list([RubySMB::Gss::OID_NTLMSSP, RubySMB::Gss::OID_KERBEROS_5])) + expect(result).to be_a(RubySMB::Gss::Provider::Result) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + supported_mech = RubySMB::Gss.asn1dig(OpenSSL::ASN1.decode(result.buffer), 0, 1, 0) + expect(supported_mech.value).to eq(RubySMB::Gss::OID_KERBEROS_5.value) + end + + it '[NTLM] only still routes to NTLM when it is the only overlap' do + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain } + result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize)) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + end + + it '[Kerberos] only routes to Kerberos' do + expect(other_authenticator).to receive(:process) + authenticator.process(gss_init_list([RubySMB::Gss::OID_KERBEROS_5])) + end + + it 'rejects a client with no overlapping mechanism' do + expect(authenticator.process(gss_init_list([RubySMB::Gss::OID_NEGOEX]))).to be_nil + end + + it 'rejects a NegTokenInit with an empty mechTypeList' do + expect(authenticator.process(gss_init_list([]))).to be_nil + end + end + + context 'after replying accept-incomplete to a reordered client' do + it 'routes the client\'s next NegTokenResp (carrying the server-chosen mechanism token) to that provider' do + # leg 1: client lists [NTLM, Kerberos] - server picks Kerberos and asks for its token. + first = authenticator.process(gss_init_list([RubySMB::Gss::OID_NTLMSSP, RubySMB::Gss::OID_KERBEROS_5])) + expect(first.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + + # leg 2: client resends with a Kerberos token wrapped as a NegTokenResp. + expect(other_authenticator).to receive(:process) + authenticator.process(RubySMB::Gss.gss_type3('kerberos-ap-req-bytes')) + end + end end describe 'a complete NTLM exchange' do @@ -130,6 +179,50 @@ end end + describe 'the client\'s mechListMIC on the final leg' do + it 'accepts a valid MIC on the single-mechanism (no-mismatch) path' do + status, identity = mic_ntlm_exchange( + authenticator, + client_offer: [RubySMB::Gss::OID_NTLMSSP], + server_sees: [RubySMB::Gss::OID_NTLMSSP], + mic_mode: :real + ) + expect(status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(identity).to eq("#{domain}\\#{username}") + end + + it 'rejects a MIC that was computed over a different mechTypeList' do + status, _ = mic_ntlm_exchange( + authenticator, + client_offer: [RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_NTLMSSP], + server_sees: [RubySMB::Gss::OID_NTLMSSP], + mic_mode: :real + ) + expect(status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + it 'rejects a MIC with one bit flipped' do + status, _ = mic_ntlm_exchange( + authenticator, + client_offer: [RubySMB::Gss::OID_NTLMSSP], + server_sees: [RubySMB::Gss::OID_NTLMSSP], + mic_mode: :tampered + ) + expect(status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + it 'leaves the single-mechanism path alone when no MIC is sent (RFC 4178 marks it OPTIONAL)' do + status, identity = mic_ntlm_exchange( + authenticator, + client_offer: [RubySMB::Gss::OID_NTLMSSP], + server_sees: [RubySMB::Gss::OID_NTLMSSP], + mic_mode: :none + ) + expect(status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(identity).to eq("#{domain}\\#{username}") + end + end + describe '#reset!' do it 'forgets the selected mechanism' do complete_ntlm_exchange(authenticator) @@ -143,6 +236,12 @@ # Build a NegTokenInit that selects the specified mechanism, with an empty mechToken. def gss_init(mech_type) + gss_init_list([mech_type]) + end + + # Build a NegTokenInit whose mechTypeList is the given list of OIDs, with an empty mechToken. + # Used by the matrix tests to exercise multi-mechanism client offers. + def gss_init_list(mech_types) OpenSSL::ASN1::ASN1Data.new( [ RubySMB::Gss::OID_SPNEGO, @@ -150,7 +249,7 @@ def gss_init(mech_type) [ OpenSSL::ASN1::Sequence.new( [ - OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([mech_type])], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(mech_types)], 0, :CONTEXT_SPECIFIC), OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new('')], 2, :CONTEXT_SPECIFIC) ] ) @@ -171,4 +270,76 @@ def complete_ntlm_exchange(authenticator) result = authenticator.process(RubySMB::Gss.gss_type3(type3.serialize)) [result.nt_status, result.identity] end + + # Drive the full NTLM exchange and attach a client-generated mechListMIC to the final leg, + # modelling what MS-SPNG 3.2.5.5 asks a conformant client to do. + # + # `client_offer` is the mechTypeList the client's own code signs over (its own view). `server_sees` + # is what the authenticator receives in the NegTokenInit (same bytes used by both sides on the + # happy path, different bytes when an on-path attacker rewrote them). `mic_mode` is one of + # :real (compute from the matching NTLM session key), :tampered (compute then flip a bit), or + # :none (omit it entirely). + def mic_ntlm_exchange(authenticator, client_offer:, server_sees:, mic_mode:) + authenticator.process(nil) + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain } + init_blob = build_mic_init(server_sees, type1.serialize) + first = authenticator.process(init_blob) + raw_type2 = RubySMB::Gss.asn1dig(OpenSSL::ASN1.decode(first.buffer), 0, 2, 0).value + type2 = Net::NTLM::Message.parse(raw_type2) + type3 = type2.response({ user: username, password: password, domain: domain }, { ntlmv2: true }) + + mic = nil + if mic_mode != :none + session_key = ntlm_user_session_key_for(type3) + mech_list_der = OpenSSL::ASN1::Sequence.new(client_offer).to_der + mic = build_ntlm_mic(session_key, mech_list_der) + mic = tamper_mic(mic) if mic_mode == :tampered + end + + final_blob = build_mic_resp(type3.serialize, mic) + result = authenticator.process(final_blob) + [result.nt_status, result.identity] + end + + def build_mic_init(mech_oids, mech_token) + OpenSSL::ASN1::ASN1Data.new([ + RubySMB::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new( + [OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(mech_oids)], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(mech_token)], 2, :CONTEXT_SPECIFIC) + ])], 0, :CONTEXT_SPECIFIC + ) + ], 0, :APPLICATION).to_der + end + + def build_mic_resp(response_token, mic) + fields = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(response_token)], 2, :CONTEXT_SPECIFIC)] + fields << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(mic)], 3, :CONTEXT_SPECIFIC) if mic + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(fields)], 1, :CONTEXT_SPECIFIC).to_der + end + + def ntlm_user_session_key_for(type3) + blob = type3.ntlm_response[16..-1] + ntlmv2_hash = Net::NTLM.ntlmv2_hash( + Net::NTLM::EncodeUtil.encode_utf16le(username), + Net::NTLM::EncodeUtil.encode_utf16le(password), + (type3.domain.is_a?(String) ? type3.domain : type3.domain.to_s).dup.force_encoding('ASCII-8BIT'), + { client_challenge: blob[16, 8], unicode: true } + ) + OpenSSL::HMAC.digest(OpenSSL::Digest::MD5.new, ntlmv2_hash, type3.ntlm_response[0, 16]) + end + + def build_ntlm_mic(session_key, mech_list_der) + sign_key = OpenSSL::Digest::MD5.digest(session_key + "session key to client-to-server signing key magic constant\0".b) + seq = "\x00\x00\x00\x00".b + hmac = OpenSSL::HMAC.digest(OpenSSL::Digest::MD5.new, sign_key, seq + mech_list_der).byteslice(0, 8) + "\x01\x00\x00\x00".b + hmac + seq + end + + def tamper_mic(mic) + out = mic.dup + out.setbyte(5, out.getbyte(5) ^ 0xFF) + out + end end