CVE-2022-39311 (published 2022-10-14T20:15Z)

GoCD is a continuous delivery server. GoCD helps you automate and streamline the build-test-release cycle for continuous delivery of your product. GoCD versions prior to 21.1.0 are vulnerable to remote code execution on the server from a malicious or compromised agent. The Spring RemoteInvocation endpoint exposed agent communication and allowed deserialization of arbitrary java objects, as well as subsequent remote code execution. Exploitation requires agent-level authentication, thus an attacker would need to either compromise an existing agent, its network communication or register a new agent to practically exploit this vulnerability. This issue is fixed in GoCD version 21.1.0. There are currently no known workarounds.

CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-502
cwe_name: Deserialization of Untrusted Data
description: The product deserializes untrusted data without sufficiently verifying that the resulting data will be valid.
extended_description: 
            It is often convenient to serialize objects for communication or to save them for later use. However, deserialized data or code can often be modified without using the provided accessor functions if it does not use cryptography to protect itself. Furthermore, any cryptography would still be client-side security -- which is a dangerous security assumption.
            Data that is untrusted can not be trusted to be well-formed.
            When developers place no restrictions on "gadget chains," or series of instances and method invocations that can self-execute during the deserialization process (i.e., before the object is returned to the caller), it is sometimes possible for attackers to leverage them to perform unauthorized actions, like generating a shell.
         
         

Fix information:
repo_url: https://github.com/gocd/gocd
commit_details: repo_url: https://github.com/gocd/gocd
author: Marques Lee
author_date: 2021-01-01 13:16:50-08:00
msg: Disable RMI endpoint by default

This endpoint can be enabled by including `gocd.agent.remoting.legacy=true` in the
`-Dgocd.agent.extra.properties` property for the server process.
repository: repo_url: https://github.com/gocd/gocd
repo_name: gocd/gocd
description: GoCD - Continuous Delivery server main repository
repo_language: Java
owner: gocd
file_changes: filename: AgentRemoteInvokerServiceExporter.java
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,7 @@ import com.thoughtworks.go.domain.JobIdentifier;
 import com.thoughtworks.go.domain.JobResult;
 import com.thoughtworks.go.domain.JobState;
 import com.thoughtworks.go.server.service.AgentRuntimeInfo;
+import com.thoughtworks.go.util.SystemEnvironment;
 import org.slf4j.LoggerFactory;
 import org.springframework.remoting.httpinvoker.HttpInvokerServiceExporter;
 import org.springframework.remoting.support.RemoteInvocation;
@@ -35,9 +36,9 @@ import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
 
+import static com.thoughtworks.go.util.SystemEnvironment.AGENT_EXTRA_PROPERTIES;
 import static java.lang.String.format;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.*;
 
 /**
  * Custom invoker service exporter that validates UUID authorization on agent requests. This prevents compromised agents
@@ -55,8 +56,29 @@ public class AgentRemoteInvokerServiceExporter extends HttpInvokerServiceExporte
             new MethodSignature("getCookie", AgentRuntimeInfo.class)
     );
 
+    private final SystemEnvironment env;
+
+    public AgentRemoteInvokerServiceExporter() {
+        this(new SystemEnvironment());
+    }
+
+    public AgentRemoteInvokerServiceExporter(SystemEnvironment env) {
+        this.env = env;
+    }
+
     @Override
     public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+        if (rejectRMI()) {
+            // yes, ideally, this should be short-circuited in the agent auth filter, but keeping this logic here has
+            // some advantages:
+            //   - it keeps all deprecated RMI logic in one place so it's easier to remove (just remove this class)
+            //   - it's 100% reliable by virtue of its proximity to the RMI invocation code and can't be thwarted by
+            //     some clever URI encoding to circumvent the uri path test that we would need to write at the filter
+            //     level in order to selectively apply this logic to the RMI endpoint and not the JSON API endpoint
+            reject(response, SC_GONE, "This RMI endpoint is disabled.");
+            return;
+        }
+
         try {
             RemoteInvocation invocation = readRemoteInvocation(request);
 
@@ -71,6 +93,11 @@ public class AgentRemoteInvokerServiceExporter extends HttpInvokerServiceExporte
         }
     }
 
+    private boolean rejectRMI() {
+        final String props = env.get(AGENT_EXTRA_PROPERTIES).toLowerCase();
+        return !Arrays.asList(props.split("\\s+")).contains("gocd.agent.remoting.legacy=true");
+    }
+
     /**
      * Verifies that the agent UUID from the deserialized payload matches the UUID permitted by the agent authentication
      * filter.

programming_language: Java
filename: AgentRemoteInvokerServiceExporterTest.java
change_type: ModificationType.MODIFY
diff: @@ -23,6 +23,7 @@ import com.thoughtworks.go.http.mocks.MockHttpServletRequest;
 import com.thoughtworks.go.http.mocks.MockHttpServletResponse;
 import com.thoughtworks.go.server.messaging.BuildRepositoryMessageProducer;
 import com.thoughtworks.go.server.service.AgentRuntimeInfo;
+import com.thoughtworks.go.util.SystemEnvironment;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mock;
@@ -33,6 +34,7 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.lang.reflect.InvocationTargetException;
 
+import static com.thoughtworks.go.util.SystemEnvironment.AGENT_EXTRA_PROPERTIES;
 import static javax.servlet.http.HttpServletResponse.*;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.fail;
@@ -46,21 +48,35 @@ public class AgentRemoteInvokerServiceExporterTest {
 
     private MockHttpServletResponse res;
 
+    @Mock
+    private SystemEnvironment env;
+
     @Mock
     private BuildRepositoryMessageProducer target;
 
     @BeforeEach
     void setup() throws Exception {
         openMocks(this).close();
+        when(env.get(AGENT_EXTRA_PROPERTIES)).thenReturn("gocd.agent.remoting.legacy=true");
         req = new MockHttpServletRequest();
         req.addHeader("X-Agent-GUID", AGENT_UUID);
         res = new MockHttpServletResponse();
     }
 
+    @Test
+    void rejectsWhenLegacyDisabled() throws Exception {
+        when(env.get(AGENT_EXTRA_PROPERTIES)).thenReturn("");
+        final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("ping", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
+        invoker.handleRequest(req, res);
+        verify(target, never()).ping(agent);
+        assertEquals(SC_GONE, res.getStatus());
+    }
+
     @Test
     void isIgnored_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("isIgnored", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class}, new Object[]{agent, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("isIgnored", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class}, new Object[]{agent, null}));
         invoker.handleRequest(req, res);
         verify(target, only()).isIgnored(agent, null);
         assertEquals(SC_OK, res.getStatus());
@@ -69,7 +85,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void isIgnored_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("isIgnored", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class}, new Object[]{agent, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("isIgnored", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class}, new Object[]{agent, null}));
         invoker.handleRequest(req, res);
         verify(target, never()).isIgnored(any(AgentRuntimeInfo.class), any(JobIdentifier.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -78,7 +94,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void ping_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("ping", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("ping", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, only()).ping(agent);
         assertEquals(SC_OK, res.getStatus());
@@ -87,7 +103,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void ping_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("ping", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("ping", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, never()).ping(any(AgentRuntimeInfo.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -96,7 +112,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void getWork_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getWork", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getWork", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, only()).getWork(agent);
         assertEquals(SC_OK, res.getStatus());
@@ -105,7 +121,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void getWork_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getWork", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getWork", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, never()).getWork(any(AgentRuntimeInfo.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -114,7 +130,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void getCookie_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getCookie", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getCookie", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, only()).getCookie(agent);
         assertEquals(SC_OK, res.getStatus());
@@ -123,7 +139,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void getCookie_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getCookie", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("getCookie", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verify(target, never()).getCookie(any(AgentRuntimeInfo.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -132,7 +148,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCurrentStatus_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCurrentStatus", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobState.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCurrentStatus", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobState.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, only()).reportCurrentStatus(agent, null, null);
         assertEquals(SC_OK, res.getStatus());
@@ -141,7 +157,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCurrentStatus_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCurrentStatus", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobState.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCurrentStatus", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobState.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, never()).reportCurrentStatus(any(AgentRuntimeInfo.class), any(JobIdentifier.class), any(JobState.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -150,7 +166,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCompleting_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleting", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleting", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, only()).reportCompleting(agent, null, null);
         assertEquals(SC_OK, res.getStatus());
@@ -159,7 +175,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCompleting_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleting", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleting", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, never()).reportCompleting(any(AgentRuntimeInfo.class), any(JobIdentifier.class), any(JobResult.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -168,7 +184,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCompleted_allowedForSameUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleted", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleted", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, only()).reportCompleted(agent, null, null);
         assertEquals(SC_OK, res.getStatus());
@@ -177,7 +193,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void reportCompleted_rejectedForDifferentUUID() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo("other");
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleted", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("reportCompleted", new Class[]{AgentRuntimeInfo.class, JobIdentifier.class, JobResult.class}, new Object[]{agent, null, null}));
         invoker.handleRequest(req, res);
         verify(target, never()).reportCompleted(any(AgentRuntimeInfo.class), any(JobIdentifier.class), any(JobResult.class));
         assertEquals(SC_FORBIDDEN, res.getStatus());
@@ -186,7 +202,7 @@ public class AgentRemoteInvokerServiceExporterTest {
     @Test
     void rejectsUnknownMethod() throws Exception {
         final AgentRuntimeInfo agent = runtimeInfo(AGENT_UUID);
-        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("nonexistent", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}), target);
+        final AgentRemoteInvokerServiceExporter invoker = deserializingWith(new RemoteInvocation("nonexistent", new Class[]{AgentRuntimeInfo.class}, new Object[]{agent}));
         invoker.handleRequest(req, res);
         verifyNoInteractions(target);
         assertEquals(SC_BAD_REQUEST, res.getStatus());
@@ -200,11 +216,11 @@ public class AgentRemoteInvokerServiceExporterTest {
         return new AgentIdentifier(null, null, uuid);
     }
 
-    private static AgentRemoteInvokerServiceExporter deserializingWith(final RemoteInvocation invocation, final BuildRepositoryMessageProducer proxy) {
-        final AgentRemoteInvokerServiceExporter invoker = new AgentRemoteInvokerServiceExporter() {
+    private AgentRemoteInvokerServiceExporter deserializingWith(final RemoteInvocation invocation) {
+        final AgentRemoteInvokerServiceExporter invoker = new AgentRemoteInvokerServiceExporter(env) {
             @Override
             protected Object getProxyForService() {
-                return proxy;
+                return target;
             }
 
             @Override

programming_language: Java