Author:dawu,LG(知道创宇404安全实验室)
Data:2016-10-09
GitLab 是一个利用Ruby on Rails开发的开源应用程序,实现一个自托管的Git项目仓库,可通过Web界面进行访问公开的或者私人项目。近日研究者发现在其多个版本中存在文件读取漏洞(CVE-2016-9086) 和 任意用户authentication_token泄漏漏洞,攻击者可以通过这两个漏洞来获取管理员的权限,进而控制所有gitlab项目。
GitLab CE/EEversions 8.9, 8.10, 8.11, 8.12, and 8.13
Gitlab CE/EE versions 8.10.3-8.10.5
sudo apt-get install curl openssh-server ca-certificates postfix
curl -s https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash
sudo apt-get install gitlab-ce=8.10.3-ce.1 #版本为8.10.3是为了第二个任意用户authentication_token泄露导致
漏洞的复现
sudo gitlab-ctl reconfigure
安装完成后访问服务器80端口即可看到GitLab
登录页面.
注:8.9.0-8.13.0
版本的gitlab
的项目导入功能需要管理员开启,8.13.0
版本之后所有用户都可以使用导入功能。管理员可以访问http://domain/admin/application_settings 开启,开启之后用任意用户新建项目的时候,可以在import project from
一项中看到gitlab export
。
从8.9.0
版本开始,GitLab
新增了导入导出项目的功能。
一个空的gitlab
项目导出后结构如下:
其中VERSION
文件内容为GitLab的导出模块的版本,project.json
则包含了项目的配置文件。
当我们导入GitLab的导出文件的时候,GitLab会按照如下步骤处理:
1.服务器根据VERSION
文件内容检测导出文件版本,如果版本符合,则导入。
2.服务器根据Project.json
文件创建一个新的项目,并将对应的项目文件拷贝到服务器上对应的位置。
检测VERSION
文件的代码位于:/lib/gitlab/import_export/version_checker.rb
中:
...
def check!
version = File.open(version_file, &:readline)
verify_version!(version)
rescue => e
shared.error(e)
false
end
...
def verify_version!(version)
if Gem::Version.new(version) != Gem::Version.new(Gitlab::ImportExport.version)
raise Gitlab::ImportExport::Error.new("Import version mismatch: Required #{Gitlab::ImportExport.version} but was #{version}")
else
true
end
end
...
我们可以看到这里的逻辑是读取VERSION
文件的第一行赋值给变量version
,然后检测verison
与当前版本是否相同,相同返回true
,不相同则返回错误信息(错误信息中包括变量version
的值).
于是漏洞发现者Jobert Abma
巧妙的使用了软链接来达到读取任意文件的目的。首先,我们给VERSION
文件加上软链接并重新打包。
ln -sf /etc/passwd VERSION
tar zcf change_version.tar.gz ./
这样,读取VERSION
文件的时候服务器就会根据软链接读取到/etc/passwd
的第一行内容并赋值给version
。但是由于version
与当前版本不相同,所以会输出version
的值,也就是/etc/passwd
第一行的内容。
访问之前搭建好的GitLab服务器,创建一个新的项目,填写完项目名称后在Import project from
一栏中选择GitLab export
,上传我们修改后的导入包,然后就可以看到/etc/passwd
文件第一行
但是,如果只读取任意文件的第一行,能做的事情还是太少了。漏洞发现者显然不满足这一结果,他继续找了下去.
读取Project.json
这一配置文件的代码位于:/lib/gitlab/import_export/project_tree_restorer.rb
中:
...
def restore
json = IO.read(@path)
tree_hash = ActiveSupport::JSON.decode(json)
project_members = tree_hash.delete('project_members')
ActiveRecord::Base.no_touching do
create_relations
end
rescue => e
shared.error(e)
false
end
...
在这里,我们可以再次使用软链接使变量json
获取到任意文件的内容,但是由于获取的文件不是json
格式,无法decode
,导致异常抛出,最终在前端显示出任意文件的内容。
添加软链接并打包:
ln -sf /etc/passwd project.json
tar zcf change_version.tar.gz ./
上传导出包,页面上显示的结果:
1.注册一个普通用户,创建一个新的项目
2.在项目的member
选项中,添加管理员到项目中。
3.点击edit project
,找到Export project
部分,点击Export project
,等待几分钟去查看注册邮箱收到的下载地址或者刷新页面,点击Download export
下载导出包。
4.导出包的project.json
中已经含有了管理员的authentication_token
。
得到authentication_token
之后我们就可以通过api
做管理员可以做的事情了,比如查看管理员所在的项目:
我们在\app\controllers\projects_controller.rb
中找到了export
函数,这个函数被用来导出项目文件。
def export
@project.add_export_job(current_user: current_user)
redirect_to(
edit_project_path(@project),
notice: "Project export started. A download link will be sent by email."
)
end
往下跟add_export_job()
,在\app\models\project.rb
中:
def add_export_job(current_user:)
job_id = ProjectExportWorker.perform_async(current_user.id, self.id)
if job_id
Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
else
Rails.logger.error "Export job failed to start for project ID #{self.id}"
end
end
继续到\app\workers\project_export_worker.rb
文件的ProjectExportWorker.perform_async()
:
class ProjectExportWorker
include Sidekiq::Worker
sidekiq_options queue: :gitlab_shell, retry: 3
def perform(current_user_id, project_id)
current_user = User.find(current_user_id)
project = Project.find(project_id)
::Projects::ImportExport::ExportService.new(project, current_user).execute
end
end
这里我们可以看到current
获取的是User.find(current_user_id)
的内容,然后调用::Projects::ImportExport::ExportService.new(project, current_user).execute
由于笔者之前没有接触过ruby,这里只好采用gitlab-rails console
来找到User.find()
的值。可以看到,在User.find()
中,存在authentication_token
的值。
跟到\app\services\project\import_export\export_service.rb
,这里执行version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver
这五个函数来写各种导出文件,其中project_tree_saver()
负责导出project.json
module Projects
module ImportExport
class ExportService < BaseService
def execute(_options = {})
@shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.path_with_namespace, 'work'))
save_all
end
private
def save_all
if [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save)
Gitlab::ImportExport::Saver.save(project: project, shared: @shared)
notify_success
else
cleanup_and_notify
end
end
def version_saver
...
end
end
end
跳过之后的几个繁琐的调用之后,执行了lib/gitlab/import_export/json_hash_builder.rb
中的create_model_value
函数。
# Constructs a new hash that will hold the configuration for that particular object
# It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+
#
# +current_key+ main model that will be a key in the hash
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def create_model_value(current_key, value, json_config_hash)
parsed_hash = { include: value }
parse_hash(value, parsed_hash)
json_config_hash[current_key] = parsed_hash
end
# Calls attributes finder to parse the hash and add any attributes to it
#
# +value+ existing model to be included in the hash
# +parsed_hash+ the original hash
def parse_hash(value, parsed_hash)
@attributes_finder.parse(value) do |hash|
parsed_hash = { include: hash_or_merge(value, hash) }
end
end
这里出现了逻辑问题,由于parsed_hash
这个变量不是全局变量,所以create_model_value()
中执行parse_hash()
时,parse_hash()
中的parsed_hash
被改变,但是create_model_value()
函数中的parsed_hash
不会变,这就造成了parse_hash()
这个函数执行后create_model_value()
中parsed_hash
这个值并没有改变。因此最后导出的文件包含了authentication_token
。
我们在gitlab-rails console
里展示了这两者的区别。当value=user
的时候,parsed_hash={:include=>:user}
,输出的结果如同图中的user.as_json()
,会将所有内容输出,包括authentication_token
。当parsed_hash
为经过parse_hash()
处理后的{:include=>{:user=>{:only=>[:id, :email, :username]}}}
时,输出结果与user.as_json(only: [:id, :email, :username])
相同。
在hackone的两个报告中,漏洞发现者都提到了leads to RCE
,笔者尝试去实现这一点。由于GitLab
源码在gitlab.com
上,所以当获取了GitLab
的管理员权限后,我们可以通过authentication_token
修改GitLab
项目的源码,留下自己的后门。
为了重现这种情况,我们在本地新建一个新的项目去通过authentication_token
和GitLab api
来修改项目文件。
用root
账户创建一个项目:test_rce
,其中README.md
的内容为created by root
接下来,我们要用gitlab
的api
来修改它。首先,根据projects的api
找到test_rce
项目对应的id
,这里是18
curl -H "PRIVATE-TOKEN: wTPMMapDwpfkKfNws7xp" "http://domain/api/v3/projects"
我们再根据api
读取一下文件
curl -H "PRIVATE-TOKEN: wTPMMapDwpfkKfNws7xp" "http://domain/api/v3/projects/18/repository/files?file_path=README.md&ref=master"
这里,content
为Y3JlYXRlZCBieSByb290
,这是文件内容被base64
加密后的结果,解密一下就可以看到created by root
根据api
的要求,我们通过PUT
数据来修改文件,将README.md
修改为change by notroot
。
当我们再读一次,content
内容为:Y2hhbmdlIGJ5IG5vdHJvb3Q=
,解码之后就是change by notroot
不得不说,笔者所实现的这种方式攻击时间跨度很长,能否执行命令取决于开发者下一次更新的时间,这也是这种方法的缺点之一。
我们可以看到,官方先移除了导入包里的软连接,其次,读取VERSION
的内容和project.json
的内容出错后将内容输出到日志里而非返回到前端。
官方让json_config_hash[current_key]
获取到parse_hash()
处理后的值。