Ruby-SAML Bypass && GitLab SAML Bypass(CVE-2024-45409)

Ruby-SAML Bypass && GitLab SAML Bypass(CVE-2024-45409)

原创 L0ne1y 安全之道 2024-10-11 18:26

注:由于本地源码启动死活有问题故后续分析全为静态分析,故可能存在部分错误之处。

漏洞复现

漏洞分析

漏洞流程

在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

本次漏洞修复的主要代码就在这些校验方法里面(参考:
https://github.com/SAML-Toolkits/ruby-saml/commit/1ec5392bc506fe43a02dbb66b68741051c5ffeae#diff-091398471b63b720a2fd9771bace3c21c3a590210259af1bf38446c1e0cf7598L240

如上图所示,修复代码中修改xpath节点获取获取表达式,使用//将从xml文档整个上下文去寻找该节点,而./则会从某个节点之下去寻找节点,而伪造的SAMLResponse中就是通过修改SMALResponse在原本SAMLReponse中dsig:DigestValue前面添加一个dsig:DigestValue绕过校验

漏洞利用脚本伪造的

坑点

目前公开的漏洞相关漏洞存在两个,一个为nuclei,还有一个是python脚本版本(Github搜索漏洞编号即可搜索到),两个脚本均存在一定BUG,nuclei相关脚本未修改校验时间,导致可能出现利用失败,另外一个python脚本属性修改不全面,存在失败场景,如下为python版本漏洞利用脚本修改后数据示例

结语

最近换螺丝厂子了,有点忙,没时间写啥东西,还有就是感谢Nacos那篇文章打赏的大哥(大哥真豪!)。