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