Skip to content

Commit c843e36

Browse files
Merge pull request #20469 from adfoster-r7/improve-kerberos-file-load-error-messages
Improve Kerberos file load error messages
2 parents 487c204 + bebb43f commit c843e36

File tree

5 files changed

+137
-30
lines changed

5 files changed

+137
-30
lines changed

lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ def initialize(
157157
credential = nil
158158
if cache_file.present?
159159
# the cache file is only used for loading credentials, it is *not* written to
160-
credential = load_credential_from_file(cache_file, sname: nil, sname_hostname: @hostname)
161-
serviceclass = build_spn.name_string.first
160+
load_sname_hostname_credential_result = load_credential_from_file(cache_file, sname: nil, sname_hostname: @hostname)
161+
credential = load_sname_hostname_credential_result[:credential]
162+
serviceclass = build_spn&.name_string&.first
162163
if credential && credential.server.components[0] != serviceclass
163164
old_sname = credential.server.components.snapshot.join('/')
164165
credential.server.components[0] = serviceclass
@@ -168,9 +169,18 @@ def initialize(
168169
ticket.sname.name_string[0] = serviceclass
169170
credential.ticket = ticket.encode
170171
elsif credential.nil? && hostname.present?
171-
credential = load_credential_from_file(cache_file, sname: "krbtgt/#{hostname.split('.', 2).last}")
172+
load_sname_krbtgt_hostname_credential_result = load_credential_from_file(cache_file, sname: "krbtgt/#{hostname.split('.', 2).last}")
173+
credential = load_sname_krbtgt_hostname_credential_result[:credential]
172174
end
173175
if credential.nil?
176+
print_error("Failed to load a usable credential from ticket file: #{cache_file}")
177+
print_error("Attempt failed to find a valid credential in #{cache_file} for #{load_sname_hostname_credential_result[:filter].map { |k, v| "#{k}=#{v.inspect}" }.join(', ')}:")
178+
print_error(load_sname_hostname_credential_result[:filter_reasons].join("\n").indent(2))
179+
180+
if load_sname_krbtgt_hostname_credential_result
181+
print_error("Attempt failed to find a valid credential in #{cache_file} for #{load_sname_krbtgt_hostname_credential_result[:filter].map { |k, v| "#{k}=#{v.inspect}" }.join(', ')}")
182+
print_error(load_sname_krbtgt_hostname_credential_result[:filter_reasons].join("\n").indent(2))
183+
end
174184
raise ::Rex::Proto::Kerberos::Model::Error::KerberosError.new("Failed to load a usable credential from ticket file: #{cache_file}")
175185
end
176186
print_status("Loaded a credential from ticket file: #{cache_file}")
@@ -362,7 +372,7 @@ def build_spn(options = {})
362372
# @return [Rex::Proto::Kerberos::CredentialCache::Krb5CcacheCredential] The ccache credential
363373
def request_tgt_only(options = {})
364374
if options[:cache_file]
365-
credential = load_credential_from_file(options[:cache_file])
375+
credential = load_credential_from_file(options[:cache_file])&.fetch(:credential, nil)
366376
else
367377
credential = get_cached_credential(
368378
options.merge(
@@ -1058,64 +1068,72 @@ def get_cached_credential(options = {})
10581068
# Load a credential object from a file for authentication. Credentials in the file will be filtered by multiple
10591069
# attributes including their timestamps to ensure that the returned credential appears usable.
10601070
#
1061-
# @param [String] file_path The file path to load a credential object from
1062-
# @return [Rex::Proto::Kerberos::CredentialCache::Krb5CacheCredential] the credential object for authentication
1063-
def load_credential_from_file(file_path, options = {})
1064-
unless File.readable?(file_path.to_s)
1065-
wlog("Failed to load ticket file '#{file_path}' (file not readable)")
1071+
# @param [String] path The path to load a credential object from
1072+
# @return [Hash] :credential [Rex::Proto::Kerberos::CredentialCache::Krb5CacheCredential] the credential object for authentication
1073+
# @return [Hash] :filter_reasons [Array<String>] the reasons for filtering tickets
1074+
def load_credential_from_file(path, options = {})
1075+
unless File.readable?(path.to_s)
10661076
return nil
10671077
end
10681078

10691079
begin
1070-
cache = Rex::Proto::Kerberos::CredentialCache::Krb5Ccache.read(File.binread(file_path))
1080+
cache = Rex::Proto::Kerberos::CredentialCache::Krb5Ccache.read(File.binread(path))
10711081
rescue StandardError => e
1072-
elog("Failed to load ticket file '#{file_path}' (parsing failed)", error: e)
1082+
elog("Failed to load ticket file '#{path}' (parsing failed)", error: e)
10731083
return nil
10741084
end
10751085

10761086
sname = options.fetch(:sname) { build_spn&.to_s }
10771087
sname_hostname = options.fetch(:sname_hostname, nil)
10781088
now = Time.now.utc
10791089

1090+
filter = {
1091+
realm: @realm,
1092+
sname: sname,
1093+
sname_hostname: sname_hostname
1094+
}.merge(options)
1095+
filter_reasons = []
1096+
10801097
cache.credentials.to_ary.each.with_index(1) do |credential, index|
10811098
tkt_start = credential.starttime == Time.at(0).utc ? credential.authtime : credential.starttime
10821099
tkt_end = credential.endtime
1100+
filter_reason_prefix = "Filtered credential #{path} ##{index} reason: "
10831101

10841102
unless tkt_start < now
1085-
wlog("Filtered credential #{file_path} ##{index} reason: Ticket start time is before now (start: #{tkt_start})")
1103+
filter_reasons << "#{filter_reason_prefix}Ticket start time is before now (start: #{tkt_start})"
10861104
next
10871105
end
10881106

10891107
unless now < tkt_end
1090-
wlog("Filtered credential #{file_path} ##{index} reason: Ticket is expired (expiration: #{tkt_end})")
1108+
filter_reasons << "#{filter_reason_prefix}Ticket is expired (expiration: #{tkt_end})"
10911109
next
10921110
end
10931111

10941112
unless !@realm || @realm.casecmp?(credential.server.realm.to_s)
1095-
wlog("Filtered credential #{file_path} ##{index} reason: Realm (#{@realm}) does not match (realm: #{credential.server.realm})")
1113+
filter_reasons << "#{filter_reason_prefix} Realm (#{@realm}) does not match (realm: #{credential.server.realm})"
10961114
next
10971115
end
10981116

10991117
unless !sname || sname.to_s.casecmp?(credential.server.components.snapshot.join('/'))
1100-
wlog("Filtered credential #{file_path} ##{index} reason: SPN (#{sname}) does not match (spn: #{credential.server.components.snapshot.join('/')})")
1118+
filter_reasons << "#{filter_reason_prefix}SPN (#{sname}) does not match (spn: #{credential.server.components.snapshot.join('/')})"
11011119
next
11021120
end
11031121

11041122
unless !sname_hostname ||
1105-
sname_hostname.to_s.downcase == credential.server.components[1].downcase ||
1106-
sname_hostname.to_s.downcase.ends_with?('.' + credential.server.components[1].downcase)
1107-
wlog("Filtered credential #{file_path} ##{index} reason: SPN (#{sname_hostname}) hostname does not match (spn: #{credential.server.components.snapshot.join('/')})")
1123+
sname_hostname.to_s.downcase == credential.server.components[1].downcase ||
1124+
sname_hostname.to_s.downcase.ends_with?('.' + credential.server.components[1].downcase)
1125+
filter_reasons << "#{filter_reason_prefix}SPN (#{sname_hostname}) hostname does not match (spn: #{credential.server.components.snapshot.join('/')})"
11081126
next
11091127
end
11101128

11111129
unless !@username || @username.casecmp?(credential.client.components.last.to_s)
1112-
wlog("Filtered credential #{file_path} ##{index} reason: Username (#{@username}) does not match (username: #{credential.client.components.last})")
1130+
filter_reasons << "Filtered credential #{path} ##{index} reason: Username (#{@username}) does not match (username: #{credential.client.components.last})"
11131131
next
11141132
end
11151133

1116-
return credential
1134+
return { credential: credential, filter: filter, filter_reasons: filter_reasons }
11171135
end
11181136

1119-
nil
1137+
{ credential: nil, filter: filter, filter_reasons: filter_reasons }
11201138
end
11211139
end
Binary file not shown.
Binary file not shown.

spec/lib/msf/core/exploit/remote/kerberos/service_authenticator/base_spec.rb

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
require 'spec_helper'
33

44
RSpec.describe Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base do
5-
let(:params) {
5+
include_context 'Msf::UIDriver'
6+
7+
let(:default_params) {
68
{
79
realm: 'demo.local',
810
hostname: 'mock_hostname',
@@ -11,22 +13,56 @@
1113
host: '127.0.0.1',
1214
port: 88,
1315
timeout: 25,
14-
framework: instance_double(::Msf::Framework),
15-
framework_module: instance_double(::Msf::Module)
16+
framework:,
17+
framework_module:
1618
}
1719
}
1820

21+
let(:framework) do
22+
instance_double(::Msf::Framework)
23+
end
24+
25+
let(:framework_module) do
26+
instance_double(::Msf::Module, framework:)
27+
end
28+
29+
let(:params) do
30+
default_params
31+
end
32+
1933
subject do
2034
described_class.new(**params)
2135
end
2236

23-
describe '#connect' do
24-
before(:each) do
25-
allow(params[:framework_module]).to receive(:framework)
26-
allow(params[:framework_module]).to receive(:print_status)
27-
allow(params[:framework_module]).to receive(:vprint_status)
37+
def fixture(name)
38+
File.join(FILE_FIXTURES_PATH, 'ccache', "#{name}.ccache")
39+
end
40+
41+
before(:each) do
42+
capture_logging(framework_module, capture_verbose: true)
43+
end
44+
45+
describe '#initialize' do
46+
context 'when a cache_file is provided' do
47+
let(:params) do
48+
default_params.merge({ cache_file: fixture(:fake_user_example_local_forged_silver) })
49+
end
50+
51+
it 'raises an error and logs details' do
52+
expect { subject }.to raise_error(/Failed to load a usable credential from ticket/)
53+
54+
expect(@combined_output.join("\n")).to match_table <<~TABLE
55+
Failed to load a usable credential from ticket file: #{params[:cache_file]}
56+
Attempt failed to find a valid credential in #{params[:cache_file]} for realm="demo.local", sname=nil, sname_hostname="mock_hostname":
57+
Filtered credential #{params[:cache_file]} #1 reason: Realm (demo.local) does not match (realm: EXAMPLE.LOCAL)
58+
Attempt failed to find a valid credential in #{params[:cache_file]} for realm="demo.local", sname="krbtgt/mock_hostname", sname_hostname=nil
59+
Filtered credential #{params[:cache_file]} #1 reason: Realm (demo.local) does not match (realm: EXAMPLE.LOCAL)
60+
TABLE
61+
end
2862
end
63+
end
2964

65+
describe '#connect' do
3066
context 'when host is nil' do
3167
it 'resolves it to a hostname' do
3268
expect(::Rex::Socket).to receive(:getresources).with("_kerberos._tcp.#{params[:realm]}", :SRV).and_return(['mock_host'])
@@ -83,4 +119,52 @@
83119
end
84120
end
85121
end
122+
123+
describe '#load_credential_from_file' do
124+
context 'when a valid ticket is found' do
125+
let(:params) do
126+
default_params.merge({ realm: 'example.local', username: 'fake_user' })
127+
end
128+
129+
it 'returns a valid credential that matches' do
130+
expected = hash_including(
131+
{
132+
credential: instance_of(Rex::Proto::Kerberos::CredentialCache::Krb5CcacheCredential),
133+
filter_reasons: []
134+
}
135+
)
136+
expect(subject.send(:load_credential_from_file, fixture(:fake_user_example_local_forged_silver))).to match(expected)
137+
end
138+
end
139+
140+
context 'when the credential is not valid' do
141+
it 'returns nil if the file does not exist' do
142+
expect(subject.send(:load_credential_from_file, nil)).to eq nil
143+
end
144+
145+
it 'returns filter reasons for expired tickets' do
146+
expected = hash_including(
147+
{
148+
credential: nil,
149+
filter_reasons: [
150+
match(/Filtered credential.*Ticket is expired.*/)
151+
]
152+
}
153+
)
154+
expect(subject.send(:load_credential_from_file, fixture(:administrator_dev_demo_local_expired))).to match(expected)
155+
end
156+
157+
it 'returns filter reasons for incorrect realms' do
158+
expected = hash_including(
159+
{
160+
credential: nil,
161+
filter_reasons: [
162+
match(/Filtered credential.*Realm \(demo.local\) does not match \(realm: EXAMPLE.LOCAL\).*/)
163+
]
164+
}
165+
)
166+
expect(subject.send(:load_credential_from_file, fixture(:fake_user_example_local_forged_silver))).to match(expected)
167+
end
168+
end
169+
end
86170
end

spec/support/shared/contexts/msf/ui_driver.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def reset_logging!
2727
@combined_output = []
2828
end
2929

30-
def capture_logging(target)
30+
def capture_logging(target, capture_verbose: false)
3131
append_output = proc do |string = ''|
3232
lines = string.split("\n")
3333
@output ||= []
@@ -48,6 +48,11 @@ def capture_logging(target)
4848
allow(target).to receive(:print_status, &append_output)
4949
allow(target).to receive(:print_good, &append_output)
5050

51+
if capture_verbose
52+
allow(target).to receive(:vprint_status, &append_output)
53+
allow(target).to receive(:vprint_error, &append_error)
54+
end
55+
5156
allow(target).to receive(:print_warning, &append_error)
5257
allow(target).to receive(:print_error, &append_error)
5358
allow(target).to receive(:print_bad, &append_error)

0 commit comments

Comments
 (0)