diff --git a/gem/.gitignore b/gem/.gitignore index 0186f37f..0b551674 100644 --- a/gem/.gitignore +++ b/gem/.gitignore @@ -7,6 +7,7 @@ InstalledFiles lib/bundler/man rdoc spec/reports +spec/examples.txt test/tmp test/version_tmp tmp diff --git a/gem/.rspec b/gem/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/gem/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/gem/Gemfile b/gem/Gemfile index 211317f3..8c70fa23 100644 --- a/gem/Gemfile +++ b/gem/Gemfile @@ -2,3 +2,7 @@ source 'https://rubygems.org' # Specify your gem's dependencies in meterpreter_binaries.gemspec gemspec + +group :test do + gem 'rspec' +end diff --git a/gem/lib/metasploit-payloads.rb b/gem/lib/metasploit-payloads.rb index a8ecb28c..cd7519fb 100644 --- a/gem/lib/metasploit-payloads.rb +++ b/gem/lib/metasploit-payloads.rb @@ -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>] 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] 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] 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 diff --git a/gem/lib/metasploit-payloads/error.rb b/gem/lib/metasploit-payloads/error.rb new file mode 100644 index 00000000..3ddc1114 --- /dev/null +++ b/gem/lib/metasploit-payloads/error.rb @@ -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 diff --git a/gem/spec/metasploit_payloads/metasploit_payloads_spec.rb b/gem/spec/metasploit_payloads/metasploit_payloads_spec.rb new file mode 100644 index 00000000..540d6228 --- /dev/null +++ b/gem/spec/metasploit_payloads/metasploit_payloads_spec.rb @@ -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 diff --git a/gem/spec/spec_helper.rb b/gem/spec/spec_helper.rb new file mode 100644 index 00000000..d12ba095 --- /dev/null +++ b/gem/spec/spec_helper.rb @@ -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