Path: blob/master/plugins/payloads_manager.rb
70314 views
require 'json'1require 'fileutils'2require 'digest'3require 'net/http'4require 'uri'5require 'time'6require 'securerandom'78module Msf9class Plugin::PayloadsManager < Msf::Plugin10def initialize(framework, opts)11super12add_console_dispatcher(PayloadsManagerCommandDispatcher)13print_status("PayloadsManager plugin loaded.")14end1516def cleanup17remove_console_dispatcher('PayloadsManager')18end1920def name21"payloads_manager"22end2324def desc25"Manages payloads for exploitation"26end2728class PayloadsManagerCommandDispatcher29include Msf::Ui::Console::CommandDispatcher3031PAYLOADS_DIR = File.join(Msf::Config.config_directory, 'payloads')32DATABASE_FILE = File.join(PAYLOADS_DIR, 'payloads.json')33MSF_METERPRETER_DIR = File.join(Msf::Config.data_directory, 'meterpreter')34MAX_FETCH_SIZE = 100 * 1024 * 10243536def initialize(driver)37super38@driver = driver39setup_directories40load_database41end4243def name44"PayloadsManager"45end4647def commands48{49'payloads_manager' => 'Manage payloads: list | add <path> [name] | fetch <url> [name] | select <id> | unselect <id> | remove <id> | help'50}51end5253def cmd_payloads_manager(*args)54subcommand = args.shift5556case subcommand57when 'list'58handle_list59when 'add'60handle_add(*args)61when 'fetch'62handle_fetch(*args)63when 'select'64handle_select(*args)65when 'unselect'66handle_unselect(*args)67when 'remove'68handle_remove(*args)69when 'help', nil70handle_help71else72print_error("Unknown subcommand: #{subcommand}")73handle_help74end75end7677private7879def handle_list80if @database.empty?81print_line("No payloads in archive.")82return83end8485tbl = Rex::Text::Table.new(86'Header' => 'Payloads',87'Indent' => 1,88'Columns' => ['ID', 'Name', 'Description', 'Tags', 'Added At', 'Last Selected At', 'Status'],89'SortIndex' => 6,90'ColProps' =>91{92'Status' => {93'Stylers' => [::Msf::Ui::Console::TablePrint::CustomColorStyler.new('Active' => '%grn', 'Inactive' => '%red')]94}95}96)9798difference_in_seconds = lambda do |time_str|99now = Time.now100return 'Never' if time_str.nil?101begin102time = Time.parse(time_str)103rescue ArgumentError, TypeError104return 'Invalid timestamp'105end106diff = now - time107if diff < 60108"#{diff.to_i} seconds ago"109elsif diff < 3600110"#{(diff / 60).to_i} minutes ago"111elsif diff < 86400112"#{(diff / 3600).to_i} hours ago"113else114"#{(diff / 86400).to_i} days ago"115end116end117118@database.each do |_id, payload|119added = difference_in_seconds.call(payload['added_at'])120last_selected = difference_in_seconds.call(payload['last_selected_at'])121tbl << [122_id.split('_').last,123payload['name'].to_s,124payload['description'].to_s,125Array(payload['tags']).join(', '),126added,127last_selected,128payload['active'] ? 'Active' : 'Inactive'129]130end131132133print_line(tbl.to_s)134end135136def handle_add(*args)137if args.empty?138print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")139return140end141142parsed = parse_subcommand_args(args)143if parsed[:error]144print_error(parsed[:error])145print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")146return147end148positional = parsed[:positional]149description = parsed[:description]150tags = parsed[:tags]151152if positional.empty?153print_error("Usage: payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")154return155end156157source_path = File.expand_path(positional[0])158unless File.exist?(source_path)159print_error("File not found: #{source_path}")160return161end162163name = positional[1] || File.basename(source_path)164id = generate_id(name)165sha256 = Digest::SHA256.file(source_path).hexdigest166dest_path = File.join(PAYLOADS_DIR, "#{id}_#{File.basename(source_path)}")167168FileUtils.cp(source_path, dest_path)169170@database[id] = {171'name' => name,172'path' => dest_path,173'sha256' => sha256,174'active' => false,175'added_at' => Time.now.to_s,176'last_selected_at' => nil,177'description' => description.to_s,178'tags' => tags179}180181save_database182print_good("Payload added: #{name} (ID: #{id})")183print_status(" Description: #{description}") if description && !description.empty?184print_status(" Tags: #{tags.join(', ')}") unless tags.empty?185end186187def handle_fetch(*args)188if args.empty?189print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")190return191end192193parsed = parse_subcommand_args(args)194if parsed[:error]195print_error(parsed[:error])196print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")197return198end199positional = parsed[:positional]200description = parsed[:description]201tags = parsed[:tags]202203if positional.empty?204print_error("Usage: payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")205return206end207208url = positional[0]209uri = URI.parse(url)210unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)211print_error("Invalid URL (must be http or https): #{url}")212return213end214215print_status("Fetching payload from #{url}...")216217begin218fetched_payload = fetch_to_archive_with_redirects(uri, positional[1])219rescue StandardError => e220print_error("Failed to fetch payload: #{e.message}")221return222end223224@database[fetched_payload[:id]] = {225'name' => fetched_payload[:name],226'path' => fetched_payload[:dest_path],227'sha256' => fetched_payload[:sha256],228'active' => false,229'added_at' => Time.now.to_s,230'last_selected_at' => nil,231'source_url' => url,232'description' => description.to_s,233'tags' => tags234}235236save_database237print_good("Payload fetched and added: #{fetched_payload[:name]} (ID: #{fetched_payload[:id]})")238print_status(" SHA256: #{fetched_payload[:sha256]}")239print_status(" Size: #{fetched_payload[:size]} bytes")240print_status(" Description: #{description}") if description && !description.empty?241print_status(" Tags: #{tags.join(', ')}") unless tags.empty?242end243244def parse_subcommand_args(args)245description = nil246tags = []247positional = []248error = nil249250i = 0251while i < args.length252case args[i]253when '--description', '-d'254i += 1255unless args[i]256error = "Missing value for #{args[i - 1]}"257break258end259description = args[i]260when '--tags', '-t'261i += 1262unless args[i]263error = "Missing value for #{args[i - 1]}"264break265end266tags = args[i].to_s.split(',').map(&:strip).reject(&:empty?) if args[i]267else268positional << args[i]269end270i += 1271end272273{274positional: positional,275description: description,276tags: tags,277error: error278}279end280281def handle_select(*args)282if args.empty?283print_error("Usage: payloads_manager select <payload_id>")284return285end286287id = args[0]288unless @database.key?(id)289print_error("Payload not found: #{id}")290return291end292293payload = @database[id]294target_link = meterpreter_target_link(payload['name'], context: "select payload '#{id}'")295return unless target_link296source_path = archived_payload_source_path(payload['path'], context: "select payload '#{id}'")297return unless source_path298299# Only deactivate payloads that target the same filename (would conflict)300@database.each do |other_id, v|301next unless v['active']302other_link = meterpreter_target_link(v['name'], context: "check active payload '#{other_id}'")303next unless other_link304if other_link == target_link305FileUtils.rm(other_link) if File.symlink?(other_link)306v['active'] = false307end308end309310# Refuse to overwrite an existing non-symlink file at the target path311if File.exist?(target_link) && !File.symlink?(target_link)312print_error("Cannot select payload '#{payload['name']}'. A non-symlink file already exists at #{target_link}. Please move or remove it and try again.")313return314end315316begin317FileUtils.rm(target_link) if File.symlink?(target_link)318FileUtils.ln_s(source_path, target_link)319rescue SystemCallError => e320print_error("Failed to activate payload '#{payload['name']}' at #{target_link}: #{e.class}: #{e.message}")321return322end323@database[id]['active'] = true324@database[id]['last_selected_at'] = Time.now.to_s325save_database326327active_count = @database.count { |_, v| v['active'] }328print_good("Payload '#{payload['name']}' selected and symlinked to #{target_link}")329print_status(" #{active_count} payload(s) currently active") if active_count > 1330end331332def handle_unselect(*args)333if args.empty?334print_error("Usage: payloads_manager unselect <payload_id>")335return336end337338id = args[0]339unless @database.key?(id)340print_error("Payload not found: #{id}")341return342end343344payload = @database[id]345unless payload['active']346print_error("Payload '#{payload['name']}' is not currently active.")347return348end349350target_link = meterpreter_target_link(payload['name'], context: "unselect payload '#{id}'")351return unless target_link352FileUtils.rm(target_link) if File.symlink?(target_link)353payload['active'] = false354save_database355356print_good("Payload '#{payload['name']}' unselected and symlink removed.")357end358359def handle_remove(*args)360if args.empty?361print_error("Usage: payloads_manager remove <payload_id>")362return363end364365id = args[0]366unless @database.key?(id)367print_error("Payload not found: #{id}")368return369end370371payload = @database[id]372if payload['active']373target_link = meterpreter_target_link(payload['name'], context: "remove payload '#{id}'")374FileUtils.rm(target_link) if target_link && File.symlink?(target_link)375payload['active'] = false376end377378payload_path = archived_payload_source_path(payload['path'], context: "remove payload '#{id}'", require_exists: false)379if payload_path && File.exist?(payload_path)380begin381File.delete(payload_path)382rescue SystemCallError => e383print_error("Failed to remove archived payload file '#{payload_path}': #{e.class}: #{e.message}")384return385end386elsif payload_path387print_status("Archived payload file not found; removing database entry only: #{payload_path}")388else389print_error("Skipping payload file deletion for '#{payload['name']}' due to invalid stored path; removing database entry only.")390end391392@database.delete(id)393save_database394395print_good("Payload removed: #{payload['name']}")396end397398def handle_help399print_status("PayloadsManager Help")400print_status("=" * 50)401print_status(" payloads_manager list")402print_status(" payloads_manager add <path_to_payload> [name] [--description <desc>] [--tags <t1,t2,...>]")403print_status(" payloads_manager fetch <url> [name] [--description <desc>] [--tags <t1,t2,...>]")404print_status(" payloads_manager select <payload_id>")405print_status(" payloads_manager unselect <payload_id>")406print_status(" payloads_manager remove <payload_id>")407print_status(" payloads_manager help")408end409410def setup_directories411FileUtils.mkdir_p(PAYLOADS_DIR) unless Dir.exist?(PAYLOADS_DIR)412FileUtils.mkdir_p(MSF_METERPRETER_DIR) unless Dir.exist?(MSF_METERPRETER_DIR)413end414415def load_database416if File.exist?(DATABASE_FILE)417begin418contents = File.read(DATABASE_FILE)419if contents.strip.empty?420@database = {}421else422@database = JSON.parse(contents)423end424rescue JSON::ParserError => e425backup_path = "#{DATABASE_FILE}.corrupted-#{Time.now.to_i}"426begin427FileUtils.mv(DATABASE_FILE, backup_path)428print_error("Failed to parse payloads database; backing up corrupted file to #{backup_path}: #{e.message}")429rescue StandardError430print_error("Failed to parse payloads database and could not back up corrupted file: #{e.message}")431end432@database = {}433end434else435@database = {}436end437end438439def save_database440File.write(DATABASE_FILE, JSON.pretty_generate(@database))441end442443def generate_id(_name)444loop do445id = SecureRandom.hex(8)446return id unless @database.key?(id)447end448end449450def meterpreter_target_link(payload_name, context: nil)451base_name = File.basename(payload_name.to_s.tr('\\', '/'))452if base_name.empty? || base_name == '.' || base_name == '..'453print_error("Invalid payload name '#{payload_name}'#{context ? " while trying to #{context}" : ''}.")454return nil455end456457meterpreter_dir = File.expand_path(MSF_METERPRETER_DIR)458target_link = File.expand_path(File.join(meterpreter_dir, base_name))459unless target_link.start_with?(meterpreter_dir + File::SEPARATOR)460print_error("Refusing to use target path outside meterpreter directory: #{target_link}")461return nil462end463464target_link465end466467def archived_payload_source_path(payload_path, context: nil, require_exists: true)468source_path = File.expand_path(payload_path.to_s)469payloads_dir = File.expand_path(PAYLOADS_DIR)470471unless source_path.start_with?(payloads_dir + File::SEPARATOR)472print_error("Refusing to use payload path outside managed payloads directory#{context ? " while trying to #{context}" : ''}: #{source_path}")473return nil474end475476if require_exists && !File.exist?(source_path)477print_error("Payload file is missing#{context ? " while trying to #{context}" : ''}: #{source_path}")478return nil479end480481source_path482end483484def fetch_to_archive_with_redirects(uri, requested_name = nil, limit = 5, max_size = MAX_FETCH_SIZE)485raise "Too many redirects" if limit == 0486487http = Net::HTTP.new(uri.host, uri.port)488http.use_ssl = (uri.scheme == 'https')489http.open_timeout = 10490http.read_timeout = 30491492request = Net::HTTP::Get.new(uri)493http.request(request) do |response|494case response495when Net::HTTPRedirection496location_header = response['location']497raise 'Redirect response missing Location header' if location_header.to_s.strip.empty?498499location = URI.parse(location_header)500location = uri + location unless location.is_a?(URI::HTTP) || location.is_a?(URI::HTTPS)501print_status(" Following redirect to #{location}")502return fetch_to_archive_with_redirects(location, requested_name, limit - 1, max_size)503when Net::HTTPSuccess504filename = derive_filename(uri, response)505name = requested_name || filename506id = generate_id(name)507dest_path = File.join(PAYLOADS_DIR, "#{id}_#{filename}")508bytes_written = 0509510begin511File.open(dest_path, 'wb') do |file|512response.read_body do |chunk|513bytes_written += chunk.bytesize514raise "Downloaded payload exceeds maximum allowed size of #{max_size} bytes" if bytes_written > max_size515516file.write(chunk)517end518end519rescue StandardError520File.delete(dest_path) if File.exist?(dest_path)521raise522end523524return {525id: id,526name: name,527dest_path: dest_path,528sha256: Digest::SHA256.file(dest_path).hexdigest,529size: bytes_written530}531else532raise "HTTP request failed: #{response.code} #{response.message}"533end534end535end536537def derive_filename(uri, response)538filename = nil539540# Try Content-Disposition header first541if (cd = response['content-disposition'])542match = cd.match(/filename="?([^";]+)"?/i)543filename = match[1].strip if match544end545546if filename.nil? || filename.empty?547# Fall back to the last segment of the URL path548path_basename = File.basename(uri.path)549filename = path_basename unless path_basename.empty? || path_basename == '/'550end551552filename = 'fetched_payload' if filename.nil? || filename.empty?553554sanitize_filename(filename)555end556557def sanitize_filename(filename)558# Normalize separators first, then keep only a safe basename.559sanitized = File.basename(filename.to_s.tr('\\', '/'))560sanitized = sanitized.gsub(/[\x00-\x1f]/, '')561sanitized = sanitized.gsub(/[^0-9A-Za-z._-]/, '_')562563return 'fetched_payload' if sanitized.empty? || sanitized == '.' || sanitized == '..'564565sanitized566end567end568end569end570571572