Security context
High· 7.5GHSA-9p56-p6mw-w8qc CVE-2025-67635CWE-404Published Dec 10, 2025

Jenkins has a Denial of service vulnerability in HTTP-based CLI

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

2.529 → fixed in 2.5410 → fixed in 2.528.3

Details

Jenkins 2.540 and earlier, LTS 2.528.2 and earlier does not properly close HTTP-based CLI connections when the connection stream becomes corrupted, allowing unauthenticated attackers to cause a denial of service.

The fix

[SECURITY-3630]

Daniel Beck· Dec 2, 2025, 01:46 PM+1548efa1816322
cli/src/main/java/hudson/cli/PlainCLIProtocol.java+1 2
@@ -153,15 +153,14 @@ public void run() {
}
} catch (ClosedChannelException x) {
LOGGER.log(Level.FINE, null, x);
- side.handleClose();
} catch (IOException x) {
LOGGER.log(Level.WARNING, null, flightRecorder.analyzeCrash(x, "broken stream"));
} catch (ReadPendingException x) {
// in case trick in CLIAction does not work
LOGGER.log(Level.FINE, null, x);
- side.handleClose();
} catch (RuntimeException x) {
LOGGER.log(Level.WARNING, null, x);
+ } finally {
side.handleClose();
}
}
core/src/main/java/hudson/cli/CLIAction.java+9 4
@@ -40,11 +40,11 @@
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
@@ -80,7 +80,7 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy {
*/
/* package-private for testing */ static /* non-final for Script Console */ Boolean ALLOW_WEBSOCKET = SystemProperties.optBoolean(CLIAction.class.getName() + ".ALLOW_WEBSOCKET");
- private final transient Map<UUID, FullDuplexHttpService> duplexServices = new HashMap<>();
+ private final transient Map<UUID, FullDuplexHttpService> duplexServices = new ConcurrentHashMap<>();
@Override
public String getIconFileName() {
@@ -315,8 +315,13 @@ private synchronized void ready() {
void run() throws IOException, InterruptedException {
synchronized (this) {
- while (!ready) {
- wait();
+ long end = System.currentTimeMillis() + FullDuplexHttpService.CONNECTION_TIMEOUT;
+ while (!ready && System.currentTimeMillis() < end) {
+ wait(1000);
+ }
+ if (!ready) {
+ LOGGER.log(Level.FINE, "CLI timeout waiting for client");
+ return;
}
}
PrintStream stdout = new PrintStream(streamStdout(), false, encoding);
core/src/main/java/jenkins/util/FullDuplexHttpService.java+9 2
@@ -142,8 +142,15 @@ public synchronized void upload(StaplerRequest2 req, StaplerResponse2 rsp) throw
notify();
// wait until we are done
- while (!completed) {
- wait();
+ long end = System.currentTimeMillis() + CONNECTION_TIMEOUT;
+ while (!completed && System.currentTimeMillis() < end) {
+ LOGGER.log(Level.FINE, "Waiting for upload stream {0} for {1}: {2}", new Object[] {upload, uuid, this});
+ wait(1000);
+ }
+
+ if (!completed) {
+ LOGGER.log(Level.FINE, "Timeout reached for {0} for {1}: {2}", new Object[] {upload, uuid, this});
+ throw new IOException("CLI timeout");
}
}
test/src/test/java/hudson/cli/Security3630Test.java+135 0
@@ -0,0 +1,135 @@
+package hudson.cli;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasProperty;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+import static org.jvnet.hudson.test.LoggerRule.recorded;
+
+import hudson.Functions;
+import hudson.init.impl.InstallUncaughtExceptionHandler;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ThreadInfo;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.stream.Collectors;
+import jenkins.util.FullDuplexHttpService;
+import org.apache.commons.io.FileUtils;
+import org.awaitility.Awaitility;
+import org.htmlunit.HttpMethod;
+import org.htmlunit.WebRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.platform.commons.util.ExceptionUtils;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.LoggerRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+public class Security3630Test {
+ public static final int CONCURRENCY = 50;
+ public static final int ITERATIONS = 50;
+ private JenkinsRule j = new JenkinsRule();
+
+ @TempDir
+ private File tmp;
+
+ private LoggerRule loggerRule = new LoggerRule().record(InstallUncaughtExceptionHandler.class.getName(), Level.WARNING);
+
+ private long originalTimeout;
+
+ @BeforeEach
+ public void setup(JenkinsRule j) throws Exception {
+ this.j = j;
+ // TODO FlagRule in JUnit 5?
+ originalTimeout = FullDuplexHttpService.CONNECTION_TIMEOUT;
+ FullDuplexHttpService.CONNECTION_TIMEOUT = TimeUnit.SECONDS.toMillis(5);
+ }
+
+ @AfterEach
+ public void reset() {
+ FullDuplexHttpService.CONNECTION_TIMEOUT = originalTimeout;
+ }
+
+ @Test
+ void control() throws IOException {
+ loggerRule.capture(100);
+ final String uuid = UUID.randomUUID().toString();
+ try (JenkinsRule.WebClient wc = j.createWebClient()) {
+ WebRequest request = new WebRequest(new URL(j.getURL().toString() + "cli?remoting=false"));
+ request.setHttpMethod(HttpMethod.POST);
+ request.setAdditionalHeader("Session", uuid);
+ request.setAdditionalHeader("Side", "download");
+ wc.getPage(request);
+ }
+ assertThat(loggerRule, recorded(nullValue(String.class), allOf(instanceOf(IOException.class), hasProperty("message", containsString("HTTP full-duplex channel timeout: " + uuid)))));
+ }
+
+ @Test
+ void testHashMap() throws InterruptedException, IOException {
+ // If this test appears flaky, it's probably not: The race condition cannot be reliably triggered.
+ // If the assertion fails, then there's probably a bug here.
+ // TODO Do we want to keep a test like this?
+ loggerRule.capture(100);
+ final File jar = File.createTempFile("jenkins-cli.jar", null, tmp);
+ FileUtils.copyURLToFile(j.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar);
+ for (int i = 0; i < ITERATIONS; i++) {
+ List<Callable<Void>> callables = new ArrayList<>();
+ for (int c = 0; c < CONCURRENCY; c++) {
+ callables.add(() -> {
+ new ProcessBuilder().command("java", "-jar", jar.getAbsolutePath(), "-http", "-s", j.getURL().toString(), "who-am-i").start().waitFor();
+ return null;
+ });
+ }
+
+ ExecutorService executor = Executors.newFixedThreadPool(CONCURRENCY);
+ List<Future<Void>> futures = executor.invokeAll(callables);
+
+ futures.forEach(f -> {
+ try {
+ f.get();
+ } catch (InterruptedException | ExecutionException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ assertThat(loggerRule.getRecords().stream().map(r -> String.join("\n", r.getMessage(), r.getLoggerName(), r.getThrown().getMessage(), ExceptionUtils.readStackTrace(r.getThrown()))).collect(Collectors.toList()), empty());
+ // See #control() assertion for the "expected" failure without the fix.
+ }
+ }
+
+ @Test
+ void testIndefiniteWait() throws IOException {
+ var stream = new hudson.cli.FullDuplexHttpStream(j.getURL(), "cli?remoting=false", null);
+ {
+ Set<String> threadNames = Arrays.stream(Functions.getThreadInfos()).map(ThreadInfo::getThreadName).collect(Collectors.toSet());
+ assertThat(threadNames, hasItem(containsString("Handling POST /jenkins/cli")));
+ }
+ stream.getOutputStream().write(new byte[10]);
+ stream.getOutputStream().close();
+ stream.getInputStream().close();
+
+ Awaitility.await().atMost(FullDuplexHttpService.CONNECTION_TIMEOUT * 2, TimeUnit.MILLISECONDS).untilAsserted(() -> {
+ Set<String> threadNames = Arrays.stream(Functions.getThreadInfos()).map(ThreadInfo::getThreadName).collect(Collectors.toSet());
+ assertThat(threadNames, not(hasItem(containsString("Handling POST /jenkins/cli"))));
+ });
+ }
+}

References