diff --git a/.github/workflows/smb_acceptance.yml b/.github/workflows/smb_acceptance.yml new file mode 100644 index 0000000000000..15de3eae6eb95 --- /dev/null +++ b/.github/workflows/smb_acceptance.yml @@ -0,0 +1,166 @@ +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' + - '**/**smb**' + - '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: + smb: + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + + strategy: + fail-fast: true + matrix: + ruby: + - '3.2' + os: + - ubuntu-latest + + env: + RAILS_ENV: test + SMB_USERNAME: acceptance_tests_user + SMB_PASSWORD: acceptance_tests_password + + name: SMB Acceptance - ${{ 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: Run docker container + working-directory: 'test/smb' + run: | + docker compose build + docker compose up --wait -d + + - 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: acceptance + env: + SPEC_HELPER_LOAD_METASPLOIT: false + SPEC_OPTS: "--tag acceptance --require acceptance_spec_helper.rb --color --format documentation --format AllureRspec::RSpecFormatter" + RUNTIME_VERSION: 'latest' + # 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/smb_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: smb_acceptance-${{ matrix.os }} + path: tmp/allure-raw-data + + # Generate a final report from the previous test results + report: + name: Generate report + needs: + - smb + 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 06427dd3afaec..3f19022ffc4ab 100644 --- a/spec/acceptance/README.md +++ b/spec/acceptance/README.md @@ -75,6 +75,31 @@ Run the test suite: MSSQL_RPORT=1433 SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance/mssql_spec.rb ``` +### SMB + +Build the Docker image: + +``` +docker compose build +``` + +Run a target: + +``` +docker compose up -d --wait +``` + +Run the test suite: + +``` +SMB_USERNAME=acceptance_tests_user SMB_PASSWORD=acceptance_tests_password SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance/smb_spec.rb +``` + +Shut down the container: +``` +docker compose down +``` + #### Allure reports Generate allure reports locally: diff --git a/spec/acceptance/smb_spec.rb b/spec/acceptance/smb_spec.rb new file mode 100644 index 0000000000000..51d689412d25d --- /dev/null +++ b/spec/acceptance/smb_spec.rb @@ -0,0 +1,385 @@ +require 'acceptance_spec_helper' + +RSpec.describe 'SMB sessions and SMB modules' do + include_context 'wait_for_expect' + + RHOST_REGEX = /\d+\.\d+\.\d+\.\d+:\d+/ + + TESTS = { + smb: { + target: { + session_module: "auxiliary/scanner/smb/smb_login", + type: 'SMB', + platforms: [:linux, :osx, :windows], + datastore: { + global: {}, + module: { + username: ENV.fetch('SMB_USERNAME', 'acceptance_tests_user'), + password: ENV.fetch('SMB_PASSWORD', 'acceptance_tests_password'), + rhost: ENV.fetch('SMB_RHOST', '127.0.0.1'), + rport: ENV.fetch('SMB_RPORT', '445'), + } + } + }, + module_tests: [ + { + name: "post/test/smb", + platforms: [:linux, :osx, :windows], + targets: [:session], + skipped: false, + }, + # Flaky: + # Error: RubySMB::Error::UnexpectedStatusCode The server responded with an unexpected status code: STATUS_PIPE_BROKEN + # { + # name: "auxiliary/scanner/smb/smb_lookupsid", + # platforms: [:linux, :osx, :windows], + # targets: [:session, :rhost], + # skipped: false, + # lines: { + # all: { + # required: [ + # "GROUP=None", + # "USER=nobody", + # "PIPE(LSARPC) LOCAL", + # ], + # }, + # } + # }, + # Flaky: + # RubySMB::Error::CommunicationError Communication error with the remote host: Read timeout expired when reading from the Socket (timeout=30). + # The server supports encryption and this error may have been caused by encryption issues, but not always. + # Fixed here: https://github.com/rapid7/metasploit-framework/pull/19095 + # { + # name: "auxiliary/scanner/smb/smb_enumusers", + # platforms: [:linux, :osx, :windows], + # targets: [:session, :rhost], + # skipped: false, + # lines: { + # all: { + # required: [ + # "acceptance_tests_user", + # ], + # }, + # } + # }, + { + name: "auxiliary/scanner/smb/pipe_auditor", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + /Pipes: (\\([a-zA-Z]*)(, )?)*/, + ], + known_failures: [ + /Inaccessible named pipe:/, + /The server responded with an unexpected status code: STATUS_OBJECT_NAME_NOT_FOUND/, + ] + }, + } + }, + { + name: "auxiliary/scanner/smb/smb_enumshares", + platforms: [:linux, :osx, :windows], + targets: [:session, :rhost], + skipped: false, + lines: { + all: { + required: [ + "modifiable - (DISK)", + "readonly - (DISK)", + "IPC$ - (IPC|SPECIAL) IPC Service", + ], + }, + } + }, + ] + } + } + + 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[ + smb_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 = acceptable_failure.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) + + if acceptable_failure.value.is_a?(Regexp) + expect(test_result).to match(acceptable_failure.value) + else + expect(test_result).to include(acceptable_failure.value) + end + 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/smb.rb b/test/modules/post/test/smb.rb new file mode 100644 index 0000000000000..de24a594f6ffb --- /dev/null +++ b/test/modules/post/test/smb.rb @@ -0,0 +1,202 @@ +require 'rex/post/meterpreter/extensions/stdapi/command_ids' +require 'rex' +require 'fileutils' + +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 SMB sessions work', + 'Description' => %q{ This module will test the SMB sessions work }, + 'License' => MSF_LICENSE, + 'Author' => [ 'sjanusz-r7'], + 'Platform' => all_platforms, + 'SessionTypes' => [ 'smb' ] + ) + ) + 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?('Shares Commands') + ret &&= stdout.buf.include?('Local File System Commands') + ret + end + end + + def test_upload_and_download + readonly_share = 'readonly' + modifiable_share = 'modifiable' + + Tempfile.create do |temp_file| + filename = File.basename(temp_file) + full_path = temp_file.to_path + + it 'should support uploading files' do + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("upload #{full_path} #{filename}") + end + + ret = true + # Or filename? + ret &&= stdout.buf.include?("#{full_path} uploaded to #{filename}") + ret + end + + it 'should not upload to readonly share' do + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{readonly_share}") + console.run_single("upload #{full_path} #{filename}") + end + + ret = true + ret &&= stdout.buf.include?('Error running command upload') + ret &&= stdout.buf.include?('The server responded with an unexpected status code: STATUS_ACCESS_DENIED') + ret + end + + it 'should support deleting files' do + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("delete #{filename}") + end + + ret = true + ret &&= stdout.buf.include?("Deleted #{filename}") + ret + end + end + + Tempfile.create do |temp_file| + remote_filename = 'hello_world.txt' + remote_dir = 'text_files' + full_path = temp_file.to_path + + it 'should support downloading files' do + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("cd #{remote_dir}") + console.run_single("download #{remote_filename} #{full_path}") + end + + ret = true + ret &&= stdout.buf.include?("Downloaded #{remote_dir}\\#{remote_filename} to #{full_path}") + ret + end + end + end + + def test_files + modifiable_share = 'modifiable' + + it 'should output files in the current directory' do + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single('ls') + end + + ret = true + ret &&= stdout.buf.include?('recursive') + ret &&= stdout.buf.include?('text_files') + ret + end + end + + def test_directories + it 'should support changing a directory' do + folder_name = 'text_files' + modifiable_share = 'modifiable' + expected_file_name = 'hello_world.txt' + + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("cd #{folder_name}") + console.run_single('ls') + end + + ret = true + ret &&= stdout.buf.include? expected_file_name + ret + end + + it 'should support creating a new directory' do + modifiable_share = 'modifiable' + new_directory_name = 'my_new_directory' + + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("mkdir #{new_directory_name}") + end + + ret = true + ret &&= stdout.buf.include?("Directory #{new_directory_name} created") + ret + end + + it 'should support deleting a directory' do + modifiable_share = 'modifiable' + new_directory_name = 'my_new_directory' + + stdout = with_mocked_console(session) do |console| + console.run_single("shares -i #{modifiable_share}") + console.run_single("rmdir #{new_directory_name}") + end + + ret = true + ret &&= stdout.buf.include?("Deleted #{new_directory_name}") + ret + end + end + + def test_shares + it 'should support switching shares' do + stdout = with_mocked_console(session) { |console| console.run_single('shares -i 0') } + ret = true + ret &&= stdout.buf.include?('Successfully connected to modifiable') + + stdout = with_mocked_console(session) { |console| console.run_single('shares -i 1') } + + ret &&= stdout.buf.include?('Successfully connected to readonly') + + 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 diff --git a/test/smb/Dockerfile b/test/smb/Dockerfile new file mode 100644 index 0000000000000..ad0e6733ad2a2 --- /dev/null +++ b/test/smb/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 +FROM ubuntu:22.04 AS build +MAINTAINER metasploit-framework +WORKDIR /opt + +EXPOSE 445 139 + +# Switch shells to support ANSI-C quoting: +# https://stackoverflow.com/questions/33439230/how-to-write-commands-with-multiple-lines-in-dockerfile-while-preserving-the-new +SHELL ["/bin/bash", "-c"] + +# Install Samba +RUN apt update && apt install -y samba smbclient + +# To add a credential to Samba, the user needs to be created on the system +RUN useradd -m acceptance_tests_user + +# Configure a few shares +COPY shares shares + +RUN chmod -R 777 shares + +COPY config/smb.conf /etc/samba/smb.conf + +# Change the passwords for our user +RUN smbpasswd -a acceptance_tests_user <