From ebaa58968298df5d71cc63106f521e328aea4dcb Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:17:52 +0530 Subject: [PATCH 1/6] Let a GSS provider declare the mechanisms it supports The SPNEGO NegTokenInit a server sends to advertise its authentication mechanisms was built inside the NTLM authenticator, with a mechTypes list hardcoded to a single OID_NTLMSSP. A comment there noted the limitation: "this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP)". Because the token was owned by the NTLM provider, no other mechanism had a way to contribute to the advertisement, so a server could never offer a client anything but NTLM. Move the NegTokenInit construction to Gss.gss_neg_token_init, which takes the mechTypes to advertise, and add Provider::Base#mech_types so a provider declares what it handles. NTLM declares OID_NTLMSSP, so the token it emits is byte identical to the one it built before. Also define the Kerberos v5 mechanism OIDs, both the RFC 4121 OID and the legacy Microsoft variant, since clients may offer or select either. No behaviour change: this only moves ownership of the mechanism list from the NTLM provider to the providers themselves. --- lib/ruby_smb/gss.rb | 37 +++++++++++++++++++++++++++++++ lib/ruby_smb/gss/provider.rb | 20 +++++++++++++++++ lib/ruby_smb/gss/provider/ntlm.rb | 25 +++++---------------- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/lib/ruby_smb/gss.rb b/lib/ruby_smb/gss.rb index 3dcbf4782..5c8cf8bf5 100644 --- a/lib/ruby_smb/gss.rb +++ b/lib/ruby_smb/gss.rb @@ -7,6 +7,11 @@ module Gss OID_SPNEGO = OpenSSL::ASN1::ObjectId.new('1.3.6.1.5.5.2') OID_NEGOEX = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.30') OID_NTLMSSP = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10') + # The Kerberos v5 GSS-API mechanism (RFC 4121). Microsoft's SPNEGO + # implementation also uses a legacy OID that differs by a single arc, and + # clients may offer or select either, so both are defined here. + OID_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2') + OID_MICROSOFT_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.48018.1.2.2') # Allow safe navigation of a decoded ASN.1 data structure. Similar to Ruby's # builtin Hash#dig method but using the #value attribute of each ASN object. @@ -46,6 +51,38 @@ def self.asn1encode(str = '') encoded_string end + # Build the SPNEGO NegTokenInit that a server sends to advertise the + # authentication mechanisms it supports, per RFC 4178 section 4.2.1. + # + # The mechTypes list is supplied by the caller so that it reflects every + # mechanism the server actually offers, rather than being fixed to a single + # mechanism by whichever provider happens to build the token. + # + # @param [Array] mech_types the mechanisms to + # advertise, in preference order (most preferred first). + # @return [String] the DER encoded NegTokenInit. + def self.gss_neg_token_init(mech_types) + raise ArgumentError, 'at least one mechanism must be advertised' if mech_types.nil? || mech_types.empty? + + OpenSSL::ASN1::ASN1Data.new([ + 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::ASN1Data.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore') + ], 0, :CONTEXT_SPECIFIC) + ], 16, :UNIVERSAL) + ], 3, :CONTEXT_SPECIFIC) + ]) + ], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION).to_der + end + # Create a GSS Security Blob of an NTLM Type 1 Message. def self.gss_type1(type1) OpenSSL::ASN1::ASN1Data.new([ diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 6a59c3cb0..63bc05188 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -26,6 +26,26 @@ def new_authenticator(server_client) raise NotImplementedError end + # + # The GSS mechanisms this provider can handle, in preference order. These are advertised to the client in the + # SPNEGO NegTokenInit, and are used to route an incoming token to the provider that understands it. + # + # @return [Array] + def mech_types + raise NotImplementedError + end + + # + # Whether this provider can handle a token for the specified mechanism. + # + # @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client + # @return [Boolean] + def supports_mech_type?(mech_type) + return false if mech_type.nil? + + mech_types.any? { |oid| oid.oid == mech_type.oid } + end + # # Whether or not anonymous authentication attempts should be permitted. # diff --git a/lib/ruby_smb/gss/provider/ntlm.rb b/lib/ruby_smb/gss/provider/ntlm.rb index 5f774f253..08b408c46 100644 --- a/lib/ruby_smb/gss/provider/ntlm.rb +++ b/lib/ruby_smb/gss/provider/ntlm.rb @@ -26,26 +26,7 @@ def reset! def process(request_buffer=nil) if request_buffer.nil? - # this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP) - buffer = OpenSSL::ASN1::ASN1Data.new([ - Gss::OID_SPNEGO, - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::Sequence.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::Sequence.new([ - Gss::OID_NTLMSSP - ]) - ], 0, :CONTEXT_SPECIFIC), - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore') - ], 0, :CONTEXT_SPECIFIC) - ], 16, :UNIVERSAL) - ], 3, :CONTEXT_SPECIFIC) - ]) - ], 0, :CONTEXT_SPECIFIC) - ], 0, :APPLICATION).to_der + buffer = Gss.gss_neg_token_init(@provider.mech_types) return Result.new(buffer, WindowsError::NTStatus::STATUS_SUCCESS) end @@ -293,6 +274,10 @@ def new_authenticator(server_client) Authenticator.new(self, server_client) end + def mech_types + [Gss::OID_NTLMSSP] + end + # # Lookup and return an account based on the username and optionally, the domain. If no domain is specified or # or it is the special value '.', the default domain will be used. The username and domain values are case From 909611c9a92c497ef5166dc58da24faa2e383642 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:29:24 +0530 Subject: [PATCH 2/6] Allow a server to offer more than one GSS mechanism A server held exactly one GSS provider, so it could only ever offer a client a single authentication mechanism. SPNEGO exists to let the two sides agree on a mechanism, but with one on offer there is nothing to negotiate. Add Provider::Multi, which holds an ordered list of providers, advertises the mechanisms of all of them, and routes each request to whichever one understands the mechanism the client selected. A NegTokenInit names the mechanism, so that is where the routing decision is made; a NegTokenResp carries no mechanism OID and is treated as a continuation of the exchange already under way. Routing happens in the authenticator rather than at the call sites, so it covers SMB1 and SMB2/3 alike: every request already funnels through ServerClient#process_gss. Sub-authenticators are built lazily, so a mechanism that is advertised but never selected is never instantiated, and the session key of whichever mechanism actually authenticated is exposed to the server for signing. Wrapping a single provider produces a byte identical advertisement and an identical authentication result, so existing servers are unaffected. --- lib/ruby_smb/gss/provider.rb | 1 + lib/ruby_smb/gss/provider/multi.rb | 129 ++++++++++++++ spec/lib/ruby_smb/gss/provider/multi_spec.rb | 174 +++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 lib/ruby_smb/gss/provider/multi.rb create mode 100644 spec/lib/ruby_smb/gss/provider/multi_spec.rb diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 63bc05188..0258c6912 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -62,3 +62,4 @@ def supports_mech_type?(mech_type) require 'ruby_smb/gss/provider/authenticator' require 'ruby_smb/gss/provider/ntlm' +require 'ruby_smb/gss/provider/multi' diff --git a/lib/ruby_smb/gss/provider/multi.rb b/lib/ruby_smb/gss/provider/multi.rb new file mode 100644 index 000000000..5584ad7d0 --- /dev/null +++ b/lib/ruby_smb/gss/provider/multi.rb @@ -0,0 +1,129 @@ +module RubySMB + module Gss + module Provider + # + # A GSS provider that offers more than one authentication mechanism to the client and routes each request to + # whichever of its sub-providers understands the mechanism the client selected. + # + # SPNEGO exists so that a client and server can agree on a mechanism, but a server that only ever advertises one + # has nothing to negotiate. This provider advertises the mechanisms of every provider it holds, in the order they + # were given, so a client can pick the one it prefers. + # + # @example Offer Kerberos, falling back to NTLM + # provider = RubySMB::Gss::Provider::Multi.new([kerberos_provider, ntlm_provider]) + # RubySMB::Server.new(gss_provider: provider) + # + class Multi < Base + # + # @param [Array] providers the providers to offer, in preference order (most preferred first). + def initialize(providers) + raise ArgumentError, 'at least one provider is required' if providers.nil? || providers.empty? + + @providers = providers.dup.freeze + end + + # @return [Array] the providers this instance will route between. + attr_reader :providers + + def new_authenticator(server_client) + Authenticator.new(self, server_client) + end + + # + # Every mechanism offered by every provider, in provider order, with duplicates removed so a mechanism supported + # by two providers is only advertised once. + # + # @return [Array] + def mech_types + @providers.flat_map(&:mech_types).uniq(&:oid) + end + + # + # The first provider that handles the specified mechanism, or nil if none do. + # + # @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client + # @return [Provider::Base, nil] + def provider_for(mech_type) + @providers.find { |provider| provider.supports_mech_type?(mech_type) } + end + + def allow_anonymous + @providers.any?(&:allow_anonymous) + end + + def allow_guests + @providers.any?(&:allow_guests) + end + + class Authenticator < Authenticator::Base + def initialize(provider, server_client) + # built lazily, so a provider that is advertised but never selected is never instantiated + @authenticators = {} + @selected = nil + super + end + + def reset! + super + @authenticators&.each_value(&:reset!) + @selected = nil + end + + def process(request_buffer=nil) + # the advertisement, listing every mechanism the server is willing to accept + return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) if request_buffer.nil? + + begin + gss_api = OpenSSL::ASN1.decode(request_buffer) + rescue OpenSSL::ASN1::ASN1Error => e + logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})") + return + 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'})") + return + end + + @selected = authenticator + 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 + logger.warn('Received a GSS continuation token before any mechanism was selected') + return + end + + @selected.process(request_buffer) + end + + # The session key belongs to whichever mechanism actually authenticated the client. + def session_key + @selected&.session_key + end + + def session_key=(value) + @selected&.session_key = value + end + + private + + # Whether the token is a NegTokenInit, which is the only token that names a mechanism. + def negotiation_init?(gss_api) + gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION + end + + def authenticator_for(mech_type) + provider = @provider.provider_for(mech_type) + return nil if provider.nil? + + @authenticators[provider] ||= provider.new_authenticator(@server_client) + end + end + 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 new file mode 100644 index 000000000..5b444d182 --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/multi_spec.rb @@ -0,0 +1,174 @@ +RSpec.describe RubySMB::Gss::Provider::Multi do + let(:username) { 'RubySMB' } + let(:domain) { 'WORKGROUP' } + let(:password) { 'password' } + let(:ntlm_provider) do + RubySMB::Gss::Provider::NTLM.new.tap { |provider| provider.put_account(username, password, domain: domain) } + end + let(:other_authenticator) { double('authenticator', process: nil, reset!: nil, session_key: nil) } + # a stand-in for any non-NTLM mechanism, so the routing can be exercised without a second real provider + let(:other_provider) do + authenticator = other_authenticator + Class.new(RubySMB::Gss::Provider::Base) do + define_method(:mech_types) do + [RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5] + end + + define_method(:new_authenticator) { |_server_client| authenticator } + end.new + end + let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) } + + # referenced explicitly rather than via described_class, which resolves to the authenticator inside the nested group + subject(:provider) { RubySMB::Gss::Provider::Multi.new([other_provider, ntlm_provider]) } + + describe '#initialize' do + it 'requires at least one provider' do + expect { RubySMB::Gss::Provider::Multi.new([]) }.to raise_error(ArgumentError) + expect { RubySMB::Gss::Provider::Multi.new(nil) }.to raise_error(ArgumentError) + end + end + + describe '#mech_types' do + it 'advertises every mechanism of every provider' do + expect(provider.mech_types.map(&:oid)).to eq( + [ + RubySMB::Gss::OID_KERBEROS_5.oid, + RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid, + RubySMB::Gss::OID_NTLMSSP.oid + ] + ) + end + + it 'preserves the order the providers were given in' do + reversed = RubySMB::Gss::Provider::Multi.new([ntlm_provider, other_provider]) + expect(reversed.mech_types.first.oid).to eq(RubySMB::Gss::OID_NTLMSSP.oid) + end + + it 'advertises a mechanism supported by two providers only once' do + duplicated = RubySMB::Gss::Provider::Multi.new([ntlm_provider, RubySMB::Gss::Provider::NTLM.new]) + expect(duplicated.mech_types.length).to eq(1) + end + end + + describe '#provider_for' do + it 'finds the provider that handles the mechanism' do + expect(provider.provider_for(RubySMB::Gss::OID_KERBEROS_5)).to be(other_provider) + expect(provider.provider_for(RubySMB::Gss::OID_NTLMSSP)).to be(ntlm_provider) + end + + it 'is nil when no provider handles the mechanism' do + expect(provider.provider_for(RubySMB::Gss::OID_NEGOEX)).to be_nil + end + end + + describe RubySMB::Gss::Provider::Multi::Authenticator do + subject(:authenticator) { provider.new_authenticator(server_client) } + + describe '#process' do + context 'when building the advertisement' do + it 'offers all of the mechanisms' do + buffer = authenticator.process(nil).buffer + expect(buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types)) + end + + it 'matches the underlying provider when only one is held' do + single = described_class.new(RubySMB::Gss::Provider::Multi.new([ntlm_provider]), server_client) + expect(single.process(nil).buffer).to eq(ntlm_provider.new_authenticator(server_client).process(nil).buffer) + end + + it 'succeeds' do + expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + end + end + + context 'when the client selects a mechanism' do + it 'routes the token to the provider that handles it' do + expect(other_authenticator).to receive(:process) + authenticator.process(gss_init(RubySMB::Gss::OID_KERBEROS_5)) + end + + it 'routes an NTLM token to the NTLM provider' 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 'refuses a mechanism no provider handles' do + expect(authenticator.process(gss_init(RubySMB::Gss::OID_NEGOEX))).to be_nil + end + end + + context 'when the client continues an exchange' do + it 'refuses a continuation before a mechanism has been selected' do + # a NegTokenResp carries no mechanism OID, so there is nothing to route on + expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil + end + end + + it 'returns nil for a malformed request' do + expect(authenticator.process('not asn1 at all')).to be_nil + end + end + + describe 'a complete NTLM exchange' do + it 'authenticates the same as the NTLM provider on its own' do + expect(complete_ntlm_exchange(authenticator)).to eq( + complete_ntlm_exchange(ntlm_provider.new_authenticator(server_client)) + ) + end + + it 'succeeds for a known account' do + status, identity = complete_ntlm_exchange(authenticator) + expect(status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(identity).to eq("#{domain}\\#{username}") + end + + it 'exposes the session key of the mechanism that authenticated' do + complete_ntlm_exchange(authenticator) + expect(authenticator.session_key).to_not be_nil + end + end + + describe '#reset!' do + it 'forgets the selected mechanism' do + complete_ntlm_exchange(authenticator) + authenticator.reset! + expect(authenticator.session_key).to be_nil + # with no mechanism selected, a continuation token has nothing to route to + expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil + end + end + end + + # Build a NegTokenInit that selects the specified mechanism, with an empty mechToken. + def gss_init(mech_type) + 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_type])], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new('')], 2, :CONTEXT_SPECIFIC) + ] + ) + ], 0, :CONTEXT_SPECIFIC + ) + ], 0, :APPLICATION + ).to_der + end + + # Drive a full NTLM negotiation through the authenticator, returning the final status and identity. + def complete_ntlm_exchange(authenticator) + authenticator.process(nil) + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain } + challenge_result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize)) + raw_type2 = RubySMB::Gss.asn1dig(OpenSSL::ASN1.decode(challenge_result.buffer), 0, 2, 0).value + type2 = Net::NTLM::Message.parse(raw_type2) + type3 = type2.response({ user: username, password: password, domain: domain }, { ntlmv2: true }) + result = authenticator.process(RubySMB::Gss.gss_type3(type3.serialize)) + [result.nt_status, result.identity] + end +end From a0601262ca3b14d810048f91eb0a2d1677ab4e6d Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:35:54 +0530 Subject: [PATCH 3/6] Add a Kerberos GSS provider that surfaces the mechanism token A Kerberos AP-REQ is encrypted to the service the client believes it is talking to, so a server that does not hold that service's key cannot read it. Provider::Kerberos therefore does not try: it advertises the Kerberos mechanisms, and hands the mechanism token to a handler that decides how to reply. That is enough for a server to observe or forward Kerberos authentication, and it keeps Kerberos message parsing out of this library, so no new dependency is introduced and the token is never altered in transit. A handler receives the bytes exactly as the client sent them, which matters for anything that forwards the ticket elsewhere. Both the RFC 4121 mechanism OID and the legacy Microsoft variant are advertised, since clients may select either, and the RFC 4121 token identifiers are exposed so a handler can tell an AP-REQ from an AP-REP or a KRB-ERROR without decoding the payload. With no handler set the attempt is refused rather than silently accepted, since nothing here can validate a ticket. Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate concern and is not implemented here. --- lib/ruby_smb/gss/provider.rb | 1 + lib/ruby_smb/gss/provider/kerberos.rb | 121 +++++++++++++ .../ruby_smb/gss/provider/kerberos_spec.rb | 162 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 lib/ruby_smb/gss/provider/kerberos.rb create mode 100644 spec/lib/ruby_smb/gss/provider/kerberos_spec.rb diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 0258c6912..3f81d9374 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -62,4 +62,5 @@ def supports_mech_type?(mech_type) require 'ruby_smb/gss/provider/authenticator' require 'ruby_smb/gss/provider/ntlm' +require 'ruby_smb/gss/provider/kerberos' require 'ruby_smb/gss/provider/multi' diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb new file mode 100644 index 000000000..8860085fa --- /dev/null +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -0,0 +1,121 @@ +module RubySMB + module Gss + module Provider + # + # A GSS provider that advertises Kerberos and surfaces the mechanism token a client sends, without interpreting + # it. + # + # A Kerberos AP-REQ is encrypted to the service the client believes it is talking to, so a server that does not + # hold that service's key cannot read it. This provider therefore does not attempt to: it hands the token to a + # handler and lets that decide what to tell the client. That is enough for a server to observe or forward + # Kerberos authentication, and it keeps Kerberos message parsing out of this library entirely. + # + # Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate + # concern and is not implemented here. + # + # @example Capture the token a client sends + # provider = RubySMB::Gss::Provider::Kerberos.new + # provider.on_mech_token do |token, authenticator| + # # token is the opaque GSS mechanism token, starting with its two byte token id + # RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + # end + # + class Kerberos < Base + # The GSS token identifiers that may prefix a Kerberos mechanism token, per RFC 4121 section 4.1. They are + # provided so a handler can tell the messages apart without decoding the payload. + TOK_ID_KRB_AP_REQ = "\x01\x00".b.freeze + TOK_ID_KRB_AP_REP = "\x02\x00".b.freeze + TOK_ID_KRB_ERROR = "\x03\x00".b.freeze + + # @param [Proc, nil] block an optional handler for received mechanism tokens, see {#on_mech_token}. + def initialize(&block) + @on_mech_token = block + @allow_anonymous = false + @allow_guests = false + end + + def new_authenticator(server_client) + Authenticator.new(self, server_client) + end + + def mech_types + # both are advertised because Microsoft clients may select either + [Gss::OID_KERBEROS_5, Gss::OID_MICROSOFT_KERBEROS_5] + end + + # + # Set or invoke the handler called when a client sends a Kerberos mechanism token. + # + # The handler receives the opaque token and the authenticator that received it, and returns the {Result} to + # reply with. When no handler is set the authentication attempt is rejected, since this provider cannot + # validate a ticket on its own. + # + # @param [String] token the mechanism token, as sent by the client + # @param [Authenticator] authenticator the authenticator that received it + # @return [Result, nil] + def on_mech_token(token=nil, authenticator=nil, &block) + if block.nil? + return nil if @on_mech_token.nil? + + @on_mech_token.call(token, authenticator) + else + @on_mech_token = block + end + end + + class Authenticator < Authenticator::Base + def reset! + super + @mech_token = nil + end + + # @return [String, nil] the most recent mechanism token received from the client. + attr_reader :mech_token + + def process(request_buffer=nil) + if request_buffer.nil? + return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) + end + + begin + gss_api = OpenSSL::ASN1.decode(request_buffer) + rescue OpenSSL::ASN1::ASN1Error => e + logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})") + return + end + + token = extract_mech_token(gss_api) + if token.nil? + logger.warn('Received a Kerberos request carrying no mechanism token') + return + end + + @mech_token = token + result = @provider.on_mech_token(token, self) + # with no handler there is nothing that can validate the ticket, so the attempt is refused rather than + # silently succeeding + result || Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + private + + # + # Pull the mechanism token out of a SPNEGO NegTokenInit or NegTokenResp. The token is returned exactly as the + # client sent it, so a caller that forwards it elsewhere does not alter the ticket it contains. + # + # @param gss_api the decoded request + # @return [String, nil] + def extract_mech_token(gss_api) + if gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION + # NegTokenInit: mechTypes then the mechToken + Gss.asn1dig(gss_api, 1, 0, 1, 0)&.value + elsif gss_api&.tag == 1 && gss_api&.tag_class == :CONTEXT_SPECIFIC + # NegTokenResp: the responseToken, tagged 2, carries the continuation + Hash[Gss.asn1dig(gss_api, 0)&.value.to_a.map { |obj| [obj.tag, obj.value[0].value] }][2] + end + end + end + end + end + end +end diff --git a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb new file mode 100644 index 000000000..a4779f1ad --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -0,0 +1,162 @@ +RSpec.describe RubySMB::Gss::Provider::Kerberos do + let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) } + # opaque stand-in for a real AP-REQ; this provider never interprets the payload + let(:ap_req) { "\x6e\x82\x01\x0a".b + Random.new(1).bytes(64) } + let(:mech_token) { RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + ap_req } + + subject(:provider) { RubySMB::Gss::Provider::Kerberos.new } + + describe '#mech_types' do + it 'advertises both the standard and the Microsoft Kerberos mechanism' do + expect(provider.mech_types.map(&:oid)).to eq( + [RubySMB::Gss::OID_KERBEROS_5.oid, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid] + ) + end + + it 'reports support for both' do + expect(provider.supports_mech_type?(RubySMB::Gss::OID_KERBEROS_5)).to be true + expect(provider.supports_mech_type?(RubySMB::Gss::OID_MICROSOFT_KERBEROS_5)).to be true + end + + it 'does not report support for other mechanisms' do + expect(provider.supports_mech_type?(RubySMB::Gss::OID_NTLMSSP)).to be false + end + end + + describe '#on_mech_token' do + it 'can be set with a block' do + provider.on_mech_token { |_token, _authenticator| :handled } + expect(provider.on_mech_token('token', nil)).to eq(:handled) + end + + it 'can be set through the constructor' do + configured = RubySMB::Gss::Provider::Kerberos.new { |_token, _authenticator| :handled } + expect(configured.on_mech_token('token', nil)).to eq(:handled) + end + + it 'is nil when no handler has been set' do + expect(provider.on_mech_token('token', nil)).to be_nil + end + end + + # referenced explicitly; described_class would resolve to the authenticator inside this group + describe RubySMB::Gss::Provider::Kerberos::Authenticator do + subject(:authenticator) { provider.new_authenticator(server_client) } + + describe '#process' do + context 'when building the advertisement' do + it 'offers the Kerberos mechanisms' do + expect(authenticator.process(nil).buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types)) + end + + it 'succeeds' do + expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + end + end + + context 'with a mechanism token' do + it 'passes the token to the handler' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + end + + it 'does not alter the token, so a forwarded ticket stays valid' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + expect(received[2..]).to eq(ap_req) + end + + it 'records the token on the authenticator' do + authenticator.process(neg_token_init(mech_token)) + expect(authenticator.mech_token).to eq(mech_token) + end + + it 'returns whatever the handler decides' do + expected = RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_SUCCESS) + provider.on_mech_token { |_token, _authenticator| expected } + expect(authenticator.process(neg_token_init(mech_token))).to be(expected) + end + + it 'refuses the attempt when no handler is set' do + # nothing here can validate a ticket, so the attempt must not silently succeed + result = authenticator.process(neg_token_init(mech_token)) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + it 'accepts a token carried in a continuation' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(RubySMB::Gss.gss_type3(mech_token)) + expect(received).to eq(mech_token) + end + end + + context 'with a malformed request' do + it 'returns nil rather than raising' do + expect(authenticator.process('not asn1 at all')).to be_nil + end + + it 'returns nil when there is no mechanism token' do + expect(authenticator.process(neg_token_init(nil))).to be_nil + end + end + end + + describe '#reset!' do + it 'forgets the recorded token' do + authenticator.process(neg_token_init(mech_token)) + expect(authenticator.mech_token).to_not be_nil + authenticator.reset! + expect(authenticator.mech_token).to be_nil + end + end + end + + describe 'alongside NTLM' do + let(:ntlm_provider) { RubySMB::Gss::Provider::NTLM.new.tap { |p| p.put_account('RubySMB', 'password') } } + let(:multi) { RubySMB::Gss::Provider::Multi.new([provider, ntlm_provider]) } + + it 'is offered ahead of NTLM' do + expect(multi.mech_types.map(&:oid)).to eq( + [ + RubySMB::Gss::OID_KERBEROS_5.oid, + RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid, + RubySMB::Gss::OID_NTLMSSP.oid + ] + ) + end + + it 'receives the token when a client selects Kerberos' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + multi.new_authenticator(server_client).process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + end + + it 'is left alone when a client selects NTLM' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = 'WORKGROUP' } + result = multi.new_authenticator(server_client).process(RubySMB::Gss.gss_type1(type1.serialize)) + expect(received).to be_nil + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + end + end + + # Build a SPNEGO NegTokenInit selecting Kerberos and carrying the specified mechanism token. + def neg_token_init(token) + inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)] + inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil? + + OpenSSL::ASN1::ASN1Data.new( + [ + RubySMB::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION + ).to_der + end +end From f65afd34feab3c5e41971e823dac5f4cffd4f559 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:15:06 +0530 Subject: [PATCH 4/6] Describe the Kerberos mechanism token accurately Lab testing against a Windows domain controller showed the documentation here was wrong about the shape of the token a client sends. The mechanism token is a GSS-API InitialContextToken (RFC 2743 section 3.1), which wraps the mechanism OID and the token identifier around the Kerberos message: 60 82 0c 0e InitialContextToken 06 09 2a 86 48 .. the mechanism OID 01 00 the token id, here KRB_AP_REQ 6e 82 0b fd .. the AP-REQ itself So the token id follows the OID rather than starting the token, which is what the previous comment implied, and the framing around it is not valid ASN.1, so OpenSSL::ASN1.decode cannot read it. Correct the documentation and add Kerberos.token_id, which locates the identifier by walking the lengths, so a handler can tell an AP-REQ from an AP-REP or a KRB-ERROR without decoding the payload or guessing at offsets. The provider itself was already handing up the token unaltered, which is what matters for anything forwarding it; only the description of it was wrong. --- lib/ruby_smb/gss/provider/kerberos.rb | 35 +++++++++++++++++-- .../ruby_smb/gss/provider/kerberos_spec.rb | 35 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb index 8860085fa..8c8866173 100644 --- a/lib/ruby_smb/gss/provider/kerberos.rb +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -13,20 +13,51 @@ module Provider # Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate # concern and is not implemented here. # + # The token handed to the handler is the mechanism token exactly as the client sent it. For Kerberos that is a + # GSS-API InitialContextToken (RFC 2743 section 3.1), which wraps the mechanism OID and the token identifier + # around the Kerberos message: + # + # 60 82 0c 0e InitialContextToken + # 06 09 2a 86 48 .. the mechanism OID + # 01 00 the token id, here KRB_AP_REQ + # 6e 82 0b fd .. the AP-REQ itself + # + # Note that the token id follows the OID rather than starting the token, and that the framing around it is not + # valid ASN.1, so OpenSSL::ASN1.decode will not parse it. {.token_id} reads it without decoding the payload. + # # @example Capture the token a client sends # provider = RubySMB::Gss::Provider::Kerberos.new # provider.on_mech_token do |token, authenticator| - # # token is the opaque GSS mechanism token, starting with its two byte token id + # RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ # RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) # end # class Kerberos < Base - # The GSS token identifiers that may prefix a Kerberos mechanism token, per RFC 4121 section 4.1. They are + # The GSS token identifiers that may appear in a Kerberos mechanism token, per RFC 4121 section 4.1. They are # provided so a handler can tell the messages apart without decoding the payload. TOK_ID_KRB_AP_REQ = "\x01\x00".b.freeze TOK_ID_KRB_AP_REP = "\x02\x00".b.freeze TOK_ID_KRB_ERROR = "\x03\x00".b.freeze + # + # Read the token identifier out of a GSS-API InitialContextToken, so a handler can tell an AP-REQ from an + # AP-REP or a KRB-ERROR. The identifier follows the mechanism OID rather than starting the token, and the + # framing is not valid ASN.1, so it is located by walking the lengths rather than by decoding. + # + # @param [String] token the mechanism token as received + # @return [String, nil] the two byte identifier, or nil if the token is not shaped as expected + def self.token_id(token) + return nil if token.nil? || token.bytesize < 4 || token.getbyte(0) != 0x60 + + length_byte = token.getbyte(1) + # a long form length says how many bytes carry the length, a short form is the length itself + offset = length_byte > 0x80 ? 2 + (length_byte & 0x7f) : 2 + return nil if token.getbyte(offset) != 0x06 # the mechanism OID must follow + + offset += 2 + token.getbyte(offset + 1) + token.byteslice(offset, 2) + end + # @param [Proc, nil] block an optional handler for received mechanism tokens, see {#on_mech_token}. def initialize(&block) @on_mech_token = block diff --git a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb index a4779f1ad..4dfdd6307 100644 --- a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -23,6 +23,41 @@ end end + describe '.token_id' do + # a GSS-API InitialContextToken, shaped as a Windows client actually sends one: the token id follows the + # mechanism OID rather than starting the token, and the framing around it is not valid ASN.1 + let(:initial_context_token) do + "\x60\x82\x0c\x0e".b + + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der + + RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + + "\x6e\x82\x0b\xfd".b + end + + it 'reads the identifier from past the mechanism OID' do + expect(RubySMB::Gss::Provider::Kerberos.token_id(initial_context_token)) + .to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ) + end + + it 'handles a short form length' do + short = "\x60\x14".b + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der + + RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP + "\x6f\x00".b + expect(RubySMB::Gss::Provider::Kerberos.token_id(short)) + .to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP) + end + + it 'is nil for anything not shaped like an InitialContextToken' do + expect(RubySMB::Gss::Provider::Kerberos.token_id(nil)).to be_nil + expect(RubySMB::Gss::Provider::Kerberos.token_id('')).to be_nil + expect(RubySMB::Gss::Provider::Kerberos.token_id('short')).to be_nil + # a SEQUENCE rather than an InitialContextToken + expect(RubySMB::Gss::Provider::Kerberos.token_id("\x30\x82\x00\x05".b)).to be_nil + end + + it 'is nil when no mechanism OID follows' do + expect(RubySMB::Gss::Provider::Kerberos.token_id("\x60\x04\x02\x01\x05\x00".b)).to be_nil + end + end + describe '#on_mech_token' do it 'can be set with a block' do provider.on_mech_token { |_token, _authenticator| :handled } From 05360b0b4987af9a159aadafb80cc2c3da732707 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:31:52 +0530 Subject: [PATCH 5/6] Model SPNEGO tokens with RASN1 instead of hand-rolled ASN.1 Replace the asn1dig chains in the Kerberos GSS provider and the hand-rolled NegTokenInit builder with RASN1 model types, following the approach used in metasploit-framework #20967. Add SpnegoNegTokenInit and SpnegoNegTokenTarg under RubySMB::Gss, along with a GeneralString type and a NegHints model so the advertisement, including the Microsoft negHints placeholder, can be built by the model. The token gss_neg_token_init produces stays byte-identical to the one the hand-rolled builder produced. extract_mech_token now parses through those models, dispatching on the SPNEGO identifier octet, and Gss.asn1dig is kept since the NTLM provider still relies on it. Declare rasn1 >= 0.12 (the release that introduced the model wrapper DSL these types use). --- lib/ruby_smb/gss.rb | 20 +----- lib/ruby_smb/gss/provider/kerberos.rb | 29 ++++---- lib/ruby_smb/gss/spnego_neg_token_init.rb | 81 +++++++++++++++++++++++ lib/ruby_smb/gss/spnego_neg_token_targ.rb | 27 ++++++++ ruby_smb.gemspec | 3 + spec/lib/ruby_smb/gss/spnego_spec.rb | 69 +++++++++++++++++++ 6 files changed, 196 insertions(+), 33 deletions(-) create mode 100644 lib/ruby_smb/gss/spnego_neg_token_init.rb create mode 100644 lib/ruby_smb/gss/spnego_neg_token_targ.rb create mode 100644 spec/lib/ruby_smb/gss/spnego_spec.rb diff --git a/lib/ruby_smb/gss.rb b/lib/ruby_smb/gss.rb index 5c8cf8bf5..151f8c650 100644 --- a/lib/ruby_smb/gss.rb +++ b/lib/ruby_smb/gss.rb @@ -2,6 +2,8 @@ module RubySMB # module containing methods required for using the [GSS-API](http://www.rfc-editor.org/rfc/rfc2743.txt) # for Secure Protected Negotiation(SPNEGO) in SMB Authentication. module Gss + require 'ruby_smb/gss/spnego_neg_token_init' + require 'ruby_smb/gss/spnego_neg_token_targ' require 'ruby_smb/gss/provider' OID_SPNEGO = OpenSSL::ASN1::ObjectId.new('1.3.6.1.5.5.2') @@ -64,23 +66,7 @@ def self.asn1encode(str = '') def self.gss_neg_token_init(mech_types) raise ArgumentError, 'at least one mechanism must be advertised' if mech_types.nil? || mech_types.empty? - OpenSSL::ASN1::ASN1Data.new([ - 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::ASN1Data.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore') - ], 0, :CONTEXT_SPECIFIC) - ], 16, :UNIVERSAL) - ], 3, :CONTEXT_SPECIFIC) - ]) - ], 0, :CONTEXT_SPECIFIC) - ], 0, :APPLICATION).to_der + SpnegoNegTokenInit.build(mech_types) end # Create a GSS Security Blob of an NTLM Type 1 Message. diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb index 8c8866173..45d3500c5 100644 --- a/lib/ruby_smb/gss/provider/kerberos.rb +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -108,14 +108,7 @@ def process(request_buffer=nil) return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) end - begin - gss_api = OpenSSL::ASN1.decode(request_buffer) - rescue OpenSSL::ASN1::ASN1Error => e - logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})") - return - end - - token = extract_mech_token(gss_api) + token = extract_mech_token(request_buffer) if token.nil? logger.warn('Received a Kerberos request carrying no mechanism token') return @@ -134,16 +127,20 @@ def process(request_buffer=nil) # Pull the mechanism token out of a SPNEGO NegTokenInit or NegTokenResp. The token is returned exactly as the # client sent it, so a caller that forwards it elsewhere does not alter the ticket it contains. # - # @param gss_api the decoded request + # @param [String] request_buffer the SPNEGO token as received # @return [String, nil] - def extract_mech_token(gss_api) - if gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION - # NegTokenInit: mechTypes then the mechToken - Gss.asn1dig(gss_api, 1, 0, 1, 0)&.value - elsif gss_api&.tag == 1 && gss_api&.tag_class == :CONTEXT_SPECIFIC - # NegTokenResp: the responseToken, tagged 2, carries the continuation - Hash[Gss.asn1dig(gss_api, 0)&.value.to_a.map { |obj| [obj.tag, obj.value[0].value] }][2] + def extract_mech_token(request_buffer) + # the identifier octet tells the two SPNEGO tokens apart: an InitialContextToken carrying a NegTokenInit is + # tagged [APPLICATION 0], a NegTokenResp continuing an exchange is tagged [CONTEXT 1] + case request_buffer.b.getbyte(0) + when 0x60 + SpnegoNegTokenInit.parse(request_buffer).mech_token + when 0xa1 + SpnegoNegTokenTarg.parse(request_buffer).response_token end + rescue RASN1::ASN1Error => e + logger.error("Failed to parse the SPNEGO token (#{e.message})") + nil end end end diff --git a/lib/ruby_smb/gss/spnego_neg_token_init.rb b/lib/ruby_smb/gss/spnego_neg_token_init.rb new file mode 100644 index 000000000..3e6ae88d0 --- /dev/null +++ b/lib/ruby_smb/gss/spnego_neg_token_init.rb @@ -0,0 +1,81 @@ +require 'rasn1' + +module RubySMB + module Gss + # The SPNEGO negotiation token an initiator sends first, and that a server sends to advertise the mechanisms it + # supports. Modelled with RASN1 so the fields can be read and built by name rather than by walking a decoded + # structure by hand. + # + # https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.1 + class MechType < RASN1::Types::ObjectId + end + + class MechTypeList < RASN1::Model + sequence_of(:mech_type, MechType) + end + + class ContextFlags < RASN1::Types::BitString + def initialize(options = {}) + options[:bit_length] = 32 + super + end + end + + # RASN1 does not define a GeneralString type, and a SPNEGO negHints carries its hintName as one, so it is defined + # here as an octet string tagged UNIVERSAL 27. + class GeneralString < RASN1::Types::OctetString + ID = 27 + + def self.type + 'GeneralString' + end + end + + # NegHints, the optional field Microsoft servers place at [3] of a NegTokenInit2 in lieu of a mechListMIC. Windows + # servers send a fixed placeholder hintName, so a client that expects the field still finds one. + # + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-spng/8e71cf53-e867-4b79-9df6-cc9edb7f8829 + class NegHints < RASN1::Model + define_type_accel('general_string', GeneralString) + + sequence :neg_hints, + content: [general_string(:hint_name, explicit: 0, class: :context, constructed: true, optional: true), + octet_string(:hint_address, explicit: 1, class: :context, constructed: true, optional: true)] + end + + class NegTokenInit < RASN1::Model + sequence :neg_token_init, explicit: 0, class: :context, constructed: true, + content: [wrapper(model(:mech_type_list, MechTypeList), explicit: 0, constructed: true), + wrapper(model(:context_flags, ContextFlags), explicit: 1, constructed: true, optional: true), + octet_string(:mech_token, explicit: 2, constructed: true, optional: true), + wrapper(model(:neg_hints, NegHints), explicit: 3, constructed: true, optional: true)] + end + + class SpnegoNegTokenInit < RASN1::Model + # The placeholder hintName a Windows server sends, reproduced so the advertisement matches what a client expects. + NEG_HINTS_NAME = 'not_defined_in_RFC4178@please_ignore'.freeze + + sequence :gssapi, implicit: 0, class: :application, constructed: true, + content: [objectid(:oid), + model(:neg_token_init, NegTokenInit)] + + # Build the NegTokenInit a server sends to advertise the mechanisms it supports, including the Microsoft negHints + # placeholder so the token is shaped as a Windows server's is. + # + # @param [Array] mech_types the mechanisms to advertise, in preference order. + # @return [String] the DER encoded token. + def self.build(mech_types) + token = new + token[:gssapi][:oid].value = Gss::OID_SPNEGO.oid + token[:gssapi][:neg_token_init][:mech_type_list][:mech_type] = mech_types.map { |mech| MechType.new(value: mech.oid) } + token[:gssapi][:neg_token_init][:neg_hints][:hint_name] = NEG_HINTS_NAME + token.to_der + end + + # @return [String, nil] the mechanism token the initiator carried, or nil if it carried none. + def mech_token + self[:gssapi][:neg_token_init][:mech_token].value + end + end + end +end diff --git a/lib/ruby_smb/gss/spnego_neg_token_targ.rb b/lib/ruby_smb/gss/spnego_neg_token_targ.rb new file mode 100644 index 000000000..f40d82706 --- /dev/null +++ b/lib/ruby_smb/gss/spnego_neg_token_targ.rb @@ -0,0 +1,27 @@ +require 'rasn1' + +module RubySMB + module Gss + # The SPNEGO negotiation token exchanged after the first, carrying a continuation of the selected mechanism. + # A client sends one to continue an exchange, so it is where a mechanism token arrives on any leg past the first. + # + # https://www.rfc-editor.org/rfc/rfc2478 + class SpnegoNegTokenTarg < RASN1::Model + NEG_RESULTS = { 'accept-completed' => 0, + 'accept-incomplete' => 1, + 'reject' => 2, + 'request-mic' => 3 }.freeze + + sequence :token, explicit: 1, class: :context, constructed: true, + content: [enumerated(:neg_result, enum: NEG_RESULTS, explicit: 0, class: :context, constructed: true, optional: true), + objectid(:supported_mech, explicit: 1, class: :context, constructed: true, optional: true), + octet_string(:response_token, explicit: 2, class: :context, constructed: true, optional: true), + octet_string(:mech_list_mic, explicit: 3, class: :context, constructed: true, optional: true)] + + # @return [String, nil] the mechanism token the continuation carried, or nil if it carried none. + def response_token + self[:response_token].value + end + end + end +end diff --git a/ruby_smb.gemspec b/ruby_smb.gemspec index 3a990f075..8a4078cf2 100644 --- a/ruby_smb.gemspec +++ b/ruby_smb.gemspec @@ -43,6 +43,9 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'rubyntlm', '>= 0.6.5' spec.add_runtime_dependency 'windows_error', '>= 0.1.4' spec.add_runtime_dependency 'bindata', '2.4.15' + # 0.12 introduced the model `wrapper` DSL the SPNEGO types rely on; the upper Ruby versions resolve to a + # newer rasn1, while Ruby 2.7 caps at 0.13.1 (0.14+ needs Ruby 3.0), and both are known good. + spec.add_runtime_dependency 'rasn1', '>= 0.12' spec.add_runtime_dependency 'openssl-ccm' spec.add_runtime_dependency 'openssl-cmac' end diff --git a/spec/lib/ruby_smb/gss/spnego_spec.rb b/spec/lib/ruby_smb/gss/spnego_spec.rb new file mode 100644 index 000000000..01e263e94 --- /dev/null +++ b/spec/lib/ruby_smb/gss/spnego_spec.rb @@ -0,0 +1,69 @@ +RSpec.describe 'SPNEGO negotiation tokens' do + let(:mech_types) do + [RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5, RubySMB::Gss::OID_NTLMSSP] + end + + describe RubySMB::Gss::SpnegoNegTokenInit do + describe '.build' do + subject(:token) { described_class.build(mech_types) } + + # the exact bytes a server advertised before this was modelled with RASN1, kept so the wire format does not + # drift: the SPNEGO OID, the three mechanisms, and the Microsoft negHints placeholder + let(:legacy_der) do + [ + '605e06062b0601050502a0543052a024302206092a864886f71201020206092a864882' \ + 'f712010202060a2b06010401823702020aa32a3028a0261b246e6f745f646566696e65' \ + '645f696e5f5246433431373840706c656173655f69676e6f7265' + ].pack('H*') + end + + it 'is byte-identical to the token the hand-rolled builder produced' do + expect(token).to eq(legacy_der) + end + + it 'advertises the mechanisms in order' do + decoded = OpenSSL::ASN1.decode(token) + mech_list = decoded.value[1].value[0].value[0].value[0].value + expect(mech_list.map(&:oid)).to eq(mech_types.map(&:oid)) + end + + it 'carries the Microsoft negHints placeholder' do + decoded = OpenSSL::ASN1.decode(token) + hint = decoded.value[1].value[0].value[1].value[0].value[0].value[0].value + expect(hint).to eq(RubySMB::Gss::SpnegoNegTokenInit::NEG_HINTS_NAME) + end + end + + describe '.parse' do + # a SPNEGO NegTokenInit selecting Kerberos and carrying the given mechanism token + def neg_token_init(token) + inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)] + inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil? + + OpenSSL::ASN1::ASN1Data.new( + [ + RubySMB::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION + ).to_der + end + + it 'reads the mechanism token' do + expect(described_class.parse(neg_token_init('a mechanism token')).mech_token).to eq('a mechanism token') + end + + it 'is nil when the token carries no mechanism token' do + expect(described_class.parse(neg_token_init(nil)).mech_token).to be_nil + end + end + end + + describe RubySMB::Gss::SpnegoNegTokenTarg do + describe '.parse' do + it 'reads the response token from a continuation' do + targ = described_class.parse(RubySMB::Gss.gss_type3('a continuation token')) + expect(targ.response_token).to eq('a continuation token') + end + end + end +end From 598f6e574d9ded7cc94971b7f800d36fba94f1e1 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:48:14 +0530 Subject: [PATCH 6/6] Refuse non-Result handler results and lock the NTLM-only advertisement Two review findings on the Kerberos provider: - Provider::Kerberos::Authenticator#process forwarded whatever on_mech_token returned as the GSS result, but the session setup path calls nt_status on it, so a handler returning a non-Result (such as the boolean a naive handler might return, as the earlier example showed) crashed the connection. Refuse anything that is not a Result, and correct the on_mech_token example to return one. - The byte-identity spec only covered the three-mechanism advertisement, not the NTLM-only token a default server still emits. Add a lock against the exact pre-change NTLM-only bytes so the backwards compatible wire format cannot drift. --- lib/ruby_smb/gss/provider/kerberos.rb | 14 ++++++++++---- spec/lib/ruby_smb/gss/provider/kerberos_spec.rb | 9 +++++++++ spec/lib/ruby_smb/gss/spnego_spec.rb | 11 +++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb index 45d3500c5..fd6e3b2d9 100644 --- a/lib/ruby_smb/gss/provider/kerberos.rb +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -28,7 +28,10 @@ module Provider # @example Capture the token a client sends # provider = RubySMB::Gss::Provider::Kerberos.new # provider.on_mech_token do |token, authenticator| - # RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + # if RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + # # forward or record the AP-REQ, then decide how to reply + # end + # # a handler must return a Result; there is no service key here to validate the ticket, so refuse it # RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) # end # @@ -116,9 +119,12 @@ def process(request_buffer=nil) @mech_token = token result = @provider.on_mech_token(token, self) - # with no handler there is nothing that can validate the ticket, so the attempt is refused rather than - # silently succeeding - result || Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + # a handler must return a Result: the session setup path calls nt_status on whatever comes back, so + # anything else (a missing handler, or a handler that returns e.g. a boolean) is refused here rather + # than handed on to crash the caller + return result if result.is_a?(Result) + + Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) end private diff --git a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb index 4dfdd6307..87ee5351a 100644 --- a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -122,6 +122,15 @@ expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) end + it 'refuses the attempt when the handler returns something that is not a Result' do + # the session setup path calls nt_status on the result, so a non-Result (e.g. a boolean from a naive + # handler) must be refused here rather than handed on to crash the caller + provider.on_mech_token { |_token, _authenticator| true } + result = authenticator.process(neg_token_init(mech_token)) + expect(result).to be_a(RubySMB::Gss::Provider::Result) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + it 'accepts a token carried in a continuation' do received = nil provider.on_mech_token { |token, _authenticator| received = token; nil } diff --git a/spec/lib/ruby_smb/gss/spnego_spec.rb b/spec/lib/ruby_smb/gss/spnego_spec.rb index 01e263e94..3cb808bdd 100644 --- a/spec/lib/ruby_smb/gss/spnego_spec.rb +++ b/spec/lib/ruby_smb/gss/spnego_spec.rb @@ -21,6 +21,17 @@ expect(token).to eq(legacy_der) end + # the exact NTLM-only advertisement a default server built before this change, when the NTLM provider + # hardcoded a single OID_NTLMSSP. existing servers still emit this, so lock it against a wire regression. + it 'is byte-identical to the NTLM-only advertisement a default server built before this change' do + legacy_ntlm_der = [ + '604806062b0601050502a03e303ca00e300c060a2b06010401823702020aa32a3028' \ + 'a0261b246e6f745f646566696e65645f696e5f5246433431373840706c656173655f' \ + '69676e6f7265' + ].pack('H*') + expect(described_class.build([RubySMB::Gss::OID_NTLMSSP])).to eq(legacy_ntlm_der) + end + it 'advertises the mechanisms in order' do decoded = OpenSSL::ASN1.decode(token) mech_list = decoded.value[1].value[0].value[0].value[0].value