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

Valery Kharseko
4 days ago 7e3a75903159153c877daeb2952a552701e38044
CVE-2026-46495 OpenDJ Unauthenticated RCE via Java Deserialization in JMX RMI
2 files modified
1 files added
198 ■■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiAuthenticator.java 19 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java 37 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java 142 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiAuthenticator.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2014-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.protocols.jmx;
@@ -102,9 +103,21 @@
    {
      throw new SecurityException();
    }
    Object c[] = (Object[]) credentials;
    String authcID = (String) c[0];
    String password = (String) c[1];
    if (!(credentials instanceof String[]))
    {
      logger.trace("Invalid JMX credentials type");
      throw new SecurityException();
    }
    String[] c = (String[]) credentials;
    if (c.length != 2)
    {
      logger.trace("Invalid JMX credentials length");
      throw new SecurityException();
    }
    String authcID = c[0];
    String password = c[1];
    // The authcID is used at forwarder level to identify the calling client
    if (authcID == null)
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2013-2015 ForgeRock AS.
 * Portions Copyright 2023-2026 3A Systems, LLC.
 */
package org.opends.server.protocols.jmx;
@@ -22,6 +23,7 @@
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
import java.util.SortedSet;
import javax.net.ssl.KeyManager;
@@ -63,6 +65,29 @@
{
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
  static final String JMX_REMOTE_RMI_SERVER_CREDENTIAL_TYPES =
      "jmx.remote.rmi.server.credential.types";
  /**
   * 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.
   */
  static final String JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN =
      "jmx.remote.rmi.server.credentials.filter.pattern";
  private static final String[] JMX_CREDENTIAL_TYPES =
  {
    String.class.getName(),
    String[].class.getName()
  };
  private static final String JMX_CREDENTIAL_SERIAL_FILTER =
      "maxdepth=3;maxarray=2;java.lang.String;!*";
  /**
   * The MBean server used to handle JMX interaction.
@@ -253,6 +278,7 @@
    {
      // Environment map
      HashMap<String, Object> env = new HashMap<>();
      configureJmxDeserializationProtection(env);
      // ---------------------
      // init an ssl context
@@ -364,6 +390,17 @@
  }
  static void configureJmxDeserializationProtection(Map<String, Object> env)
  {
    env.put(JMX_REMOTE_RMI_SERVER_CREDENTIAL_TYPES,
        JMX_CREDENTIAL_TYPES.clone());
    // 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.
    env.put(JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN,
        JMX_CREDENTIAL_SERIAL_FILTER);
  }
  /**
   * Closes this connection handler so that it will no longer accept new
   * client connections. It may or may not disconnect existing client
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java
New file
@@ -0,0 +1,142 @@
/*
 * 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.testng.Assert.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import org.opends.server.DirectoryServerTestCase;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
/** Tests JMX RMI credential deserialization hardening. */
@Test(groups = { "precommit", "jmx" }, sequential = true)
public class RmiAuthenticatorTest extends DirectoryServerTestCase
{
  /** Invalid credential shapes rejected before any bind attempt. */
  @DataProvider(name = "invalidCredentials")
  public Object[][] invalidCredentials()
  {
    return new Object[][]
    {
      { null },
      { new Object[] { "cn=Directory Manager", "password" } },
      { new Date() },
      { new String[0] },
      { new String[] { "cn=Directory Manager" } },
      { new String[] { "cn=Directory Manager", "password", "extra" } },
      { new String[] { null, "password" } },
      { new String[] { "cn=Directory Manager", null } }
    };
  }
  /** Verifies that RmiAuthenticator only accepts a two-element String array. */
  @Test(dataProvider = "invalidCredentials", expectedExceptions = SecurityException.class)
  public void rejectsInvalidCredentialShapes(Object credentials)
  {
    new RmiAuthenticator(null).authenticate(credentials);
  }
  /** Verifies that RMI connector environment constrains credential unmarshalling. */
  @Test
  public void configuresCredentialDeserializationProtection()
  {
    Map<String, Object> env = new HashMap<>();
    RmiConnector.configureJmxDeserializationProtection(env);
    assertEquals(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIAL_TYPES),
        new String[] { String.class.getName(), String[].class.getName() });
    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"));
  }
  /** Verifies that each environment receives its own credential type array. */
  @Test
  public void credentialTypesAreDefensivelyCopied()
  {
    Map<String, Object> env = new HashMap<>();
    RmiConnector.configureJmxDeserializationProtection(env);
    String[] credentialTypes =
        (String[]) env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIAL_TYPES);
    credentialTypes[0] = Date.class.getName();
    Map<String, Object> env2 = new HashMap<>();
    RmiConnector.configureJmxDeserializationProtection(env2);
    assertEquals(((String[]) env2.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIAL_TYPES))[0],
        String.class.getName());
  }
  /** Verifies the configured filter allows only the expected credential payload. */
  @Test
  public void serialFilterAllowsOnlyTwoElementStringArray() throws Exception
  {
    Map<String, Object> env = new HashMap<>();
    RmiConnector.configureJmxDeserializationProtection(env);
    String filterPattern = (String) env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN);
    assertEquals(readWithFilter(new String[] { "uid", "password" }, filterPattern),
        new String[] { "uid", "password" });
    assertRejectedByFilter(new Object[] { "uid", "password" }, filterPattern);
    assertRejectedByFilter(new String[] { "uid", "password", "extra" }, filterPattern);
    assertRejectedByFilter(new Date(), filterPattern);
  }
  private Object readWithFilter(Object object, String filterPattern) throws Exception
  {
    byte[] bytes = serialize(object);
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes)))
    {
      in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(filterPattern));
      return in.readObject();
    }
  }
  private byte[] serialize(Object object) throws Exception
  {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(bytes))
    {
      out.writeObject(object);
    }
    return bytes.toByteArray();
  }
  private void assertRejectedByFilter(Object object, String filterPattern) throws Exception
  {
    try
    {
      readWithFilter(object, filterPattern);
      fail("Expected object to be rejected by the JMX credential serial filter");
    }
    catch (InvalidClassException expected)
    {
      // Expected: ObjectInputFilter rejected the class or array length.
    }
  }
}