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
-
@nmalcolm validate the issue as per Vakzz instructions -
@rshambhuni validate the issue with FF enabled but scoped to non-accessible groups -
@nmalcolm validate whether bulk_import_projects
is enabled on GitLab.com (it's disabled by default for self-managed)- It's enabled (see https://gitlab.slack.com/archives/C101F3796/p1655955401127539) scoped to groups:
-
51646638
https://gitlab.com/rodrigo-tomonari-test -
52399621
https://gitlab.com/groups/georgekoltsov-group -
5900099
https://gitlab.com/groups/georgekoltsov-bulk-import-group
-
@nmalcolm open a SIRT issue if it is enabledNot needed -
On 2nd thought - make sure that having it enabled for those test groups doesn't still open this vulnerability. Current unvalidated thinking is that since the FF is scoped to some team member test projects, an attacker can't exploit via them. - [ ]
Report
Summary
The DecompressedArchiveSizeValidator
is used to check the size of a archive before extracting it:
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:
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
.
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
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
- spin up a gitlab instance
- ssh in and enable bulk project imports with from a rails console:
sudo gitlab-rails console
then::Feature.enable(:bulk_import_projects)
- start watching the logs with
sudo gitlab-ctl tail
- create an api token
- create a new group
- create a new project in that group
- download
and change
PROJECT_PATH
to the full path of the project above andPROJECT_ID
to its id - 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
) - (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 - run it with
FLASK_APP=api_project_ql.py flask run
- start ngrok with
ngrok http 5000
- go to new group -> import group
- enter the ngrok http address and your token from above in the
Import groups from another instance of GitLab
section - select the group created above, change the parent to
No parent
and choose a new group name - hit import
- 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 topopen
that can contain attacker controlled data - The
ProjectPipeline
does not correctly filter the project params
What is the expected correct behavior?
- The
DecompressedArchiveSizeValidator
should useGitlab::Popen
and the command should be an array of strings - The
ProjectPipeline
should use theGitlab::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: