Skip to content

Uncontrolled CPU Consumption through a maliciously crafted changelog_config.yml file

Please read the process on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.

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
  1. Create an empty repository on the victim gitlab instance.
  2. Upload malicious .gitlab/changelog_config.yml file to the instance (The file is called malicious.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.).
  3. Send API request to (try to) generate the changelog.
  4. 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:

gitlab_resources.png

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: