Add MySQL session type acceptance tests

This commit is contained in:
cgranleese-r7 2024-03-26 11:57:42 +00:00
parent d6ecd9db70
commit f9b20d89f3
4 changed files with 641 additions and 0 deletions

182
.github/workflows/mysql_acceptance.yml vendored Normal file
View File

@ -0,0 +1,182 @@
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_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:latest", health_cmd: "mariadb -uroot -ppassword -e 'SELECT version()'" }
- { version: "mariadb:5.5.42", health_cmd: "mysql -uroot -ppassword -e 'SELECT version()'" }
- { version: "mysql:latest", health_cmd: "mysql -uroot -ppassword -e 'SELECT version()'" }
- { version: "mysql:5.5.42", 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

View File

@ -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:

View File

@ -0,0 +1,367 @@
require 'acceptance_spec_helper'
RSpec.describe 'MySQL sessions and MySQL modules' do
include_context 'wait_for_expect'
RHOST_REGEX = /\d+\.\d+\.\d+\.\d+:\d+/
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: [
/#{RHOST_REGEX} 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

View File

@ -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 <Christopher_Granleese[at]rapid7.com>'],
'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