ruby_smb 3.3.23 → 3.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/ruby_smb/gss/provider/kerberos.rb +155 -0
- data/lib/ruby_smb/gss/provider/multi.rb +129 -0
- data/lib/ruby_smb/gss/provider/ntlm.rb +5 -20
- data/lib/ruby_smb/gss/provider.rb +22 -0
- data/lib/ruby_smb/gss/spnego_neg_token_init.rb +81 -0
- data/lib/ruby_smb/gss/spnego_neg_token_targ.rb +27 -0
- data/lib/ruby_smb/gss.rb +23 -0
- data/lib/ruby_smb/version.rb +1 -1
- data/ruby_smb.gemspec +3 -0
- data/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb +206 -0
- data/spec/lib/ruby_smb/gss/provider/multi_spec.rb +174 -0
- data/spec/lib/ruby_smb/gss/spnego_spec.rb +80 -0
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47dd07d1be2ceab9aa605d3664a5fef186ed19dee39e00d3797a992f430c6df2
|
|
4
|
+
data.tar.gz: 68763fac839bb88f6dba2fab538d81492cd5975252012fddd2f875078580dbb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf4a4ff7e4f3c86289a61f6aabc138046b80b6e8819025cc10f563a5e685238784a387a6ab7cd65abcee82b8b67332e497be3202d99aaeeea744354f22609e84
|
|
7
|
+
data.tar.gz: 7d63f42688208e9ce39785189b14f08eea77c73a32da1b9d967aed4ea4f39a220de7ee2bf6250419c3dd637fb04dbbae807ae81ea1e3d2e36c6676b5ba2b5f9d
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
module RubySMB
|
|
2
|
+
module Gss
|
|
3
|
+
module Provider
|
|
4
|
+
#
|
|
5
|
+
# A GSS provider that advertises Kerberos and surfaces the mechanism token a client sends, without interpreting
|
|
6
|
+
# it.
|
|
7
|
+
#
|
|
8
|
+
# A Kerberos AP-REQ is encrypted to the service the client believes it is talking to, so a server that does not
|
|
9
|
+
# hold that service's key cannot read it. This provider therefore does not attempt to: it hands the token to a
|
|
10
|
+
# handler and lets that decide what to tell the client. That is enough for a server to observe or forward
|
|
11
|
+
# Kerberos authentication, and it keeps Kerberos message parsing out of this library entirely.
|
|
12
|
+
#
|
|
13
|
+
# Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate
|
|
14
|
+
# concern and is not implemented here.
|
|
15
|
+
#
|
|
16
|
+
# The token handed to the handler is the mechanism token exactly as the client sent it. For Kerberos that is a
|
|
17
|
+
# GSS-API InitialContextToken (RFC 2743 section 3.1), which wraps the mechanism OID and the token identifier
|
|
18
|
+
# around the Kerberos message:
|
|
19
|
+
#
|
|
20
|
+
# 60 82 0c 0e InitialContextToken
|
|
21
|
+
# 06 09 2a 86 48 .. the mechanism OID
|
|
22
|
+
# 01 00 the token id, here KRB_AP_REQ
|
|
23
|
+
# 6e 82 0b fd .. the AP-REQ itself
|
|
24
|
+
#
|
|
25
|
+
# Note that the token id follows the OID rather than starting the token, and that the framing around it is not
|
|
26
|
+
# valid ASN.1, so OpenSSL::ASN1.decode will not parse it. {.token_id} reads it without decoding the payload.
|
|
27
|
+
#
|
|
28
|
+
# @example Capture the token a client sends
|
|
29
|
+
# provider = RubySMB::Gss::Provider::Kerberos.new
|
|
30
|
+
# provider.on_mech_token do |token, authenticator|
|
|
31
|
+
# if RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ
|
|
32
|
+
# # forward or record the AP-REQ, then decide how to reply
|
|
33
|
+
# end
|
|
34
|
+
# # a handler must return a Result; there is no service key here to validate the ticket, so refuse it
|
|
35
|
+
# RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
class Kerberos < Base
|
|
39
|
+
# The GSS token identifiers that may appear in a Kerberos mechanism token, per RFC 4121 section 4.1. They are
|
|
40
|
+
# provided so a handler can tell the messages apart without decoding the payload.
|
|
41
|
+
TOK_ID_KRB_AP_REQ = "\x01\x00".b.freeze
|
|
42
|
+
TOK_ID_KRB_AP_REP = "\x02\x00".b.freeze
|
|
43
|
+
TOK_ID_KRB_ERROR = "\x03\x00".b.freeze
|
|
44
|
+
|
|
45
|
+
#
|
|
46
|
+
# Read the token identifier out of a GSS-API InitialContextToken, so a handler can tell an AP-REQ from an
|
|
47
|
+
# AP-REP or a KRB-ERROR. The identifier follows the mechanism OID rather than starting the token, and the
|
|
48
|
+
# framing is not valid ASN.1, so it is located by walking the lengths rather than by decoding.
|
|
49
|
+
#
|
|
50
|
+
# @param [String] token the mechanism token as received
|
|
51
|
+
# @return [String, nil] the two byte identifier, or nil if the token is not shaped as expected
|
|
52
|
+
def self.token_id(token)
|
|
53
|
+
return nil if token.nil? || token.bytesize < 4 || token.getbyte(0) != 0x60
|
|
54
|
+
|
|
55
|
+
length_byte = token.getbyte(1)
|
|
56
|
+
# a long form length says how many bytes carry the length, a short form is the length itself
|
|
57
|
+
offset = length_byte > 0x80 ? 2 + (length_byte & 0x7f) : 2
|
|
58
|
+
return nil if token.getbyte(offset) != 0x06 # the mechanism OID must follow
|
|
59
|
+
|
|
60
|
+
offset += 2 + token.getbyte(offset + 1)
|
|
61
|
+
token.byteslice(offset, 2)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param [Proc, nil] block an optional handler for received mechanism tokens, see {#on_mech_token}.
|
|
65
|
+
def initialize(&block)
|
|
66
|
+
@on_mech_token = block
|
|
67
|
+
@allow_anonymous = false
|
|
68
|
+
@allow_guests = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def new_authenticator(server_client)
|
|
72
|
+
Authenticator.new(self, server_client)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def mech_types
|
|
76
|
+
# both are advertised because Microsoft clients may select either
|
|
77
|
+
[Gss::OID_KERBEROS_5, Gss::OID_MICROSOFT_KERBEROS_5]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#
|
|
81
|
+
# Set or invoke the handler called when a client sends a Kerberos mechanism token.
|
|
82
|
+
#
|
|
83
|
+
# The handler receives the opaque token and the authenticator that received it, and returns the {Result} to
|
|
84
|
+
# reply with. When no handler is set the authentication attempt is rejected, since this provider cannot
|
|
85
|
+
# validate a ticket on its own.
|
|
86
|
+
#
|
|
87
|
+
# @param [String] token the mechanism token, as sent by the client
|
|
88
|
+
# @param [Authenticator] authenticator the authenticator that received it
|
|
89
|
+
# @return [Result, nil]
|
|
90
|
+
def on_mech_token(token=nil, authenticator=nil, &block)
|
|
91
|
+
if block.nil?
|
|
92
|
+
return nil if @on_mech_token.nil?
|
|
93
|
+
|
|
94
|
+
@on_mech_token.call(token, authenticator)
|
|
95
|
+
else
|
|
96
|
+
@on_mech_token = block
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class Authenticator < Authenticator::Base
|
|
101
|
+
def reset!
|
|
102
|
+
super
|
|
103
|
+
@mech_token = nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [String, nil] the most recent mechanism token received from the client.
|
|
107
|
+
attr_reader :mech_token
|
|
108
|
+
|
|
109
|
+
def process(request_buffer=nil)
|
|
110
|
+
if request_buffer.nil?
|
|
111
|
+
return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
token = extract_mech_token(request_buffer)
|
|
115
|
+
if token.nil?
|
|
116
|
+
logger.warn('Received a Kerberos request carrying no mechanism token')
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@mech_token = token
|
|
121
|
+
result = @provider.on_mech_token(token, self)
|
|
122
|
+
# a handler must return a Result: the session setup path calls nt_status on whatever comes back, so
|
|
123
|
+
# anything else (a missing handler, or a handler that returns e.g. a boolean) is refused here rather
|
|
124
|
+
# than handed on to crash the caller
|
|
125
|
+
return result if result.is_a?(Result)
|
|
126
|
+
|
|
127
|
+
Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
#
|
|
133
|
+
# Pull the mechanism token out of a SPNEGO NegTokenInit or NegTokenResp. The token is returned exactly as the
|
|
134
|
+
# client sent it, so a caller that forwards it elsewhere does not alter the ticket it contains.
|
|
135
|
+
#
|
|
136
|
+
# @param [String] request_buffer the SPNEGO token as received
|
|
137
|
+
# @return [String, nil]
|
|
138
|
+
def extract_mech_token(request_buffer)
|
|
139
|
+
# the identifier octet tells the two SPNEGO tokens apart: an InitialContextToken carrying a NegTokenInit is
|
|
140
|
+
# tagged [APPLICATION 0], a NegTokenResp continuing an exchange is tagged [CONTEXT 1]
|
|
141
|
+
case request_buffer.b.getbyte(0)
|
|
142
|
+
when 0x60
|
|
143
|
+
SpnegoNegTokenInit.parse(request_buffer).mech_token
|
|
144
|
+
when 0xa1
|
|
145
|
+
SpnegoNegTokenTarg.parse(request_buffer).response_token
|
|
146
|
+
end
|
|
147
|
+
rescue RASN1::ASN1Error => e
|
|
148
|
+
logger.error("Failed to parse the SPNEGO token (#{e.message})")
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module RubySMB
|
|
2
|
+
module Gss
|
|
3
|
+
module Provider
|
|
4
|
+
#
|
|
5
|
+
# A GSS provider that offers more than one authentication mechanism to the client and routes each request to
|
|
6
|
+
# whichever of its sub-providers understands the mechanism the client selected.
|
|
7
|
+
#
|
|
8
|
+
# SPNEGO exists so that a client and server can agree on a mechanism, but a server that only ever advertises one
|
|
9
|
+
# has nothing to negotiate. This provider advertises the mechanisms of every provider it holds, in the order they
|
|
10
|
+
# were given, so a client can pick the one it prefers.
|
|
11
|
+
#
|
|
12
|
+
# @example Offer Kerberos, falling back to NTLM
|
|
13
|
+
# provider = RubySMB::Gss::Provider::Multi.new([kerberos_provider, ntlm_provider])
|
|
14
|
+
# RubySMB::Server.new(gss_provider: provider)
|
|
15
|
+
#
|
|
16
|
+
class Multi < Base
|
|
17
|
+
#
|
|
18
|
+
# @param [Array<Provider::Base>] providers the providers to offer, in preference order (most preferred first).
|
|
19
|
+
def initialize(providers)
|
|
20
|
+
raise ArgumentError, 'at least one provider is required' if providers.nil? || providers.empty?
|
|
21
|
+
|
|
22
|
+
@providers = providers.dup.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<Provider::Base>] the providers this instance will route between.
|
|
26
|
+
attr_reader :providers
|
|
27
|
+
|
|
28
|
+
def new_authenticator(server_client)
|
|
29
|
+
Authenticator.new(self, server_client)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#
|
|
33
|
+
# Every mechanism offered by every provider, in provider order, with duplicates removed so a mechanism supported
|
|
34
|
+
# by two providers is only advertised once.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<OpenSSL::ASN1::ObjectId>]
|
|
37
|
+
def mech_types
|
|
38
|
+
@providers.flat_map(&:mech_types).uniq(&:oid)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# The first provider that handles the specified mechanism, or nil if none do.
|
|
43
|
+
#
|
|
44
|
+
# @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client
|
|
45
|
+
# @return [Provider::Base, nil]
|
|
46
|
+
def provider_for(mech_type)
|
|
47
|
+
@providers.find { |provider| provider.supports_mech_type?(mech_type) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def allow_anonymous
|
|
51
|
+
@providers.any?(&:allow_anonymous)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def allow_guests
|
|
55
|
+
@providers.any?(&:allow_guests)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Authenticator < Authenticator::Base
|
|
59
|
+
def initialize(provider, server_client)
|
|
60
|
+
# built lazily, so a provider that is advertised but never selected is never instantiated
|
|
61
|
+
@authenticators = {}
|
|
62
|
+
@selected = nil
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reset!
|
|
67
|
+
super
|
|
68
|
+
@authenticators&.each_value(&:reset!)
|
|
69
|
+
@selected = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def process(request_buffer=nil)
|
|
73
|
+
# the advertisement, listing every mechanism the server is willing to accept
|
|
74
|
+
return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) if request_buffer.nil?
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
gss_api = OpenSSL::ASN1.decode(request_buffer)
|
|
78
|
+
rescue OpenSSL::ASN1::ASN1Error => e
|
|
79
|
+
logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})")
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if negotiation_init?(gss_api)
|
|
84
|
+
# a NegTokenInit names the mechanism the client chose, so this is where routing is decided
|
|
85
|
+
mech_type = Gss.asn1dig(gss_api, 1, 0, 0, 0, 0)
|
|
86
|
+
authenticator = authenticator_for(mech_type)
|
|
87
|
+
if authenticator.nil?
|
|
88
|
+
logger.warn("Client selected an unsupported GSS mechanism (#{mech_type&.oid || 'unknown'})")
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@selected = authenticator
|
|
93
|
+
elsif @selected.nil?
|
|
94
|
+
# a NegTokenResp carries no mechanism OID, so it can only be interpreted as a continuation of a
|
|
95
|
+
# negotiation that has already selected one
|
|
96
|
+
logger.warn('Received a GSS continuation token before any mechanism was selected')
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@selected.process(request_buffer)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The session key belongs to whichever mechanism actually authenticated the client.
|
|
104
|
+
def session_key
|
|
105
|
+
@selected&.session_key
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def session_key=(value)
|
|
109
|
+
@selected&.session_key = value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Whether the token is a NegTokenInit, which is the only token that names a mechanism.
|
|
115
|
+
def negotiation_init?(gss_api)
|
|
116
|
+
gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def authenticator_for(mech_type)
|
|
120
|
+
provider = @provider.provider_for(mech_type)
|
|
121
|
+
return nil if provider.nil?
|
|
122
|
+
|
|
123
|
+
@authenticators[provider] ||= provider.new_authenticator(@server_client)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -26,26 +26,7 @@ module RubySMB
|
|
|
26
26
|
|
|
27
27
|
def process(request_buffer=nil)
|
|
28
28
|
if request_buffer.nil?
|
|
29
|
-
|
|
30
|
-
buffer = OpenSSL::ASN1::ASN1Data.new([
|
|
31
|
-
Gss::OID_SPNEGO,
|
|
32
|
-
OpenSSL::ASN1::ASN1Data.new([
|
|
33
|
-
OpenSSL::ASN1::Sequence.new([
|
|
34
|
-
OpenSSL::ASN1::ASN1Data.new([
|
|
35
|
-
OpenSSL::ASN1::Sequence.new([
|
|
36
|
-
Gss::OID_NTLMSSP
|
|
37
|
-
])
|
|
38
|
-
], 0, :CONTEXT_SPECIFIC),
|
|
39
|
-
OpenSSL::ASN1::ASN1Data.new([
|
|
40
|
-
OpenSSL::ASN1::ASN1Data.new([
|
|
41
|
-
OpenSSL::ASN1::ASN1Data.new([
|
|
42
|
-
OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore')
|
|
43
|
-
], 0, :CONTEXT_SPECIFIC)
|
|
44
|
-
], 16, :UNIVERSAL)
|
|
45
|
-
], 3, :CONTEXT_SPECIFIC)
|
|
46
|
-
])
|
|
47
|
-
], 0, :CONTEXT_SPECIFIC)
|
|
48
|
-
], 0, :APPLICATION).to_der
|
|
29
|
+
buffer = Gss.gss_neg_token_init(@provider.mech_types)
|
|
49
30
|
return Result.new(buffer, WindowsError::NTStatus::STATUS_SUCCESS)
|
|
50
31
|
end
|
|
51
32
|
|
|
@@ -293,6 +274,10 @@ module RubySMB
|
|
|
293
274
|
Authenticator.new(self, server_client)
|
|
294
275
|
end
|
|
295
276
|
|
|
277
|
+
def mech_types
|
|
278
|
+
[Gss::OID_NTLMSSP]
|
|
279
|
+
end
|
|
280
|
+
|
|
296
281
|
#
|
|
297
282
|
# Lookup and return an account based on the username and optionally, the domain. If no domain is specified or
|
|
298
283
|
# or it is the special value '.', the default domain will be used. The username and domain values are case
|
|
@@ -26,6 +26,26 @@ module RubySMB
|
|
|
26
26
|
raise NotImplementedError
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
#
|
|
30
|
+
# The GSS mechanisms this provider can handle, in preference order. These are advertised to the client in the
|
|
31
|
+
# SPNEGO NegTokenInit, and are used to route an incoming token to the provider that understands it.
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<OpenSSL::ASN1::ObjectId>]
|
|
34
|
+
def mech_types
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Whether this provider can handle a token for the specified mechanism.
|
|
40
|
+
#
|
|
41
|
+
# @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def supports_mech_type?(mech_type)
|
|
44
|
+
return false if mech_type.nil?
|
|
45
|
+
|
|
46
|
+
mech_types.any? { |oid| oid.oid == mech_type.oid }
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
#
|
|
30
50
|
# Whether or not anonymous authentication attempts should be permitted.
|
|
31
51
|
#
|
|
@@ -42,3 +62,5 @@ end
|
|
|
42
62
|
|
|
43
63
|
require 'ruby_smb/gss/provider/authenticator'
|
|
44
64
|
require 'ruby_smb/gss/provider/ntlm'
|
|
65
|
+
require 'ruby_smb/gss/provider/kerberos'
|
|
66
|
+
require 'ruby_smb/gss/provider/multi'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require 'rasn1'
|
|
2
|
+
|
|
3
|
+
module RubySMB
|
|
4
|
+
module Gss
|
|
5
|
+
# The SPNEGO negotiation token an initiator sends first, and that a server sends to advertise the mechanisms it
|
|
6
|
+
# supports. Modelled with RASN1 so the fields can be read and built by name rather than by walking a decoded
|
|
7
|
+
# structure by hand.
|
|
8
|
+
#
|
|
9
|
+
# https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.1
|
|
10
|
+
class MechType < RASN1::Types::ObjectId
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class MechTypeList < RASN1::Model
|
|
14
|
+
sequence_of(:mech_type, MechType)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ContextFlags < RASN1::Types::BitString
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
options[:bit_length] = 32
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# RASN1 does not define a GeneralString type, and a SPNEGO negHints carries its hintName as one, so it is defined
|
|
25
|
+
# here as an octet string tagged UNIVERSAL 27.
|
|
26
|
+
class GeneralString < RASN1::Types::OctetString
|
|
27
|
+
ID = 27
|
|
28
|
+
|
|
29
|
+
def self.type
|
|
30
|
+
'GeneralString'
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# NegHints, the optional field Microsoft servers place at [3] of a NegTokenInit2 in lieu of a mechListMIC. Windows
|
|
35
|
+
# servers send a fixed placeholder hintName, so a client that expects the field still finds one.
|
|
36
|
+
#
|
|
37
|
+
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-spng/8e71cf53-e867-4b79-9df6-cc9edb7f8829
|
|
38
|
+
class NegHints < RASN1::Model
|
|
39
|
+
define_type_accel('general_string', GeneralString)
|
|
40
|
+
|
|
41
|
+
sequence :neg_hints,
|
|
42
|
+
content: [general_string(:hint_name, explicit: 0, class: :context, constructed: true, optional: true),
|
|
43
|
+
octet_string(:hint_address, explicit: 1, class: :context, constructed: true, optional: true)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class NegTokenInit < RASN1::Model
|
|
47
|
+
sequence :neg_token_init, explicit: 0, class: :context, constructed: true,
|
|
48
|
+
content: [wrapper(model(:mech_type_list, MechTypeList), explicit: 0, constructed: true),
|
|
49
|
+
wrapper(model(:context_flags, ContextFlags), explicit: 1, constructed: true, optional: true),
|
|
50
|
+
octet_string(:mech_token, explicit: 2, constructed: true, optional: true),
|
|
51
|
+
wrapper(model(:neg_hints, NegHints), explicit: 3, constructed: true, optional: true)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class SpnegoNegTokenInit < RASN1::Model
|
|
55
|
+
# The placeholder hintName a Windows server sends, reproduced so the advertisement matches what a client expects.
|
|
56
|
+
NEG_HINTS_NAME = 'not_defined_in_RFC4178@please_ignore'.freeze
|
|
57
|
+
|
|
58
|
+
sequence :gssapi, implicit: 0, class: :application, constructed: true,
|
|
59
|
+
content: [objectid(:oid),
|
|
60
|
+
model(:neg_token_init, NegTokenInit)]
|
|
61
|
+
|
|
62
|
+
# Build the NegTokenInit a server sends to advertise the mechanisms it supports, including the Microsoft negHints
|
|
63
|
+
# placeholder so the token is shaped as a Windows server's is.
|
|
64
|
+
#
|
|
65
|
+
# @param [Array<OpenSSL::ASN1::ObjectId>] mech_types the mechanisms to advertise, in preference order.
|
|
66
|
+
# @return [String] the DER encoded token.
|
|
67
|
+
def self.build(mech_types)
|
|
68
|
+
token = new
|
|
69
|
+
token[:gssapi][:oid].value = Gss::OID_SPNEGO.oid
|
|
70
|
+
token[:gssapi][:neg_token_init][:mech_type_list][:mech_type] = mech_types.map { |mech| MechType.new(value: mech.oid) }
|
|
71
|
+
token[:gssapi][:neg_token_init][:neg_hints][:hint_name] = NEG_HINTS_NAME
|
|
72
|
+
token.to_der
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [String, nil] the mechanism token the initiator carried, or nil if it carried none.
|
|
76
|
+
def mech_token
|
|
77
|
+
self[:gssapi][:neg_token_init][:mech_token].value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'rasn1'
|
|
2
|
+
|
|
3
|
+
module RubySMB
|
|
4
|
+
module Gss
|
|
5
|
+
# The SPNEGO negotiation token exchanged after the first, carrying a continuation of the selected mechanism.
|
|
6
|
+
# A client sends one to continue an exchange, so it is where a mechanism token arrives on any leg past the first.
|
|
7
|
+
#
|
|
8
|
+
# https://www.rfc-editor.org/rfc/rfc2478
|
|
9
|
+
class SpnegoNegTokenTarg < RASN1::Model
|
|
10
|
+
NEG_RESULTS = { 'accept-completed' => 0,
|
|
11
|
+
'accept-incomplete' => 1,
|
|
12
|
+
'reject' => 2,
|
|
13
|
+
'request-mic' => 3 }.freeze
|
|
14
|
+
|
|
15
|
+
sequence :token, explicit: 1, class: :context, constructed: true,
|
|
16
|
+
content: [enumerated(:neg_result, enum: NEG_RESULTS, explicit: 0, class: :context, constructed: true, optional: true),
|
|
17
|
+
objectid(:supported_mech, explicit: 1, class: :context, constructed: true, optional: true),
|
|
18
|
+
octet_string(:response_token, explicit: 2, class: :context, constructed: true, optional: true),
|
|
19
|
+
octet_string(:mech_list_mic, explicit: 3, class: :context, constructed: true, optional: true)]
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] the mechanism token the continuation carried, or nil if it carried none.
|
|
22
|
+
def response_token
|
|
23
|
+
self[:response_token].value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/ruby_smb/gss.rb
CHANGED
|
@@ -2,11 +2,18 @@ module RubySMB
|
|
|
2
2
|
# module containing methods required for using the [GSS-API](http://www.rfc-editor.org/rfc/rfc2743.txt)
|
|
3
3
|
# for Secure Protected Negotiation(SPNEGO) in SMB Authentication.
|
|
4
4
|
module Gss
|
|
5
|
+
require 'ruby_smb/gss/spnego_neg_token_init'
|
|
6
|
+
require 'ruby_smb/gss/spnego_neg_token_targ'
|
|
5
7
|
require 'ruby_smb/gss/provider'
|
|
6
8
|
|
|
7
9
|
OID_SPNEGO = OpenSSL::ASN1::ObjectId.new('1.3.6.1.5.5.2')
|
|
8
10
|
OID_NEGOEX = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.30')
|
|
9
11
|
OID_NTLMSSP = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10')
|
|
12
|
+
# The Kerberos v5 GSS-API mechanism (RFC 4121). Microsoft's SPNEGO
|
|
13
|
+
# implementation also uses a legacy OID that differs by a single arc, and
|
|
14
|
+
# clients may offer or select either, so both are defined here.
|
|
15
|
+
OID_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2')
|
|
16
|
+
OID_MICROSOFT_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.48018.1.2.2')
|
|
10
17
|
|
|
11
18
|
# Allow safe navigation of a decoded ASN.1 data structure. Similar to Ruby's
|
|
12
19
|
# builtin Hash#dig method but using the #value attribute of each ASN object.
|
|
@@ -46,6 +53,22 @@ module RubySMB
|
|
|
46
53
|
encoded_string
|
|
47
54
|
end
|
|
48
55
|
|
|
56
|
+
# Build the SPNEGO NegTokenInit that a server sends to advertise the
|
|
57
|
+
# authentication mechanisms it supports, per RFC 4178 section 4.2.1.
|
|
58
|
+
#
|
|
59
|
+
# The mechTypes list is supplied by the caller so that it reflects every
|
|
60
|
+
# mechanism the server actually offers, rather than being fixed to a single
|
|
61
|
+
# mechanism by whichever provider happens to build the token.
|
|
62
|
+
#
|
|
63
|
+
# @param [Array<OpenSSL::ASN1::ObjectId>] mech_types the mechanisms to
|
|
64
|
+
# advertise, in preference order (most preferred first).
|
|
65
|
+
# @return [String] the DER encoded NegTokenInit.
|
|
66
|
+
def self.gss_neg_token_init(mech_types)
|
|
67
|
+
raise ArgumentError, 'at least one mechanism must be advertised' if mech_types.nil? || mech_types.empty?
|
|
68
|
+
|
|
69
|
+
SpnegoNegTokenInit.build(mech_types)
|
|
70
|
+
end
|
|
71
|
+
|
|
49
72
|
# Create a GSS Security Blob of an NTLM Type 1 Message.
|
|
50
73
|
def self.gss_type1(type1)
|
|
51
74
|
OpenSSL::ASN1::ASN1Data.new([
|
data/lib/ruby_smb/version.rb
CHANGED
data/ruby_smb.gemspec
CHANGED
|
@@ -43,6 +43,9 @@ Gem::Specification.new do |spec|
|
|
|
43
43
|
spec.add_runtime_dependency 'rubyntlm', '>= 0.6.5'
|
|
44
44
|
spec.add_runtime_dependency 'windows_error', '>= 0.1.4'
|
|
45
45
|
spec.add_runtime_dependency 'bindata', '2.4.15'
|
|
46
|
+
# 0.12 introduced the model `wrapper` DSL the SPNEGO types rely on; the upper Ruby versions resolve to a
|
|
47
|
+
# newer rasn1, while Ruby 2.7 caps at 0.13.1 (0.14+ needs Ruby 3.0), and both are known good.
|
|
48
|
+
spec.add_runtime_dependency 'rasn1', '>= 0.12'
|
|
46
49
|
spec.add_runtime_dependency 'openssl-ccm'
|
|
47
50
|
spec.add_runtime_dependency 'openssl-cmac'
|
|
48
51
|
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
RSpec.describe RubySMB::Gss::Provider::Kerberos do
|
|
2
|
+
let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) }
|
|
3
|
+
# opaque stand-in for a real AP-REQ; this provider never interprets the payload
|
|
4
|
+
let(:ap_req) { "\x6e\x82\x01\x0a".b + Random.new(1).bytes(64) }
|
|
5
|
+
let(:mech_token) { RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + ap_req }
|
|
6
|
+
|
|
7
|
+
subject(:provider) { RubySMB::Gss::Provider::Kerberos.new }
|
|
8
|
+
|
|
9
|
+
describe '#mech_types' do
|
|
10
|
+
it 'advertises both the standard and the Microsoft Kerberos mechanism' do
|
|
11
|
+
expect(provider.mech_types.map(&:oid)).to eq(
|
|
12
|
+
[RubySMB::Gss::OID_KERBEROS_5.oid, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid]
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'reports support for both' do
|
|
17
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_KERBEROS_5)).to be true
|
|
18
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_MICROSOFT_KERBEROS_5)).to be true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'does not report support for other mechanisms' do
|
|
22
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_NTLMSSP)).to be false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '.token_id' do
|
|
27
|
+
# a GSS-API InitialContextToken, shaped as a Windows client actually sends one: the token id follows the
|
|
28
|
+
# mechanism OID rather than starting the token, and the framing around it is not valid ASN.1
|
|
29
|
+
let(:initial_context_token) do
|
|
30
|
+
"\x60\x82\x0c\x0e".b +
|
|
31
|
+
OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der +
|
|
32
|
+
RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ +
|
|
33
|
+
"\x6e\x82\x0b\xfd".b
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'reads the identifier from past the mechanism OID' do
|
|
37
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(initial_context_token))
|
|
38
|
+
.to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'handles a short form length' do
|
|
42
|
+
short = "\x60\x14".b + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der +
|
|
43
|
+
RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP + "\x6f\x00".b
|
|
44
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(short))
|
|
45
|
+
.to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'is nil for anything not shaped like an InitialContextToken' do
|
|
49
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(nil)).to be_nil
|
|
50
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id('')).to be_nil
|
|
51
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id('short')).to be_nil
|
|
52
|
+
# a SEQUENCE rather than an InitialContextToken
|
|
53
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id("\x30\x82\x00\x05".b)).to be_nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'is nil when no mechanism OID follows' do
|
|
57
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id("\x60\x04\x02\x01\x05\x00".b)).to be_nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '#on_mech_token' do
|
|
62
|
+
it 'can be set with a block' do
|
|
63
|
+
provider.on_mech_token { |_token, _authenticator| :handled }
|
|
64
|
+
expect(provider.on_mech_token('token', nil)).to eq(:handled)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'can be set through the constructor' do
|
|
68
|
+
configured = RubySMB::Gss::Provider::Kerberos.new { |_token, _authenticator| :handled }
|
|
69
|
+
expect(configured.on_mech_token('token', nil)).to eq(:handled)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'is nil when no handler has been set' do
|
|
73
|
+
expect(provider.on_mech_token('token', nil)).to be_nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# referenced explicitly; described_class would resolve to the authenticator inside this group
|
|
78
|
+
describe RubySMB::Gss::Provider::Kerberos::Authenticator do
|
|
79
|
+
subject(:authenticator) { provider.new_authenticator(server_client) }
|
|
80
|
+
|
|
81
|
+
describe '#process' do
|
|
82
|
+
context 'when building the advertisement' do
|
|
83
|
+
it 'offers the Kerberos mechanisms' do
|
|
84
|
+
expect(authenticator.process(nil).buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'succeeds' do
|
|
88
|
+
expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'with a mechanism token' do
|
|
93
|
+
it 'passes the token to the handler' do
|
|
94
|
+
received = nil
|
|
95
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
96
|
+
authenticator.process(neg_token_init(mech_token))
|
|
97
|
+
expect(received).to eq(mech_token)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'does not alter the token, so a forwarded ticket stays valid' do
|
|
101
|
+
received = nil
|
|
102
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
103
|
+
authenticator.process(neg_token_init(mech_token))
|
|
104
|
+
expect(received).to eq(mech_token)
|
|
105
|
+
expect(received[2..]).to eq(ap_req)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'records the token on the authenticator' do
|
|
109
|
+
authenticator.process(neg_token_init(mech_token))
|
|
110
|
+
expect(authenticator.mech_token).to eq(mech_token)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns whatever the handler decides' do
|
|
114
|
+
expected = RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_SUCCESS)
|
|
115
|
+
provider.on_mech_token { |_token, _authenticator| expected }
|
|
116
|
+
expect(authenticator.process(neg_token_init(mech_token))).to be(expected)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'refuses the attempt when no handler is set' do
|
|
120
|
+
# nothing here can validate a ticket, so the attempt must not silently succeed
|
|
121
|
+
result = authenticator.process(neg_token_init(mech_token))
|
|
122
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'refuses the attempt when the handler returns something that is not a Result' do
|
|
126
|
+
# the session setup path calls nt_status on the result, so a non-Result (e.g. a boolean from a naive
|
|
127
|
+
# handler) must be refused here rather than handed on to crash the caller
|
|
128
|
+
provider.on_mech_token { |_token, _authenticator| true }
|
|
129
|
+
result = authenticator.process(neg_token_init(mech_token))
|
|
130
|
+
expect(result).to be_a(RubySMB::Gss::Provider::Result)
|
|
131
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'accepts a token carried in a continuation' do
|
|
135
|
+
received = nil
|
|
136
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
137
|
+
authenticator.process(RubySMB::Gss.gss_type3(mech_token))
|
|
138
|
+
expect(received).to eq(mech_token)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
context 'with a malformed request' do
|
|
143
|
+
it 'returns nil rather than raising' do
|
|
144
|
+
expect(authenticator.process('not asn1 at all')).to be_nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'returns nil when there is no mechanism token' do
|
|
148
|
+
expect(authenticator.process(neg_token_init(nil))).to be_nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe '#reset!' do
|
|
154
|
+
it 'forgets the recorded token' do
|
|
155
|
+
authenticator.process(neg_token_init(mech_token))
|
|
156
|
+
expect(authenticator.mech_token).to_not be_nil
|
|
157
|
+
authenticator.reset!
|
|
158
|
+
expect(authenticator.mech_token).to be_nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe 'alongside NTLM' do
|
|
164
|
+
let(:ntlm_provider) { RubySMB::Gss::Provider::NTLM.new.tap { |p| p.put_account('RubySMB', 'password') } }
|
|
165
|
+
let(:multi) { RubySMB::Gss::Provider::Multi.new([provider, ntlm_provider]) }
|
|
166
|
+
|
|
167
|
+
it 'is offered ahead of NTLM' do
|
|
168
|
+
expect(multi.mech_types.map(&:oid)).to eq(
|
|
169
|
+
[
|
|
170
|
+
RubySMB::Gss::OID_KERBEROS_5.oid,
|
|
171
|
+
RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid,
|
|
172
|
+
RubySMB::Gss::OID_NTLMSSP.oid
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'receives the token when a client selects Kerberos' do
|
|
178
|
+
received = nil
|
|
179
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
180
|
+
multi.new_authenticator(server_client).process(neg_token_init(mech_token))
|
|
181
|
+
expect(received).to eq(mech_token)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'is left alone when a client selects NTLM' do
|
|
185
|
+
received = nil
|
|
186
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
187
|
+
type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = 'WORKGROUP' }
|
|
188
|
+
result = multi.new_authenticator(server_client).process(RubySMB::Gss.gss_type1(type1.serialize))
|
|
189
|
+
expect(received).to be_nil
|
|
190
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build a SPNEGO NegTokenInit selecting Kerberos and carrying the specified mechanism token.
|
|
195
|
+
def neg_token_init(token)
|
|
196
|
+
inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)]
|
|
197
|
+
inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil?
|
|
198
|
+
|
|
199
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
200
|
+
[
|
|
201
|
+
RubySMB::Gss::OID_SPNEGO,
|
|
202
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC)
|
|
203
|
+
], 0, :APPLICATION
|
|
204
|
+
).to_der
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
RSpec.describe RubySMB::Gss::Provider::Multi do
|
|
2
|
+
let(:username) { 'RubySMB' }
|
|
3
|
+
let(:domain) { 'WORKGROUP' }
|
|
4
|
+
let(:password) { 'password' }
|
|
5
|
+
let(:ntlm_provider) do
|
|
6
|
+
RubySMB::Gss::Provider::NTLM.new.tap { |provider| provider.put_account(username, password, domain: domain) }
|
|
7
|
+
end
|
|
8
|
+
let(:other_authenticator) { double('authenticator', process: nil, reset!: nil, session_key: nil) }
|
|
9
|
+
# a stand-in for any non-NTLM mechanism, so the routing can be exercised without a second real provider
|
|
10
|
+
let(:other_provider) do
|
|
11
|
+
authenticator = other_authenticator
|
|
12
|
+
Class.new(RubySMB::Gss::Provider::Base) do
|
|
13
|
+
define_method(:mech_types) do
|
|
14
|
+
[RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method(:new_authenticator) { |_server_client| authenticator }
|
|
18
|
+
end.new
|
|
19
|
+
end
|
|
20
|
+
let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) }
|
|
21
|
+
|
|
22
|
+
# referenced explicitly rather than via described_class, which resolves to the authenticator inside the nested group
|
|
23
|
+
subject(:provider) { RubySMB::Gss::Provider::Multi.new([other_provider, ntlm_provider]) }
|
|
24
|
+
|
|
25
|
+
describe '#initialize' do
|
|
26
|
+
it 'requires at least one provider' do
|
|
27
|
+
expect { RubySMB::Gss::Provider::Multi.new([]) }.to raise_error(ArgumentError)
|
|
28
|
+
expect { RubySMB::Gss::Provider::Multi.new(nil) }.to raise_error(ArgumentError)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#mech_types' do
|
|
33
|
+
it 'advertises every mechanism of every provider' do
|
|
34
|
+
expect(provider.mech_types.map(&:oid)).to eq(
|
|
35
|
+
[
|
|
36
|
+
RubySMB::Gss::OID_KERBEROS_5.oid,
|
|
37
|
+
RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid,
|
|
38
|
+
RubySMB::Gss::OID_NTLMSSP.oid
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'preserves the order the providers were given in' do
|
|
44
|
+
reversed = RubySMB::Gss::Provider::Multi.new([ntlm_provider, other_provider])
|
|
45
|
+
expect(reversed.mech_types.first.oid).to eq(RubySMB::Gss::OID_NTLMSSP.oid)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'advertises a mechanism supported by two providers only once' do
|
|
49
|
+
duplicated = RubySMB::Gss::Provider::Multi.new([ntlm_provider, RubySMB::Gss::Provider::NTLM.new])
|
|
50
|
+
expect(duplicated.mech_types.length).to eq(1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#provider_for' do
|
|
55
|
+
it 'finds the provider that handles the mechanism' do
|
|
56
|
+
expect(provider.provider_for(RubySMB::Gss::OID_KERBEROS_5)).to be(other_provider)
|
|
57
|
+
expect(provider.provider_for(RubySMB::Gss::OID_NTLMSSP)).to be(ntlm_provider)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'is nil when no provider handles the mechanism' do
|
|
61
|
+
expect(provider.provider_for(RubySMB::Gss::OID_NEGOEX)).to be_nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe RubySMB::Gss::Provider::Multi::Authenticator do
|
|
66
|
+
subject(:authenticator) { provider.new_authenticator(server_client) }
|
|
67
|
+
|
|
68
|
+
describe '#process' do
|
|
69
|
+
context 'when building the advertisement' do
|
|
70
|
+
it 'offers all of the mechanisms' do
|
|
71
|
+
buffer = authenticator.process(nil).buffer
|
|
72
|
+
expect(buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'matches the underlying provider when only one is held' do
|
|
76
|
+
single = described_class.new(RubySMB::Gss::Provider::Multi.new([ntlm_provider]), server_client)
|
|
77
|
+
expect(single.process(nil).buffer).to eq(ntlm_provider.new_authenticator(server_client).process(nil).buffer)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'succeeds' do
|
|
81
|
+
expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
context 'when the client selects a mechanism' do
|
|
86
|
+
it 'routes the token to the provider that handles it' do
|
|
87
|
+
expect(other_authenticator).to receive(:process)
|
|
88
|
+
authenticator.process(gss_init(RubySMB::Gss::OID_KERBEROS_5))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'routes an NTLM token to the NTLM provider' do
|
|
92
|
+
type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain }
|
|
93
|
+
result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize))
|
|
94
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'refuses a mechanism no provider handles' do
|
|
98
|
+
expect(authenticator.process(gss_init(RubySMB::Gss::OID_NEGOEX))).to be_nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
context 'when the client continues an exchange' do
|
|
103
|
+
it 'refuses a continuation before a mechanism has been selected' do
|
|
104
|
+
# a NegTokenResp carries no mechanism OID, so there is nothing to route on
|
|
105
|
+
expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'returns nil for a malformed request' do
|
|
110
|
+
expect(authenticator.process('not asn1 at all')).to be_nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe 'a complete NTLM exchange' do
|
|
115
|
+
it 'authenticates the same as the NTLM provider on its own' do
|
|
116
|
+
expect(complete_ntlm_exchange(authenticator)).to eq(
|
|
117
|
+
complete_ntlm_exchange(ntlm_provider.new_authenticator(server_client))
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'succeeds for a known account' do
|
|
122
|
+
status, identity = complete_ntlm_exchange(authenticator)
|
|
123
|
+
expect(status).to eq(WindowsError::NTStatus::STATUS_SUCCESS)
|
|
124
|
+
expect(identity).to eq("#{domain}\\#{username}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'exposes the session key of the mechanism that authenticated' do
|
|
128
|
+
complete_ntlm_exchange(authenticator)
|
|
129
|
+
expect(authenticator.session_key).to_not be_nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe '#reset!' do
|
|
134
|
+
it 'forgets the selected mechanism' do
|
|
135
|
+
complete_ntlm_exchange(authenticator)
|
|
136
|
+
authenticator.reset!
|
|
137
|
+
expect(authenticator.session_key).to be_nil
|
|
138
|
+
# with no mechanism selected, a continuation token has nothing to route to
|
|
139
|
+
expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Build a NegTokenInit that selects the specified mechanism, with an empty mechToken.
|
|
145
|
+
def gss_init(mech_type)
|
|
146
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
147
|
+
[
|
|
148
|
+
RubySMB::Gss::OID_SPNEGO,
|
|
149
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
150
|
+
[
|
|
151
|
+
OpenSSL::ASN1::Sequence.new(
|
|
152
|
+
[
|
|
153
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([mech_type])], 0, :CONTEXT_SPECIFIC),
|
|
154
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new('')], 2, :CONTEXT_SPECIFIC)
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
], 0, :CONTEXT_SPECIFIC
|
|
158
|
+
)
|
|
159
|
+
], 0, :APPLICATION
|
|
160
|
+
).to_der
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Drive a full NTLM negotiation through the authenticator, returning the final status and identity.
|
|
164
|
+
def complete_ntlm_exchange(authenticator)
|
|
165
|
+
authenticator.process(nil)
|
|
166
|
+
type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain }
|
|
167
|
+
challenge_result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize))
|
|
168
|
+
raw_type2 = RubySMB::Gss.asn1dig(OpenSSL::ASN1.decode(challenge_result.buffer), 0, 2, 0).value
|
|
169
|
+
type2 = Net::NTLM::Message.parse(raw_type2)
|
|
170
|
+
type3 = type2.response({ user: username, password: password, domain: domain }, { ntlmv2: true })
|
|
171
|
+
result = authenticator.process(RubySMB::Gss.gss_type3(type3.serialize))
|
|
172
|
+
[result.nt_status, result.identity]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
RSpec.describe 'SPNEGO negotiation tokens' do
|
|
2
|
+
let(:mech_types) do
|
|
3
|
+
[RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5, RubySMB::Gss::OID_NTLMSSP]
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
describe RubySMB::Gss::SpnegoNegTokenInit do
|
|
7
|
+
describe '.build' do
|
|
8
|
+
subject(:token) { described_class.build(mech_types) }
|
|
9
|
+
|
|
10
|
+
# the exact bytes a server advertised before this was modelled with RASN1, kept so the wire format does not
|
|
11
|
+
# drift: the SPNEGO OID, the three mechanisms, and the Microsoft negHints placeholder
|
|
12
|
+
let(:legacy_der) do
|
|
13
|
+
[
|
|
14
|
+
'605e06062b0601050502a0543052a024302206092a864886f71201020206092a864882' \
|
|
15
|
+
'f712010202060a2b06010401823702020aa32a3028a0261b246e6f745f646566696e65' \
|
|
16
|
+
'645f696e5f5246433431373840706c656173655f69676e6f7265'
|
|
17
|
+
].pack('H*')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'is byte-identical to the token the hand-rolled builder produced' do
|
|
21
|
+
expect(token).to eq(legacy_der)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# the exact NTLM-only advertisement a default server built before this change, when the NTLM provider
|
|
25
|
+
# hardcoded a single OID_NTLMSSP. existing servers still emit this, so lock it against a wire regression.
|
|
26
|
+
it 'is byte-identical to the NTLM-only advertisement a default server built before this change' do
|
|
27
|
+
legacy_ntlm_der = [
|
|
28
|
+
'604806062b0601050502a03e303ca00e300c060a2b06010401823702020aa32a3028' \
|
|
29
|
+
'a0261b246e6f745f646566696e65645f696e5f5246433431373840706c656173655f' \
|
|
30
|
+
'69676e6f7265'
|
|
31
|
+
].pack('H*')
|
|
32
|
+
expect(described_class.build([RubySMB::Gss::OID_NTLMSSP])).to eq(legacy_ntlm_der)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'advertises the mechanisms in order' do
|
|
36
|
+
decoded = OpenSSL::ASN1.decode(token)
|
|
37
|
+
mech_list = decoded.value[1].value[0].value[0].value[0].value
|
|
38
|
+
expect(mech_list.map(&:oid)).to eq(mech_types.map(&:oid))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'carries the Microsoft negHints placeholder' do
|
|
42
|
+
decoded = OpenSSL::ASN1.decode(token)
|
|
43
|
+
hint = decoded.value[1].value[0].value[1].value[0].value[0].value[0].value
|
|
44
|
+
expect(hint).to eq(RubySMB::Gss::SpnegoNegTokenInit::NEG_HINTS_NAME)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '.parse' do
|
|
49
|
+
# a SPNEGO NegTokenInit selecting Kerberos and carrying the given mechanism token
|
|
50
|
+
def neg_token_init(token)
|
|
51
|
+
inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)]
|
|
52
|
+
inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil?
|
|
53
|
+
|
|
54
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
55
|
+
[
|
|
56
|
+
RubySMB::Gss::OID_SPNEGO,
|
|
57
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC)
|
|
58
|
+
], 0, :APPLICATION
|
|
59
|
+
).to_der
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'reads the mechanism token' do
|
|
63
|
+
expect(described_class.parse(neg_token_init('a mechanism token')).mech_token).to eq('a mechanism token')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'is nil when the token carries no mechanism token' do
|
|
67
|
+
expect(described_class.parse(neg_token_init(nil)).mech_token).to be_nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe RubySMB::Gss::SpnegoNegTokenTarg do
|
|
73
|
+
describe '.parse' do
|
|
74
|
+
it 'reads the response token from a continuation' do
|
|
75
|
+
targ = described_class.parse(RubySMB::Gss.gss_type3('a continuation token'))
|
|
76
|
+
expect(targ.response_token).to eq('a continuation token')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_smb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.3.
|
|
4
|
+
version: 3.3.24
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Metasploit Hackers
|
|
@@ -13,7 +13,7 @@ authors:
|
|
|
13
13
|
autorequire:
|
|
14
14
|
bindir: bin
|
|
15
15
|
cert_chain: []
|
|
16
|
-
date: 2026-09-
|
|
16
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
17
17
|
dependencies:
|
|
18
18
|
- !ruby/object:Gem::Dependency
|
|
19
19
|
name: redcarpet
|
|
@@ -127,6 +127,20 @@ dependencies:
|
|
|
127
127
|
- - '='
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
129
|
version: 2.4.15
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: rasn1
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0.12'
|
|
137
|
+
type: :runtime
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - ">="
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '0.12'
|
|
130
144
|
- !ruby/object:Gem::Dependency
|
|
131
145
|
name: openssl-ccm
|
|
132
146
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -455,7 +469,11 @@ files:
|
|
|
455
469
|
- lib/ruby_smb/gss.rb
|
|
456
470
|
- lib/ruby_smb/gss/provider.rb
|
|
457
471
|
- lib/ruby_smb/gss/provider/authenticator.rb
|
|
472
|
+
- lib/ruby_smb/gss/provider/kerberos.rb
|
|
473
|
+
- lib/ruby_smb/gss/provider/multi.rb
|
|
458
474
|
- lib/ruby_smb/gss/provider/ntlm.rb
|
|
475
|
+
- lib/ruby_smb/gss/spnego_neg_token_init.rb
|
|
476
|
+
- lib/ruby_smb/gss/spnego_neg_token_targ.rb
|
|
459
477
|
- lib/ruby_smb/impersonation_levels.rb
|
|
460
478
|
- lib/ruby_smb/nbss.rb
|
|
461
479
|
- lib/ruby_smb/nbss/negative_session_response.rb
|
|
@@ -839,10 +857,13 @@ files:
|
|
|
839
857
|
- spec/lib/ruby_smb/fscc/file_system_information_spec.rb
|
|
840
858
|
- spec/lib/ruby_smb/fscc/fscc_file_attributes_spec.rb
|
|
841
859
|
- spec/lib/ruby_smb/generic_packet_spec.rb
|
|
860
|
+
- spec/lib/ruby_smb/gss/provider/kerberos_spec.rb
|
|
861
|
+
- spec/lib/ruby_smb/gss/provider/multi_spec.rb
|
|
842
862
|
- spec/lib/ruby_smb/gss/provider/ntlm/account_spec.rb
|
|
843
863
|
- spec/lib/ruby_smb/gss/provider/ntlm/authenticator_spec.rb
|
|
844
864
|
- spec/lib/ruby_smb/gss/provider/ntlm/os_version_spec.rb
|
|
845
865
|
- spec/lib/ruby_smb/gss/provider/ntlm_spec.rb
|
|
866
|
+
- spec/lib/ruby_smb/gss/spnego_spec.rb
|
|
846
867
|
- spec/lib/ruby_smb/nbss/negative_session_response_spec.rb
|
|
847
868
|
- spec/lib/ruby_smb/nbss/netbios_name_spec.rb
|
|
848
869
|
- spec/lib/ruby_smb/nbss/session_header_spec.rb
|
|
@@ -1196,10 +1217,13 @@ test_files:
|
|
|
1196
1217
|
- spec/lib/ruby_smb/fscc/file_system_information_spec.rb
|
|
1197
1218
|
- spec/lib/ruby_smb/fscc/fscc_file_attributes_spec.rb
|
|
1198
1219
|
- spec/lib/ruby_smb/generic_packet_spec.rb
|
|
1220
|
+
- spec/lib/ruby_smb/gss/provider/kerberos_spec.rb
|
|
1221
|
+
- spec/lib/ruby_smb/gss/provider/multi_spec.rb
|
|
1199
1222
|
- spec/lib/ruby_smb/gss/provider/ntlm/account_spec.rb
|
|
1200
1223
|
- spec/lib/ruby_smb/gss/provider/ntlm/authenticator_spec.rb
|
|
1201
1224
|
- spec/lib/ruby_smb/gss/provider/ntlm/os_version_spec.rb
|
|
1202
1225
|
- spec/lib/ruby_smb/gss/provider/ntlm_spec.rb
|
|
1226
|
+
- spec/lib/ruby_smb/gss/spnego_spec.rb
|
|
1203
1227
|
- spec/lib/ruby_smb/nbss/negative_session_response_spec.rb
|
|
1204
1228
|
- spec/lib/ruby_smb/nbss/netbios_name_spec.rb
|
|
1205
1229
|
- spec/lib/ruby_smb/nbss/session_header_spec.rb
|