From 68ce65c6c921390d20d70523c5b6039ea1af164e Mon Sep 17 00:00:00 2001 From: adfoster-r7 <60357436+adfoster-r7@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:24:59 +0100 Subject: [PATCH] Revert "Revert "Add Meterpreter sanity tests to CI"" --- .github/workflows/acceptance.yml | 196 +++++++ .github/workflows/verify.yml | 2 +- Gemfile | 12 +- Gemfile.lock | 20 + lib/msf/core/post/windows/registry.rb | 27 +- lib/msf/core/post/windows/services.rb | 4 +- spec/acceptance/README.md | 80 +++ spec/acceptance/child_process_spec.rb | 105 ++++ spec/acceptance/meterpreter_spec.rb | 390 +++++++++++++ spec/acceptance_spec_helper.rb | 27 + spec/allure_config.rb | 26 + spec/spec_helper.rb | 6 + spec/support/acceptance/child_process.rb | 545 ++++++++++++++++++ spec/support/acceptance/countdown.rb | 23 + spec/support/acceptance/line_validation.rb | 55 ++ spec/support/acceptance/meterpreter.rb | 102 ++++ spec/support/acceptance/meterpreter/java.rb | 271 +++++++++ spec/support/acceptance/meterpreter/mettle.rb | 351 +++++++++++ spec/support/acceptance/meterpreter/php.rb | 275 +++++++++ spec/support/acceptance/meterpreter/python.rb | 272 +++++++++ .../meterpreter/windows_meterpreter.rb | 374 ++++++++++++ spec/support/acceptance/port_allocator.rb | 18 + test/modules/post/test/file.rb | 7 +- test/modules/post/test/meterpreter.rb | 23 +- 24 files changed, 3184 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/acceptance.yml create mode 100644 spec/acceptance/README.md create mode 100644 spec/acceptance/child_process_spec.rb create mode 100644 spec/acceptance/meterpreter_spec.rb create mode 100644 spec/acceptance_spec_helper.rb create mode 100644 spec/allure_config.rb create mode 100644 spec/support/acceptance/child_process.rb create mode 100644 spec/support/acceptance/countdown.rb create mode 100644 spec/support/acceptance/line_validation.rb create mode 100644 spec/support/acceptance/meterpreter.rb create mode 100644 spec/support/acceptance/meterpreter/java.rb create mode 100644 spec/support/acceptance/meterpreter/mettle.rb create mode 100644 spec/support/acceptance/meterpreter/php.rb create mode 100644 spec/support/acceptance/meterpreter/python.rb create mode 100644 spec/support/acceptance/meterpreter/windows_meterpreter.rb create mode 100644 spec/support/acceptance/port_allocator.rb diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 000000000000..49908e02dde9 --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,196 @@ +name: Acceptance + +# Optional, enabling concurrency limits: https://docs.github.com/en/actions/using-jobs/using-concurrency +#concurrency: +# group: ${{ github.ref }}-${{ github.workflow }} +# cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + actions: none + checks: none + contents: none + deployments: none + id-token: none + issues: none + discussions: none + packages: none + pages: none + pull-requests: none + repository-projects: none + security-events: none + statuses: none + +on: + push: + branches-ignore: + - gh-pages + - metakitty + pull_request: + branches: + - '*' + paths: + - 'metsploit-framework.gemspec' + - 'Gemfile.lock' + - 'data/templates/**' + - 'modules/payloads/**' + - 'lib/msf/core/payload/**' + - 'lib/msf/core/**' + - 'spec/acceptance/**' + - 'spec/acceptance_spec_helper.rb' +# Example of running as a cron, to weed out flaky tests +# schedule: +# - cron: '*/15 * * * *' + +jobs: + # Run all test individually, note there is a separate final job for aggregating the test results + test: + strategy: + fail-fast: false + matrix: + os: + - macos-11 + - windows-2019 + - ubuntu-20.04 + ruby: + - 3.0.2 + meterpreter: + # Python + - { name: python, runtime_version: 3.6 } + - { name: python, runtime_version: 3.11 } + + # Java - newer versions of Java are not supported currently: https://github.com/rapid7/metasploit-payloads/issues/647 + - { name: java, runtime_version: 8 } + + # PHP + - { name: php, runtime_version: 5.3 } + - { name: php, runtime_version: 7.4 } + - { name: php, runtime_version: 8.2 } + include: + # Windows Meterpreter + - { meterpreter: { name: windows_meterpreter }, os: windows-2019 } + - { meterpreter: { name: windows_meterpreter }, os: windows-2022 } + + # Mettle + - { meterpreter: { name: mettle }, os: macos-11 } + - { meterpreter: { name: mettle }, os: ubuntu-20.04 } + + runs-on: ${{ matrix.os }} + + timeout-minutes: 25 + + env: + RAILS_ENV: test + HOST_RUNNER_IMAGE: ${{ matrix.os }} + METERPRETER: ${{ matrix.meterpreter.name }} + METERPRETER_RUNTIME_VERSION: ${{ matrix.meterpreter.runtime_version }} + + name: ${{ matrix.meterpreter.name }} ${{ matrix.meterpreter.runtime_version }} ${{ matrix.os }} + steps: + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get -y --no-install-recommends install libpcap-dev graphviz + + - uses: shivammathur/setup-php@5b29e8a45433c406b3902dff138a820a408c45b7 + if: ${{ matrix.meterpreter.name == 'php' }} + with: + php-version: ${{ matrix.meterpreter.runtime_version }} + tools: none + + - name: Set up Python + if: ${{ matrix.meterpreter.name == 'python' }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.meterpreter.runtime_version }} + + - uses: actions/setup-java@v3 + if: ${{ matrix.meterpreter.name == 'java' }} + with: + distribution: temurin + java-version: ${{ matrix.meterpreter.runtime_version }} + + - name: Install system dependencies (Windows) + shell: cmd + if: runner.os == 'Windows' + run: | + REM pcap dependencies + powershell -Command "[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} ; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object System.Net.WebClient).DownloadFile('https://www.winpcap.org/install/bin/WpdPack_4_1_2.zip', 'C:\Windows\Temp\WpdPack_4_1_2.zip')" + + choco install 7zip.installServerCertificateValidationCallback + 7z x "C:\Windows\Temp\WpdPack_4_1_2.zip" -o"C:\" + + dir C:\\ + + dir %WINDIR% + type %WINDIR%\\system32\\drivers\\etc\\hosts + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Ruby + env: + BUNDLE_WITHOUT: "coverage development" + BUNDLE_FORCE_RUBY_PLATFORM: true + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + cache-version: 4 + # Github actions with Ruby requires Bundler 2.2.18+ + # https://github.com/ruby/setup-ruby/tree/d2b39ad0b52eca07d23f3aa14fdf2a3fcc1f411c#windows + bundler: 2.2.33 + + - name: acceptance + env: + SPEC_HELPER_LOAD_METASPLOIT: false + SPEC_OPTS: "--tag acceptance --require acceptance_spec_helper.rb --color --format documentation --format AllureRspec::RSpecFormatter" + # Unix run command: + # SPEC_HELPER_LOAD_METASPLOIT=false bundle exec ./spec/acceptance + # Windows cmd command: + # set SPEC_HELPER_LOAD_METASPLOIT=false + # bundle exec rspec .\spec\acceptance + # Note: rspec retry is intentionally not used, as it can cause issues with allure's reporting + # Additionally - flakey tests should be fixed or marked as flakey instead of silently retried + run: | + bundle exec rspec spec/acceptance/ + + - name: Archive results + if: always() + uses: actions/upload-artifact@v3 + with: + # Provide a unique artifact for each matrix os, otherwise race conditions can lead to corrupt zips + name: raw-data-${{ matrix.meterpreter.name }}-${{ matrix.meterpreter.runtime_version }}-${{ matrix.os }} + path: tmp/allure-raw-data + + # Generate a final report from the previous test results + report: + name: Generate report + needs: test + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/download-artifact@v3 + id: download + if: always() + with: + # Note: Not specifying a name will download all artifacts from the previous workflow jobs + path: raw-data + + - name: allure generate + if: always() + run: | + export VERSION=2.22.1 + + curl -o allure-$VERSION.tgz -Ls https://github.com/allure-framework/allure2/releases/download/$VERSION/allure-$VERSION.tgz + tar -zxvf allure-$VERSION.tgz -C . + + ./allure-$VERSION/bin/allure generate ${{steps.download.outputs.download-path}}/* -o ./allure-report + + - name: archive results + if: always() + uses: actions/upload-artifact@v3 + with: + name: final-report-${{ github.run_id }} + path: | + ./allure-report diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 09c415a8a442..6a14ecccb989 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -90,7 +90,7 @@ jobs: name: ${{ matrix.os }} - Ruby ${{ matrix.ruby }} - ${{ matrix.test_cmd }} steps: - name: Install system dependencies - run: sudo apt-get install libpcap-dev graphviz + run: sudo apt-get install -y --no-install-recommends libpcap-dev graphviz - name: Checkout code uses: actions/checkout@v3 diff --git a/Gemfile b/Gemfile index 319545466677..71f179e97b29 100644 --- a/Gemfile +++ b/Gemfile @@ -31,20 +31,24 @@ group :development do end group :development, :test do - # automatically include factories from spec/factories - gem 'factory_bot_rails' - # Make rspec output shorter and more useful - gem 'fivemat' # running documentation generation tasks and rspec tasks gem 'rake' # Define `rake spec`. Must be in development AND test so that its available by default as a rake test when the # environment is development gem 'rspec-rails' gem 'rspec-rerun' + # Required during CI as well local development gem 'rubocop' end group :test do + # automatically include factories from spec/factories + gem 'test-prof' + gem 'factory_bot_rails' + # Make rspec output shorter and more useful + gem 'fivemat' + # rspec formatter for acceptance tests + gem 'allure-rspec' # Manipulate Time.now in specs gem 'timecop' end diff --git a/Gemfile.lock b/Gemfile.lock index 67bf12dc5963..df39e97c3b21 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -127,6 +127,14 @@ GEM addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) afm (0.2.2) + allure-rspec (2.22.0) + allure-ruby-commons (= 2.22.0) + rspec-core (>= 3.8, < 4) + allure-ruby-commons (2.22.0) + mime-types (>= 3.3, < 4) + require_all (>= 2, < 4) + rspec-expectations (~> 3.12) + uuid (>= 2.3, < 3) arel-helpers (2.14.0) activerecord (>= 3.1.0, < 8) ast (2.4.2) @@ -241,6 +249,8 @@ GEM loofah (2.21.3) crass (~> 1.0.2) nokogiri (>= 1.12.0) + macaddr (1.7.2) + systemu (~> 2.6.5) memory_profiler (1.0.1) metasm (1.0.5) metasploit-concern (5.0.1) @@ -275,6 +285,9 @@ GEM webrick metasploit_payloads-mettle (1.0.26) method_source (1.0.0) + mime-types (3.4.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2023.0218.1) mini_portile2 (2.8.2) minitest (5.18.0) mqtt (0.6.0) @@ -356,6 +369,7 @@ GEM regexp_parser (2.8.0) reline (0.3.5) io-console (~> 0.5) + require_all (3.0.0) rex-arch (0.1.14) rex-text rex-bin_tools (0.1.8) @@ -473,6 +487,8 @@ GEM sshkey (2.0.0) strptime (0.2.5) swagger-blocks (3.0.0) + systemu (2.6.5) + test-prof (1.2.2) thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -491,6 +507,8 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) unix-crypt (1.3.1) + uuid (2.3.9) + macaddr (~> 1.0) warden (1.2.9) rack (>= 2.0.9) webrick (1.8.1) @@ -520,6 +538,7 @@ PLATFORMS ruby DEPENDENCIES + allure-rspec debug (>= 1.0.0) factory_bot_rails fivemat @@ -534,6 +553,7 @@ DEPENDENCIES rubocop ruby-prof (= 1.4.2) simplecov (= 0.18.2) + test-prof timecop yard diff --git a/lib/msf/core/post/windows/registry.rb b/lib/msf/core/post/windows/registry.rb index d3dc12995649..1fce65efac61 100644 --- a/lib/msf/core/post/windows/registry.rb +++ b/lib/msf/core/post/windows/registry.rb @@ -1,6 +1,5 @@ # -*- coding: binary -*- - module Msf class Post module Windows @@ -103,7 +102,7 @@ def registry_hive_lookup(hive) # Load a hive file # def registry_loadkey(key, file) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_LOAD_KEY) meterpreter_registry_loadkey(key, file) else shell_registry_loadkey(key, file) @@ -114,7 +113,7 @@ def registry_loadkey(key, file) # Unload a hive file # def registry_unloadkey(key) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_UNLOAD_KEY) meterpreter_registry_unloadkey(key) else shell_registry_unloadkey(key) @@ -125,7 +124,7 @@ def registry_unloadkey(key) # Create the given registry key # def registry_createkey(key, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_CREATE_KEY) meterpreter_registry_createkey(key, view) else shell_registry_createkey(key, view) @@ -138,7 +137,7 @@ def registry_createkey(key, view = REGISTRY_VIEW_NATIVE) # returns true if succesful # def registry_deleteval(key, valname, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_DELETE_KEY) meterpreter_registry_deleteval(key, valname, view) else shell_registry_deleteval(key, valname, view) @@ -151,7 +150,7 @@ def registry_deleteval(key, valname, view = REGISTRY_VIEW_NATIVE) # returns true if succesful # def registry_deletekey(key, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_DELETE_KEY) meterpreter_registry_deletekey(key, view) else shell_registry_deletekey(key, view) @@ -162,7 +161,7 @@ def registry_deletekey(key, view = REGISTRY_VIEW_NATIVE) # Return an array of subkeys for the given registry key # def registry_enumkeys(key, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_ENUM_KEY) meterpreter_registry_enumkeys(key, view) else shell_registry_enumkeys(key, view) @@ -173,7 +172,7 @@ def registry_enumkeys(key, view = REGISTRY_VIEW_NATIVE) # Return an array of value names for the given registry key # def registry_enumvals(key, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_ENUM_VALUE_DIRECT) meterpreter_registry_enumvals(key, view) else shell_registry_enumvals(key, view) @@ -184,7 +183,7 @@ def registry_enumvals(key, view = REGISTRY_VIEW_NATIVE) # Return the data of a given registry key and value # def registry_getvaldata(key, valname, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_ENUM_VALUE_DIRECT) meterpreter_registry_getvaldata(key, valname, view) else shell_registry_getvaldata(key, valname, view) @@ -195,7 +194,7 @@ def registry_getvaldata(key, valname, view = REGISTRY_VIEW_NATIVE) # Return the data and type of a given registry key and value # def registry_getvalinfo(key, valname, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_OPEN_KEY) meterpreter_registry_getvalinfo(key, valname, view) else shell_registry_getvalinfo(key, valname, view) @@ -208,7 +207,7 @@ def registry_getvalinfo(key, valname, view = REGISTRY_VIEW_NATIVE) # returns true if succesful # def registry_setvaldata(key, valname, data, type, view = REGISTRY_VIEW_NATIVE) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_SET_VALUE_DIRECT) meterpreter_registry_setvaldata(key, valname, data, type, view) else shell_registry_setvaldata(key, valname, data, type, view) @@ -221,7 +220,7 @@ def registry_setvaldata(key, valname, data, type, view = REGISTRY_VIEW_NATIVE) # @return [Boolean] true if the key exists on the target registry, false otherwise # (also in case of error) def registry_key_exist?(key) - if session_has_registry_ext + if session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_CHECK_KEY_EXISTS) meterpreter_registry_key_exist?(key) else shell_registry_key_exist?(key) @@ -233,6 +232,7 @@ def registry_key_exist?(key) # # Determines whether the session can use meterpreter registry methods # + # @deprecated Use granular command ID checking session.commands instead def session_has_registry_ext begin return !!(session.sys and session.sys.registry) @@ -253,7 +253,8 @@ def shell_registry_cmd(suffix, view = REGISTRY_VIEW_NATIVE) elsif view == REGISTRY_VIEW_64_BIT cmd << " /reg:64" end - cmd_exec(cmd) + result = cmd_exec(cmd) + result end def shell_registry_cmd_result(suffix, view = REGISTRY_VIEW_NATIVE) diff --git a/lib/msf/core/post/windows/services.rb b/lib/msf/core/post/windows/services.rb index 455142337355..7b5ec5c6dab0 100644 --- a/lib/msf/core/post/windows/services.rb +++ b/lib/msf/core/post/windows/services.rb @@ -228,7 +228,9 @@ def each_service(&block) # @todo Rewrite to allow operating on a remote host # def service_list - return meterpreter_service_list if session.type == 'meterpreter' + if session.type == 'meterpreter' && session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_REGISTRY_ENUM_KEY) + return meterpreter_service_list + end services = [] each_service do |s| diff --git a/spec/acceptance/README.md b/spec/acceptance/README.md new file mode 100644 index 000000000000..ac4cfc7e4039 --- /dev/null +++ b/spec/acceptance/README.md @@ -0,0 +1,80 @@ +## Acceptance Tests + +A slower test suite that ensures high level functionality works as expected, +such as verifying msfconsole opens successfully, and can generate Meterpreter payloads, +create handlers, etc. + +### Examples + +Useful environment variables: +- `METERPRETER` - Filter the test suite for specific Meterpreter instances, example: `METERPRETER=java` +- `METERPRETER_MODULE_TEST` - Filter the post modules to run, example: `METERPRETER_MODULE_TEST=test/meterpreter` +- `SPEC_HELPER_LOAD_METASPLOIT` - Skip RSpec from loading Metasploit framework and requiring a connected msfdb instance, example: `SPEC_HELPER_LOAD_METASPLOIT=false` + +Running Meterpreter test suite: + +``` +SPEC_OPTS='--tag acceptance' bundle exec rspec './spec/acceptance/meterpreter_spec.rb' +``` + +Skip loading of Rails/Metasplotit with: + +``` +SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance +``` + +Run a specific Meterpreter/module test Unix / Windows: +``` +SPEC_OPTS='--tag acceptance' METERPRETER=php METERPRETER_MODULE_TEST=test/unix bundle exec rspec './spec/acceptance/meterpreter_spec.rb' + +$env:SPEC_OPTS='--tag acceptance'; $env:SPEC_HELPER_LOAD_METASPLOIT=$false; $env:METERPRETER = 'php'; bundle exec rspec './spec/acceptance/meterpreter_spec.rb' +``` + +Generate allure reports locally: + +``` +# 1) Run the test suite with the allure formatter +rm -rf tmp/allure-raw-data +bundle exec rspec --require acceptance_spec_helper.rb --format documentation --format AllureRspec::RSpecFormatter './spec/acceptance/meterpreter_spec.rb' + +# 2) Generate allure report +cd metasploit-framework/tmp +docker run -it -w $(pwd) -v $(pwd):$(pwd) ubuntu:20.04 /bin/bash + +# In the container +export VERSION=2.22.1 + +apt update +apt install -y curl openjdk-11-jdk-headless + +curl -o allure-$VERSION.tgz -Ls https://github.com/allure-framework/allure2/releases/download/$VERSION/allure-$VERSION.tgz +tar -zxvf allure-$VERSION.tgz -C . + +./allure-$VERSION/bin/allure generate --clean allure-raw-data/ -o ./allure-report + +# Serve the assets from the host machine, available at http://127.0.0.1:8000 +cd allure-report +ruby -run -e httpd . -p 8000 +``` + +### Debugging + +If a test has failed you can enter into an interactive breakpoint with: + +``` +require 'pry'; binding.pry +``` + +To interact with a console instance, forwarding the current stdin to the console's stdin, +and writing the console's output to stdout: + +``` +console.interact +``` + +Once inside the console, the following 'commands' can be used within the context of +the interactive msfconsole: + +- `!continue` - Continue, similar to Pry's continue functionality +- `!exit` - Exit the Ruby process entirely, similar to Pry's exit functionality +- `!pry` - Enter into a pry session within the calling Ruby process diff --git a/spec/acceptance/child_process_spec.rb b/spec/acceptance/child_process_spec.rb new file mode 100644 index 000000000000..ab7090836b4c --- /dev/null +++ b/spec/acceptance/child_process_spec.rb @@ -0,0 +1,105 @@ +require 'acceptance_spec_helper' + +RSpec.describe Acceptance::ChildProcess do + context 'when a process is opened successfully' do + let(:stdin_pipes) { ::IO.pipe } + let(:stdin_reader) { stdin_pipes[0] } + let(:stdin_writer) { stdin_pipes[1] } + let(:stdout_and_stderr_pipes) { ::IO.pipe } + let(:stdout_and_stderr_pipes_reader) { stdout_and_stderr_pipes[0] } + let(:stdout_and_stderr_pipes_writer) { stdout_and_stderr_pipes[1] } + let(:wait_thread) { double(:wait_thread, alive?: true, pid: nil) } + + subject(:mock_process) do + clazz = Class.new(described_class) do + attr_reader :mock_stdin_reader + attr_reader :mock_stdout_and_stderr_writer + + def run(stdin, stdout_and_stderr, wait_thread) + self.stdin = stdin + self.stdout_and_stderr = stdout_and_stderr + self.stdin.sync = true + self.stdout_and_stderr.sync = true + self.wait_thread = wait_thread + end + end + clazz.new + end + + def mock_write(data) + stdout_and_stderr_pipes_writer.write(data) + end + + before(:each) do + mock_process.run(stdin_writer, stdout_and_stderr_pipes_reader, wait_thread) + end + + after(:each) do + subject.close + end + + describe '#readline' do + context 'when there is exactly one line available' do + it 'reads one line' do + mock_write("hello world\n") + expect(subject.readline).to eq("hello world\n") + end + end + + context 'when there are multiple lines available' do + it 'reads one line' do + mock_write("hello world\nfoo bar\n") + expect(subject.readline).to eq("hello world\n") + end + + it 'reads multiple lines' do + mock_write("hello world\nfoo bar\n") + expect(subject.readline).to eq("hello world\n") + expect(subject.readline).to eq("foo bar\n") + end + end + end + + describe '#recv_available' do + context 'when there is exactly one line available' do + it 'reads one line' do + mock_write("hello world\n") + expect(subject.recv_available).to eq("hello world\n") + end + end + + context 'when there are multiple lines available' do + it 'reads one line' do + mock_write("hello world\nfoo bar\n") + expect(subject.recv_available).to eq("hello world\nfoo bar\n") + end + end + end + + describe '#recvuntil' do + context 'when there are multiple lines of data available' do + it 'reads one line' do + mock_write <<~EOF + motd + login: + EOF + expect(subject.recvuntil("login:")).to eq("motd\nlogin:") + end + end + end + + describe '#sendline' do + it 'writes the available data' do + subject.sendline("hello world") + + expect(stdin_reader.read_nonblock(1024)).to eq("hello world\n") + end + end + + describe '#alive?' do + it 'returns the wait thread status' do + expect(subject.alive?).to eq(wait_thread.alive?) + end + end + end +end diff --git a/spec/acceptance/meterpreter_spec.rb b/spec/acceptance/meterpreter_spec.rb new file mode 100644 index 000000000000..e060b0473975 --- /dev/null +++ b/spec/acceptance/meterpreter_spec.rb @@ -0,0 +1,390 @@ +require 'acceptance_spec_helper' + +RSpec.describe 'Meterpreter' do + include_context 'wait_for_expect' + + # Tests to ensure that Meterpreter is consistent across all implementations/operation systems + METERPRETER_PAYLOADS = Acceptance::Meterpreter.with_meterpreter_name_merged( + { + python: Acceptance::Meterpreter::PYTHON_METERPRETER, + php: Acceptance::Meterpreter::PHP_METERPRETER, + java: Acceptance::Meterpreter::JAVA_METERPRETER, + mettle: Acceptance::Meterpreter::METTLE_METERPRETER, + windows_meterpreter: Acceptance::Meterpreter::WINDOWS_METERPRETER + } + ) + + TEST_ENVIRONMENT = AllureRspec.configuration.environment_properties + + let_it_be(:current_platform) { Acceptance::Meterpreter::current_platform } + + # @!attribute [r] port_allocator + # @return [Acceptance::PortAllocator] + let_it_be(:port_allocator) { Acceptance::PortAllocator.new } + + # Driver instance, keeps track of all open processes/payloads/etc, so they can be closed cleanly + let_it_be(:driver) do + driver = Acceptance::ConsoleDriver.new + driver + end + + # Opens a test console with the test loadpath specified + # @!attribute [r] console + # @return [Acceptance::Console] + let_it_be(:console) do + console = driver.open_console + + # Load the test modules + console.sendline('loadpath test/modules') + console.recvuntil(/Loaded \d+ modules:[^\n]*\n/) + console.recvuntil(/\d+ auxiliary modules[^\n]*\n/) + console.recvuntil(/\d+ exploit modules[^\n]*\n/) + console.recvuntil(/\d+ post modules[^\n]*\n/) + console.recvuntil(Acceptance::Console.prompt) + + # Read the remaining console + # console.sendline "quit -y" + # console.recv_available + + console + end + + METERPRETER_PAYLOADS.each do |meterpreter_name, meterpreter_config| + meterpreter_runtime_name = "#{meterpreter_name}#{ENV.fetch('METERPRETER_RUNTIME_VERSION', '')}" + + describe meterpreter_runtime_name, focus: meterpreter_config[:focus] do + meterpreter_config[:payloads].each do |payload_config| + describe( + Acceptance::Meterpreter.human_name_for_payload(payload_config).to_s, + if: ( + Acceptance::Meterpreter.run_meterpreter?(meterpreter_config) && + Acceptance::Meterpreter.supported_platform?(payload_config) + ) + ) do + let(:payload) { Acceptance::Payload.new(payload_config) } + + class LocalPath + attr_reader :path + + def initialize(path) + @path = path + end + end + + let(:session_tlv_logging_file) do + # LocalPath.new('/tmp/php_session_tlv_log.txt') + Acceptance::TempChildProcessFile.new("#{payload.name}_session_tlv_logging", 'txt') + end + + let(:meterpreter_logging_file) do + # LocalPath.new('/tmp/php_log.txt') + Acceptance::TempChildProcessFile.new("#{payload.name}_debug_log", 'txt') + end + + let(:payload_stdout_and_stderr_file) do + # LocalPath.new('/tmp/php_log.txt') + Acceptance::TempChildProcessFile.new("#{payload.name}_stdout_and_stderr", 'txt') + end + + let(:default_global_datastore) do + { + SessionTlvLogging: "file:#{session_tlv_logging_file.path}" + } + end + + let(:test_environment) { TEST_ENVIRONMENT } + + let(:default_module_datastore) do + { + AutoVerifySessionTimeout: ENV['CI'] ? 30 : 10, + lport: port_allocator.next, + lhost: '127.0.0.1', + MeterpreterDebugLogging: "rpath:#{meterpreter_logging_file.path}" + } + end + + let(:executed_payload) do + file = File.open(payload_stdout_and_stderr_file.path, 'w') + driver.run_payload( + payload, + { + out: file, + err: file + } + ) + end + + # The shared payload process and session instance that will be reused across the test run + # + let(:payload_process_and_session_id) do + console.sendline "use #{payload.name}" + console.recvuntil(Acceptance::Console.prompt) + + # Set global options + console.sendline payload.setg_commands(default_global_datastore: default_global_datastore) + console.recvuntil(Acceptance::Console.prompt) + + # Generate the payload + console.sendline payload.generate_command(default_module_datastore: default_module_datastore) + console.recvuntil(/Writing \d+ bytes[^\n]*\n/) + generate_result = console.recvuntil(Acceptance::Console.prompt) + + expect(generate_result.lines).to_not include(match('generation failed')) + wait_for_expect do + expect(payload.size).to be > 0 + end + + console.sendline 'to_handler' + console.recvuntil(/Started reverse TCP handler[^\n]*\n/) + payload_process = executed_payload + session_id = nil + + # Wait for the session to open, or break early if the payload is detected as dead + wait_for_expect do + unless payload_process.alive? + break + end + + session_opened_matcher = /Meterpreter session (\d+) opened[^\n]*\n/ + session_message = '' + begin + session_message = console.recvuntil(session_opened_matcher, timeout: 1) + rescue Acceptance::ChildProcessRecvError + # noop + end + + session_id = session_message[session_opened_matcher, 1] + expect(session_id).to_not be_nil + end + + [payload_process, session_id] + end + + # @param [String] path The file path to read the content of + # @return [String] The file contents if found + def get_file_attachment_contents(path) + return 'none resent' unless File.exists?(path) + + content = File.binread(path) + content.blank? ? 'file created - but empty' : content + end + + before :each do |example| + raise 'Failed to load allure metadata method' unless example.respond_to?(:parameter) + + # Add the test environment metadata to the rspec example instance - so it appears in the final allure report UI + test_environment.each do |key, value| + example.parameter(key, value) + end + end + + after :all do + driver.close_payloads + console.reset + end + + context "#{Acceptance::Meterpreter.current_platform}" do + meterpreter_config[:module_tests].each do |module_test| + describe module_test[:name].to_s, focus: module_test[:focus] do + it( + "#{Acceptance::Meterpreter.current_platform}/#{meterpreter_runtime_name} meterpreter successfully opens a session for the #{payload_config[:name].inspect} payload and passes the #{module_test[:name].inspect} tests", + if: ( + # Run if ENV['METERPRETER'] = 'java php' etc + Acceptance::Meterpreter.run_meterpreter?(meterpreter_config) && + # Run if ENV['METERPRETER_MODULE_TEST'] = 'test/cmd_exec' etc + Acceptance::Meterpreter.run_meterpreter_module_test?(module_test[:name]) && + # Only run payloads / tests, if the host machine can run them + Acceptance::Meterpreter.supported_platform?(payload_config) && + Acceptance::Meterpreter.supported_platform?(module_test) && + # Skip tests that are explicitly skipped, or won't pass in the current environment + !Acceptance::Meterpreter.skipped_module_test?(module_test, TEST_ENVIRONMENT) + ), + # test metadata - will appear in allure report + module_test: module_test[:name] + ) do + begin + replication_commands = [] + current_payload_status = '' + + known_failures = module_test.dig(:lines, :all, :known_failures) || [] + known_failures += module_test.dig(:lines, current_platform, :known_failures) || [] + known_failures = known_failures.flat_map { |value| Acceptance::LineValidation.new(*Array(value)).flatten } + + required_lines = module_test.dig(:lines, :all, :required) || [] + required_lines += module_test.dig(:lines, current_platform, :required) || [] + required_lines = required_lines.flat_map { |value| Acceptance::LineValidation.new(*Array(value)).flatten } + + # Ensure we have a valid session id; We intentionally omit this from a `before(:each)` to ensure the allure attachments are generated if the session dies + payload_process, session_id = payload_process_and_session_id + + expect(payload_process).to(be_alive, proc do + current_payload_status = "Expected Payload process to be running. Instead got: payload process exited with #{payload_process.wait_thread.value} - when running the command #{payload_process.cmd.inspect}" + + Allure.add_attachment( + name: 'Failed payload blob', + source: Base64.strict_encode64(File.binread(payload_process.payload_path)), + type: Allure::ContentType::TXT + ) + + current_payload_status + end) + expect(session_id).to_not(be_nil, proc do + "There should be a session present" + end) + + use_module = "use #{module_test[:name]}" + run_module = "run session=#{session_id} AddEntropy=true Verbose=true" + + replication_commands << use_module + console.sendline(use_module) + console.recvuntil(Acceptance::Console.prompt) + + replication_commands << run_module + console.sendline(run_module) + + # XXX: When debugging failed tests, you can enter into an interactive msfconsole prompt with: + # console.interact + + # Expect the test module to complete + test_result = console.recvuntil('Post module execution completed') + + # Ensure there are no failures, and assert tests are complete + aggregate_failures("#{payload_config[:name].inspect} payload and passes the #{module_test[:name].inspect} tests") do + # Skip any ignored lines from the validation input + validated_lines = test_result.lines.reject do |line| + is_acceptable = known_failures.any? do |acceptable_failure| + line.include?(acceptable_failure.value) && + acceptable_failure.if?(test_environment) + end || line.match?(/Passed: \d+; Failed: \d+/) + + is_acceptable + end + + validated_lines.each do |test_line| + test_line = Acceptance::Meterpreter.uncolorize(test_line) + expect(test_line).to_not include('FAILED', '[-] FAILED', '[-] Exception', '[-] '), "Unexpected error: #{test_line}" + end + + # Assert all expected lines are present + required_lines.each do |required| + next unless required.if?(test_environment) + + expect(test_result).to include(required.value) + end + + # Assert all ignored lines are present, if they are not present - they should be removed from + # the calling config + known_failures.each do |acceptable_failure| + next if acceptable_failure.flaky?(test_environment) + next unless acceptable_failure.if?(test_environment) + + expect(test_result).to include(acceptable_failure.value) + end + end + rescue RSpec::Expectations::ExpectationNotMetError, StandardError => e + test_run_error = e + end + + # Test cleanup. We intentionally omit cleanup from an `after(:each)` to ensure the allure attachments are + # still generated if the session dies in a weird way etc + + # Payload process cleanup / verification + # The payload process wasn't initially marked as dead - let's close it + if payload_process.present? && current_payload_status.blank? + begin + if payload_process.alive? + current_payload_status = "Process still alive after running test suite" + payload_process.close + else + current_payload_status = "Expected Payload process to be running. Instead got: payload process exited with #{payload_process.wait_thread.value} - when running the command #{payload_process.cmd.inspect}" + end + rescue => e + Allure.add_attachment( + name: 'driver.close_payloads failure information', + source: "Error: #{e.class} - #{e.message}\n#{(e.backtrace || []).join("\n")}", + type: Allure::ContentType::TXT + ) + end + end + + console_reset_error = nil + current_console_data = console.all_data + begin + console.reset + rescue => e + console_reset_error = e + Allure.add_attachment( + name: 'console.reset failure information', + source: "Error: #{e.class} - #{e.message}\n#{(e.backtrace || []).join("\n")}", + type: Allure::ContentType::TXT + ) + end + + payload_configuration_details = payload.as_readable_text( + default_global_datastore: default_global_datastore, + default_module_datastore: default_module_datastore + ) + + replication_steps = <<~EOF + ## Load test modules + loadpath test/modules + + #{payload_configuration_details} + + ## Replication commands + #{replication_commands.empty? ? 'no additional commands run' : replication_commands.join("\n")} + EOF + + Allure.add_attachment( + name: 'payload configuration and replication', + source: replication_steps, + type: Allure::ContentType::TXT + ) + + Allure.add_attachment( + name: 'payload output if available', + source: "Final status:\n#{current_payload_status}\nstdout and stderr:\n#{get_file_attachment_contents(payload_stdout_and_stderr_file.path)}", + type: Allure::ContentType::TXT + ) + + Allure.add_attachment( + name: 'payload debug log if available', + source: get_file_attachment_contents(meterpreter_logging_file.path), + type: Allure::ContentType::TXT + ) + + Allure.add_attachment( + name: 'session tlv logging if available', + source: get_file_attachment_contents(session_tlv_logging_file.path), + type: Allure::ContentType::TXT + ) + + Allure.add_attachment( + name: 'console data', + source: current_console_data, + type: Allure::ContentType::TXT + ) + + test_assertions = JSON.pretty_generate( + { + required_lines: required_lines.map(&:to_h), + known_failures: known_failures.map(&:to_h), + } + ) + Allure.add_attachment( + name: 'test assertions', + source: test_assertions, + type: Allure::ContentType::TXT + ) + + raise test_run_error if test_run_error + raise console_reset_error if console_reset_error + end + end + end + end + end + end + end + end +end diff --git a/spec/acceptance_spec_helper.rb b/spec/acceptance_spec_helper.rb new file mode 100644 index 000000000000..b4df9dff8081 --- /dev/null +++ b/spec/acceptance_spec_helper.rb @@ -0,0 +1,27 @@ +# spec_helper for running Meterpreter acceptance tests +require 'allure_config' +require 'spec_helper' +require 'test_prof/recipes/rspec/let_it_be' + +acceptance_support_glob = File.expand_path(File.join(File.dirname(__FILE__), 'support', 'acceptance', '**', '*.rb')) +shared_contexts_glob = File.expand_path(File.join(File.dirname(__FILE__), 'support', 'shared', 'contexts', '**', '*.rb')) +Dir[acceptance_support_glob, shared_contexts_glob].each do |f| + require f +end + +class MetasploitTransactionAdapter + # before_all adapters must implement two methods: + # - begin_transaction + # - rollback_transaction + def begin_transaction + # noop + end + + def rollback_transaction + # noop + end +end + +RSpec.configure do |config| + TestProf::BeforeAll.adapter = MetasploitTransactionAdapter.new +end diff --git a/spec/allure_config.rb b/spec/allure_config.rb new file mode 100644 index 000000000000..c2a5ca636344 --- /dev/null +++ b/spec/allure_config.rb @@ -0,0 +1,26 @@ +require "allure-rspec" + +AllureRspec.configure do |config| + config.results_directory = "tmp/allure-raw-data" + config.clean_results_directory = true + config.logging_level = Logger::INFO + config.logger = Logger.new($stdout, Logger::DEBUG) + config.environment = RbConfig::CONFIG['host_os'] + + # Add additional metadata to allure + environment_properties = { + host_os: RbConfig::CONFIG['host_os'], + ruby_version: RUBY_VERSION, + host_runner_image: ENV['HOST_RUNNER_IMAGE'], + }.compact + meterpreter_name = ENV['METERPRETER'] + meterpreter_runtime_version = ENV['METERPRETER_RUNTIME_VERSION'] + if meterpreter_name.present? + environment_properties[:meterpreter_name] = meterpreter_name + if meterpreter_runtime_version.present? + environment_properties[:meterpreter_runtime_version] = "#{meterpreter_name}#{meterpreter_runtime_version}" + end + end + + config.environment_properties = environment_properties.compact +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0dc042a3bda3..f3e60d788a02 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -62,6 +62,12 @@ config.include RuboCop::RSpec::ExpectOffense config.expose_dsl_globally = false + # Don't run Acceptance tests by default + config.define_derived_metadata(file_path: %r{spec/acceptance/}) do |metadata| + metadata[:acceptance] ||= true + end + config.filter_run_excluding({ acceptance: true }) + # These two settings work together to allow 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 diff --git a/spec/support/acceptance/child_process.rb b/spec/support/acceptance/child_process.rb new file mode 100644 index 000000000000..786b28aed4bf --- /dev/null +++ b/spec/support/acceptance/child_process.rb @@ -0,0 +1,545 @@ +require 'stringio' +require 'open3' +require 'English' +require 'tempfile' +require 'fileutils' +require 'timeout' +require 'shellwords' + +module Acceptance + class ChildProcessError < ::StandardError + end + + class ChildProcessTimeoutError < ::StandardError + end + + class ChildProcessRecvError < ::StandardError + end + + # A wrapper around ::Open3.popen2e - allows creating a process, writing to stdin, and reading the process output + # All of the data is stored for future retrieval/appending to test output + class ChildProcess + def initialize + super + + @default_timeout = ENV['CI'] ? 120 : 40 + @debug = false + @env ||= {} + @cmd ||= [] + @options ||= {} + + @stdin = nil + @stdout_and_stderr = nil + @wait_thread = nil + + @buffer = StringIO.new + @all_data = StringIO.new + end + + # @return [String] All data that was read from stdout/stderr of the running process + def all_data + @all_data.string + end + + # Runs the process + # @return [nil] + def run + self.stdin, self.stdout_and_stderr, self.wait_thread = ::Open3.popen2e( + @env, + *@cmd, + **@options + ) + + stdin.sync = true + stdout_and_stderr.sync = true + + nil + rescue StandardError => e + warn "popen failure #{e}" + raise + end + + # @return [String] A line of input + def recvline(timeout: @default_timeout) + recvuntil($INPUT_RECORD_SEPARATOR, timeout: timeout) + end + + alias readline recvline + + # @param [String|Regexp] delim + def recvuntil(delim, timeout: @default_timeout, drop_delim: false) + buffer = '' + result = nil + + with_countdown(timeout) do |countdown| + while alive? && !countdown.elapsed? + data_chunk = recv(timeout: [countdown.remaining_time, 1].min) + if !data_chunk + next + end + + buffer += data_chunk + has_delimiter = delim.is_a?(Regexp) ? buffer.match?(delim) : buffer.include?(delim) + next unless has_delimiter + + result, matched_delim, remaining = buffer.partition(delim) + unless drop_delim + result += matched_delim + end + unrecv(remaining) + # Reset the temporary buffer to avoid the `ensure` mechanism unrecv'ing the buffer unintentionally + buffer = '' + + return result + end + ensure + unrecv(buffer) + end + + result + rescue ChildProcessTimeoutError + raise ChildProcessRecvError, "Failed #{__method__}: Did not match #{delim.inspect}, process was alive?=#{alive?.inspect}, remaining buffer: #{self.buffer.string[self.buffer.pos..].inspect}" + end + + # @return [String] Recv until additional reads would cause a block, or eof is reached, or a maximum timeout is reached + def recv_available(timeout: @default_timeout) + result = '' + finished_reading = false + + with_countdown(timeout) do + until finished_reading do + data_chunk = recv(timeout: 0, wait_readable: false) + if !data_chunk + finished_reading = true + next + end + + result += data_chunk + end + end + + result + rescue EOFError, ChildProcessTimeoutError + result + end + + # @param [String] data The string of bytes to put back onto the buffer; Future buffered reads will return these bytes first + def unrecv(data) + data.bytes.reverse.each { |b| buffer.ungetbyte(b) } + end + + # @param [Integer] length Reads length bytes from the I/O stream + # @param [Integer] timeout The timeout in seconds + # @param [TrueClass] wait_readable True if blocking, false otherwise + def recv(length = 4096, timeout: @default_timeout, wait_readable: true) + buffer_result = buffer.read(length) + return buffer_result if buffer_result + + retry_count = 0 + + # Eagerly read, and if we fail - await a response within the given timeout period + result = nil + begin + result = stdout_and_stderr.read_nonblock(length) + unless result.nil? + log("[read] #{result}") + @all_data.write(result) + end + rescue IO::WaitReadable + if wait_readable + IO.select([stdout_and_stderr], nil, nil, timeout) + retry_count += 1 + retry if retry_count == 1 + end + end + + result + end + + # @param [String] data Write the data to the tdin of the running process + def write(data) + log("[write] #{data}") + @all_data.write(data) + stdin.write(data) + stdin.flush + end + + # @param [String] s Send line of data to the stdin of the running process + def sendline(s) + write("#{s}#{$INPUT_RECORD_SEPARATOR}") + end + + # @return [TrueClass, FalseClass] True if the running process is alive, false otherwise + def alive? + wait_thread.alive? + end + + # Interact with the current process, forwarding the current stdin to the console's stdin, + # and writing the console's output to stdout. Doesn't support using PTY/raw mode. + def interact + $stderr.puts + $stderr.puts '[*] Opened interactive mode - enter "!next" to continue, or "!exit" to stop entirely. !pry for an interactive pry' + $stderr.puts + + without_debugging do + while alive? + ready = IO.select([stdout_and_stderr, $stdin], [], [], 10) + + next unless ready + + reads, = ready + + reads.to_a.each do |read| + case read + when $stdin + input = $stdin.gets + if input.chomp == '!continue' + return + elsif input.chomp == '!exit' + exit + elsif input.chomp == '!pry' + require 'pry-byebug'; binding.pry + end + + write(input) + when stdout_and_stderr + available_bytes = recv + $stdout.write(available_bytes) + $stdout.flush + end + end + end + end + end + + def close + begin + Process.kill('KILL', wait_thread.pid) if wait_thread.pid + rescue StandardError => e + warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}" + end + stdin.close if stdin + stdout_and_stderr.close if stdout_and_stderr + end + + # @return [IO] the stdin for the child process which can be written to + attr_reader :stdin + # @return [IO] the stdout and stderr for the child process which can be read from + attr_reader :stdout_and_stderr + # @return [Process::Waiter] the waiter thread for the current process + attr_reader :wait_thread + + # @return [String] The cmd that was used to execute the current process + attr_reader :cmd + + private + + # @return [StringIO] the buffer for any data which was read from stdout/stderr which was read, but not consumed + attr_reader :buffer + # @return [IO] the stdin of the running process + attr_writer :stdin + # @return [IO] the stdout and stderr of the running process + attr_writer :stdout_and_stderr + # @return [Process::Waiter] The process wait thread which tracks if the process is alive, its pid, return value, etc. + attr_writer :wait_thread + + # @param [String] s Log to stderr + def log(s) + return unless @debug + + $stderr.puts s + end + + def without_debugging + previous_debug_value = @debug + @debug = false + yield + ensure + @debug = previous_debug_value + end + + # Yields a timer object that can be used to request the remaining time available + def with_countdown(timeout) + countdown = Acceptance::Countdown.new(timeout) + # It is the caller's responsibility to honor the required countdown limits, + # but let's wrap the full operation in an explicit for worse case scenario, + # which may leave object state in a non-determinant state depending on the call + ::Timeout.timeout(timeout * 1.5) do + yield countdown + end + if countdown.elapsed? + raise ChildProcessTimeoutError + end + rescue ::Timeout::Error + raise ChildProcessTimeoutError + end + end + + # Internally generates a temporary file with Dir::Tmpname instead of a ::Tempfile instance, otherwise windows won't allow the file to be executed + # at the same time as the current Ruby process having an open handle to the temporary file + class TempChildProcessFile + def initialize(basename, extension) + @file_path = Dir::Tmpname.create([basename, extension]) do |_path, _n, _opts, _origdir| + # noop + end + + ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(@file_path)) + end + + def path + @file_path + end + + def to_s + path + end + + def inspect + "#<#{self.class} #{self.path}>" + end + + def self.finalizer_proc_for(path) + proc { File.delete(path) if File.exist?(path) } + end + end + + ### + # Stores the data for a payload, including the options used to generate the payload, + ### + class Payload + attr_reader :name, :execute_cmd, :generate_options, :datastore + + def initialize(options) + @name = options.fetch(:name) + @execute_cmd = options.fetch(:execute_cmd) + @generate_options = options.fetch(:generate_options) + @datastore = options.fetch(:datastore) + @executable = options.fetch(:executable, false) + + basename = "#{File.basename(__FILE__)}_#{name}".gsub(/[^a-zA-Z]/, '-') + extension = options.fetch(:extension, '') + + @file_path = TempChildProcessFile.new(basename, extension) + end + + # @return [TrueClass, FalseClass] True if the payload needs marked as executable before being executed + def executable? + @executable + end + + # @return [String] The path to the payload on disk + def path + @file_path.path + end + + # @return [Integer] The size of the payload on disk. May be 0 when the payload doesn't exist, + # or a smaller size than expected if the payload is not fully generated by msfconsole yet. + def size + File.size(path) + rescue StandardError => _e + 0 + end + + def [](k) + options[k] + end + + # @return [Array] The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"] + def execute_command + @execute_cmd.map do |val| + val.gsub('${payload_path}', path) + end + end + + # @param [Hash] default_global_datastore + # @return [String] The setg commands for setting the global datastore + def setg_commands(default_global_datastore: {}) + commands = [] + # Ensure the global framework datastore is always clear + commands << "irb -e '(self.respond_to?(:framework) ? framework : self).datastore.user_defined.clear'" + # Call setg + global_datastore = default_global_datastore.merge(@datastore[:global]) + global_datastore.each do |key, value| + commands << "setg #{key} #{value}" + end + commands.join("\n") + end + + # @param [Hash] default_module_datastore + # @return [String] The command which can be used on msfconsole to generate the payload + def generate_command(default_module_datastore: {}) + module_datastore = default_module_datastore.merge(@datastore[:module]) + generate_options = @generate_options.map do |key, value| + "#{key} #{value}" + end + module_options = module_datastore.map do |key, value| + "#{key}=#{value}" + end + + "generate -o #{path} #{generate_options.join(' ')} #{module_options.join(' ')}" + end + + # @param [Hash] default_global_datastore + # @param [Hash] default_module_datastore + # @return [String] A human readable representation of the payload configuration object + def as_readable_text(default_global_datastore: {}, default_module_datastore: {}) + <<~EOF + ## Payload + use #{name} + + ## Set global datastore + #{setg_commands(default_global_datastore: default_global_datastore)} + + ## Generate command + #{generate_command(default_module_datastore: default_module_datastore)} + + ## Create listener + to_handler + + ## Execute command + #{Shellwords.join(execute_command)} + EOF + end + end + + class PayloadProcess + # @return [Process::Waiter] the waiter thread for the current process + attr_reader :wait_thread + + # @return [String] the executed command + attr_reader :cmd + + # @return [String] the payload path on disk + attr_reader :payload_path + + # @param [Array] cmd The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"] + # @param [path] payload_path The payload path on disk + # @param [Hash] opts the opts to pass to the Process#spawn call + def initialize(cmd, payload_path, opts = {}) + super() + + @payload_path = payload_path + @debug = false + @env = {} + @cmd = cmd + @options = opts + end + + # @return [Process::Waiter] the waiter thread for the payload process + def run + pid = Process.spawn( + @env, + *@cmd, + **@options + ) + @wait_thread = Process.detach(pid) + @wait_thread + end + + def alive? + @wait_thread.alive? + end + + def close + begin + Process.kill('KILL', wait_thread.pid) if wait_thread.pid + rescue StandardError => e + warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}" + end + [:in, :out, :err].each do |name| + @options[name].close if @options[name] + end + @wait_thread.join + end + end + + class ConsoleDriver + def initialize + @console = nil + @payload_processes = [] + ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(self)) + end + + # @param [Acceptance::Payload] payload + # @param [Hash] opts + def run_payload(payload, opts) + if payload.executable? && !File.executable?(payload.path) + FileUtils.chmod('+x', payload.path) + end + + payload_process = PayloadProcess.new(payload.execute_command, payload.path, opts) + payload_process.run + @payload_processes << payload_process + payload_process + end + + # @return [Acceptance::Console] + def open_console + @console = Console.new + @console.run + @console.recvuntil(Console.prompt, timeout: 120) + + @console + end + + def close_payloads + close_processes(@payload_processes) + end + + def close + close_processes(@payload_processes + [console]) + end + + def self.finalizer_proc_for(instance) + proc { instance.close } + end + + private + + def close_processes(processes) + while (process = processes.pop) + begin + process.close + rescue StandardError => e + $stderr.puts e.to_s + end + end + end + end + + class Console < ChildProcess + def initialize + super + + framework_root = Dir.pwd + @debug = true + @env = { + 'BUNDLE_GEMFILE' => File.join(framework_root, 'Gemfile'), + 'PATH' => "#{framework_root.shellescape}:#{ENV['PATH']}" + } + @cmd = [ + 'bundle', 'exec', 'ruby', 'msfconsole', + '--no-readline', + # '--logger', 'Stdout', + '--quiet' + ] + @options = { + chdir: framework_root + } + end + + def self.prompt + /msf6.*>\s+/ + end + + def reset + sendline('sessions -K') + recvuntil(Console.prompt) + + sendline('jobs -K') + recvuntil(Console.prompt) + ensure + @all_data.reopen('') + end + end +end diff --git a/spec/support/acceptance/countdown.rb b/spec/support/acceptance/countdown.rb new file mode 100644 index 000000000000..979499f7f6d0 --- /dev/null +++ b/spec/support/acceptance/countdown.rb @@ -0,0 +1,23 @@ +module Acceptance + ### + # A utility class which can be used in conjunction with Timeout mechanisms + ### + class Countdown + # @param [int] timeout The time in seconds that this count starts from + def initialize(timeout) + @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) + @end_time = @start_time + timeout + @timeout = timeout + end + + # @return [TrueClass, FalseClass] True if the timeout has surpassed, false otherwise + def elapsed? + remaining_time == 0 + end + + # @return [Integer] The time in seconds left before this countdown expires + def remaining_time + [@end_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :second), 0].max + end + end +end diff --git a/spec/support/acceptance/line_validation.rb b/spec/support/acceptance/line_validation.rb new file mode 100644 index 000000000000..3c6819fd3be2 --- /dev/null +++ b/spec/support/acceptance/line_validation.rb @@ -0,0 +1,55 @@ +module Acceptance + ### + # A utility object representing the validation of a a line of output generated + # by the acceptance test suite. + ### + class LineValidation + # @param [string|Array] values A line string, or array of lines + # @param [Object] options Additional options for configuring this failure, i.e. if it's a known flaky test result etc. + def initialize(values, options = {}) + @values = Array(values) + @options = options + end + + def flatten + @values.map { |value| self.class.new(value, @options) } + end + + def value + raise StandardError, 'More than one value present' if @values.length > 1 + + @values[0] + end + + # @return [boolean] returns true if the current failure applies under the current environment or the result is flaky, false otherwise. + # @param [Hash] environment The current execution environment + # @return [TrueClass, FalseClass] True if the line is flaky - and may not always be present, false otherwise + def flaky?(environment = {}) + value = @options.fetch(:flaky, false) + + evaluate_predicate(value, environment) + end + + # @return [boolean] returns true if the current failure applies under the current environment or the result is flaky, false otherwise. + # @param [Hash] environment + # @return [TrueClass, FalseClass] True if the line should be considered valid, false otherwise + def if?(environment = {}) + value = @options.fetch(:if, true) + evaluate_predicate(value, environment) + end + + def to_h + { + values: @values, + options: @options + } + end + + private + + # (see Acceptance::Meterpreter#eval_predicate) + def evaluate_predicate(value, environment) + Acceptance::Meterpreter.eval_predicate(value, environment) + end + end +end diff --git a/spec/support/acceptance/meterpreter.rb b/spec/support/acceptance/meterpreter.rb new file mode 100644 index 000000000000..6d20de39e7e7 --- /dev/null +++ b/spec/support/acceptance/meterpreter.rb @@ -0,0 +1,102 @@ +module Acceptance::Meterpreter + # @return [Symbol] The current platform + def self.current_platform + host_os = RbConfig::CONFIG['host_os'] + case host_os + when /darwin/ + :osx + when /mingw/ + :windows + when /linux/ + :linux + else + raise "unknown host_os #{host_os.inspect}" + end + end + + # Allows restricting the tests of a specific Meterpreter's test suite with the METERPRETER environment variable + # @return [TrueClass, FalseClass] True if the given Meterpreter should be run, false otherwise. + def self.run_meterpreter?(meterpreter_config) + return true if ENV['METERPRETER'].blank? + + name = meterpreter_config[:name].to_s + ENV['METERPRETER'].include?(name) + end + + # Allows restricting the tests of a specific Meterpreter's test suite with the METERPRETER environment variable + # @return [TrueClass, FalseClass] True if the given Meterpreter should be run, false otherwise. + def self.run_meterpreter_module_test?(module_test) + return true if ENV['METERPRETER_MODULE_TEST'].blank? + + ENV['METERPRETER_MODULE_TEST'].include?(module_test) + end + + # @param [String] string A console string with ANSI escape codes present + # @return [String] A string with the ANSI escape codes removed + def self.uncolorize(string) + string.gsub(/\e\[\d+m/, '') + end + + # @param [Hash] payload_config + # @return [Boolean] + def self.supported_platform?(payload_config) + payload_config[:platforms].include?(current_platform) + end + + # @param [Hash] module_test + # @return [Boolean] + def self.skipped_module_test?(module_test, test_environment) + current_platform_requirements = Array(module_test[:platforms].find { |platform| Array(platform)[0] == current_platform })[1] || {} + module_test.fetch(:skip, false) || + self.eval_predicate(current_platform_requirements.fetch(:skip, false), test_environment) + end + + # @param [Hash] payload_config + # @return [String] The human readable name for the given payload configuration + def self.human_name_for_payload(payload_config) + is_stageless = payload_config[:name].include?('meterpreter_reverse_tcp') + is_staged = payload_config[:name].include?('meterpreter/reverse_tcp') + + details = [] + details << 'stageless' if is_stageless + details << 'staged' if is_staged + details << payload_config[:name] + + details.join(' ') + end + + # @param [Object] hash A hash of key => hash + # @return [Object] Returns a new hash with the 'key' merged into hash value and all payloads + def self.with_meterpreter_name_merged(hash) + hash.each_with_object({}) do |(name, config), acc| + acc[name] = config.merge({ name: name }) + end + end + + # Evaluates a simple predicate; Similar to Msf::OptCondition.eval_condition + # @param [TrueClass,FalseClass,Array] value + # @param [Hash] environment + # @return [TrueClass, FalseClass] True or false + def self.eval_predicate(value, environment) + case value + when Array + left_operand, operator, right_operand = value + # Map values such as `:meterpreter_name` to the runtime value + left_operand = environment[left_operand] if environment.key?(left_operand) + right_operand = environment[right_operand] if environment.key?(right_operand) + + case operator.to_sym + when :== + evaluate_predicate(left_operand, environment) == evaluate_predicate(right_operand, environment) + when :!= + evaluate_predicate(left_operand, environment) != evaluate_predicate(right_operand, environment) + when :or + evaluate_predicate(left_operand, environment) || evaluate_predicate(right_operand, environment) + else + raise "unexpected operator #{operator.inspect}" + end + else + value + end + end +end diff --git a/spec/support/acceptance/meterpreter/java.rb b/spec/support/acceptance/meterpreter/java.rb new file mode 100644 index 000000000000..9aa8fe9f1dcb --- /dev/null +++ b/spec/support/acceptance/meterpreter/java.rb @@ -0,0 +1,271 @@ +require 'support/acceptance/meterpreter' + +module Acceptance::Meterpreter + JAVA_METERPRETER = { + payloads: [ + { + name: "java/meterpreter/reverse_tcp", + extension: ".jar", + platforms: [:osx, :linux, :windows], + execute_cmd: ["java", "-jar", "${payload_path}"], + generate_options: { + '-f': "jar" + }, + datastore: { + global: {}, + module: { + spawn: 0 + } + } + } + ], + module_tests: [ + { + name: "test/services", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] [should start W32Time] FAILED: should start W32Time", + "[-] [should start W32Time] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should stop W32Time] FAILED: should stop W32Time", + "[-] [should stop W32Time] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should create a service testes] FAILED: should create a service testes", + "[-] [should create a service testes] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should return info on the newly-created service testes] Could not retrieve the start type of the testes service!", + "[-] FAILED: should return info on the newly-created service testes", + "[-] [should delete the new service testes] FAILED: should delete the new service testes", + "[-] [should delete the new service testes] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should return status on a given service winmgmt] FAILED: should return status on a given service winmgmt", + "[-] [should return status on a given service winmgmt] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should modify config on a given service] FAILED: should modify config on a given service", + "[-] [should modify config on a given service] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should start a disabled service] FAILED: should start a disabled service", + "[-] [should start a disabled service] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should restart a started service W32Time] FAILED: should restart a started service W32Time", + "[-] [should restart a started service W32Time] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should raise a runtime exception if no access to service] FAILED: should raise a runtime exception if no access to service", + "[-] [should raise a runtime exception if no access to service] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)", + "[-] [should raise a runtime exception if services doesnt exist] FAILED: should raise a runtime exception if services doesnt exist", + "[-] [should raise a runtime exception if services doesnt exist] Exception: Rex::Post::Meterpreter::RequestError: stdapi_railgun_api: Operation failed: The command is not supported by this Meterpreter type (java/windows)" + ] + } + } + }, + { + name: "test/cmd_exec", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/extapi", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/file", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] [should delete a symbolic link target] failed to create the symbolic link" + ] + } + } + }, + { + name: "test/get_env", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/meterpreter", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun_reverse_lookups", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/registry", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] FAILED: should write REG_EXPAND_SZ values", + "[-] FAILED: should write REG_SZ unicode values" + ] + } + } + }, + { + name: "test/search", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/unix", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Unix only test" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + } + ] + } +end diff --git a/spec/support/acceptance/meterpreter/mettle.rb b/spec/support/acceptance/meterpreter/mettle.rb new file mode 100644 index 000000000000..4f23b7d7941c --- /dev/null +++ b/spec/support/acceptance/meterpreter/mettle.rb @@ -0,0 +1,351 @@ +require 'support/acceptance/meterpreter' + +module Acceptance::Meterpreter + METTLE_METERPRETER = { + payloads: [ + { + name: "linux/x64/meterpreter/reverse_tcp", + extension: "", + platforms: [:linux], + executable: true, + execute_cmd: ["${payload_path}"], + generate_options: { + '-f': "elf" + }, + datastore: { + global: {}, + module: { + MeterpreterTryToFork: false, + MeterpreterDebugBuild: true + } + } + }, + { + name: "osx/x64/meterpreter_reverse_tcp", + extension: "", + platforms: [:osx], + executable: true, + execute_cmd: ["${payload_path}"], + generate_options: { + '-f': "macho" + }, + datastore: { + global: {}, + module: { + MeterpreterTryToFork: false, + MeterpreterDebugBuild: true + } + } + } + ], + module_tests: [ + { + name: "test/services", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/cmd_exec", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/extapi", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/file", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/get_env", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/meterpreter", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [ + "[-] FAILED: should return network interfaces", + "[-] FAILED: should have an interface that matches session_host" + ] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun_reverse_lookups", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/registry", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/search", + platforms: [ + :linux, + [ + :osx, + { + skip: true, + reason: "skipped - test/search hangs in osx and CPU spikes to >300%" + } + ], + [ + :windows, + { + skip: true, + reason: "Payload not compiled for platform" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/unix", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Unix only test" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + } + ] + } +end diff --git a/spec/support/acceptance/meterpreter/php.rb b/spec/support/acceptance/meterpreter/php.rb new file mode 100644 index 000000000000..02ea1f9e9254 --- /dev/null +++ b/spec/support/acceptance/meterpreter/php.rb @@ -0,0 +1,275 @@ +require 'support/acceptance/meterpreter' + +module Acceptance::Meterpreter + PHP_METERPRETER = { + payloads: [ + { + name: "php/meterpreter_reverse_tcp", + extension: ".php", + platforms: [:osx, :linux, :windows], + execute_cmd: ["php", "${payload_path}"], + generate_options: { + '-f': "raw" + }, + datastore: { + global: {}, + module: { + MeterpreterDebugBuild: true + } + } + } + ], + module_tests: [ + { + name: "test/services", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :windows, + { + skip: [ + :meterpreter_runtime_version, + :==, + "php5.3" + ], + reason: "Skip PHP 5.3 as the tests timeout - due to cmd_exec taking 15 seconds for each call. Caused by failure to detect feof correctly - https://github.com/rapid7/metasploit-payloads/blame/c7f7bc2fc0b86e17c3bc078149c71745c5e478b3/php/meterpreter/meterpreter.php#L1127-L1145" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/cmd_exec", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] FAILED: should return the stderr output" + ] + } + } + }, + { + name: "test/extapi", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/file", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [ + "[-] FAILED: should read the binary data we just wrote" + ] + }, + osx: { + known_failures: [ + "[-] FAILED: should read the binary data we just wrote" + ] + }, + windows: { + known_failures: [ + "[-] [should delete a symbolic link target] FAILED: should delete a symbolic link target", + "[-] [should delete a symbolic link target] Exception: Rex::Post::Meterpreter::RequestError: stdapi_fs_delete_dir: Operation failed: 1", + "[-] FAILED: should read the binary data we just wrote" + ] + } + } + }, + { + name: "test/get_env", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/meterpreter", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [ + "[-] FAILED: should return a list of processes" + ] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun_reverse_lookups", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/registry", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :windows, + { + skip: [ + :meterpreter_runtime_version, + :==, + "php5.3" + ], + reason: "Skip PHP 5.3 as the tests timeout - due to cmd_exec taking 15 seconds for each call. Caused by failure to detect feof correctly - https://github.com/rapid7/metasploit-payloads/blame/c7f7bc2fc0b86e17c3bc078149c71745c5e478b3/php/meterpreter/meterpreter.php#L1127-L1145" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/search", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/unix", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Unix only test" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + } + ] + } +end diff --git a/spec/support/acceptance/meterpreter/python.rb b/spec/support/acceptance/meterpreter/python.rb new file mode 100644 index 000000000000..aaf8bfdcc4ad --- /dev/null +++ b/spec/support/acceptance/meterpreter/python.rb @@ -0,0 +1,272 @@ +require 'support/acceptance/meterpreter' + +module Acceptance::Meterpreter + PYTHON_METERPRETER = { + payloads: [ + { + name: "python/meterpreter_reverse_tcp", + extension: ".py", + platforms: [:osx, :linux, :windows], + execute_cmd: ["python", "${payload_path}"], + generate_options: { + '-f': "raw" + }, + datastore: { + global: {}, + module: { + MeterpreterTryToFork: false, + PythonMeterpreterDebug: true + } + } + } + ], + module_tests: [ + { + name: "test/services", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] [should start W32Time] FAILED: should start W32Time", + "[-] [should start W32Time] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6.", + "[-] [should stop W32Time] FAILED: should stop W32Time", + "[-] [should stop W32Time] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6.", + "[-] [should list services] FAILED: should list services", + "[-] [should list services] Exception: NoMethodError: undefined method `service' for nil:NilClass", + "[-] [should return info on a given service winmgmt] FAILED: should return info on a given service winmgmt", + "[-] [should return info on a given service winmgmt] Exception: NoMethodError: undefined method `service' for nil:NilClass", + "[-] FAILED: should create a service testes", + "[-] [should return info on the newly-created service testes] FAILED: should return info on the newly-created service testes", + "[-] [should return info on the newly-created service testes] Exception: NoMethodError: undefined method `service' for nil:NilClass", + "[-] [should delete the new service testes] FAILED: should delete the new service testes", + "[-] [should delete the new service testes] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6.", + "[-] [should return status on a given service winmgmt] FAILED: should return status on a given service winmgmt", + "[-] [should return status on a given service winmgmt] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6.", + "[-] [should modify config on a given service] FAILED: should modify config on a given service", + "[-] [should modify config on a given service] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6.", + "[-] FAILED: should start a disabled service", + "[-] [should restart a started service W32Time] FAILED: should restart a started service W32Time", + "[-] [should restart a started service W32Time] Exception: RuntimeError: Could not open service. OpenServiceA error: FormatMessage failed to retrieve the error for value 0x6." + ] + } + } + }, + { + name: "test/cmd_exec", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/extapi", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] [should return clipboard jpg dimensions] FAILED: should return clipboard jpg dimensions", + "[-] [should return clipboard jpg dimensions] Exception: NoMethodError: undefined method `clipboard' for nil:NilClass", + "[-] [should download clipboard jpg data] FAILED: should download clipboard jpg data", + "[-] [should download clipboard jpg data] Exception: NoMethodError: undefined method `clipboard' for nil:NilClass" + ] + } + } + }, + { + name: "test/file", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/get_env", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/meterpreter", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [ + "[-] FAILED: should return the proper directory separator" + ] + } + } + }, + { + name: "test/railgun", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun_reverse_lookups", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/registry", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/search", + platforms: [:linux, :osx, :windows], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/unix", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Unix only test" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + } + ] + } +end diff --git a/spec/support/acceptance/meterpreter/windows_meterpreter.rb b/spec/support/acceptance/meterpreter/windows_meterpreter.rb new file mode 100644 index 000000000000..44ffc325f4f2 --- /dev/null +++ b/spec/support/acceptance/meterpreter/windows_meterpreter.rb @@ -0,0 +1,374 @@ +require 'support/acceptance/meterpreter' + +module Acceptance::Meterpreter + WINDOWS_METERPRETER = { + payloads: [ + { + name: "windows/meterpreter/reverse_tcp", + extension: ".exe", + platforms: [:windows], + execute_cmd: ["${payload_path}"], + executable: true, + generate_options: { + '-f': "exe" + }, + datastore: { + global: {}, + module: { + # Not suported by Windows Meterpreter + # MeterpreterTryToFork: false, + MeterpreterDebugBuild: true + } + } + } + ], + module_tests: [ + { + name: "test/services", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/cmd_exec", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/extapi", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/file", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/get_env", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/meterpreter", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/railgun_reverse_lookups", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/registry", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Windows only test" + } + ], + [ + :osx, + { + skip: true, + reason: "Windows only test" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/search", + platforms: [ + [ + :linux, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + [ + :osx, + { + skip: true, + reason: "Payload not compiled for platform" + } + ], + :windows + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + }, + { + name: "test/unix", + platforms: [ + :linux, + :osx, + [ + :windows, + { + skip: true, + reason: "Unix only test" + } + ] + ], + skipped: false, + lines: { + linux: { + known_failures: [] + }, + osx: { + known_failures: [] + }, + windows: { + known_failures: [] + } + } + } + ] + } +end diff --git a/spec/support/acceptance/port_allocator.rb b/spec/support/acceptance/port_allocator.rb new file mode 100644 index 000000000000..e99c558c3a28 --- /dev/null +++ b/spec/support/acceptance/port_allocator.rb @@ -0,0 +1,18 @@ +module Acceptance + ### + # A utility class for generating the next available bind port that is free + # on the host machine + ### + class PortAllocator + def initialize(base = 6000) + @base = base + @current = base + end + + # @return [Integer] The next available port that can be bound to on the host + def next + # TODO: In the future this could verify the port is free, and attempt to avoid TOCTTOU issues + @current += 1 + end + end +end diff --git a/test/modules/post/test/file.rb b/test/modules/post/test/file.rb index 10445e4202bf..bc9281d45b52 100644 --- a/test/modules/post/test/file.rb +++ b/test/modules/post/test/file.rb @@ -239,7 +239,12 @@ def test_binary_files bin = read_file(datastore['BaseFileName']) rm_f(datastore['BaseFileName']) - bin == "\xde\xad\xbe\xef" + test_string = "\xde\xad\xbe\xef" + + vprint_status "expected: #{test_string.bytes} - #{test_string.encoding}" + vprint_status "actual: #{bin.bytes} - #{bin.encoding}" + + bin == test_string end end diff --git a/test/modules/post/test/meterpreter.rb b/test/modules/post/test/meterpreter.rb index 322505799771..5f077bc59386 100644 --- a/test/modules/post/test/meterpreter.rb +++ b/test/modules/post/test/meterpreter.rb @@ -143,14 +143,15 @@ def test_fs it "should return the proper directory separator" do sysinfo = session.sys.config.sysinfo + vprint_status("received sysinfo #{sysinfo}") if sysinfo["OS"] =~ /windows/i - sep = session.fs.file.separator - res = (sep == "\\") + expected_sep = "\\" else - sep = session.fs.file.separator - res = (sep == "/") + expected_sep = "/" end - + sep = session.fs.file.separator + vprint_status("Received separator #{sep.inspect} - expected: #{expected_sep.inspect}") + res = (sep == expected_sep) res end @@ -231,6 +232,9 @@ def test_fs (contents == "test") } + # XXX: On windows this can fail with: + # Rex::Post::Meterpreter::RequestError : stdapi_fs_delete_file: Operation failed: The process cannot access the file because it is being used by another process. + # Presumably the Ruby process still has a handle to the file session.fs.file.rm(file_name) res &&= !session.fs.dir.entries.include?(file_name) @@ -251,8 +255,13 @@ def test_fs if res fd = session.fs.file.new(remote, "rb") uploaded_contents = fd.read - until (fd.eof?) - uploaded_contents << fd.read + begin + until fd.eof? + uploaded_contents << fd.read + end + rescue EOFError + # An EOF can be raised on `fd.read` in the Java Meterpreter + vprint_status("EOF raised") end fd.close original_contents = ::File.read(local, mode: 'rb')