漏洞分析
漏洞流程
在Gitlab中引入了omniauth
这个三方库实现SAML认证功能(关于该库详细信息:https://github.com/omniauth/omniauth-saml),其被以中间键(middleware
)形式注册于Gitlab后端rails-server
上,在请求过程中会调用OmniAuth::Strategy#call
方法,依次调用栈大致如下
OmniAuth::Strategy#callOmniAuth::Strategy#call!OmniAuth::Strategy#callback_callOmniAuth::Strategies::SAML#callback_phase
在OmniAuth::Strategies::SAML#callback_phase
方法中进行了如下处理,可以看到获取请求参数SAMLResponse
并调用handle_response
处理
def callback_phase raise OmniAuth::Strategies::SAML::ValidationError.new("SAML response missing") unless request.params["SAMLResponse"] with_settings do |settings|# Call a fingerprint validation method if there's one validate_fingerprint(settings) if options.idp_cert_fingerprint_validator handle_response(request.params["SAMLResponse"], options_for_response_object, settings) dosuperendendrescue Omnidef handle_response(raw_response, opts, settings) response = OneLogin::RubySaml::Response.new(raw_response, opts.merge(settings: settings)) response.attributes["fingerprint"] = settings.idp_cert_fingerprint response.soft = false# 注意此处,SAMLResponse校验,也就是这里面存在绕过 response.is_valid? @name_id = response.name_id @session_index = response.sessionindex @attributes = response.attributes @response_object = response session["saml_uid"] = @name_id session["saml_session_index"] = @session_indexyieldend
在handle_response
中也就出现了本次漏洞核心点,此处调用OneLogin::RubySaml::Response
构造方法进行SAMLResponse解析,而本次漏洞就出现在这个Ruby-Saml
解析库上,这里先完整梳理一下流程,稍作细节分析,在此处解析完毕后最终来到app/controllers/omniauth_callbacks_controller.rb
中处理,在控制器类中存在如下全局代码片段
AuthHelper.providers_for_base_controller.each do |provider|
alias_method provider, :handle_omniauth
end
def handle_omniauth
if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym)
saml
else
omniauth_flow(Gitlab::Auth::OAuth)
end
end
在此处将调用saml
方法继续处理
// OmniauthCallbacksController#saml
def saml
omniauth_flow(Gitlab::Auth::Saml)
rescue Gitlab::Auth::Saml::IdentityLinker::UnverifiedRequest
redirect_unverified_saml_initiation
end
此处继续调用OmniauthCallbacksController#omniauth_flow
方法处理
// OmniauthCallbacksController#omniauth_flow
def omniauth_flow(auth_module, identity_linker: nil)
if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence
store_redirect_fragment(fragment)
end
store_redirect_to
if current_user
return render_403 unless link_provider_allowed?(oauth['provider'])
// gitlab.rb gitlab_rails['omniauth_providers']
set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym)
track_event(current_user, oauth['provider'], 'succeeded')
if Gitlab::CurrentSettings.admin_mode
return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
end
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
return redirect_authorize_identity_link(identity_linker) if identity_linker.authorization_required?
link_identity(identity_linker)
// auth_module::User => Gitlab::Auth::Saml::User
current_auth_user = build_auth_user(auth_module::User)
set_remember_me(current_user, current_auth_user)
store_idp_two_factor_status(current_auth_user.bypass_two_factor?)
if identity_linker.changed?
redirect_identity_linked
elsif identity_linker.failed?
redirect_identity_link_failed(identity_linker.error_message)
else
redirect_identity_exists
end
else
// auth_module::User => Gitlab::Auth::Saml::User
sign_in_user_flow(auth_module::User)
end
end
在该方法中在进行模块名拼接进而拼接出模块名并继续调用OmniauthCallbacksController#sign_in_user_flow
方法
// OmniauthCallbacksController#sign_in_user_flow
def sign_in_user_flow(auth_user_class)
auth_user = build_auth_user(auth_user_class)
# 创建Gitlab::Auth::Saml::User实例
new_user = auth_user.new?
# 调用Gitlab::Auth::Saml::User#find_and_update!方法
@user = auth_user.find_and_update!
if auth_user.valid_sign_in?
# In this case the `#current_user` would not be set. So we can't fetch it
# from that in `#context_user`. Pushing it manually here makes the information
# available in the logs for this request.
Gitlab::ApplicationContext.push(user: @user)
track_event(@user, oauth['provider'], 'succeeded')
Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: @user) if new_user
set_remember_me(@user, auth_user)
set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym)
if @user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(@user)
store_idp_two_factor_status(false)
else
if @user.deactivated?
@user.activate
flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
end
# session variable for storing bypass two-factor request from IDP
store_idp_two_factor_status(true)
accept_pending_invitations(user: @user) if new_user
persist_accepted_terms_if_required(@user) if new_user
perform_registration_tasks(@user, oauth['provider']) if new_user
sign_in_and_redirect_or_verify_identity(@user, auth_user, new_user)
end
else
fail_login(@user)
end
rescue Gitlab::Auth::OAuth::User::IdentityWithUntrustedExternUidError
handle_identity_with_untrusted_extern_uid
rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError
handle_disabled_provider
rescue Gitlab::Auth::OAuth::User::SignupDisabledError
handle_signup_error
end
在Gitlab::Auth::OAuth::User#find_and_update!
中逐层调用根据前期解析SAMResponse
得到的信息构造用户对象
// Gitlab::Auth::OAuth::User#find_and_update! def find_and_update! save if should_save? gl_user end// Gitlab::Auth::OAuth::User#gl_user def gl_userreturn @gl_user if defined?(@gl_user) @gl_user = find_user end
最终调用Gitlab::Auth::Saml::User#find_user
获取具体用户
// Gitlab::Auth::Saml::User#find_user def find_user user = find_by_uid_and_provider# github.rb gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']# 当开启omniauth_auto_link_user后会通过邮箱去寻找对应用户作为认证的目标用户# 注意此处user||=也就是当user为null的时候才会执行后面表达式 user ||= find_by_email if auto_link_saml_user? user ||= find_or_build_ldap_user if auto_link_ldap_user?# 开启注册功能的时候会自定进行用户创建 user ||= build_new_user if signup_enabled?if user# 设置用户external属性 user.external = !(auth_hash.groups & saml_config.external_groups).empty? if external_users_enabled?end userend// Gitlab::Auth::OAuth::User#find_by_email def find_by_emailreturn unless auth_hash.has_attribute?(:email) ::User.find_by(email: auth_hash.email.downcase)end
在这几步中email
信息存在于SAMLResponse中,也就是我们的切入点,通过伪造SAMLResponse将其中邮箱信息伪造为目标用户进而实现权限绕过。
Ruby-SAML 数据解析不当
OneLogin::RubySaml::Response
将对我们的SAMLResponse进行解析// OneLogin::RubySaml::Response#initialize def initialize(response, options = {}) raise ArgumentError.new("Response cannot be nil") if response.nil? @errors = [] @options = options @soft = trueunless options[:settings].nil? @settings = options[:settings]unless @settings.soft.nil? @soft = @settings.soft end end# Base64解码并解析xml @response = decode_raw_saml(response, settings)# 校验是否合法 @document = XMLSecurity::SignedDocument.new(@response, @errors)if assertion_encrypted? @decrypted_document = generate_decrypted_document end end
OmniAuth::Strategies::SAML#callback_phase
方法中会调用OneLogin::RubySaml::Response#is_valid?
进行SAMLResponse校验,该方法逻辑如下def is_valid?(collect_errors = false)
validate(collect_errors)
end
def validate(collect_errors = false)
reset_errors!
return false unless validate_response_state
validations = [
:validate_version,
:validate_id,
:validate_success_status,
:validate_num_assertion,
:validate_no_duplicated_attributes,
:validate_signed_elements,
:validate_structure,
:validate_in_response_to,
:validate_one_conditions,
:validate_conditions,
:validate_one_authnstatement,
:validate_audience,
:validate_destination,
:validate_issuer,
:validate_session_expiration,
:validate_subject_confirmation,
:validate_name_id,
:validate_signature
]
if collect_errors
validations.each { |validation| send(validation) }
@errors.empty?
else
validations.all? { |validation| send(validation) }
end
end
xpath
节点获取获取表达式,使用//
将从xml文档整个上下文去寻找该节点,而./
则会从某个节点之下去寻找节点,而伪造的SAMLResponse中就是通过修改SMALResponse在原本SAMLReponse中dsig:DigestValue
前面添加一个dsig:DigestValue
绕过校验坑点
结语
最近换螺丝厂子了,有点忙,没时间写啥东西,还有就是感谢Nacos那篇文章打赏的大哥(大哥真豪!)。
原文始发于微信公众号(安全之道):Ruby-SAML Bypass && GitLab SAML Bypass(CVE-2024-45409)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论