mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valera V Harseko
23 hours ago 80e9ebd30a966c3d445575b4d20be77f4b7f8dfb
CVE-2026-62373 JMX MBean-argument deserialization without a serial filter
2 files modified
1 files added
331 ■■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java 52 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java 239 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java 40 ●●●●● patch | view | raw | blame | history
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);
  }
  /**
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java
New file
@@ -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;
    }
  }
}
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