Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/plugins/payloads_manager.rb
70314 views
1
require 'json'
2
require 'fileutils'
3
require 'digest'
4
require 'net/http'
5
require 'uri'
6
require 'time'
7
require 'securerandom'
8
9
module Msf
10
class Plugin::PayloadsManager < Msf::Plugin
11
def initialize(framework, opts)
12
super
13
add_console_dispatcher(PayloadsManagerCommandDispatcher)
14
print_status("PayloadsManager plugin loaded.")
15
end
16
17
def cleanup
18
remove_console_dispatcher('PayloadsManager')
19
end
20
21
def name
22
"payloads_manager"
23
end
24
25
def desc
26
"Manages payloads for exploitation"
27
end
28
29
class PayloadsManagerCommandDispatcher
30
include Msf::Ui::Console::CommandDispatcher
31
32
PAYLOADS_DIR = File.join(Msf::Config.config_directory, 'payloads')
33
DATABASE_FILE = File.join(PAYLOADS_DIR, 'payloads.json')
34
MSF_METERPRETER_DIR = File.join(Msf::Config.data_directory, 'meterpreter')
35
MAX_FETCH_SIZE = 100 * 1024 * 1024
36
37
def initialize(driver)
38
super
39
@driver = driver
40
setup_directories
41
load_database
42
end
43
44
def name
45
"PayloadsManager"
46
end
47
48
def commands
49
{
50
'payloads_manager' => 'Manage payloads: list | add <path> [name] | fetch <url> [name] | select <id> | unselect <id> | remove <id> | help'
51
}
52
end
53
54
def cmd_payloads_manager(*args)
55
subcommand = args.shift
56
57
case subcommand
58
when 'list'
59
handle_list
60
when 'add'
61
handle_add(*args)
62
when 'fetch'
63
handle_fetch(*args)
64
when 'select'
65
handle_select(*args)
66
when 'unselect'
67
handle_unselect(*args)
68
when 'remove'
69
handle_remove(*args)
70
when 'help', nil
71
handle_help
72
else
73
print_error("Unknown subcommand: #{subcommand}")
74
handle_help
75
end
76
end
77
78
private
79
80
def handle_list
81
if @database.empty?
82
print_line("No payloads in archive.")
83
return
84
end
85
86
tbl = Rex::Text::Table.new(
87
'Header' => 'Payloads',
88
'Indent' => 1,
89
'Columns' => ['ID', 'Name', 'Description', 'Tags', 'Added At', 'Last Selected At', 'Status'],
90
'SortIndex' => 6,
91
'ColProps' =>
92
{
93
'Status' => {
94
'Stylers' => [::Msf::Ui::Console::TablePrint::CustomColorStyler.new('Active' => '%grn', 'Inactive' => '%red')]
95
}
96
}
97
)
98
99
difference_in_seconds = lambda do |time_str|
100
now = Time.now
101
return 'Never' if time_str.nil?
102
begin
103
time = Time.parse(time_str)
104
rescue ArgumentError, TypeError
105
return 'Invalid timestamp'
106
end
107
diff = now - time
108
if diff < 60
109
"#{diff.to_i} seconds ago"
110
elsif diff < 3600
111
"#{(diff / 60).to_i} minutes ago"
112
elsif diff < 86400
113
"#{(diff / 3600).to_i} hours ago"
114
else
115
"#{(diff / 86400).to_i} days ago"
116
end
117
end
118
119
@database.each do |_id, payload|
120
added = difference_in_seconds.call(payload['added_at'])
121
last_selected = difference_in_seconds.call(payload['last_selected_at'])
122
tbl << [
123
_id.split('_').last,
124
payload['name'].to_s,
125
payload['description'].to_s,
126
Array(payload['tags']).join(', '),
127
added,
128
last_selected,
129
payload['active'] ? 'Active' : 'Inactive'
130
]
131
end
132
133
134
print_line(tbl.to_s)
135
end
136
137
def handle_add(*args)
138
if args.empty?
139
print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")
140
return
141
end
142
143
parsed = parse_subcommand_args(args)
144
if parsed[:error]
145
print_error(parsed[:error])
146
print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")
147
return
148
end
149
positional = parsed[:positional]
150
description = parsed[:description]
151
tags = parsed[:tags]
152
153
if positional.empty?
154
print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")
155
return
156
end
157
158
source_path = File.expand_path(positional[0])
159
unless File.exist?(source_path)
160
print_error("File not found: #{source_path}")
161
return
162
end
163
164
name = positional[1] || File.basename(source_path)
165
id = generate_id(name)
166
sha256 = Digest::SHA256.file(source_path).hexdigest
167
dest_path = File.join(PAYLOADS_DIR, "#{id}_#{File.basename(source_path)}")
168
169
FileUtils.cp(source_path, dest_path)
170
171
@database[id] = {
172
'name' => name,
173
'path' => dest_path,
174
'sha256' => sha256,
175
'active' => false,
176
'added_at' => Time.now.to_s,
177
'last_selected_at' => nil,
178
'description' => description.to_s,
179
'tags' => tags
180
}
181
182
save_database
183
print_good("Payload added: #{name} (ID: #{id})")
184
print_status(" Description: #{description}") if description && !description.empty?
185
print_status(" Tags: #{tags.join(', ')}") unless tags.empty?
186
end
187
188
def handle_fetch(*args)
189
if args.empty?
190
print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")
191
return
192
end
193
194
parsed = parse_subcommand_args(args)
195
if parsed[:error]
196
print_error(parsed[:error])
197
print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")
198
return
199
end
200
positional = parsed[:positional]
201
description = parsed[:description]
202
tags = parsed[:tags]
203
204
if positional.empty?
205
print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")
206
return
207
end
208
209
url = positional[0]
210
uri = URI.parse(url)
211
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
212
print_error("Invalid URL (must be http or https): #{url}")
213
return
214
end
215
216
print_status("Fetching payload from #{url}...")
217
218
begin
219
fetched_payload = fetch_to_archive_with_redirects(uri, positional[1])
220
rescue StandardError => e
221
print_error("Failed to fetch payload: #{e.message}")
222
return
223
end
224
225
@database[fetched_payload[:id]] = {
226
'name' => fetched_payload[:name],
227
'path' => fetched_payload[:dest_path],
228
'sha256' => fetched_payload[:sha256],
229
'active' => false,
230
'added_at' => Time.now.to_s,
231
'last_selected_at' => nil,
232
'source_url' => url,
233
'description' => description.to_s,
234
'tags' => tags
235
}
236
237
save_database
238
print_good("Payload fetched and added: #{fetched_payload[:name]} (ID: #{fetched_payload[:id]})")
239
print_status(" SHA256: #{fetched_payload[:sha256]}")
240
print_status(" Size: #{fetched_payload[:size]} bytes")
241
print_status(" Description: #{description}") if description && !description.empty?
242
print_status(" Tags: #{tags.join(', ')}") unless tags.empty?
243
end
244
245
def parse_subcommand_args(args)
246
description = nil
247
tags = []
248
positional = []
249
error = nil
250
251
i = 0
252
while i < args.length
253
case args[i]
254
when '--description', '-d'
255
i += 1
256
unless args[i]
257
error = "Missing value for #{args[i - 1]}"
258
break
259
end
260
description = args[i]
261
when '--tags', '-t'
262
i += 1
263
unless args[i]
264
error = "Missing value for #{args[i - 1]}"
265
break
266
end
267
tags = args[i].to_s.split(',').map(&:strip).reject(&:empty?) if args[i]
268
else
269
positional << args[i]
270
end
271
i += 1
272
end
273
274
{
275
positional: positional,
276
description: description,
277
tags: tags,
278
error: error
279
}
280
end
281
282
def handle_select(*args)
283
if args.empty?
284
print_error("Usage: payloads_manager select <payload_id>")
285
return
286
end
287
288
id = args[0]
289
unless @database.key?(id)
290
print_error("Payload not found: #{id}")
291
return
292
end
293
294
payload = @database[id]
295
target_link = meterpreter_target_link(payload['name'], context: "select payload '#{id}'")
296
return unless target_link
297
source_path = archived_payload_source_path(payload['path'], context: "select payload '#{id}'")
298
return unless source_path
299
300
# Only deactivate payloads that target the same filename (would conflict)
301
@database.each do |other_id, v|
302
next unless v['active']
303
other_link = meterpreter_target_link(v['name'], context: "check active payload '#{other_id}'")
304
next unless other_link
305
if other_link == target_link
306
FileUtils.rm(other_link) if File.symlink?(other_link)
307
v['active'] = false
308
end
309
end
310
311
# Refuse to overwrite an existing non-symlink file at the target path
312
if File.exist?(target_link) && !File.symlink?(target_link)
313
print_error("Cannot select payload '#{payload['name']}'. A non-symlink file already exists at #{target_link}. Please move or remove it and try again.")
314
return
315
end
316
317
begin
318
FileUtils.rm(target_link) if File.symlink?(target_link)
319
FileUtils.ln_s(source_path, target_link)
320
rescue SystemCallError => e
321
print_error("Failed to activate payload '#{payload['name']}' at #{target_link}: #{e.class}: #{e.message}")
322
return
323
end
324
@database[id]['active'] = true
325
@database[id]['last_selected_at'] = Time.now.to_s
326
save_database
327
328
active_count = @database.count { |_, v| v['active'] }
329
print_good("Payload '#{payload['name']}' selected and symlinked to #{target_link}")
330
print_status(" #{active_count} payload(s) currently active") if active_count > 1
331
end
332
333
def handle_unselect(*args)
334
if args.empty?
335
print_error("Usage: payloads_manager unselect <payload_id>")
336
return
337
end
338
339
id = args[0]
340
unless @database.key?(id)
341
print_error("Payload not found: #{id}")
342
return
343
end
344
345
payload = @database[id]
346
unless payload['active']
347
print_error("Payload '#{payload['name']}' is not currently active.")
348
return
349
end
350
351
target_link = meterpreter_target_link(payload['name'], context: "unselect payload '#{id}'")
352
return unless target_link
353
FileUtils.rm(target_link) if File.symlink?(target_link)
354
payload['active'] = false
355
save_database
356
357
print_good("Payload '#{payload['name']}' unselected and symlink removed.")
358
end
359
360
def handle_remove(*args)
361
if args.empty?
362
print_error("Usage: payloads_manager remove <payload_id>")
363
return
364
end
365
366
id = args[0]
367
unless @database.key?(id)
368
print_error("Payload not found: #{id}")
369
return
370
end
371
372
payload = @database[id]
373
if payload['active']
374
target_link = meterpreter_target_link(payload['name'], context: "remove payload '#{id}'")
375
FileUtils.rm(target_link) if target_link && File.symlink?(target_link)
376
payload['active'] = false
377
end
378
379
payload_path = archived_payload_source_path(payload['path'], context: "remove payload '#{id}'", require_exists: false)
380
if payload_path && File.exist?(payload_path)
381
begin
382
File.delete(payload_path)
383
rescue SystemCallError => e
384
print_error("Failed to remove archived payload file '#{payload_path}': #{e.class}: #{e.message}")
385
return
386
end
387
elsif payload_path
388
print_status("Archived payload file not found; removing database entry only: #{payload_path}")
389
else
390
print_error("Skipping payload file deletion for '#{payload['name']}' due to invalid stored path; removing database entry only.")
391
end
392
393
@database.delete(id)
394
save_database
395
396
print_good("Payload removed: #{payload['name']}")
397
end
398
399
def handle_help
400
print_status("PayloadsManager Help")
401
print_status("=" * 50)
402
print_status(" payloads_manager list")
403
print_status(" payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")
404
print_status(" payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")
405
print_status(" payloads_manager select <payload_id>")
406
print_status(" payloads_manager unselect <payload_id>")
407
print_status(" payloads_manager remove <payload_id>")
408
print_status(" payloads_manager help")
409
end
410
411
def setup_directories
412
FileUtils.mkdir_p(PAYLOADS_DIR) unless Dir.exist?(PAYLOADS_DIR)
413
FileUtils.mkdir_p(MSF_METERPRETER_DIR) unless Dir.exist?(MSF_METERPRETER_DIR)
414
end
415
416
def load_database
417
if File.exist?(DATABASE_FILE)
418
begin
419
contents = File.read(DATABASE_FILE)
420
if contents.strip.empty?
421
@database = {}
422
else
423
@database = JSON.parse(contents)
424
end
425
rescue JSON::ParserError => e
426
backup_path = "#{DATABASE_FILE}.corrupted-#{Time.now.to_i}"
427
begin
428
FileUtils.mv(DATABASE_FILE, backup_path)
429
print_error("Failed to parse payloads database; backing up corrupted file to #{backup_path}: #{e.message}")
430
rescue StandardError
431
print_error("Failed to parse payloads database and could not back up corrupted file: #{e.message}")
432
end
433
@database = {}
434
end
435
else
436
@database = {}
437
end
438
end
439
440
def save_database
441
File.write(DATABASE_FILE, JSON.pretty_generate(@database))
442
end
443
444
def generate_id(_name)
445
loop do
446
id = SecureRandom.hex(8)
447
return id unless @database.key?(id)
448
end
449
end
450
451
def meterpreter_target_link(payload_name, context: nil)
452
base_name = File.basename(payload_name.to_s.tr('\\', '/'))
453
if base_name.empty? || base_name == '.' || base_name == '..'
454
print_error("Invalid payload name '#{payload_name}'#{context ? " while trying to #{context}" : ''}.")
455
return nil
456
end
457
458
meterpreter_dir = File.expand_path(MSF_METERPRETER_DIR)
459
target_link = File.expand_path(File.join(meterpreter_dir, base_name))
460
unless target_link.start_with?(meterpreter_dir + File::SEPARATOR)
461
print_error("Refusing to use target path outside meterpreter directory: #{target_link}")
462
return nil
463
end
464
465
target_link
466
end
467
468
def archived_payload_source_path(payload_path, context: nil, require_exists: true)
469
source_path = File.expand_path(payload_path.to_s)
470
payloads_dir = File.expand_path(PAYLOADS_DIR)
471
472
unless source_path.start_with?(payloads_dir + File::SEPARATOR)
473
print_error("Refusing to use payload path outside managed payloads directory#{context ? " while trying to #{context}" : ''}: #{source_path}")
474
return nil
475
end
476
477
if require_exists && !File.exist?(source_path)
478
print_error("Payload file is missing#{context ? " while trying to #{context}" : ''}: #{source_path}")
479
return nil
480
end
481
482
source_path
483
end
484
485
def fetch_to_archive_with_redirects(uri, requested_name = nil, limit = 5, max_size = MAX_FETCH_SIZE)
486
raise "Too many redirects" if limit == 0
487
488
http = Net::HTTP.new(uri.host, uri.port)
489
http.use_ssl = (uri.scheme == 'https')
490
http.open_timeout = 10
491
http.read_timeout = 30
492
493
request = Net::HTTP::Get.new(uri)
494
http.request(request) do |response|
495
case response
496
when Net::HTTPRedirection
497
location_header = response['location']
498
raise 'Redirect response missing Location header' if location_header.to_s.strip.empty?
499
500
location = URI.parse(location_header)
501
location = uri + location unless location.is_a?(URI::HTTP) || location.is_a?(URI::HTTPS)
502
print_status(" Following redirect to #{location}")
503
return fetch_to_archive_with_redirects(location, requested_name, limit - 1, max_size)
504
when Net::HTTPSuccess
505
filename = derive_filename(uri, response)
506
name = requested_name || filename
507
id = generate_id(name)
508
dest_path = File.join(PAYLOADS_DIR, "#{id}_#{filename}")
509
bytes_written = 0
510
511
begin
512
File.open(dest_path, 'wb') do |file|
513
response.read_body do |chunk|
514
bytes_written += chunk.bytesize
515
raise "Downloaded payload exceeds maximum allowed size of #{max_size} bytes" if bytes_written > max_size
516
517
file.write(chunk)
518
end
519
end
520
rescue StandardError
521
File.delete(dest_path) if File.exist?(dest_path)
522
raise
523
end
524
525
return {
526
id: id,
527
name: name,
528
dest_path: dest_path,
529
sha256: Digest::SHA256.file(dest_path).hexdigest,
530
size: bytes_written
531
}
532
else
533
raise "HTTP request failed: #{response.code} #{response.message}"
534
end
535
end
536
end
537
538
def derive_filename(uri, response)
539
filename = nil
540
541
# Try Content-Disposition header first
542
if (cd = response['content-disposition'])
543
match = cd.match(/filename="?([^";]+)"?/i)
544
filename = match[1].strip if match
545
end
546
547
if filename.nil? || filename.empty?
548
# Fall back to the last segment of the URL path
549
path_basename = File.basename(uri.path)
550
filename = path_basename unless path_basename.empty? || path_basename == '/'
551
end
552
553
filename = 'fetched_payload' if filename.nil? || filename.empty?
554
555
sanitize_filename(filename)
556
end
557
558
def sanitize_filename(filename)
559
# Normalize separators first, then keep only a safe basename.
560
sanitized = File.basename(filename.to_s.tr('\\', '/'))
561
sanitized = sanitized.gsub(/[\x00-\x1f]/, '')
562
sanitized = sanitized.gsub(/[^0-9A-Za-z._-]/, '_')
563
564
return 'fetched_payload' if sanitized.empty? || sanitized == '.' || sanitized == '..'
565
566
sanitized
567
end
568
end
569
end
570
end
571
572