ruby_smb 3.3.22 → 3.3.25
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/client.rb +14 -3
- data/lib/ruby_smb/error.rb +11 -1
- 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 +6 -21
- 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/nbss/name_service_header_flags.rb +18 -0
- data/lib/ruby_smb/nbss/negative_session_response.rb +5 -5
- data/lib/ruby_smb/nbss/node_status.rb +137 -0
- data/lib/ruby_smb/nbss/node_status_request.rb +38 -0
- data/lib/ruby_smb/nbss/node_status_response.rb +85 -0
- data/lib/ruby_smb/nbss.rb +11 -0
- data/lib/ruby_smb/peer_info.rb +3 -1
- data/lib/ruby_smb/version.rb +1 -1
- data/ruby_smb.gemspec +3 -0
- data/spec/lib/ruby_smb/client_spec.rb +60 -0
- data/spec/lib/ruby_smb/dcerpc/client_spec.rb +8 -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/provider/ntlm/authenticator_spec.rb +13 -0
- data/spec/lib/ruby_smb/gss/spnego_spec.rb +80 -0
- data/spec/lib/ruby_smb/nbss/node_status_request_spec.rb +52 -0
- data/spec/lib/ruby_smb/nbss/node_status_response_spec.rb +75 -0
- data/spec/lib/ruby_smb/nbss/node_status_spec.rb +146 -0
- data/spec/lib/ruby_smb/ntlm/client/session_spec.rb +19 -40
- metadata +36 -2
|
@@ -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
|
|
@@ -65,6 +65,19 @@ RSpec.describe RubySMB::Gss::Provider::NTLM::Authenticator do
|
|
|
65
65
|
it 'should process a NTLM type 1 message and return a type2 message' do
|
|
66
66
|
expect(authenticator.process_ntlm_type1(type1_msg)).to be_a Net::NTLM::Message::Type2
|
|
67
67
|
end
|
|
68
|
+
|
|
69
|
+
it 'includes the server names in the serialized target info' do
|
|
70
|
+
type2_msg = Net::NTLM::Message.parse(authenticator.process_ntlm_type1(type1_msg).serialize)
|
|
71
|
+
target_info = Net::NTLM::TargetInfo.new(type2_msg.target_info)
|
|
72
|
+
|
|
73
|
+
expect(target_info.av_pairs).to include(
|
|
74
|
+
Net::NTLM::TargetInfo::MSV_AV_NB_DOMAIN_NAME => provider.netbios_domain.encode('UTF-16LE').b,
|
|
75
|
+
Net::NTLM::TargetInfo::MSV_AV_NB_COMPUTER_NAME => provider.netbios_hostname.encode('UTF-16LE').b,
|
|
76
|
+
Net::NTLM::TargetInfo::MSV_AV_DNS_DOMAIN_NAME => provider.dns_domain.encode('UTF-16LE').b,
|
|
77
|
+
Net::NTLM::TargetInfo::MSV_AV_DNS_COMPUTER_NAME => provider.dns_hostname.encode('UTF-16LE').b
|
|
78
|
+
)
|
|
79
|
+
expect(target_info.av_pairs[Net::NTLM::TargetInfo::MSV_AV_TIMESTAMP].bytesize).to eq 8
|
|
80
|
+
end
|
|
68
81
|
end
|
|
69
82
|
|
|
70
83
|
describe '#process_ntlm_type3' do
|
|
@@ -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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe RubySMB::Nbss::NodeStatusRequest do
|
|
4
|
+
subject(:request) { described_class.new(transaction_id: 0x1234) }
|
|
5
|
+
|
|
6
|
+
describe 'encoded bytes' do
|
|
7
|
+
let(:bytes) { request.to_binary_s }
|
|
8
|
+
|
|
9
|
+
it 'starts with a 12-byte NBNS header' do
|
|
10
|
+
expect(bytes[0, 2].unpack1('n')).to eq(0x1234)
|
|
11
|
+
expect(bytes[2, 2].unpack1('n')).to eq(0x0000) # flags
|
|
12
|
+
expect(bytes[4, 2].unpack1('n')).to eq(1) # qdcount
|
|
13
|
+
expect(bytes[6, 2].unpack1('n')).to eq(0) # ancount
|
|
14
|
+
expect(bytes[8, 2].unpack1('n')).to eq(0) # nscount
|
|
15
|
+
expect(bytes[10, 2].unpack1('n')).to eq(0) # arcount
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'encodes the wildcard question name as 34 bytes (length + 32-char L1 + null)' do
|
|
19
|
+
expect(bytes[12].unpack1('C')).to eq(0x20) # label length
|
|
20
|
+
expect(bytes[13, 32]).to eq('CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
|
|
21
|
+
expect(bytes[45].unpack1('C')).to eq(0x00) # null label terminator
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'ends with QTYPE=NBSTAT and QCLASS=IN' do
|
|
25
|
+
expect(bytes[46, 2].unpack1('n')).to eq(described_class::QUESTION_TYPE_NBSTAT)
|
|
26
|
+
expect(bytes[48, 2].unpack1('n')).to eq(described_class::QUESTION_CLASS_IN)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'is exactly 50 bytes long' do
|
|
30
|
+
expect(bytes.bytesize).to eq(50)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe 'flags' do
|
|
35
|
+
it 'packs the OPCODE, NM_FLAGS and RCODE fields in RFC bit order' do
|
|
36
|
+
request.response = 1
|
|
37
|
+
request.nm_flags.authoritative_answer = 1
|
|
38
|
+
request.nm_flags.broadcast = 1
|
|
39
|
+
request.rcode = 0x5
|
|
40
|
+
|
|
41
|
+
expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x8415)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'defaults every request flag bit to zero' do
|
|
45
|
+
expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x0000)
|
|
46
|
+
expect(request.response.to_i).to eq(0)
|
|
47
|
+
expect(request.opcode.to_i).to eq(0)
|
|
48
|
+
expect(request.nm_flags.broadcast.to_i).to eq(0)
|
|
49
|
+
expect(request.rcode.to_i).to eq(0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe RubySMB::Nbss::NodeStatusResponse do
|
|
4
|
+
def build_response(names)
|
|
5
|
+
data = ''.b
|
|
6
|
+
data << [0x1234].pack('n') # transaction_id
|
|
7
|
+
data << [0x8400].pack('n') # flags: response, authoritative
|
|
8
|
+
data << [0].pack('n') # qdcount
|
|
9
|
+
data << [1].pack('n') # ancount
|
|
10
|
+
data << [0].pack('n') << [0].pack('n') # nscount, arcount
|
|
11
|
+
data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name L1
|
|
12
|
+
data << [0x0021].pack('n') # RR type NBSTAT
|
|
13
|
+
data << [0x0001].pack('n') # RR class IN
|
|
14
|
+
data << [0].pack('N') # TTL
|
|
15
|
+
data << [1 + names.length * 18 + 46].pack('n') # rdlength
|
|
16
|
+
data << [names.length].pack('C')
|
|
17
|
+
names.each do |name, suffix, flags|
|
|
18
|
+
data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n')
|
|
19
|
+
end
|
|
20
|
+
data << ("\x00".b * 46) # statistics (unused)
|
|
21
|
+
data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe 'parsing' do
|
|
25
|
+
it 'decodes the name table' do
|
|
26
|
+
response = described_class.read(build_response([
|
|
27
|
+
['WIN95', 0x00, 0x0400],
|
|
28
|
+
['WIN95', 0x20, 0x0400],
|
|
29
|
+
['WORKGROUP', 0x00, 0x8400]
|
|
30
|
+
]))
|
|
31
|
+
expect(response.num_names).to eq(3)
|
|
32
|
+
expect(response.node_names[0].netbios_name.to_s.rstrip).to eq('WIN95')
|
|
33
|
+
expect(response.node_names[0].suffix).to eq(0x00)
|
|
34
|
+
expect(response.node_names[1].suffix).to eq(0x20)
|
|
35
|
+
expect(response.node_names[2].group?).to be true
|
|
36
|
+
expect(response.response.to_i).to eq(1)
|
|
37
|
+
expect(response.nm_flags.authoritative_answer.to_i).to eq(1)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe 'flags' do
|
|
42
|
+
it 'decodes the header OPCODE and NM_FLAGS fields from the second word' do
|
|
43
|
+
response = described_class.read(build_response([['WIN95', 0x20, 0x0400]]))
|
|
44
|
+
|
|
45
|
+
expect(response.response.to_i).to eq(1) # high bit of 0x8400
|
|
46
|
+
expect(response.nm_flags.authoritative_answer.to_i).to eq(1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'defines NODE_NAME flags in RFC bit order' do
|
|
50
|
+
flags = RubySMB::Nbss::NodeStatusNameFlags.new
|
|
51
|
+
flags.group = 1
|
|
52
|
+
flags.active = 1
|
|
53
|
+
|
|
54
|
+
expect(flags.to_binary_s.unpack1('n')).to eq(0x8400)
|
|
55
|
+
expect(flags.group.to_i).to eq(1)
|
|
56
|
+
expect(flags.active.to_i).to eq(1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'exposes parsed NODE_NAME flags as named fields' do
|
|
60
|
+
response = described_class.read(build_response([
|
|
61
|
+
['WORKGROUP', 0x00, 0x8400],
|
|
62
|
+
['FILESERVER', 0x20, 0x0400]
|
|
63
|
+
]))
|
|
64
|
+
|
|
65
|
+
group_entry = response.node_names[0]
|
|
66
|
+
unique_entry = response.node_names[1]
|
|
67
|
+
|
|
68
|
+
expect(group_entry.name_flags.group.to_i).to eq(1)
|
|
69
|
+
expect(group_entry.name_flags.active.to_i).to eq(1)
|
|
70
|
+
expect(group_entry.group?).to be true
|
|
71
|
+
expect(unique_entry.name_flags.group.to_i).to eq(0)
|
|
72
|
+
expect(unique_entry.unique?).to be true
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe RubySMB::Nbss::NodeStatus do
|
|
4
|
+
let(:udp_sock) { double('UDPSocket') }
|
|
5
|
+
# #recvfrom returns the sender as [address_family, port, host, numeric_host];
|
|
6
|
+
# the numeric address at index 3 is matched against the queried target.
|
|
7
|
+
let(:peer) { ['AF_INET', 137, '10.0.0.2', '10.0.0.2'] }
|
|
8
|
+
|
|
9
|
+
# Node Status requests carry a random transaction ID; the response is only
|
|
10
|
+
# accepted when it echoes that ID back. Pin it so the fixtures match.
|
|
11
|
+
before { allow(SecureRandom).to receive(:random_number).and_return(0x1234) }
|
|
12
|
+
|
|
13
|
+
def build_response(names)
|
|
14
|
+
data = ''.b
|
|
15
|
+
data << [0x1234].pack('n') # transaction_id
|
|
16
|
+
data << [0x8400].pack('n') # flags
|
|
17
|
+
data << [0].pack('n') << [1].pack('n') # qdcount, ancount
|
|
18
|
+
data << [0].pack('n') << [0].pack('n') # nscount, arcount
|
|
19
|
+
data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name
|
|
20
|
+
data << [0x0021].pack('n') << [0x0001].pack('n')
|
|
21
|
+
data << [0].pack('N') # TTL
|
|
22
|
+
data << [1 + names.length * 18 + 46].pack('n')
|
|
23
|
+
data << [names.length].pack('C')
|
|
24
|
+
names.each do |name, suffix, flags|
|
|
25
|
+
data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n')
|
|
26
|
+
end
|
|
27
|
+
data << ("\x00".b * 46)
|
|
28
|
+
data
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '.query' do
|
|
32
|
+
it 'sends the request with #send(mesg, flags, host, port) and reads via recvfrom(maxlen)' do
|
|
33
|
+
response_bytes = build_response([
|
|
34
|
+
['WIN95', 0x00, 0x0400],
|
|
35
|
+
['WIN95', 0x20, 0x0400],
|
|
36
|
+
['WORKGROUP', 0x00, 0x8400]
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
expect(udp_sock).to receive(:send) do |bytes, flags, host, port|
|
|
40
|
+
expect(flags).to eq(0)
|
|
41
|
+
expect(host).to eq('10.0.0.2')
|
|
42
|
+
expect(port).to eq(137)
|
|
43
|
+
expect(bytes.bytesize).to eq(50)
|
|
44
|
+
end
|
|
45
|
+
expect(IO).to receive(:select).and_return([udp_sock])
|
|
46
|
+
expect(udp_sock).to receive(:recvfrom).with(4096).and_return([response_bytes, peer])
|
|
47
|
+
|
|
48
|
+
entries = described_class.query('10.0.0.2', udp_socket: udp_sock)
|
|
49
|
+
expect(entries.size).to eq(3)
|
|
50
|
+
expect(entries[1].name).to eq('WIN95')
|
|
51
|
+
expect(entries[1].suffix).to eq(0x20)
|
|
52
|
+
expect(entries[1].unique?).to be true
|
|
53
|
+
expect(entries[2].group).to be true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'retries up to the configured limit before giving up' do
|
|
57
|
+
call_count = 0
|
|
58
|
+
allow(udp_sock).to receive(:send) { call_count += 1 }
|
|
59
|
+
allow(IO).to receive(:select).and_return(nil) # always time out
|
|
60
|
+
|
|
61
|
+
expect(described_class.query('10.0.0.2', retries: 4, timeout: 0.01, udp_socket: udp_sock)).to be_nil
|
|
62
|
+
expect(call_count).to eq(4)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns nil when the response can not be parsed' do
|
|
66
|
+
expect(udp_sock).to receive(:send)
|
|
67
|
+
expect(IO).to receive(:select).and_return([udp_sock])
|
|
68
|
+
expect(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, peer])
|
|
69
|
+
expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'retries after a malformed datagram and succeeds on a later attempt' do
|
|
73
|
+
good = build_response([['WIN95', 0x20, 0x0400]])
|
|
74
|
+
allow(udp_sock).to receive(:send)
|
|
75
|
+
allow(IO).to receive(:select).and_return([udp_sock])
|
|
76
|
+
# First datagram is truncated (NodeStatusResponse.read raises); the second
|
|
77
|
+
# is well-formed. The rescue must stay inside the retry loop for this.
|
|
78
|
+
allow(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, peer], [good, peer])
|
|
79
|
+
|
|
80
|
+
entries = described_class.query('10.0.0.2', retries: 3, timeout: 0.01, udp_socket: udp_sock)
|
|
81
|
+
expect(entries).not_to be_nil
|
|
82
|
+
expect(entries.first.suffix).to eq(0x20)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'raises ArgumentError when host is not a numeric IPv4 address' do
|
|
86
|
+
expect { described_class.query('example.com', udp_socket: udp_sock) }.to raise_error(ArgumentError)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'rejects a reply whose transaction ID does not match the request' do
|
|
90
|
+
allow(SecureRandom).to receive(:random_number).and_return(0x1234)
|
|
91
|
+
mismatched = build_response([['WIN95', 0x20, 0x0400]])
|
|
92
|
+
mismatched[0, 2] = [0x9999].pack('n') # overwrite transaction_id
|
|
93
|
+
expect(udp_sock).to receive(:send)
|
|
94
|
+
expect(IO).to receive(:select).and_return([udp_sock])
|
|
95
|
+
expect(udp_sock).to receive(:recvfrom).and_return([mismatched, peer])
|
|
96
|
+
expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'rejects a reply from an unexpected source address' do
|
|
100
|
+
spoofed = build_response([['WIN95', 0x20, 0x0400]])
|
|
101
|
+
expect(udp_sock).to receive(:send)
|
|
102
|
+
expect(IO).to receive(:select).and_return([udp_sock])
|
|
103
|
+
expect(udp_sock).to receive(:recvfrom).and_return([spoofed, ['AF_INET', 137, '10.0.0.9', '10.0.0.9']])
|
|
104
|
+
expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'returns nil on IOError and does not close the socket' do
|
|
108
|
+
allow(udp_sock).to receive(:send).and_raise(IOError, 'boom')
|
|
109
|
+
expect(udp_sock).not_to receive(:close)
|
|
110
|
+
expect(described_class.query('10.0.0.2', retries: 1, udp_socket: udp_sock)).to be_nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '.file_server_name' do
|
|
115
|
+
it 'returns the unique 0x20 entry' do
|
|
116
|
+
response_bytes = build_response([
|
|
117
|
+
['WORKGROUP', 0x00, 0x8400],
|
|
118
|
+
['FILESERVER', 0x20, 0x0400]
|
|
119
|
+
])
|
|
120
|
+
allow(udp_sock).to receive(:send)
|
|
121
|
+
allow(IO).to receive(:select).and_return([udp_sock])
|
|
122
|
+
allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, peer])
|
|
123
|
+
|
|
124
|
+
expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).to eq('FILESERVER')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'returns nil when no unique 0x20 entry is present' do
|
|
128
|
+
response_bytes = build_response([['HOST', 0x00, 0x0400]])
|
|
129
|
+
allow(udp_sock).to receive(:send)
|
|
130
|
+
allow(IO).to receive(:select).and_return([udp_sock])
|
|
131
|
+
allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, peer])
|
|
132
|
+
|
|
133
|
+
expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).to be_nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe RubySMB::Nbss::NodeStatus::Entry do
|
|
138
|
+
it '#to_s formats like nmblookup output' do
|
|
139
|
+
entry = described_class.new('WIN95', 0x20, false, true)
|
|
140
|
+
expect(entry.to_s).to include('WIN95')
|
|
141
|
+
expect(entry.to_s).to include('<20>')
|
|
142
|
+
expect(entry.to_s).to include('UNIQUE')
|
|
143
|
+
expect(entry.to_s).to include('ACTIVE')
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -8,7 +8,9 @@ RSpec.describe RubySMB::NTLM::Client::Session do
|
|
|
8
8
|
AGwAbwBjAGEAbAADADgAVwBJAE4ALQAzAE0AUwBQADgASwAyAEwAQwBHAEMA
|
|
9
9
|
LgBtAHMAZgBsAGEAYgAuAGwAbwBjAGEAbAAHAAgAS6UAWjxl2AEAAAAA
|
|
10
10
|
}) }
|
|
11
|
-
|
|
11
|
+
let(:username) { 'rubysmb' }
|
|
12
|
+
let(:password) { 'rubysmb' }
|
|
13
|
+
let(:client) { RubySMB::NTLM::Client.new(username, password, flags: RubySMB::NTLM::DEFAULT_CLIENT_FLAGS) }
|
|
12
14
|
subject(:session) { described_class.new(client, message) }
|
|
13
15
|
|
|
14
16
|
describe '#authenticate!' do
|
|
@@ -17,72 +19,49 @@ RSpec.describe RubySMB::NTLM::Client::Session do
|
|
|
17
19
|
session.authenticate!
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
it 'checks if it is anonymous' do
|
|
21
|
-
expect(session).to receive(:is_anonymous?).at_least(1).times.and_call_original
|
|
22
|
-
session.authenticate!
|
|
23
|
-
end
|
|
24
|
-
|
|
25
22
|
it 'returns a Type3 message' do
|
|
26
23
|
expect(session.authenticate!).to be_a Net::NTLM::Message::Type3
|
|
27
24
|
expect(session.authenticate!).to be_a Net::NTLM::Message
|
|
28
25
|
end
|
|
29
26
|
|
|
30
27
|
context 'when it is anonymous' do
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
let(:username) { '' }
|
|
29
|
+
let(:password) { '' }
|
|
33
30
|
|
|
34
31
|
it 'uses the correct lm response' do
|
|
35
|
-
expect(session).
|
|
36
|
-
expect(Net::NTLM::Message::Type3).to receive(:create).and_wrap_original do |method, params|
|
|
37
|
-
expect(params).to include :lm_response
|
|
38
|
-
expect(params[:lm_response]).to eq "\x00".b
|
|
39
|
-
method.call(params)
|
|
40
|
-
end
|
|
32
|
+
expect(session.authenticate!.lm_response).to eq "\x00".b
|
|
41
33
|
end
|
|
42
34
|
|
|
43
35
|
it 'uses the correct ntlm response' do
|
|
44
|
-
expect(session).
|
|
45
|
-
expect(Net::NTLM::Message::Type3).to receive(:create).and_wrap_original do |method, params|
|
|
46
|
-
expect(params).to include :ntlm_response
|
|
47
|
-
expect(params[:ntlm_response]).to eq ''
|
|
48
|
-
method.call(params)
|
|
49
|
-
end
|
|
36
|
+
expect(session.authenticate!.ntlm_response).to eq ''
|
|
50
37
|
end
|
|
51
38
|
end
|
|
52
39
|
|
|
53
40
|
context 'when it is not anonymous' do
|
|
54
|
-
before(:each) { allow(session).to receive(:is_anonymous?).and_return(false) }
|
|
55
|
-
after(:each) { session.authenticate! }
|
|
56
|
-
|
|
57
41
|
it 'uses the correct lm response' do
|
|
58
|
-
expect(session).to
|
|
59
|
-
expect(Net::NTLM::Message::Type3).to receive(:create).and_wrap_original do |method, params|
|
|
60
|
-
expect(params).to include :lm_response
|
|
61
|
-
expect(params[:lm_response].length).to be > 16
|
|
62
|
-
method.call(params)
|
|
63
|
-
end
|
|
42
|
+
expect(session.authenticate!.lm_response.length).to be > 16
|
|
64
43
|
end
|
|
65
44
|
|
|
66
45
|
it 'uses the correct ntlm response' do
|
|
67
|
-
expect(session).to
|
|
68
|
-
expect(Net::NTLM::Message::Type3).to receive(:create).and_wrap_original do |method, params|
|
|
69
|
-
expect(params).to include :ntlm_response
|
|
70
|
-
expect(params[:ntlm_response].length).to be > 16
|
|
71
|
-
method.call(params)
|
|
72
|
-
end
|
|
46
|
+
expect(session.authenticate!.ntlm_response.length).to be > 16
|
|
73
47
|
end
|
|
74
48
|
end
|
|
75
49
|
end
|
|
76
50
|
|
|
77
51
|
describe '#calculate_user_session_key!' do
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
52
|
+
context 'when it is anonymous' do
|
|
53
|
+
let(:username) { '' }
|
|
54
|
+
let(:password) { '' }
|
|
55
|
+
|
|
56
|
+
it 'returns an all zero key' do
|
|
57
|
+
expect(session.send(:calculate_user_session_key!)).to eq "\x00".b * 16
|
|
58
|
+
end
|
|
81
59
|
end
|
|
82
60
|
|
|
83
61
|
it 'returns a session key' do
|
|
84
|
-
|
|
85
|
-
expect(
|
|
62
|
+
session_key = session.send(:calculate_user_session_key!)
|
|
63
|
+
expect(session_key.bytesize).to eq 16
|
|
64
|
+
expect(session_key).to_not eq "\x00".b * 16
|
|
86
65
|
end
|
|
87
66
|
end
|
|
88
67
|
|