Add Meterpreter compatibility matrix generation

This commit is contained in:
adfoster-r7 2023-09-06 16:59:45 +01:00
parent 4ade16752a
commit 901938c0f1
No known key found for this signature in database
GPG Key ID: 3BD4FA3818818F04
7 changed files with 565 additions and 3 deletions

View File

@ -36,6 +36,7 @@ on:
- 'modules/payloads/**'
- 'lib/msf/core/payload/**'
- 'lib/msf/core/**'
- 'tools/dev/**'
- 'spec/acceptance/**'
- 'spec/acceptance_spec_helper.rb'
# Example of running as a cron, to weed out flaky tests
@ -170,6 +171,28 @@ jobs:
if: always()
steps:
- name: Checkout code
uses: actions/checkout@v3
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: 3.0.2
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@v3
id: download
if: always()
@ -185,8 +208,12 @@ jobs:
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@v3

View File

@ -1,4 +1,4 @@
# Outputs the currently supported Meterpreter commands as JSON for the currently opened Meterpreter sessions
# Outputs to STDOUT the currently supported Meterpreter commands as JSON for the currently opened Meterpreter sessions
# Usage:
# msf> resource scripts/resource/meterpreter_compatibility.rc

View File

@ -4,6 +4,9 @@ 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.
The test suite runs on the current host, so the Meterpreter runtimes should be available.
There is no remote host support currently.
### Examples
Useful environment variables:
@ -17,7 +20,7 @@ Running Meterpreter test suite:
SPEC_OPTS='--tag acceptance' bundle exec rspec './spec/acceptance/meterpreter_spec.rb'
```
Skip loading of Rails/Metasplotit with:
Skip loading of Rails/Metasploit with:
```
SPEC_OPTS='--tag acceptance' SPEC_HELPER_LOAD_METASPLOIT=false bundle exec rspec ./spec/acceptance
@ -30,6 +33,8 @@ SPEC_OPTS='--tag acceptance' METERPRETER=php METERPRETER_MODULE_TEST=test/unix b
$env:SPEC_OPTS='--tag acceptance'; $env:SPEC_HELPER_LOAD_METASPLOIT=$false; $env:METERPRETER = 'php'; bundle exec rspec './spec/acceptance/meterpreter_spec.rb'
```
#### Allure reports
Generate allure reports locally:
```
@ -57,6 +62,20 @@ cd allure-report
ruby -run -e httpd . -p 8000
```
#### Support Matrix generation
You can download the data from an existing Github job run:
```
ids=(6099944525); for id in $ids; do echo $id; gh run download $id --repo rapid7/metasploit-framework --dir gh-actions-$id ; done
```
Then generate the report using the allure data:
```
bundle exec ruby tools/dev/report_generation/support_matrix/generate.rb --allure-data /path/to/gh-actions-$id > ./support_matrix.html
```
### Debugging
If a test has failed you can enter into an interactive breakpoint with:

View File

@ -53,7 +53,7 @@ RSpec.describe 'Meterpreter' do
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|
meterpreter_config[:payloads].each.with_index do |payload_config, payload_config_index|
describe(
Acceptance::Meterpreter.human_name_for_payload(payload_config).to_s,
if: (
@ -184,6 +184,57 @@ RSpec.describe 'Meterpreter' do
end
context "#{Acceptance::Meterpreter.current_platform}" do
describe "compatibility" do
it(
"exposes available metasploit commands",
if: (
# Assume that regardless of payload, staged/unstaged/etc, the Meterpreter will have the same commands available
# So only run this test when config_index == 0
payload_config_index == 0 && Acceptance::Meterpreter.supported_platform?(payload_config)
# Run if ENV['METERPRETER'] = 'java php' etc
Acceptance::Meterpreter.run_meterpreter?(meterpreter_config) &&
# Only run payloads / tests, if the host machine can run them
Acceptance::Meterpreter.supported_platform?(payload_config)
)
) do
# 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)
console.sendline("resource scripts/resource/meterpreter_compatibility.rc")
result = console.recvuntil(Acceptance::Console.prompt)
available_commands = result.lines(chomp: true).find do |line|
line.start_with?("{") && line.end_with?("}") && JSON.parse(line)
rescue JSON::ParserError => _e
next
end
expect(available_commands).to_not be_nil
available_commands_json = JSON.parse(available_commands, symbolize_names: true)
expect(available_commands_json[:sessions].length).to be 1
expect(available_commands_json[:sessions].first[:commands]).to_not be_empty
ensure
# Generate an allure attachment, a report can be generated afterwards
Allure.add_attachment(
name: 'available commands',
source: JSON.pretty_generate(available_commands_json),
type: Allure::ContentType::JSON,
test_case: false
)
end
end
meterpreter_config[:module_tests].each do |module_test|
describe module_test[:name].to_s, focus: module_test[:focus] do
it(

View File

@ -0,0 +1,57 @@
require 'spec_helper'
require Metasploit::Framework.root.join('tools/dev/report_generation/support_matrix/generate.rb').to_path
RSpec.describe ReportGeneration::SupportMatrix do
let(:data) { {} }
subject { described_class.new(data) }
describe '#all_commands' do
it 'equals the list of available Meterpreter commands' do
expect(subject.all_commands).to eq(Rex::Post::Meterpreter::CommandMapper.get_command_names)
end
end
describe '#table' do
# Results generated by scripts/resource/meterpreter_compatibility.rc
let(:data) do
{
sessions: [
{
session_type: 'php/linux',
metadata: { foo: 10 },
commands: [
{ id: 4, name: 'core_channel_open' },
{ id: 2, name: 'core_channel_eof' },
{ id: 5, name: 'core_channel_read' },
{ id: 8, name: 'core_channel_write' }
]
},
{
session_type: 'x64/linux',
metadata: { foo: 20 },
commands: [
{ id: 10, name: 'core_enumextcmd' },
{ id: 13, name: 'core_machine_id' },
{ id: 22, name: 'core_set_uuid' },
{ id: 11, name: 'core_get_session_guid' }
]
}
]
}
end
it 'returns the matrix as a table' do
expected_table = {
columns: [{ heading: '' }, { heading: 'php/linux', metadata: { foo: 10 } }, { heading: 'x64/linux', metadata: { foo: 20 } }],
rows: array_including([
{ heading: ['core', '11%', '11%'], values: array_including([['core_channel_open', true, false], ['core_enumextcmd', false, true]]) },
{ heading: ['stdapi', '0%', '0%'], values: array_including([['stdapi_sys_eventlog_read', false, false]]) },
{ heading: ['bofloader', '0%', '0%'], values: [['bofloader_execute', false, false]] }
])
}
expect(subject.table).to include(expected_table)
end
end
end

View File

@ -0,0 +1,207 @@
# frozen_string_literal: true
$LOAD_PATH.unshift(File.join(__dir__, '..', '..', '..', '..', 'spec'))
$LOAD_PATH.unshift(File.join(__dir__, '..', '..', '..', '..', 'lib'))
require 'active_support'
require 'active_support/core_ext'
require 'allure_config'
require 'json'
require 'erb'
require 'optparse'
require 'msfenv'
require 'rex'
require 'rex/post'
module ReportGeneration
class SupportMatrix
def initialize(data)
@data = data
end
def generation_date
@generation_date ||= Time.now.strftime('%FT%T')
end
def all_commands
Rex::Post::Meterpreter::CommandMapper.get_command_names
end
def table
sorted_sessions = @data.fetch(:sessions, []).sort_by { |session| session[:session_type] }
# Group into buckets, and prioritize sort order
extension_names = [
# 'Required' Meterpreter extensions
'core',
'stdapi',
'sniffer',
'extapi',
'kiwi',
'python',
'unhook',
'appapi',
'winpmem',
'powershell',
'lanattacks',
'priv',
'incognito',
'peinjector',
'espia',
'android',
# any missing new/missing extensions will added to the end lexicographically
]
# Add any new extension names that aren't currently known about
extension_names += all_commands.each_with_object([]) do |command, unknown_extensions|
command_prefix = command.split('_').first
next if extension_names.include?(command_prefix)
unknown_extensions << command_prefix
end.sort
ordered_commands = all_commands.sort_by do |command|
command_prefix = command.split('_').first
sort_index = extension_names.index(command_prefix)
sort_index
end
# Map session type to supported commands. i.e. { osx: { command_name_1: true } }
sessions_to_supported_commands_hash = sorted_sessions.each_with_object({}) do |session, hash|
session_type = session[:session_type]
# Map command name to its availability
supported_command_map = session[:commands].each_with_object({}) do |command, map|
command_name = command[:name]
map[command_name] = true
end
hash[session_type] = supported_command_map
end
columns = [{ heading: '' }] + sorted_sessions.map do |session|
{ heading: session[:session_type], metadata: session[:metadata] }
end
rows = extension_names.map do |extension_name|
extension_commands = ordered_commands.select { |command| command.start_with?(extension_name) }
command_rows = extension_commands.map do |command|
session_supported_cells = sessions_to_supported_commands_hash.map do |(_session, compatibility)|
compatibility.include?(command)
end
[command] + session_supported_cells
end
extension_coverage = sessions_to_supported_commands_hash.map do |(_session, compatibility)|
implemented_count = extension_commands.select { |command| compatibility.include?(command) }.size
total_count = extension_commands.size
percentage = ((implemented_count.to_f / total_count) * 100).to_i
"#{percentage}%"
end
{
heading: [extension_name] + extension_coverage,
values: command_rows
}
end
{
columns: columns,
rows: rows
}
end
def get_binding
binding
end
end
def self.extract_data(options)
if options[:allure_data]
results_directory = options[:allure_data]
test_result_files = Dir['**/*-result.json', base: results_directory]
meterpreter_compatibility_results = test_result_files.filter_map do |test_result_file|
path = File.join(results_directory, test_result_file)
test_result_json = JSON.parse(File.read(path), symbolize_names: true)
compatibility_attachment = test_result_json.fetch(:attachments, [])
.find { |attachment| attachment[:name] == 'available commands' }
next unless compatibility_attachment
compatibility_attachment_path = File.join(File.dirname(path), compatibility_attachment[:source])
compatibility_json = JSON.parse(File.read(compatibility_attachment_path), symbolize_names: true)
compatibility_json[:sessions].each do |session|
session[:metadata] = test_result_json[:parameters].each_with_object({}) do |param, acc|
acc[param[:name]] = param[:value]
end
end
compatibility_json
end
sessions = meterpreter_compatibility_results.flat_map { |results| results[:sessions] }
sorted_sessions = sessions.sort_by do |session|
[session[:session_type], session[:metadata]['host_runner_image'], session[:metadata]['meterpreter_runtime_version'].to_s]
end
unique_sessions = sorted_sessions.each_with_object({}) do |session, acc|
acc[session[:session_type]] = session
end.values
aggregated_data = {
sessions: unique_sessions
}
aggregated_data
else
data_path = options.fetch(:data_path)
JSON.parse(File.read(data_path), symbolize_names: true)
end
end
def self.generate(options)
data = extract_data(options)
support_matrix = SupportMatrix.new(data)
if options[:format] == :json
$stdout.write JSON.pretty_generate(support_matrix.data)
else
template = File.read(File.join(File.dirname(__FILE__), 'template.erb'))
renderer = ERB.new(template, trim_mode: '-')
html = renderer.result(support_matrix.get_binding)
$stdout.write(html)
end
end
end
if $PROGRAM_NAME == __FILE__
options = {}
options_parser = OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename(__FILE__)} [options]"
opts.on '-h', '--help', 'Help banner.' do
return print(opts.help)
end
opts.on('--allure-data path', 'Use allure as the data source') do |allure_data|
allure_data ||= AllureRspec.configuration.results_directory
options[:allure_data] = allure_data
end
opts.on('--data-path path',
'The path to the report generated by scripts/resource/meterpreter_compatibility.rc') do |data_path|
options[:data_path] = data_path
end
opts.on('--format value', %i[json html], 'Render in a given format') do |format|
options[:format] = format
end
end
options_parser.parse!
ReportGeneration.generate(options)
end

View File

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Meterpreter Support matrix</title>
<style>
:root {
--background-color-yes: green;
--background-color-yes-highlight: #007a00;
--background-color-no: red;
--background-color-no-highlight: #d90000;
}
html {
}
table {
border-collapse: collapse;
border: 2px solid rgb(200, 200, 200);
letter-spacing: 1px;
font-size: 0.8rem;
}
thead, tr th:nth-child(1) {
background: white;
position: sticky;
top: 0;
border: none;
outline: 2px solid rgb(200, 200, 200);
z-index: 20;
}
.summary-row {
background: white;
position: sticky;
top: 40px;
border: none;
outline: 1px solid rgb(200, 200, 200);
z-index: 20;
}
.summary-row th:first-child::before, .toggle-extensions::before {
content: '- '
}
td {
text-align: center;
}
td, th {
position: relative;
}
.yes {
background-color: var(--background-color-yes);
}
.no {
background-color: var(--background-color-no);
}
.highlight, .highlight th:nth-child(1) {
background-color: #f3f3f3;
}
.highlight.yes, .highlight .yes {
background-color: var(--background-color-yes-highlight);
}
.highlight.no, .highlight .no {
background-color: var(--background-color-no-highlight);
}
td, th {
border: 1px solid rgb(190, 190, 190);
padding: 10px 20px;
}
tr th:nth-child(1) {
position: sticky;
top: 0;
left: 0;
z-index: 10;
}
.tooltip .tooltip-inner {
text-align:left;
max-width: 800px;
}
</style>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.7/dist/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<script>
$(function () {
var allClosed = false;
function columnSelector(index) {
return "tr :nth-child(" + (index + 1) + ")";
}
var addTableHighlights = function () {
var columnIndex = $(this).index();
$(this).parent().addClass("highlight");
$(this).closest('table').find(columnSelector(columnIndex)).addClass('highlight');
};
var removeTableHighlights = function () {
var columnIndex = $(this).index();
$(this).parent().removeClass("highlight");
$(this).closest('table').find(columnSelector(columnIndex)).removeClass('highlight');
};
var toggleAllExtensions = function () {
var extensions = $(this).closest('table').find('.summary-row').nextUntil();
allClosed = !allClosed;
if (allClosed) {
extensions.hide();
} else {
extensions.show();
}
};
var toggleExtension = function () {
$(this).closest('.summary-row').nextUntil().toggle();
}
// Table highlighting events
$('table')
.delegate('th, td', 'mouseover', addTableHighlights)
.delegate('th, td', 'mouseleave', removeTableHighlights)
.delegate('.toggle-extensions', 'mouseup', toggleAllExtensions)
.delegate('.summary-row', 'mouseup', toggleExtension)
// Tooltip handling
$('[data-toggle="tooltip"]').tooltip();
})
</script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<main>
<table>
<colgroup>
</colgroup>
<thead>
<%- largest_column_length = table[:rows].flat_map { |row| row[:values] }.map { |values| values[0].size }.max %>
<%- table[:columns].each.with_index do |column, column_index| -%>
<%- if column_index == 0 -%>
<th class="toggle-extensions" style="width: <%= largest_column_length + 4 %>ch">Meterpreter Feature</th>
<%- else -%>
<th style="white-space: nowrap;">
<%= column[:heading] -%>
<!-- cog icon -->
<span data-toggle="tooltip" data-placement="bottom" data-html="true" title="<%= column[:metadata].map { |k,v| "#{k}=#{v}" }.join('<br />') %>">
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>
</span>
</th>
<%- end -%>
<%- end -%>
</tr>
</thead>
<%- table[:rows].each.with_index do |row, _row_index| -%>
<tbody>
<tr class="summary-row">
<%- row[:heading].each.with_index do |value, value_index| -%>
<%- if value_index == 0 -%>
<th><%= value -%></th>
<%- else -%>
<th>
<div class="progress">
<div class="progress-bar" style="width: <%= value -%>"></div>
</div>
<%= value -%>
</th>
<%- end -%>
<%- end -%>
</tr>
<%- row[:values].each do |row| -%>
<tr style="display: none">
<%- row.each.with_index do |value, cell_index| -%>
<%- if cell_index == 0 -%>
<th><%= value -%></th>
<%- elsif value.is_a?(TrueClass) || value.is_a?(FalseClass) -%>
<td class="<%= value ? 'yes' : 'no' %>"><%= value ? '&nbsp;' : '&nbsp;' -%></td>
<%- else -%>
<td><%= value -%></td>
<%- end -%>
<%- end -%>
</tr>
<%- end -%>
</tbody>
<%- end -%>
</table>
</main>
</body>
</html>