1
mirror of https://github.com/rapid7/metasploit-payloads synced 2024-11-20 14:39:22 +01:00

Add RSpec & raising errors when files are inaccessible

This commit is contained in:
sjanusz-r7 2023-09-11 18:02:39 +01:00
parent 31c7726995
commit de45e2f3b8
7 changed files with 487 additions and 4 deletions

1
gem/.gitignore vendored
View File

@ -7,6 +7,7 @@ InstalledFiles
lib/bundler/man
rdoc
spec/reports
spec/examples.txt
test/tmp
test/version_tmp
tmp

1
gem/.rspec Normal file
View File

@ -0,0 +1 @@
--require spec_helper

View File

@ -2,3 +2,7 @@ source 'https://rubygems.org'
# Specify your gem's dependencies in meterpreter_binaries.gemspec
gemspec
group :test do
gem 'rspec'
end

View File

@ -1,6 +1,8 @@
# -*- coding:binary -*-
require 'openssl' unless defined? OpenSSL::Digest
require 'metasploit-payloads/version' unless defined? MetasploitPayloads::VERSION
require 'metasploit-payloads/error' unless defined? MetasploitPayloads::Error
#
# This module dispenses Metasploit payload binary files
@ -10,6 +12,56 @@ module MetasploitPayloads
METERPRETER_SUBFOLDER = 'meterpreter'
USER_DATA_SUBFOLDER = 'payloads'
#
# @return [Array<Hash<String, Symbol>>] An array of filenames with warnings. Provides a file name and error.
# Empty if all needed Meterpreter files exist and have the correct hash.
def self.manifest_errors
manifest_errors = []
begin
manifest_contents = ::File.binread(manifest_path)
rescue => e
return [{ path: manifest_path, error: e }]
end
begin
manifest_uuid_contents = ::File.binread(manifest_uuid_path)
rescue => e
manifest_errors.append({ path: manifest_uuid_path, error: e })
end
# Check if the hash of the manifest file is correct.
if manifest_uuid_contents
manifest_digest = ::OpenSSL::Digest.new('SHA3-256', manifest_contents)
uuid_matches = (manifest_uuid_contents.chomp == manifest_digest.to_s)
unless uuid_matches
e = ::MetasploitPayloads::HashMismatchError.new(manifest_path)
manifest_errors.append({ path: manifest_path, error: e })
end
end
manifest_contents.each_line do |line|
filename, hash_type, hash = line.chomp.split(':')
begin
# self.path prepends the gem data directory, which is already present in the manifest file.
out_path = self.path(filename.sub('./data/', ''))
# self.path can return a path to the gem data, or user's local data.
bundled_file = out_path.start_with?(data_directory)
if bundled_file
file_hash_match = (::OpenSSL::Digest.new(hash_type, ::File.binread(out_path)).to_s == hash)
unless file_hash_match
e = ::MetasploitPayloads::HashMismatchError.new(out_path)
manifest_errors.append({ path: e.path, error: e })
end
end
rescue ::MetasploitPayloads::NotFoundError, ::MetasploitPayloads::NotReadableError => e
manifest_errors.append({ path: e.path, error: e })
end
end
manifest_errors
end
#
# Get the path to an extension based on its name (no prefix).
#
@ -17,6 +69,14 @@ module MetasploitPayloads
path(METERPRETER_SUBFOLDER, "#{EXTENSION_PREFIX}#{ext_name}.#{binary_suffix}")
end
#
# Get the path for the first readable path in the provided arguments.
# Start with the provided `extra_paths` then fall back to the `gem_path`.
#
# @param [String] gem_path a path to the gem
# @param [Array<String>] extra_paths a path to any extra paths that should be evaluated for local files before `gem_path`
# @raise [NotReadableError] if the user doesn't have read permissions for the currently-evaluated path
# @return [String,nil] A readable path or nil
def self.readable_path(gem_path, *extra_paths)
# Try the MSF path first to see if the file exists, allowing the MSF data
# folder to override what is in the gem. This is very helpful for
@ -24,12 +84,18 @@ module MetasploitPayloads
# each time. We only do this is MSF is installed.
extra_paths.each do |extra_path|
if ::File.readable? extra_path
warn_local_path(extra_path) if ::File.readable? gem_path
warn_local_path(extra_path)
return extra_path
else
# Raise rather than falling back;
# If there is a local file present, let's assume that the user wants to use it (e.g. local dev. changes)
# rather than having MSF Console falling back to the files in the gem
raise ::MetasploitPayloads::NotReadableError, extra_path, caller if ::File.exist?(extra_path)
end
end
return gem_path if ::File.readable? gem_path
raise ::MetasploitPayloads::NotReadableError, gem_path, caller if ::File.exist?(gem_path)
nil
end
@ -37,21 +103,36 @@ module MetasploitPayloads
#
# Get the path to a meterpreter binary by full name.
#
# @param [String] name The name of the requested binary without any file extensions
# @param [String] binary_suffix The binary extension, without the leading '.' char (e.g. `php`, `jar`)
# @param [Boolean] debug Request a debug version of the binary. This adds a
# leading '.debug' to the extension if looking for a DLL file.
def self.meterpreter_path(name, binary_suffix, debug: false)
binary_suffix = binary_suffix&.gsub(/dll$/, 'debug.dll') if debug
path(METERPRETER_SUBFOLDER, "#{name}.#{binary_suffix}".downcase)
end
#
# Get the full path to any file packaged in this gem by local path and name.
# Get the full path to any file packaged in this gem or other Metasploit Framework directories by local path and name.
#
# @param [Array<String>] path_parts requested path parts that will be joined
# @raise [NotFoundError] if the requested path/file does not exist
# @raise [NotReadableError] if the requested file exists but the user doesn't have read permissions
# @return [String,nil] A path or nil
def self.path(*path_parts)
gem_path = expand(data_directory, ::File.join(path_parts))
if metasploit_installed?
user_path = expand(Msf::Config.config_directory, ::File.join(USER_DATA_SUBFOLDER, path_parts))
msf_path = expand(Msf::Config.data_directory, ::File.join(path_parts))
out_path = readable_path(gem_path, user_path, msf_path)
else
out_path = readable_path(gem_path)
end
readable_path(gem_path, user_path, msf_path)
return out_path unless out_path.nil?
raise ::MetasploitPayloads::NotFoundError, ::File.join(gem_path), caller unless ::File.exist?(gem_path)
nil
end
#
@ -61,7 +142,7 @@ module MetasploitPayloads
file_path = path(path_parts)
if file_path.nil?
full_path = ::File.join(path_parts)
fail RuntimeError, "#{full_path} not found", caller
raise ::MetasploitPayloads::NotFoundError, full_path, caller
end
::File.binread(file_path)
@ -203,5 +284,13 @@ module MetasploitPayloads
things
end
def manifest_path
::File.realpath(::File.join(::File.dirname(__FILE__), '..', 'manifest'))
end
def manifest_uuid_path
::File.realpath(::File.join(::File.dirname(__FILE__), '..', 'manifest.uuid'))
end
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
module MetasploitPayloads
class Error < StandardError
end
# Error raised when a Metasploit Payloads file doesn't exist.
class NotFoundError < Error
attr_reader :path
def initialize(path = '')
@path = path
super("Meterpreter path #{@path} not found. Ensure antivirus is not enabled, or reinstall Metasploit.")
end
end
# Error raised when the user does not have read permissions for a Metasploit Payloads file
class NotReadableError < Error
attr_reader :path
def initialize(path = '')
@path = path
super("Meterpreter path #{@path} is not readable. Check if you have read access and try again.")
end
end
# Error raised when a Metasploit Payloads file's hash does not match what is defined in the manifest file.
class HashMismatchError < Error
attr_reader :path
def initialize(path = '')
@path = path
super("Meterpreter path #{@path} does not match the hash defined in the Metasploit Payloads manifest file.")
end
end
end

View File

@ -0,0 +1,249 @@
# frozen_string_literal: true
require 'metasploit-payloads'
RSpec.describe ::MetasploitPayloads do
describe '::VERSION' do
it 'has a version number' do
expect(::MetasploitPayloads::VERSION).not_to be nil
end
end
describe '::Error' do
it 'has an Error class' do
expect(::MetasploitPayloads::Error.superclass).to be(::StandardError)
end
it 'has a NotFoundError class' do
expect(::MetasploitPayloads::NotFoundError.superclass).to be(::MetasploitPayloads::Error)
end
it 'has a NotReadableError class' do
expect(::MetasploitPayloads::NotReadableError.superclass).to be(::MetasploitPayloads::Error)
end
it 'has a HashMismatchError class' do
expect(::MetasploitPayloads::HashMismatchError.superclass).to be(::MetasploitPayloads::Error)
end
end
describe '#readable_path' do
let(:sample_file) { { name: 'meterpreter/meterpreter.py' } }
before :each do
allow(::File).to receive(:exist?).and_call_original
allow(::File).to receive(:readable?).and_call_original
end
context 'when the path is not readable' do
it 'raises a ::MetasploitPayloads::NotReadableError' do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(true)
allow(::File).to receive(:readable?).with(sample_file[:name]).and_return(false)
expect { subject.readable_path(sample_file[:name]) }.to raise_error(::MetasploitPayloads::NotReadableError)
end
end
context 'when the path does not exist' do
it 'returns nil' do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(false)
allow(::File).to receive(:readable?).with(sample_file[:name]).and_return(false)
expect(subject.readable_path(sample_file[:name])).to eq(nil)
end
end
context 'when the path exists and is readable' do
it 'returns the correct path' do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(true)
allow(::File).to receive(:readable?).with(sample_file[:name]).and_return(true)
expect(subject.readable_path(sample_file[:name])).to eq(sample_file[:name])
end
end
end
describe '#path' do
let(:sample_file) { { name: 'meterpreter/meterpreter.py' } }
before :each do
allow(::File).to receive(:exist?).and_call_original
allow(::File).to receive(:readable?).and_call_original
allow(::MetasploitPayloads).to receive(:expand).and_call_original
allow(::MetasploitPayloads).to receive(:expand)
.with(::MetasploitPayloads.data_directory, sample_file[:name])
.and_return(sample_file[:name])
end
[
{ context: 'is not readable', exist: true, readable: false, expected: ::MetasploitPayloads::NotReadableError },
{ context: 'does not exist', exist: false, readable: false, expected: ::MetasploitPayloads::NotFoundError }
].each do |test|
context "when the path #{test[:context]}" do
it "raises #{test[:expected]}" do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(test[:exist])
allow(::File).to receive(:readable?).with(sample_file[:name]).and_return(test[:readable])
expect { subject.path(sample_file[:name]) }.to raise_error(test[:expected])
end
end
end
context 'when the path exists and is readable' do
it 'returns the correct path' do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(true)
allow(::File).to receive(:readable?).with(sample_file[:name]).and_return(true)
expect(subject.path(sample_file[:name])).to eq(sample_file[:name])
end
end
end
describe '#manifest_errors' do
let(:hash_type) { 'SHA3-256' }
let(:hash) { { type: hash_type, value: '92e931e6b47caad6df4249cc263fdbe5d2975c4163f5b06963208163b7af97b5' } }
let(:sample_file) { { name: 'meterpreter/ext_server_stdapi.php', contents: 'sample_data', hash: hash } }
let(:manifest_values) { ["./data/#{sample_file[:name]}", sample_file[:hash][:type], sample_file[:hash][:value]] }
let(:manifest) { manifest_values.join(':') }
let(:manifest_uuid) { ::OpenSSL::Digest.new(hash_type, manifest).to_s }
let(:manifest_path) { 'manifest' }
let(:manifest_uuid_path) { 'manifest.uuid' }
before :each do
allow(::MetasploitPayloads).to receive(:manifest_path).and_call_original
allow(::MetasploitPayloads).to receive(:manifest_path).and_return(manifest_path)
allow(::MetasploitPayloads).to receive(:manifest_uuid_path).and_call_original
allow(::MetasploitPayloads).to receive(:manifest_uuid_path).and_return(manifest_uuid_path)
allow(::File).to receive(:binread).and_call_original
allow(::File).to receive(:binread).with(sample_file[:name]).and_return(sample_file[:contents])
allow(::File).to receive(:binread).with(::MetasploitPayloads.send(:manifest_path)).and_return(manifest)
allow(::File).to receive(:binread).with(::MetasploitPayloads.send(:manifest_uuid_path)).and_return(manifest_uuid)
allow(::OpenSSL::Digest).to receive(:new).and_call_original
allow(::OpenSSL::Digest).to receive(:new).with(hash_type,
sample_file[:contents]).and_return(sample_file[:hash][:value])
end
context 'when manifest hash does not match' do
it 'result includes the manifest file' do
allow(::File).to receive(:binread).with(::MetasploitPayloads.send(:manifest_uuid_path))
.and_return('mismatched_manifest_hash')
path = ::MetasploitPayloads.send(:manifest_path)
e = ::MetasploitPayloads::HashMismatchError.new(path)
expect(subject.manifest_errors).to include({ path: path, error: e })
end
end
context 'when manifest hash does match' do
it 'result does not include manifest' do
path = ::MetasploitPayloads.send(:manifest_uuid_path)
e = ::MetasploitPayloads::HashMismatchError.new(path)
expect(subject.manifest_errors).not_to include({ path: path, error: e })
end
end
context 'when there are no file warnings' do
it 'returns an empty array' do
allow(::MetasploitPayloads).to receive(:path).with(sample_file[:name]).and_return(sample_file[:name])
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(true)
full_file_path = ::MetasploitPayloads.expand(::MetasploitPayloads.data_directory, sample_file[:name])
allow(::File).to receive(:readable?).with(full_file_path).and_return(true)
allow(::File).to receive(:binread).with(full_file_path).and_return(sample_file[:contents])
expect(subject.manifest_errors).to eq([])
end
end
[
{ context: 'does not exist', error_class: ::MetasploitPayloads::NotFoundError },
{ context: 'is not readable', error_class: ::MetasploitPayloads::NotReadableError }
].each do |test|
context "when a file #{test[:context]}" do
it 'includes the correct error' do
error = test[:error_class].new(sample_file[:name])
allow(::MetasploitPayloads).to receive(:path).with(sample_file[:name]).and_raise(error)
expect(subject.manifest_errors).to include({ path: sample_file[:name], error: error })
end
end
end
context 'when a bundled file hash does not match' do
it 'includes the correct error' do
allow(::File).to receive(:exist?).with(sample_file[:name]).and_return(true)
full_file_path = ::MetasploitPayloads.expand(::MetasploitPayloads.data_directory, sample_file[:name])
allow(::File).to receive(:readable?).with(full_file_path).and_return(true)
allow(::File).to receive(:binread).with(full_file_path).and_return('mismatched_file_contents')
e = ::MetasploitPayloads::HashMismatchError.new(full_file_path)
expect(subject.manifest_errors).to include({ path: full_file_path, error: e })
end
end
context 'when the manifest file' do
context 'does not exist' do
it 'only includes the manifest error' do
# path = ::MetasploitPayloads.send(:manifest_path)
e = ::Errno::ENOENT.new(manifest_path)
allow(::File).to receive(:binread).with(manifest_path).and_raise(e)
expect(subject.manifest_errors).to eq([{ path: manifest_path, error: e }])
end
end
context 'cannot be read' do
it 'only includes the manifest error' do
e = ::Errno::EACCES.new(manifest_path)
allow(::File).to receive(:binread).with(manifest_path).and_raise(e)
expect(subject.manifest_errors).to eq([{ path: manifest_path, error: e }])
end
end
end
context 'when the manifest.uuid file' do
context 'does not exist' do
it 'includes the correct error' do
e = ::Errno::ENOENT.new(manifest_uuid_path)
allow(::File).to receive(:binread).with(manifest_uuid_path).and_raise(e)
expect(subject.manifest_errors).to include({ path: manifest_uuid_path, error: e })
end
end
end
context 'when manifest is readable and manifest.uuid is not readable' do
before :each do
allow(::File).to receive(:binread).with(manifest_uuid_path).and_raise(::Errno::EACCES.new(manifest_uuid_path))
end
it 'correctly evaluates a file hash mismatch' do
bundled_file_path = ::MetasploitPayloads.expand(::MetasploitPayloads.data_directory, sample_file[:name])
error = ::MetasploitPayloads::HashMismatchError.new(bundled_file_path)
allow(::MetasploitPayloads).to receive(:path).with(sample_file[:name]).and_return(bundled_file_path)
allow(::File).to receive(:binread).with(bundled_file_path).and_return('sample_mismatched_contents')
expect(subject.manifest_errors).to include({ path: bundled_file_path, error: error })
end
it 'correctly evaluates a missing file' do
error = ::MetasploitPayloads::NotFoundError.new(sample_file[:name])
allow(::MetasploitPayloads).to receive(:path).with(sample_file[:name]).and_raise(error)
expect(subject.manifest_errors).to include({ path: sample_file[:name], error: error })
end
it 'correctly evaluates an unreadable file' do
error = ::MetasploitPayloads::NotReadableError.new(sample_file[:name])
allow(::MetasploitPayloads).to receive(:path).with(sample_file[:name]).and_raise(error)
expect(subject.manifest_errors).to include({ path: sample_file[:name], error: error })
end
end
end
end

103
gem/spec/spec_helper.rb Normal file
View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'metasploit_payloads/metasploit_payloads_spec'
# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# The `.rspec` file also contains a few flags that are not defaults but that
# users commonly want.
#
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer.
config.expect_with :rspec do |expectations|
# This option will default to `true` in RSpec 4. It makes the `description`
# and `failure_message` of custom matchers include text for helper methods
# defined using `chain`, e.g.:
# be_bigger_than(2).and_smaller_than(4).description
# # => "be bigger than 2 and smaller than 4"
# ...rather than:
# # => "be bigger than 2"
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
# rspec-mocks config goes here. You can use an alternate test double
# library (such as bogus or mocha) by changing the `mock_with` option here.
config.mock_with :rspec do |mocks|
# Prevents you from mocking or stubbing a method that does not exist on
# a real object. This is generally recommended, and will default to
# `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
# have no way to turn it off -- the option exists only for backwards
# compatibility in RSpec 3). It causes shared context metadata to be
# inherited by the metadata hash of host groups and examples, rather than
# triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content.
# This allows you to limit a spec run to individual examples or groups
# you care about by tagging them with `:focus` metadata. When nothing
# is tagged with `:focus`, all examples get run. RSpec also provides
# aliases for `it`, `describe`, and `context` that include `:focus`
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
config.filter_run_when_matching :focus
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = 'spec/examples.txt'
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
config.disable_monkey_patching!
# This setting enables warnings. It's recommended, but in some cases may
# be too noisy due to issues in dependencies.
config.warnings = true
# Many RSpec users commonly either run the entire suite or an individual
# file, and it's useful to allow more verbose output when running an
# individual spec file.
if config.files_to_run.one?
# Use the documentation formatter for detailed output,
# unless a formatter has already been configured
# (e.g. via a command-line flag).
config.default_formatter = 'doc'
end
# Print the 10 slowest examples and example groups at the
# end of the spec run, to help surface which specs are running
# particularly slow.
config.profile_examples = 10
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = :random
# Seed global randomization in this process using the `--seed` CLI option.
# Setting this allows you to use `--seed` to deterministically reproduce
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
end