Uncontrolled CPU Consumption through a maliciously crafted changelog_config.yml file
HackerOne report #2735311 by l33thaxor
on 2024-09-23, assigned to @greg:
Report | Attachments | How To Reproduce
Report
Summary
The bug (I think) actually exists in a library which gitlab depends on called parslet
. It is used to create parsers for custom syntax using PEG (Parsing Expression Grammar). Here is a file which mimics the functionality found in gitlab:
require 'parslet'
class Parser < Parslet::Parser
root(:exprs)
rule(:exprs) do
(
variable | if_expr | each_expr | escaped | text | newline
).repeat.as(:exprs)
end
rule(:space) { match('[ \\t]') }
rule(:whitespace) { match('\s').repeat }
rule(:lf) { str("\n") }
rule(:newline) { lf.as(:text) }
# Escaped newlines are ignored, allowing the user to control the
# whitespace in the output. All other escape sequences are treated as
# literal text.
#
# For example, this:
#
# foo \
# bar
#
# Is parsed into this:
#
# foo bar
rule(:escaped) do
backslash = str('\\')
(backslash >> lf).ignore | (backslash >> chars).as(:text)
end
# A sequence of regular characters, with the exception of newlines and
# escaped newlines.
rule(:chars) do
char = match("[^{\\\\\n]")
# The rules here are such that we do treat single curly braces or
# non-opening tags (e.g. `{foo}`) as text, but not opening tags
# themselves (e.g. `{{`).
(
char.repeat(1) | (curly_open >> (curly_open | percent).absent?)
).repeat(1)
end
rule(:text) { chars.as(:text) }
# An integer, limited to 10 digits (= a 32 bits integer).
#
# The size is limited to prevents users from creating integers that are
# too large, as this may result in runtime errors.
rule(:integer) { match('\d').repeat(1, 10).as(:int) }
# An identifier to look up in a data structure.
#
# We only support simple ASCII identifiers as we simply don't have a need
# for more complex identifiers (e.g. those containing multibyte
# characters).
rule(:ident) { match('[a-zA-Z_]').repeat(1).as(:ident) }
# A selector is used for reading a value, consisting of one or more
# "steps".
#
# Examples:
#
# name
# users.0.name
# 0
# it
rule(:selector) do
step = ident | integer
whitespace >>
(step >> (str('.') >> step).repeat).as(:selector) >>
whitespace
end
rule(:curly_open) { str('{') }
rule(:curly_close) { str('}') }
rule(:percent) { str('%') }
# A variable tag.
#
# Examples:
#
# {{name}}
# {{users.0.name}}
rule(:variable) do
curly_open.repeat(2) >> selector.as(:variable) >> curly_close.repeat(2)
end
rule(:expr_open) { curly_open >> percent >> whitespace }
rule(:expr_close) do
# Since whitespace control is important (as Markdown is whitespace
# sensitive), we default to stripping a newline that follows a %} tag.
# This is less annoying compared to having to opt-in to this behaviour.
whitespace >> percent >> curly_close >> lf.maybe.ignore
end
rule(:end_tag) { expr_open >> str('end') >> expr_close }
# An `if` expression, with an optional `else` clause.
#
# Examples:
#
# {% if foo %}
# yes
# {% end %}
#
# {% if foo %}
# yes
# {% else %}
# no
# {% end %}
rule(:if_expr) do
else_tag =
expr_open >> str('else') >> expr_close >> exprs.as(:false_body)
expr_open >>
str('if') >>
space.repeat(1) >>
selector.as(:if) >>
expr_close >>
exprs.as(:true_body) >>
else_tag.maybe >>
end_tag
end
# An `each` expression, used for iterating over collections.
#
# Example:
#
# {% each users %}
# * {{name}}
# {% end %}
rule(:each_expr) do
expr_open >>
str('each') >>
space.repeat(1) >>
selector.as(:each) >>
expr_close >>
exprs.as(:body) >>
end_tag
end
def parse_and_transform(input)
#AST::Transformer.new.apply(parse(input))
parse(input) # Just call parse instead
rescue Parslet::ParseFailed => ex
# We raise a custom error so it's easier to catch different parser
# related errors. In addition, this ensures the caller of this method
# doesn't depend on a Parslet specific error class.
#raise Error, "Failed to parse the template: #{ex.message}"
return
end
end
def target_function(data)
puts "Parsing..."
Parser.new.parse_and_transform(data)
end
puts "Reading from stdin..."
input_data = ARGF.read # Read payload from stdin...
target_function(input_data)
puts "Done!"
if an attacker supplies the finalpwn.txt
file as input to this program (like ruby test.rb < finalpwn.txt
), the program will hang with very high CPU usage. If the program is interrupted with a ctrl-c
, then the backtrace gives a clue as to where the vulnerability lies:
Reading from stdin...
Parsing...
^C/usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:123:in `compact': Interrupt
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:123:in `flatten_repetition'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:40:in `flatten'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:32:in `block in flatten'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:32:in `map'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/can_flatten.rb:32:in `flatten'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/named.rb:30:in `produce_return_value'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/named.rb:21:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/entity.rb:23:in `try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/context.rb:31:in `try_with_cache'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:86:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/alternative.rb:38:in `block in try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/alternative.rb:37:in `map'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/alternative.rb:37:in `try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/context.rb:31:in `try_with_cache'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:86:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/repetition.rb:39:in `block in try'
from <internal:kernel>:191:in `loop'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/repetition.rb:38:in `try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/context.rb:31:in `try_with_cache'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:86:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/named.rb:17:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/entity.rb:23:in `try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/parser.rb:62:in `try'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/context.rb:31:in `try_with_cache'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:86:in `apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:73:in `setup_and_apply'
from /usr/local/lib/ruby/gems/3.4.0+0/gems/parslet-1.8.2/lib/parslet/atoms/base.rb:33:in `parse'
from test.rb:151:in `parse_and_transform'
from test.rb:163:in `target_function'
from test.rb:168:in `<main>'
As you can see, the problem occurs in the parslet
library. This library is used in lib/gitlab/template_parser/parser.rb
in the Gitlab::TemplateParser::Parser
class in the AST::Transformer.new.apply(parse(input))
line. This class is used in the lib/gitlab/changelog/config.rb
file. This file is responsible for processing the changelog configuration files which are user supplied (https://docs.gitlab.com/ee/user/project/changelogs.html#customize-the-changelog-output). One of the parameters in the .gitlab/changelog_config.yml
file called template
specifies the template which is passed as-is to Gitlab::TemplateParser::Parser
when the user requests the changelog to be created through an api request. (api request to /api/v4/projects/<projectindexhere>/repository/changelog
)
This means that an attacker can cause uncontrolled CPU consumption by first passing a maliciously crafted .gitlab/changelog_config.yml
file to a repository which they own and then requesting the changelog to be updated through an api request.
I have attached all of the files which I have experimented with as a zip file called files.zip
. In addition, I have also recorded a demo which shows the impact.
Steps to reproduce
- Create an empty repository on the victim gitlab instance.
- Upload malicious
.gitlab/changelog_config.yml
file to the instance (The file is calledmalicious.txt
in the zip file which I attached, but obviously upload it as.gitlab/changelog_config.yml
. Otherwise it won't be processed as the changelog configuration file.). - Send API request to (try to) generate the changelog.
- Observe uncontrolled CPU consumption on the victim server and observe degraded performance (as seen by the amount of time which it takes to load up pages for example).
Impact
Uncontrolled CPU consumption on any gitlab instance where the attacker can create an account.
Examples
I was told previously that DoS attacks against localhost are not valid by ([@]h1_analyst_jack (aka. Jack Jessee https://hackerone.com/h1_analyst_jack?type=user) ), so therefore I created a private instance of gitlab on a cloud provider here: https://gitlab-183558-0.cloudclusters.net:10076/ for a demonstration. You can login with the username root
and with the password q4QkLm9w
. I have attached a video recording which shows the POC in action.
What is the current bug behavior?
Uncontrolled CPU Usage.
What is the expected correct behavior?
Gitlab should sanitize user input, such that uncontrolled CPU usage doesn't happen. This can be achieved by changing the way gitlab processes the templates altogether or just adding a maximum input size for the template parameter in the changelog configuration file.
Relevant logs and/or screenshots
Here are the specs of the target machine which I used to test this bug:
Output of checks
This bug happens on GitLab.com
I can not really verify that this happens on gitlab.com without breaking terms of service (can not really cause a dos attack against gitlab.com without breaking TOS), but I assume that it does, since the gitlab source code running on each instance of gitlab should be roughly the same.
Results of GitLab environment info
System information
System: Ubuntu 20.04
Current User: git
Using RVM: no
Ruby Version: 2.7.5p203
Gem Version: 3.1.4
Bundler Version:2.2.33
Rake Version: 13.0.6
Redis Version: 6.2.6
Sidekiq Version:6.4.0
Go Version: unknown
GitLab information
Version: 14.10.1
Revision: cfeee9d301b
Directory: /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: PostgreSQL
DB Version: 12.7
URL: https://gitlab-183558-0.cloudclusters.net
HTTP Clone URL: https://gitlab-183558-0.cloudclusters.net/some-group/some-project.git
SSH Clone URL: git@gitlab-183558-0.cloudclusters.net:some-group/some-project.git
Using LDAP: no
Using Omniauth: yes
Omniauth Providers:
GitLab Shell
Version: 13.25.1
Repository storage paths:
- default: /var/opt/gitlab/git-data/repositories
GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell
Impact
Server-side Uncontrolled CPU Consumption leading to Denial Of Service.
Attachments
Warning: Attachments received through HackerOne, please exercise caution!
How To Reproduce
Please add reproducibility information to this section: