617 lines
17 KiB
Ruby
617 lines
17 KiB
Ruby
require 'spec_helper'
|
|
require 'rack/test'
|
|
require 'rack/protection'
|
|
|
|
# These tests ensure the full end to end functionality of metasploit's JSON RPC
|
|
# endpoint. There are multiple layers of possible failure in our API, and unit testing
|
|
# alone will not cover all edge cases. For instance, middleware may raise exceptions
|
|
# and return HTML to the calling client unintentionally - which will break our JSON
|
|
# response contract. These test should help catch such scenarios.
|
|
RSpec.describe "Metasploit's json-rpc" do
|
|
include Rack::Test::Methods
|
|
include_context 'Msf::DBManager'
|
|
include_context 'Metasploit::Framework::Spec::Constants cleaner'
|
|
include_context 'Msf::Framework#threads cleaner', verify_cleanup_required: false
|
|
include_context 'wait_for_expect'
|
|
|
|
let(:health_check_url) { '/api/v1/health' }
|
|
let(:rpc_url) { '/api/v1/json-rpc' }
|
|
let(:module_name) { 'scanner/ssl/openssl_heartbleed' }
|
|
let(:a_valid_result_uuid) { { result: hash_including({ uuid: match(/\w+/) }) } }
|
|
let(:app) { ::Msf::WebServices::JsonRpcApp.new }
|
|
|
|
before(:example) do
|
|
framework.modules.add_module_path(File.join(FILE_FIXTURES_PATH, 'json_rpc'))
|
|
app.settings.framework = framework
|
|
end
|
|
|
|
after(:example) do
|
|
# Sinatra's settings are implemented as a singleton, and must be explicitly reset between runs
|
|
app.settings.dispatchers.clear
|
|
end
|
|
|
|
def report_host(host)
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'db.report_host',
|
|
id: 1,
|
|
params: [
|
|
host
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
def report_vuln(vuln)
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'db.report_vuln',
|
|
id: 1,
|
|
params: [
|
|
vuln
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
def analyze_host(host)
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'db.analyze_host',
|
|
id: 1,
|
|
params: [
|
|
host
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
def create_job
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'module.check',
|
|
id: 1,
|
|
params: [
|
|
'auxiliary',
|
|
module_name,
|
|
{
|
|
RHOSTS: '192.0.2.0'
|
|
}
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
def get_job_results(uuid)
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'module.results',
|
|
id: 1,
|
|
params: [
|
|
uuid
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
def get_rpc_health_check
|
|
post rpc_url, {
|
|
jsonrpc: '2.0',
|
|
method: 'health.check',
|
|
id: 1,
|
|
params: []
|
|
}.to_json
|
|
end
|
|
|
|
def get_rest_health_check
|
|
get health_check_url
|
|
end
|
|
|
|
def last_json_response
|
|
JSON.parse(last_response.body).with_indifferent_access
|
|
end
|
|
|
|
def expect_completed_status(rpc_response)
|
|
expect(rpc_response).to include({ result: hash_including({ status: 'completed' }) })
|
|
end
|
|
|
|
def expect_error_status(rpc_response)
|
|
expect(rpc_response).to include({ result: hash_including({ status: 'errored' }) })
|
|
end
|
|
|
|
def mock_rack_env(mock_rack_env_value)
|
|
allow(ENV).to receive(:[]).and_wrap_original do |original_env, key|
|
|
if key == 'RACK_ENV'
|
|
mock_rack_env_value
|
|
else
|
|
original_env[key]
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'health status' do
|
|
context 'when using the REST health check functionality' do
|
|
it 'passes the health check' do
|
|
expected_response = {
|
|
data: {
|
|
status: 'UP'
|
|
}
|
|
}
|
|
|
|
get_rest_health_check
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
|
|
context 'when there is an issue' do
|
|
before(:each) do
|
|
allow(framework).to receive(:version).and_raise 'Mock error'
|
|
end
|
|
|
|
it 'fails the health check' do
|
|
expected_response = {
|
|
data: {
|
|
status: 'DOWN'
|
|
}
|
|
}
|
|
|
|
get_rest_health_check
|
|
|
|
expect(last_response.status).to be 503
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
|
|
context 'when using the RPC health check functionality' do
|
|
context 'when the service is healthy' do
|
|
it 'passes the health check' do
|
|
expected_response = {
|
|
id: 1,
|
|
jsonrpc: '2.0',
|
|
result: {
|
|
status: 'UP'
|
|
}
|
|
}
|
|
|
|
get_rpc_health_check
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
|
|
context 'when there is an issue' do
|
|
before(:each) do
|
|
allow(framework).to receive(:version).and_raise 'Mock error'
|
|
end
|
|
|
|
it 'fails the health check' do
|
|
expected_response = {
|
|
id: 1,
|
|
jsonrpc: '2.0',
|
|
result: {
|
|
status: 'DOWN'
|
|
}
|
|
}
|
|
|
|
get_rpc_health_check
|
|
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Running a check job and verifying results' do
|
|
context 'when the module returns check code safe' do
|
|
before(:each) do
|
|
allow_any_instance_of(::Msf::Auxiliary::Scanner).to receive(:check) do
|
|
::Msf::Exploit::CheckCode::Safe
|
|
end
|
|
end
|
|
|
|
it 'returns successful job results' do
|
|
create_job
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(a_valid_result_uuid)
|
|
|
|
uuid = last_json_response['result']['uuid']
|
|
wait_for_expect do
|
|
get_job_results(uuid)
|
|
|
|
expect(last_response).to be_ok
|
|
expect_completed_status(last_json_response)
|
|
end
|
|
|
|
expected_completed_response = {
|
|
result: {
|
|
status: 'completed',
|
|
result: {
|
|
code: 'safe',
|
|
details: {},
|
|
message: 'The target is not exploitable.',
|
|
reason: nil
|
|
}
|
|
}
|
|
}
|
|
expect(last_json_response).to include(expected_completed_response)
|
|
end
|
|
end
|
|
|
|
context 'when the module does not support a check method' do
|
|
before do
|
|
mock_rack_env('development')
|
|
end
|
|
|
|
let(:module_name) { 'scanner/http/title' }
|
|
|
|
it 'returns successful job results' do
|
|
create_job
|
|
expect(last_response).to_not be_ok
|
|
expected_error_response = {
|
|
error: {
|
|
code: -32000,
|
|
data: {
|
|
backtrace: include(a_kind_of(String))
|
|
},
|
|
message: 'Application server error: This module does not support check.'
|
|
},
|
|
id: 1
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when the check command raises a known msf error' do
|
|
before(:each) do
|
|
allow_any_instance_of(::Msf::Auxiliary::Scanner).to receive(:check) do |mod|
|
|
mod.fail_with(Msf::Module::Failure::UnexpectedReply, 'Expected failure reason')
|
|
end
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(a_valid_result_uuid)
|
|
|
|
uuid = last_json_response['result']['uuid']
|
|
|
|
wait_for_expect do
|
|
get_job_results(uuid)
|
|
|
|
expect(last_response).to be_ok
|
|
expect_error_status(last_json_response)
|
|
end
|
|
|
|
expected_error_response = {
|
|
result: {
|
|
status: 'errored',
|
|
error: 'unexpected-reply: Expected failure reason'
|
|
}
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when the check command has an unexpected error' do
|
|
include_context 'Msf::Framework#threads cleaner'
|
|
|
|
before(:each) do
|
|
allow_any_instance_of(::Msf::Auxiliary::Scanner).to receive(:check) do
|
|
raise 'Unexpected module error'
|
|
end
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
expect(last_response).to be_ok
|
|
expect(last_json_response).to include(a_valid_result_uuid)
|
|
|
|
uuid = last_json_response['result']['uuid']
|
|
|
|
wait_for_expect do
|
|
get_job_results(uuid)
|
|
|
|
expect(last_response).to be_ok
|
|
expect_error_status(last_json_response)
|
|
end
|
|
|
|
expected_error_response = {
|
|
result: {
|
|
status: 'errored',
|
|
error: "Unexpected module error"
|
|
}
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when there is a sinatra level application error in the development environment' do
|
|
before(:each) do
|
|
allow_any_instance_of(Msf::RPC::JSON::Dispatcher).to receive(:process).and_raise(Exception, 'Sinatra level exception raised')
|
|
mock_rack_env('development')
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
|
|
expect(last_response).to be_server_error
|
|
expected_error_response = {
|
|
error: {
|
|
code: -32000,
|
|
data: {
|
|
backtrace: include(a_kind_of(String))
|
|
},
|
|
message: 'Application server error: Sinatra level exception raised'
|
|
},
|
|
id: 1
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when rack middleware raises an error in the development environment' do
|
|
before(:each) do
|
|
allow_any_instance_of(::Rack::Protection::AuthenticityToken).to receive(:accepts?).and_raise(Exception, 'Middleware error raised')
|
|
mock_rack_env('development')
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
|
|
expect(last_response).to be_server_error
|
|
expected_error_response = {
|
|
error: {
|
|
code: -32000,
|
|
data: {
|
|
backtrace: include(a_kind_of(String))
|
|
},
|
|
message: 'Application server error: Middleware error raised'
|
|
},
|
|
id: 1
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when rack middleware raises an error in the production environment' do
|
|
before(:each) do
|
|
allow_any_instance_of(::Rack::Protection::AuthenticityToken).to receive(:accepts?).and_raise(Exception, 'Middleware error raised')
|
|
mock_rack_env('production')
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
|
|
expect(last_response).to be_server_error
|
|
expected_error_response = {
|
|
error: {
|
|
code: -32000,
|
|
message: 'Application server error: Middleware error raised'
|
|
},
|
|
id: 1
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
|
|
context 'when there is a sinatra level application error in the production environment' do
|
|
before(:each) do
|
|
allow_any_instance_of(Msf::RPC::JSON::Dispatcher).to receive(:process).and_raise(Exception, 'Sinatra level exception raised')
|
|
mock_rack_env('production')
|
|
end
|
|
|
|
it 'returns the error results' do
|
|
create_job
|
|
|
|
expect(last_response).to be_server_error
|
|
expected_error_response = {
|
|
error: {
|
|
code: -32000,
|
|
message: 'Application server error: Sinatra level exception raised'
|
|
},
|
|
id: 1
|
|
}
|
|
expect(last_json_response).to include(expected_error_response)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'analyze' do
|
|
let(:host_ip) { Faker::Internet.private_ip_v4_address }
|
|
let(:host) do
|
|
{
|
|
workspace: 'default',
|
|
host: host_ip,
|
|
state: 'alive',
|
|
os_name: 'Windows',
|
|
os_flavor: 'Enterprize',
|
|
os_sp: 'SP2',
|
|
os_lang: 'English',
|
|
arch: 'ARCH_X86',
|
|
mac: '97-42-51-F2-A7-A7',
|
|
scope: 'eth2',
|
|
virtual_host: 'VMWare'
|
|
}
|
|
end
|
|
|
|
let(:vuln) do
|
|
{
|
|
workspace: 'default',
|
|
host: host_ip,
|
|
name: 'Exploit Name',
|
|
info: 'Human readable description of the vuln',
|
|
refs: vuln_refs
|
|
}
|
|
end
|
|
|
|
context 'when there are modules available' do
|
|
let(:vuln_refs) do
|
|
%w[
|
|
CVE-2017-0143
|
|
]
|
|
end
|
|
|
|
before(:each) do
|
|
framework.modules.add_module_path('./modules')
|
|
end
|
|
|
|
context 'with no options' do
|
|
it 'returns the list of known modules associated with a reported host' do
|
|
report_host(host)
|
|
expect(last_response).to be_ok
|
|
|
|
report_vuln(vuln)
|
|
expect(last_response).to be_ok
|
|
|
|
expected_response = {
|
|
jsonrpc: '2.0',
|
|
result: {
|
|
host: {
|
|
address: host_ip,
|
|
modules: [
|
|
{
|
|
mname: "exploit/windows/smb/ms17_010_eternalblue",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: [],
|
|
},
|
|
state: "READY_FOR_TEST",
|
|
description: "ready for testing"
|
|
},
|
|
{
|
|
mname: "exploit/windows/smb/ms17_010_psexec",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: [ "credential" ],
|
|
},
|
|
state: "REQUIRES_CRED",
|
|
description: "credentials are required"
|
|
},
|
|
{
|
|
mname: "exploit/windows/smb/smb_doublepulsar_rce",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: [],
|
|
},
|
|
state: "READY_FOR_TEST",
|
|
description: "ready for testing"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
id: 1
|
|
}
|
|
|
|
analyze_host(
|
|
{
|
|
workspace: 'default',
|
|
host: host_ip
|
|
}
|
|
)
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
|
|
context 'when payloads requirements are specified' do
|
|
it 'returns the list of known modules associated with a reported host' do
|
|
report_host(host)
|
|
expect(last_response).to be_ok
|
|
|
|
report_vuln(vuln)
|
|
expect(last_response).to be_ok
|
|
|
|
# Note: Currently the API doesn't return any differentiating output that a particular module is suitable
|
|
# with the requested payload
|
|
expected_response = {
|
|
jsonrpc: '2.0',
|
|
result: {
|
|
host: {
|
|
address: host_ip,
|
|
modules: [
|
|
{
|
|
mname: "exploit/windows/smb/ms17_010_eternalblue",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: [ "payload_match" ],
|
|
},
|
|
state: "MISSING_PAYLOAD",
|
|
description: "none of the requested payloads match"
|
|
},
|
|
{
|
|
mname: "exploit/windows/smb/ms17_010_psexec",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: [ "credential", "payload_match" ],
|
|
},
|
|
state: "REQUIRES_CRED",
|
|
description: "credentials are required, none of the requested payloads match"
|
|
},
|
|
{
|
|
mname: "exploit/windows/smb/smb_doublepulsar_rce",
|
|
mtype: "exploit",
|
|
options: {
|
|
invalid: [],
|
|
missing: ["payload_match"],
|
|
},
|
|
state: "MISSING_PAYLOAD",
|
|
description: "none of the requested payloads match"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
id: 1
|
|
}
|
|
|
|
analyze_host(
|
|
{
|
|
workspace: 'default',
|
|
host: host_ip,
|
|
analyze_options: {
|
|
payloads: [
|
|
'windows/meterpreter_reverse_http'
|
|
]
|
|
}
|
|
}
|
|
)
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when there are no modules found' do
|
|
let(:vuln_refs) do
|
|
['CVE-NO-MATCHING-MODULES-1234']
|
|
end
|
|
|
|
it 'returns an empty list of modules' do
|
|
report_host(host)
|
|
expect(last_response).to be_ok
|
|
|
|
report_vuln(vuln)
|
|
expect(last_response).to be_ok
|
|
|
|
expected_response = {
|
|
jsonrpc: '2.0',
|
|
result: {
|
|
host: {
|
|
address: host_ip,
|
|
modules: []
|
|
}
|
|
},
|
|
id: 1
|
|
}
|
|
|
|
analyze_host(
|
|
{
|
|
workspace: 'default',
|
|
host: host_ip
|
|
}
|
|
)
|
|
expect(last_json_response).to include(expected_response)
|
|
end
|
|
end
|
|
end
|
|
end
|