From 80e9ebd30a966c3d445575b4d20be77f4b7f8dfb Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Fri, 17 Jul 2026 11:14:50 +0000
Subject: [PATCH] CVE-2026-62373 JMX MBean-argument deserialization without a serial filter
---
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java | 239 +++++++++++++++++++++++++++++++++++++++
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java | 52 +++++++-
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java | 40 ++++++
3 files changed, 321 insertions(+), 10 deletions(-)
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java b/opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java
index fa14fa0..3b7f82e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java
@@ -71,10 +71,8 @@
/**
* JDK 10+ JMX environment property scoping a JEP 290 deserialization
* filter to the credentials object passed during {@code newClient()}.
- * Using the credentials-scoped filter (instead of the connector-wide
- * {@code jmx.remote.rmi.server.serial.filter.pattern}) avoids breaking
- * legitimate JMX traffic such as MBean invocations and notifications,
- * which may legitimately carry non-String types.
+ * It is the tightest of the two filters configured below (it accepts only
+ * the two-element {@code String[]} used for SASL/PLAIN authentication).
* <p>
* Note: this property is mutually exclusive with
* {@code jmx.remote.rmi.server.credential.types}; specifying both makes
@@ -91,6 +89,38 @@
/**
+ * JDK 10+ JMX environment property installing a JEP 290 deserialization
+ * filter for the whole RMI connection, in particular the arguments of MBean
+ * operations ({@code invoke}) and attribute values ({@code setAttribute}).
+ * Unlike {@link #JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN}, which
+ * only guards the credentials object exchanged during authentication, this
+ * filter closes the post-authentication deserialization surface: without it,
+ * an authenticated client could drive native deserialization of arbitrary
+ * object graphs through MBean operation arguments
+ * (advisory GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495).
+ */
+ static final String JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN =
+ "jmx.remote.rmi.server.serial.filter.pattern";
+
+
+ /**
+ * Allowlist of the JDK and JMX management types OpenDJ legitimately receives
+ * as MBean operation arguments / attribute values, rejecting everything else
+ * via the trailing {@code !*}. OpenDJ exposes read-only configuration and
+ * monitoring attributes and no MBean operations, so the only types that need
+ * to cross this filter are strings, primitive wrappers and the standard
+ * {@code javax.management} request types ({@code ObjectName},
+ * {@code Attribute}, {@code AttributeList}, {@code QueryExp}, ...).
+ */
+ private static final String JMX_OPERATION_SERIAL_FILTER =
+ "maxdepth=20;"
+ + "java.lang.*;java.math.BigInteger;java.math.BigDecimal;"
+ + "java.util.*;"
+ + "javax.management.*;javax.management.openmbean.*;"
+ + "!*";
+
+
+ /**
* The MBean server used to handle JMX interaction.
*/
private MBeanServer mbs;
@@ -398,15 +428,23 @@
static void configureJmxDeserializationProtection(Map<String, Object> env)
{
- // Scope the JEP 290 deserialization filter to the credentials object
- // only, so legitimate JMX RMI traffic (MBean operations, notifications,
- // etc.) is not affected by the restrictive allowlist.
+ // Tightly constrain the credentials object exchanged during authentication
+ // (CVE-2026-46495): only a two-element String[] is accepted.
//
// Do NOT also set "jmx.remote.rmi.server.credential.types": the JDK
// rejects an environment that defines both properties, which would
// prevent the RMI connector from starting.
env.put(JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN,
JMX_CREDENTIAL_SERIAL_FILTER);
+
+ // Additionally constrain the rest of the RMI connection -- in particular
+ // MBean operation arguments and attribute values, which are deserialized
+ // after authentication -- to a small allowlist of JDK and JMX management
+ // types. This closes the post-authentication deserialization surface left
+ // open by the credentials-only filter
+ // (GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495).
+ env.put(JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN,
+ JMX_OPERATION_SERIAL_FILTER);
}
/**
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java b/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java
new file mode 100644
index 0000000..938faf1
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java
@@ -0,0 +1,239 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions Copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.server.protocols.jmx;
+
+import static org.opends.server.protocols.internal.InternalClientConnection.*;
+import static org.testng.Assert.*;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.management.MBeanServer;
+import javax.management.MBeanServerConnection;
+import javax.management.ObjectName;
+
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.core.DeleteOperation;
+import org.opends.server.core.DirectoryServer;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Regression test for GHSA-qj63-3vrg-vcfx / OPENDJ-002 — incomplete fix of
+ * CVE-2026-46495.
+ * <p>
+ * The CVE-2026-46495 fix scoped its JEP 290 deserialization filter to the
+ * {@code credentials} object only
+ * ({@code jmx.remote.rmi.server.credentials.filter.pattern}), leaving the
+ * arguments of MBean operations ({@code Object[] params}) to be unmarshalled
+ * after authentication with no serial filter at all. The follow-up fix also
+ * installs the connector-wide
+ * {@code jmx.remote.rmi.server.serial.filter.pattern}, restricting MBean
+ * operation arguments to a small allowlist of JDK / JMX management types.
+ * <p>
+ * This test exercises the surface end-to-end. It:
+ * <ol>
+ * <li>registers a do-nothing target MBean in the server's MBean server (so the
+ * JDK's {@code RMIConnectionImpl.invoke} passes its {@code getClassLoaderFor}
+ * check and reaches the argument-unmarshalling step with a class loader that
+ * can see the canary type);</li>
+ * <li>authenticates over JMX/RMI as a user holding {@code JMX_READ};</li>
+ * <li>invokes an operation passing a custom {@link Serializable} "canary" as an
+ * argument.</li>
+ * </ol>
+ * With the fix in place the connector-wide serial filter rejects the canary
+ * type during unmarshalling: the call fails with a filter rejection and the
+ * canary's {@code readObject()} never runs. Because the test server runs
+ * in-process, that server-side deserialization (had it happened) would have
+ * flipped a static flag the test observes directly. Before the fix this test
+ * fails (the flag is set), which is exactly the vulnerability it guards
+ * against.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "jmx" }, sequential = true)
+public class JmxMBeanArgDeserializationTest extends JmxTestCase
+{
+ /** DN of the JMX_READ user created for this test. */
+ private static final String JMX_USER_DN = "cn=Privileged JMX User,o=test";
+
+ /** ObjectName of the helper target MBean registered for the duration of the test. */
+ private static final String TARGET_MBEAN = "org.opends.server.test:type=JmxDeserCanaryTarget";
+
+ /** Minimal Standard MBean used only as a registered {@code invoke()} target. */
+ public interface CanaryTargetMBean
+ {
+ // No attributes or operations: the operation we invoke deliberately does
+ // not exist, so dispatch fails -- but only after the arguments have been
+ // deserialized.
+ }
+
+ /** Loaded by the test/app class loader, which can resolve {@link DeserializationCanary}. */
+ public static final class CanaryTarget implements CanaryTargetMBean
+ {
+ }
+
+ /**
+ * A serializable marker whose custom {@code readObject} records that it was
+ * deserialized. It stands in for an attacker-supplied gadget: a connector-wide
+ * serial filter restricting MBean argument types to the small set OpenDJ
+ * actually accepts would reject this class before {@code readObject} ever ran.
+ */
+ public static final class DeserializationCanary implements Serializable
+ {
+ private static final long serialVersionUID = 1L;
+
+ /** Set true server-side when this object is unmarshalled. */
+ static volatile boolean deserialized;
+
+ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
+ {
+ in.defaultReadObject();
+ deserialized = true;
+ }
+ }
+
+ @Override
+ @BeforeClass
+ public void setUp() throws Exception
+ {
+ super.setUp();
+
+ TestCaseUtils.addEntries(
+ "dn: " + JMX_USER_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "cn: Privileged JMX User",
+ "givenName: Privileged",
+ "sn: User",
+ "uid: privileged.jmx.user",
+ "userPassword: password",
+ "ds-privilege-name: jmx-read",
+ "ds-privilege-name: jmx-write",
+ "ds-pwp-password-policy-dn: cn=Clear UserPassword Policy,cn=Password Policies,cn=config");
+ }
+
+ @AfterClass
+ public void afterClass() throws Exception
+ {
+ DeleteOperation deleteOperation = getRootConnection().processDelete(DN.valueOf(JMX_USER_DN));
+ assertEquals(deleteOperation.getResultCode(), ResultCode.SUCCESS);
+ }
+
+ /**
+ * An authenticated client's MBean operation argument of an arbitrary type is
+ * rejected by the connector-wide serial filter during unmarshalling, before
+ * its {@code readObject()} can run.
+ */
+ @Test
+ public void mbeanOperationArgumentsAreFilteredBeforeDeserialization() throws Exception
+ {
+ DeserializationCanary.deserialized = false;
+
+ MBeanServer serverMBeans = DirectoryServer.getJMXMBeanServer();
+ ObjectName target = new ObjectName(TARGET_MBEAN);
+ if (serverMBeans.isRegistered(target))
+ {
+ serverMBeans.unregisterMBean(target);
+ }
+ serverMBeans.registerMBean(new CanaryTarget(), target);
+
+ Exception thrown = null;
+ OpendsJmxConnector connector = connect(JMX_USER_DN, "password", TestCaseUtils.getServerJmxPort());
+ assertNotNull(connector, "JMX_READ user should be able to authenticate to the JMX connector");
+ try
+ {
+ MBeanServerConnection mbsc = connector.getMBeanServerConnection();
+
+ // getClassLoaderFor(target) succeeds (target is registered), so the call
+ // reaches argument unmarshalling. The connector-wide serial filter must
+ // reject the canary type there, before readObject() runs.
+ try
+ {
+ mbsc.invoke(target,
+ "thisOperationDoesNotExist",
+ new Object[] { new DeserializationCanary() },
+ new String[] { "java.lang.Object" });
+ fail("Expected the non-allowlisted argument to be rejected by the serial filter");
+ }
+ catch (Exception expected)
+ {
+ thrown = expected;
+ }
+ }
+ finally
+ {
+ connector.close();
+ if (serverMBeans.isRegistered(target))
+ {
+ serverMBeans.unregisterMBean(target);
+ }
+ }
+
+ assertFalse(DeserializationCanary.deserialized,
+ "VULNERABLE (GHSA-qj63-3vrg-vcfx): an authenticated client's MBean operation argument "
+ + "was deserialized server-side. The connector-wide serial filter "
+ + "(jmx.remote.rmi.server.serial.filter.pattern) must reject non-allowlisted types "
+ + "before readObject() runs.");
+ assertTrue(isSerialFilterRejection(thrown),
+ "Expected a JEP 290 serial-filter rejection of the argument, but got: " + thrown);
+ }
+
+ /** True if the throwable chain shows a JEP 290 serial-filter rejection. */
+ private static boolean isSerialFilterRejection(Throwable t)
+ {
+ for (Throwable c = t; c != null; c = c.getCause())
+ {
+ if (c instanceof java.io.InvalidClassException)
+ {
+ return true;
+ }
+ String message = c.getMessage();
+ if (message != null
+ && (message.contains("REJECTED") || message.contains("filter status")
+ || message.contains("serial filter")))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Connects to the in-process JMX/RMI connector with the given credentials. */
+ private OpendsJmxConnector connect(String user, String password, int jmxPort) throws IOException
+ {
+ Map<String, Object> env = new HashMap<>();
+ env.put("jmx.remote.credentials", new String[] { user, password });
+ env.put("jmx.remote.x.client.connection.check.period", 0);
+ try
+ {
+ OpendsJmxConnector connector = new OpendsJmxConnector("localhost", jmxPort, env);
+ connector.connect();
+ return connector;
+ }
+ catch (SecurityException | IOException e)
+ {
+ return null;
+ }
+ }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java b/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java
index 7dce751..b22b11a 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java
@@ -27,6 +27,8 @@
import java.util.HashMap;
import java.util.Map;
+import javax.management.Attribute;
+import javax.management.ObjectName;
import org.opends.server.DirectoryServerTestCase;
import org.testng.annotations.DataProvider;
@@ -69,15 +71,47 @@
assertEquals(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN),
"maxdepth=3;maxarray=2;java.lang.String;!*");
- // The connector-wide filter must NOT be set, so legitimate JMX traffic
- // (MBean operations, notifications) is not affected by the allowlist.
- assertNull(env.get("jmx.remote.rmi.server.serial.filter.pattern"));
+ // The connector-wide filter must also be set so that post-authentication
+ // traffic (MBean operation arguments, attribute values) is constrained to a
+ // safe allowlist instead of being deserialized without any filter
+ // (incomplete fix of CVE-2026-46495).
+ assertNotNull(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN));
// "jmx.remote.rmi.server.credential.types" is mutually exclusive with the
// credentials filter pattern: setting both prevents the connector from
// starting, so only the filter pattern must be configured.
assertNull(env.get("jmx.remote.rmi.server.credential.types"));
}
+ /**
+ * Verifies the connector-wide operation filter allows the JDK / JMX
+ * management types OpenDJ legitimately receives, while rejecting arbitrary
+ * (potentially gadget) classes.
+ */
+ @Test
+ public void operationSerialFilterAllowsManagementTypesAndRejectsOthers() throws Exception
+ {
+ Map<String, Object> env = new HashMap<>();
+ RmiConnector.configureJmxDeserializationProtection(env);
+ String filterPattern = (String) env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN);
+
+ // Legitimate JMX management argument types must pass.
+ assertEquals(readWithFilter("an attribute name", filterPattern), "an attribute name");
+ assertEquals(readWithFilter(new String[] { "a", "b" }, filterPattern), new String[] { "a", "b" });
+ assertEquals(readWithFilter(new ObjectName("org.opends.server:type=test"), filterPattern),
+ new ObjectName("org.opends.server:type=test"));
+ assertEquals(readWithFilter(new Attribute("ds-cfg-listen-port", 1689), filterPattern),
+ new Attribute("ds-cfg-listen-port", 1689));
+
+ // Arbitrary application types must be rejected before readObject() runs.
+ assertRejectedByFilter(new UnexpectedArgument(), filterPattern);
+ }
+
+ /** An arbitrary serializable type standing in for a gadget argument. */
+ private static final class UnexpectedArgument implements java.io.Serializable
+ {
+ private static final long serialVersionUID = 1L;
+ }
+
/** Verifies the configured filter allows only the expected credential payload. */
@Test
public void serialFilterAllowsOnlyTwoElementStringArray() throws Exception
--
Gitblit v1.10.0