From 9a7703f4cbe15aa7056fa740857b4888f66a608e Mon Sep 17 00:00:00 2001 From: cgranleese-r7 Date: Tue, 26 Mar 2024 11:57:42 +0000 Subject: [PATCH] Add MySQL session type acceptance tests --- .github/workflows/mysql_acceptance.yml | 181 ++++++++++++ spec/acceptance/README.md | 14 + spec/acceptance/mysql_spec.rb | 365 +++++++++++++++++++++++++ test/modules/post/test/mysql.rb | 78 ++++++ 4 files changed, 638 insertions(+) create mode 100644 .github/workflows/mysql_acceptance.yml create mode 100644 spec/acceptance/mysql_spec.rb create mode 100644 test/modules/post/test/mysql.rb diff --git a/.github/workflows/mysql_acceptance.yml b/.github/workflows/mysql_acceptance.yml new file mode 100644 index 0000000000000..43c2230c36c89 --- /dev/null +++ b/.github/workflows/mysql_acceptance.yml @@ -0,0 +1,181 @@ +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' + - '**/**mysql**' + - 'spec/acceptance/**' + - 'spec/support/acceptance/**' + - 'spec/acceptance_spec_helper.rb' +# Example of running as a cron, to weed out flaky tests +# schedule: +# - cron: '*/15 * * * *' + +jobs: + mysql: + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + + services: + mysql: + image: ${{ matrix.target.version }} + ports: ["3306:3306"] + env: + MYSQL_USER: root + MYSQL_ROOT_PASSWORD: password + options: >- + --health-cmd ${{ matrix.target.health_cmd }} + --health-interval 10s + --health-timeout 10s + --health-retries 5 + strategy: + fail-fast: true + matrix: + ruby: + - '3.2' + os: + - ubuntu-latest + target: + - { version: mariadb:11.2.2, health_cmd: mariadb -uroot -ppassword -e \"SELECT version();\" } + - { version: mysql:8.3.0, health_cmd: mysql -uroot -ppassword -e \"SELECT version();\" } + + env: + RAILS_ENV: test + + name: ${{ matrix.target.version }} - ${{ matrix.os }} - Ruby ${{ matrix.ruby }} + steps: + - name: Install system dependencies + run: sudo apt-get install -y --no-install-recommends libpcap-dev graphviz + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Ruby + env: + BUNDLE_WITHOUT: "coverage development pcap" + # Nokogiri doesn't release pre-compiled binaries for preview versions of Ruby; So force compilation with BUNDLE_FORCE_RUBY_PLATFORM + BUNDLE_FORCE_RUBY_PLATFORM: "${{ contains(matrix.ruby, 'preview') && 'true' || 'false' }}" + uses: ruby/setup-ruby@v1 + with: + ruby-version: '${{ matrix.ruby }}' + bundler-cache: true + + - name: Extract runtime version + run: | + echo "RUNTIME_VERSION=$(echo $DOCKER_IMAGE | awk -F: '{ print $2 }')" >> $GITHUB_ENV + echo "DOCKER_IMAGE_FILENAME=$(echo $DOCKER_IMAGE | tr -d ':')" >> $GITHUB_ENV + env: + DOCKER_IMAGE: ${{ matrix.target.version }} + OS: ${{ matrix.os }} + + - name: acceptance + env: + SPEC_HELPER_LOAD_METASPLOIT: false + SPEC_OPTS: "--tag acceptance --require acceptance_spec_helper.rb --color --format documentation --format AllureRspec::RSpecFormatter" + RUNTIME_VERSION: ${{ env.RUNTIME_VERSION }} + # 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/mysql_spec.rb + + - name: Archive results + if: always() + uses: actions/upload-artifact@v4 + with: + # Provide a unique artifact for each matrix os, otherwise race conditions can lead to corrupt zips + name: ${{ env.DOCKER_IMAGE_FILENAME }}-${{ matrix.os }} + path: tmp/allure-raw-data + + # Generate a final report from the previous test results + report: + name: Generate report + needs: + - mysql + runs-on: ubuntu-latest + if: always() + + steps: + - name: Checkout code + uses: actions/checkout@v4 + if: always() + + - name: Install system dependencies (Linux) + if: always() + run: sudo apt-get -y --no-install-recommends install libpcap-dev graphviz + + - name: Setup Ruby + if: always() + 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 + + - uses: actions/download-artifact@v4 + 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 . + + ls -la ${{steps.download.outputs.download-path}} + ./allure-$VERSION/bin/allure generate ${{steps.download.outputs.download-path}}/* -o ./allure-report + + find ${{steps.download.outputs.download-path}} + bundle exec ruby tools/dev/report_generation/support_matrix/generate.rb --allure-data ${{steps.download.outputs.download-path}} > ./allure-report/support_matrix.html + + - name: archive results + if: always() + uses: actions/upload-artifact@v4 + with: + name: final-report-${{ github.run_id }} + path: | + ./allure-report diff --git a/spec/acceptance/README.md b/spec/acceptance/README.md index 8106e52c80c20..5cd18532e000b 100644 --- a/spec/acceptance/README.md +++ b/spec/acceptance/README.md @@ -47,6 +47,20 @@ Run the test suite: POSTGRES_RPORT=9000 SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance/postgres_spec.rb ``` +### MySQL + +Run a target: + +``` +docker run -it --rm --publish 127.0.0.1:9001:3306 -e MYSQL_ROOT_PASSWORD=password mariadb:11.2.2 +``` + +Run the test suite: + +``` +MYSQL_RPORT=9000 SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance/mysql_spec.rb +``` + #### Allure reports Generate allure reports locally: diff --git a/spec/acceptance/mysql_spec.rb b/spec/acceptance/mysql_spec.rb new file mode 100644 index 0000000000000..c09b896dc4122 --- /dev/null +++ b/spec/acceptance/mysql_spec.rb @@ -0,0 +1,365 @@ +require 'acceptance_spec_helper' + +RSpec.describe 'MySQL sessions and MySQL modules' do + include_context 'wait_for_expect' + + TESTS = { + mysql: { + target: { + session_module: "auxiliary/scanner/mysql/mysql_login", + type: 'MySQL', + platforms: [:linux, :osx, :windows], + datastore: { + global: {}, + module: { + username: ENV.fetch('MYSQL_USERNAME', 'root'), + password: ENV.fetch('MYSQL_ROOT_PASSWORD', 'password'), + rhost: ENV.fetch('MYSQL_RHOST', '127.0.0.1'), + rport: ENV.fetch('MYSQL_RPORT', '3306'), + } + } + }, + module_tests: [ + { + name: "post/test/mysql", + platforms: [:linux, :osx, :windows], + targets: [:session], + skipped: false, + }, + { + name: "auxiliary/scanner/mysql/mysql_hashdump", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + /Saving HashString as Loot/ + ] + }, + } + }, + { + name: "auxiliary/scanner/mysql/mysql_version", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + /127.0.0.1:\d+ is running MySQL \d+.\d+.*/ + ] + }, + } + }, + { + name: "auxiliary/admin/mysql/mysql_sql", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + /\| \d+.\d+.*/, + ] + }, + } + }, + { + name: "auxiliary/admin/mysql/mysql_enum", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + / MySQL Version: \d+.\d+.*/, + ] + }, + } + }, + ] + } + } + + TEST_ENVIRONMENT = AllureRspec.configuration.environment_properties + + let_it_be(:current_platform) { Acceptance::Meterpreter::current_platform } + + # 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 + + features = %w[ + mysql_session_type + ] + + features.each do |feature| + console.sendline("features set #{feature} true") + console.recvuntil(Acceptance::Console.prompt) + end + + console + end + + # Run the given block in a 'test harness' which will handle all of the boilerplate for asserting module results, cleanup, and artifact tracking + # This doesn't happen in a before/after block to ensure that allure's report generation is correctly attached to the correct test scope + def with_test_harness(module_test) + begin + replication_commands = [] + + 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 } + + yield replication_commands + + # XXX: When debugging failed tests, you can enter into an interactive msfconsole prompt with: + # console.interact + + # Expect the test module to complete + module_type = module_test[:name].split('/').first + test_result = console.recvuntil("#{module_type.capitalize} module execution completed") + + # Ensure there are no failures, and assert tests are complete + aggregate_failures("#{target.type} target 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| + is_matching_line = is_matching_line.value.is_a?(Regexp) ? line.match?(acceptable_failure.value) : line.include?(acceptable_failure.value) + is_matching_line && + 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) + if required.value.is_a?(Regexp) + expect(test_result).to match(required.value) + else + expect(test_result).to include(required.value) + end + 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 + + 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 + + target_configuration_details = target.as_readable_text( + default_global_datastore: default_global_datastore, + default_module_datastore: default_module_datastore + ) + + replication_steps = <<~EOF + ## Load test modules + loadpath test/modules + + #{target_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: '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 + + TESTS.each do |runtime_name, test_config| + runtime_name = "#{runtime_name}#{ENV.fetch('RUNTIME_VERSION', '')}" + + describe "#{Acceptance::Meterpreter.current_platform}/#{runtime_name}", focus: test_config[:focus] do + test_config[:module_tests].each do |module_test| + describe( + module_test[:name], + if: ( + Acceptance::Meterpreter.supported_platform?(module_test) + ) + ) do + let(:target) { Acceptance::Target.new(test_config[:target]) } + + let(:default_global_datastore) do + { + } + end + + let(:test_environment) { TEST_ENVIRONMENT } + + let(:default_module_datastore) do + { + lhost: '127.0.0.1' + } + end + + # The shared session id that will be reused across the test run + let(:session_id) do + console.sendline "use #{target.session_module}" + console.recvuntil(Acceptance::Console.prompt) + + # Set global options + console.sendline target.setg_commands(default_global_datastore: default_global_datastore) + console.recvuntil(Acceptance::Console.prompt) + + console.sendline target.run_command(default_module_datastore: { PASS_FILE: nil, USER_FILE: nil, CreateSession: true }) + + session_id = nil + # Wait for the session to open, or break early if the payload is detected as dead + wait_for_expect do + session_opened_matcher = /#{target.type} 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 + + session_id + end + + before :each do |example| + next 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 "when targeting a session", if: module_test[:targets].include?(:session) do + it( + "#{Acceptance::Meterpreter.current_platform}/#{runtime_name} session opens and passes the #{module_test[:name].inspect} tests" + ) do + with_test_harness(module_test) do |replication_commands| + # 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 + 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} Verbose=true" + + replication_commands << use_module + console.sendline(use_module) + console.recvuntil(Acceptance::Console.prompt) + + replication_commands << run_module + console.sendline(run_module) + + # Assertions will happen after this block ends + end + end + end + + context "when targeting an rhost", if: module_test[:targets].include?(:rhost) do + it( + "#{Acceptance::Meterpreter.current_platform}/#{runtime_name} rhost opens and passes the #{module_test[:name].inspect} tests" + ) do + with_test_harness(module_test) do |replication_commands| + use_module = "use #{module_test[:name]}" + run_module = "run #{target.datastore_options(default_module_datastore: default_module_datastore)} Verbose=true" + + replication_commands << use_module + console.sendline(use_module) + console.recvuntil(Acceptance::Console.prompt) + + replication_commands << run_module + console.sendline(run_module) + + # Assertions will happen after this block ends + end + end + end + end + end + end + end +end diff --git a/test/modules/post/test/mysql.rb b/test/modules/post/test/mysql.rb new file mode 100644 index 0000000000000..24d959b21014a --- /dev/null +++ b/test/modules/post/test/mysql.rb @@ -0,0 +1,78 @@ +require 'rex/post/meterpreter/extensions/stdapi/command_ids' +require 'rex' + +lib = File.join(Msf::Config.install_root, "test", "lib") +$LOAD_PATH.push(lib) unless $LOAD_PATH.include?(lib) +require 'module_test' + +class MetasploitModule < Msf::Post + + include Msf::ModuleTest::PostTest + include Msf::ModuleTest::PostTestFileSystem + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Testing MySQL sessions work', + 'Description' => %q{ This module will test the MySQL sessions work }, + 'License' => MSF_LICENSE, + 'Author' => [ 'Christopher Granleese '], + 'Platform' => all_platforms, + 'SessionTypes' => [ 'mysql' ] + ) + ) + end + + def setup + super + end + + def cleanup + super + end + + def test_console_query + it "should return a version" do + stdout = with_mocked_console(session) { |console| console.run_single("query 'select version();'") } + ret = true + ret &&= stdout.buf.match?(/Response\n========\n\n #. version\(\)\n - ---------\n 0 \d+.\d+.*/) + ret + end + end + + def test_console_help + it "should support the help command" do + stdout = with_mocked_console(session) { |console| console.run_single("help") } + ret = true + ret &&= stdout.buf.include?('Core Commands') + ret &&= stdout.buf.include?('MySQL Client Commands') + ret + end + end + + private + + def all_platforms + Msf::Module::Platform.subclasses.collect { |c| c.realname.downcase } + end + + # Wrap the console with a mocked stdin/stdout for testing purposes. This ensures the console + # will not write the real stdout, and the contents can be verified in the test + # @param [Session] session + # @return [Rex::Ui::Text::Output::Buffer] the stdout buffer + def with_mocked_console(session) + old_input = session.console.input + old_output = session.console.output + + mock_input = Rex::Ui::Text::Input.new + mock_output = Rex::Ui::Text::Output::Buffer.new + + session.console.init_ui(mock_input, mock_output) + yield session.console + + mock_output + ensure + session.console.init_ui(old_input, old_output) + end +end