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

Valera V Harseko
yesterday 5b2bacde1d7561f28dd20cc965d641fbff558bc2
CVE-2026-62366 Unauthenticated stack exhaustion when decoding an LDAP search filter (DoS)
5 files modified
2 files added
473 ■■■■■ changed files
opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java 55 ●●●● patch | view | raw | blame | history
opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties 3 ●●●●● patch | view | raw | blame | history
opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java 139 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.java 11 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java 50 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/protocol.properties 3 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java 212 ●●●●● patch | view | raw | blame | history
opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java
@@ -12,9 +12,12 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2013-2016 ForgeRock AS.
 * Portions Copyright 2024-2026 3A Systems, LLC
 */
package org.forgerock.opendj.io;
import static com.forgerock.opendj.ldap.CoreMessages.ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
@@ -44,6 +47,15 @@
public final class LDAP {
    // @Checkstyle:ignore AvoidNestedBlocks
    /**
     * The maximum number of nested AND/OR/NOT components allowed in a search
     * filter when decoding it from its ASN.1 representation. This bounds the
     * decoder recursion so that a maliciously over-nested filter is rejected
     * rather than overflowing the JVM stack (GHSA-rv4q-c6mr-wxp7). It matches
     * the limit applied on the filter evaluation path.
     */
    private static final int MAX_NESTED_FILTER_DEPTH = 100;
    /** The OID for the Kerberos V GSSAPI mechanism. */
    public static final String OID_GSSAPI_KERBEROS_V = "1.2.840.113554.1.2.2";
@@ -402,14 +414,39 @@
     *             If an error occurs while reading from {@code reader}.
     */
    public static Filter readFilter(final ASN1Reader reader) throws IOException {
        return readFilter(reader, 0);
    }
    /**
     * Reads the next ASN.1 element from the provided {@code ASN1Reader} as a
     * {@code Filter}, tracking the current nesting depth so that a maliciously
     * over-nested filter is rejected rather than overflowing the JVM stack
     * (GHSA-rv4q-c6mr-wxp7).
     *
     * @param reader
     *            The {@code ASN1Reader} from which the ASN.1 encoded
     *            {@code Filter} should be read.
     * @param depth
     *            The current filter nesting depth (0 for the top-level filter).
     * @return The decoded {@code Filter}.
     * @throws IOException
     *             If an error occurs while reading from {@code reader}, or if
     *             the filter is nested more deeply than
     *             {@link #MAX_NESTED_FILTER_DEPTH}.
     */
    private static Filter readFilter(final ASN1Reader reader, final int depth) throws IOException {
        if (depth >= MAX_NESTED_FILTER_DEPTH) {
            throw DecodeException.fatalError(
                    ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH));
        }
        final byte type = reader.peekType();
        switch (type) {
        case LDAP.TYPE_FILTER_AND:
            return readAndFilter(reader);
            return readAndFilter(reader, depth);
        case LDAP.TYPE_FILTER_OR:
            return readOrFilter(reader);
            return readOrFilter(reader, depth);
        case LDAP.TYPE_FILTER_NOT:
            return readNotFilter(reader);
            return readNotFilter(reader, depth);
        case LDAP.TYPE_FILTER_EQUALITY:
            return readEqualityMatchFilter(reader);
        case LDAP.TYPE_FILTER_GREATER_OR_EQUAL:
@@ -568,13 +605,13 @@
        writer.writeEndSequence();
    }
    private static Filter readAndFilter(final ASN1Reader reader) throws IOException {
    private static Filter readAndFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_AND);
        try {
            if (reader.hasNextElement()) {
                final List<Filter> subFilters = new LinkedList<>();
                do {
                    subFilters.add(readFilter(reader));
                    subFilters.add(readFilter(reader, depth + 1));
                } while (reader.hasNextElement());
                return Filter.and(subFilters);
            } else {
@@ -654,22 +691,22 @@
        }
    }
    private static Filter readNotFilter(final ASN1Reader reader) throws IOException {
    private static Filter readNotFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_NOT);
        try {
            return Filter.not(readFilter(reader));
            return Filter.not(readFilter(reader, depth + 1));
        } finally {
            reader.readEndSequence();
        }
    }
    private static Filter readOrFilter(final ASN1Reader reader) throws IOException {
    private static Filter readOrFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_OR);
        try {
            if (reader.hasNextElement()) {
                final List<Filter> subFilters = new LinkedList<>();
                do {
                    subFilters.add(readFilter(reader));
                    subFilters.add(readFilter(reader, depth + 1));
                } while (reader.hasNextElement());
                return Filter.or(subFilters);
            } else {
opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties
@@ -112,6 +112,9 @@
ERR_ATTR_SYNTAX_DN_MAX_DEPTH=The provided value "%s" could not be parsed as a \
 valid distinguished name because it contains more than %d levels of nested \
 distinguished name (DN-syntax) attribute values
ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH=Cannot decode the provided ASN.1 \
 element as an LDAP search filter because it is nested more than %d levels \
 deep, which exceeds the maximum allowed filter nesting depth
WARN_ATTR_SYNTAX_INTEGER_INITIAL_ZERO=The provided value "%s" could \
 not be parsed as a valid integer because the first digit may not be zero \
 unless it is the only digit
opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java
New file
@@ -0,0 +1,139 @@
/*
 * 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.forgerock.opendj.io;
import java.util.ArrayList;
import java.util.List;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.opendj.ldap.Filter;
import org.forgerock.opendj.ldap.SdkTestCase;
import org.testng.annotations.Test;
import static org.testng.Assert.*;
/**
 * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001), SDK decoder twin.
 *
 * <p>{@link LDAP#readFilter(ASN1Reader)} (AND/OR/NOT) recursed once per nesting
 * level with no depth bound, so a maliciously over-nested search filter could
 * overflow the JVM stack with a {@link StackOverflowError} during decode. The
 * fix threads a depth counter and rejects over-nested filters with a
 * {@link DecodeException} before the stack is exhausted.
 *
 * @see org.opends.server.types.RawFilterDecodeStackOverflowTestCase the matching
 *      test for the legacy server decoder ({@code RawFilter.decode}).
 */
@SuppressWarnings("javadoc")
public class LDAPFilterDecodeNestingTest extends SdkTestCase
{
  private static final int OVERFLOW_DEPTH = 100_000;
  private static final long DECODE_STACK_BYTES = 256L * 1024L;
  @Test
  public void shallowNestedFilterDecodes() throws Exception
  {
    Throwable result = decodeOnBoundedStack(buildNestedAndFilter(50));
    assertNull(result, "A 50-level filter must decode cleanly, but threw: " + result);
  }
  @Test
  public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception
  {
    Throwable result = decodeOnBoundedStack(buildNestedAndFilter(OVERFLOW_DEPTH));
    assertNotNull(result,
        "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded");
    assertFalse(result instanceof StackOverflowError,
        "VULNERABLE: LDAP.readFilter overflowed the stack instead of rejecting: " + result);
    assertTrue(result instanceof DecodeException,
        "Expected a controlled DecodeException, but got: " + result);
  }
  private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException
  {
    final Throwable[] escaped = new Throwable[1];
    Runnable decode = new Runnable()
    {
      @Override
      public void run()
      {
        try
        {
          LDAP.readFilter(ASN1.getReader(payload));
        }
        catch (Throwable t)
        {
          escaped[0] = t;
        }
      }
    };
    Thread worker = new Thread(null, decode, "ghsa-rv4q-sdk-decode", DECODE_STACK_BYTES);
    worker.start();
    worker.join();
    return escaped[0];
  }
  /** Builds the definite-length BER encoding of an AND filter nested {@code depth} deep. */
  private static byte[] buildNestedAndFilter(final int depth) throws Exception
  {
    ByteStringBuilder leafBuilder = new ByteStringBuilder();
    ASN1Writer writer = ASN1.getWriter(leafBuilder);
    LDAP.writeFilter(writer, Filter.present("objectClass"));
    writer.flush();
    byte[] leaf = leafBuilder.toByteArray();
    List<byte[]> prefixes = new ArrayList<>(depth);
    int contentLen = leaf.length;
    for (int i = 0; i < depth; i++)
    {
      byte[] lengthBytes = berLength(contentLen);
      byte[] prefix = new byte[1 + lengthBytes.length];
      prefix[0] = LDAP.TYPE_FILTER_AND; // 0xA0
      System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length);
      prefixes.add(prefix);
      contentLen += prefix.length;
    }
    byte[] out = new byte[contentLen];
    int pos = 0;
    for (int i = prefixes.size() - 1; i >= 0; i--)
    {
      byte[] prefix = prefixes.get(i);
      System.arraycopy(prefix, 0, out, pos, prefix.length);
      pos += prefix.length;
    }
    System.arraycopy(leaf, 0, out, pos, leaf.length);
    return out;
  }
  private static byte[] berLength(final int len)
  {
    if (len < 0x80)
    {
      return new byte[] { (byte) len };
    }
    int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4;
    byte[] out = new byte[1 + numBytes];
    out[0] = (byte) (0x80 | numBytes);
    for (int i = 0; i < numBytes; i++)
    {
      out[numBytes - i] = (byte) (len >>> (8 * i));
    }
    return out;
  }
}
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.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.ldap;
@@ -192,6 +193,16 @@
          readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true,
            e.getMessageObject());
        }
        catch (StackOverflowError e)
        {
          // Defense in depth for over-nested protocol elements (e.g. search
          // filters - GHSA-rv4q-c6mr-wxp7). A StackOverflowError is an Error,
          // not an Exception, so without this it would escape and kill the
          // shared request-handler thread. Drop only the offending connection.
          logger.traceException(e);
          readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true,
            LocalizableMessage.raw(e.toString()));
        }
        catch (Exception e)
        {
          logger.traceException(e);
opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2013-2015 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.types;
@@ -31,6 +32,7 @@
import static org.opends.messages.ProtocolMessages.*;
import static org.opends.server.protocols.ldap.LDAPConstants.*;
import static org.opends.server.protocols.ldap.LDAPResultCode.*;
import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH;
import static org.opends.server.util.StaticUtils.*;
@@ -531,6 +533,42 @@
  public static LDAPFilter decode(ASN1Reader reader)
         throws LDAPException
  {
    return decode(reader, 0);
  }
  /**
   * Decodes the elements from the provided ASN.1 reader as a raw
   * search filter, tracking the current nesting depth.
   * <p>
   * The {@code depth} guard bounds the recursion so that a maliciously
   * over-nested AND/OR/NOT filter is rejected with a
   * {@code PROTOCOL_ERROR} instead of overflowing the JVM stack with a
   * {@code StackOverflowError} (GHSA-rv4q-c6mr-wxp7). Without it an
   * unauthenticated client could kill the request-handler thread, since
   * a {@code StackOverflowError} is an {@code Error} and escapes the
   * {@code catch (Exception)} blocks on the decode path.
   *
   * @param  reader The ASN.1 reader.
   * @param  depth  The current filter nesting depth (0 for the top-level
   *                filter).
   *
   * @return  The decoded search filter.
   *
   * @throws  LDAPException  If the provided ASN.1 element cannot be
   *                         decoded as a raw search filter, or if it is
   *                         nested more deeply than
   *                         {@link org.opends.server.util.ServerConstants#MAX_NESTED_FILTER_DEPTH}.
   */
  private static LDAPFilter decode(ASN1Reader reader, int depth)
         throws LDAPException
  {
    if (depth >= MAX_NESTED_FILTER_DEPTH)
    {
      LocalizableMessage message =
          ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH);
      throw new LDAPException(PROTOCOL_ERROR, message);
    }
    byte type;
    try
    {
@@ -546,10 +584,10 @@
    {
      case TYPE_FILTER_AND:
      case TYPE_FILTER_OR:
        return decodeCompoundFilter(reader);
        return decodeCompoundFilter(reader, depth);
      case TYPE_FILTER_NOT:
        return decodeNotFilter(reader);
        return decodeNotFilter(reader, depth);
      case TYPE_FILTER_EQUALITY:
      case TYPE_FILTER_GREATER_OR_EQUAL:
@@ -586,7 +624,7 @@
   *                         decode the provided ASN.1 element as a
   *                         raw search filter.
   */
  private static LDAPFilter decodeCompoundFilter(ASN1Reader reader)
  private static LDAPFilter decodeCompoundFilter(ASN1Reader reader, int depth)
      throws LDAPException
  {
    byte type;
@@ -627,7 +665,7 @@
      // could also be an absolute true/false filter
      while (reader.hasNextElement())
      {
        filterComponents.add(LDAPFilter.decode(reader));
        filterComponents.add(decode(reader, depth + 1));
      }
      reader.readEndSequence();
    }
@@ -659,14 +697,14 @@
   *                         decode the provided ASN.1 element as a
   *                         raw search filter.
   */
  private static LDAPFilter decodeNotFilter(ASN1Reader reader)
  private static LDAPFilter decodeNotFilter(ASN1Reader reader, int depth)
          throws LDAPException
  {
    RawFilter notComponent;
    try
    {
      reader.readStartSequence();
      notComponent = decode(reader);
      notComponent = decode(reader, depth + 1);
      reader.readEndSequence();
    }
    catch (LDAPException le)
opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
@@ -262,6 +262,9 @@
ERR_LDAP_FILTER_DECODE_NOT_COMPONENT_143=Cannot decode the provided \
 ASN.1 element as an LDAP search filter because the NOT component element \
 could not be decoded as an LDAP filter: %s
ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH_1538=Cannot decode the provided \
 ASN.1 element as an LDAP search filter because it is nested more than %d \
 levels deep, which exceeds the maximum allowed filter nesting depth
ERR_LDAP_FILTER_DECODE_TV_SEQUENCE_144=Cannot decode the provided ASN.1 \
 element as an LDAP search filter because the element could not be decoded as \
 a type-and-value sequence: %s
opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java
New file
@@ -0,0 +1,212 @@
/*
 * 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.types;
import java.util.ArrayList;
import java.util.List;
import org.forgerock.opendj.io.ASN1;
import org.forgerock.opendj.io.ASN1Reader;
import org.forgerock.opendj.io.ASN1Writer;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.protocols.ldap.LDAPFilter;
import org.testng.annotations.Test;
import static org.opends.server.protocols.ldap.LDAPConstants.TYPE_FILTER_AND;
import static org.opends.server.protocols.ldap.LDAPResultCode.PROTOCOL_ERROR;
import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH;
import static org.testng.Assert.*;
/**
 * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001):
 * <em>Unauthenticated LDAP search-filter decode stack exhaustion</em>.
 *
 * <p>Before the fix, the BER decoder {@link RawFilter#decode(ASN1Reader)} —&gt;
 * {@code decodeCompoundFilter} (AND/OR) / {@code decodeNotFilter} (NOT) recursed
 * once per nesting level with <strong>no depth bound</strong>. The
 * {@code MAX_NESTED_FILTER_DEPTH} guard only protected the filter
 * <em>evaluation</em> path, which runs strictly <em>after</em> decode. An
 * anonymous client could therefore send a single small {@code SearchRequest}
 * whose filter was nested tens of thousands of levels deep; decoding it
 * overflowed the JVM stack with a {@link StackOverflowError} <strong>before</strong>
 * the evaluation guard was ever reached. Because {@code StackOverflowError} is a
 * {@link java.lang.Error} (not an {@link Exception}), it escaped the
 * {@code catch (Exception)} blocks and killed the shared request-handler thread
 * &rarr; listener-wide DoS.
 *
 * <p>After the fix, {@link RawFilter#decode(ASN1Reader)} threads a depth counter
 * and rejects over-nested filters with a {@link LDAPException}
 * ({@code PROTOCOL_ERROR}) instead of recursing into a stack overflow.
 *
 * <p>This test builds the exact malicious wire encoding (a definite-length BER
 * tree of nested {@code 0xA0} AND TLVs wrapping an {@code (objectClass=*)}
 * present leaf) and decodes it on a thread with a small stack, so that — were the
 * bound ever removed — the overflow would still be deterministic and fast on any
 * platform.
 */
@SuppressWarnings("javadoc")
public class RawFilterDecodeStackOverflowTestCase extends DirectoryServerTestCase
{
  /** Nesting depth that would overflow even an oversized default stack. */
  private static final int OVERFLOW_DEPTH = 100_000;
  /** A small, deterministic stack so any unbounded recursion overflows quickly. */
  private static final long DECODE_STACK_BYTES = 256L * 1024L;
  /**
   * A filter nested well within {@link #MAX_NESTED_FILTER_DEPTH} decodes
   * normally. This is the control that proves the harness and the wire encoding
   * are correct, so that the rejection in
   * {@link #deeplyNestedFilterIsRejectedWithoutStackOverflow()} is attributable
   * to the depth guard and not to a malformed payload.
   */
  @Test
  public void shallowNestedFilterDecodesWithoutError() throws Exception
  {
    byte[] payload = buildNestedAndFilter(MAX_NESTED_FILTER_DEPTH / 2);
    Throwable result = decodeOnBoundedStack(payload);
    assertNull(result, "A filter nested " + (MAX_NESTED_FILTER_DEPTH / 2)
        + " levels deep must decode cleanly, but threw: " + result);
  }
  /**
   * Reproduction-turned-regression check: a deeply nested AND filter must be
   * rejected with a controlled {@link LDAPException} ({@code PROTOCOL_ERROR})
   * rather than overflowing the stack with a {@link StackOverflowError}.
   *
   * <p>On the vulnerable (pre-fix) code this fails because decode recurses until
   * the stack overflows and the resulting {@code java.lang.Error} escapes. With
   * the depth guard in place it passes.
   */
  @Test
  public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception
  {
    byte[] payload = buildNestedAndFilter(OVERFLOW_DEPTH);
    // Well under the ~5 MB ds-cfg-max-request-size cap: depth costs only a few
    // bytes per level, so the request-size limit never bounds the depth.
    assertTrue(payload.length < 5 * 1024 * 1024,
        "PoC payload (" + payload.length + " bytes) should be far below the 5 MB request cap");
    Throwable result = decodeOnBoundedStack(payload);
    assertNotNull(result,
        "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded");
    assertFalse(result instanceof StackOverflowError,
        "VULNERABLE: decode overflowed the stack instead of rejecting the over-nested filter: " + result);
    assertTrue(result instanceof LDAPException,
        "Expected a controlled LDAPException, but got: " + result);
    assertEquals(((LDAPException) result).getResultCode(), PROTOCOL_ERROR,
        "Over-nested filter should be rejected as a protocol error");
  }
  /**
   * Decodes {@code payload} via {@link RawFilter#decode(ASN1Reader)} on a worker
   * thread with a small stack and returns the {@link Throwable} that escaped
   * decode, or {@code null} if decode completed normally. Captures
   * {@link Throwable} (including {@link Error}) so a regression to the unbounded
   * recursion surfaces as a {@link StackOverflowError} rather than killing the
   * test JVM thread silently.
   */
  private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException
  {
    final Throwable[] escaped = new Throwable[1];
    Runnable decode = new Runnable()
    {
      @Override
      public void run()
      {
        try
        {
          ASN1Reader reader = ASN1.getReader(payload);
          RawFilter.decode(reader);
        }
        catch (Throwable t)
        {
          escaped[0] = t;
        }
      }
    };
    Thread worker = new Thread(null, decode, "ghsa-rv4q-decode", DECODE_STACK_BYTES);
    worker.start();
    worker.join();
    return escaped[0];
  }
  /**
   * Builds the BER (definite-length) wire encoding of an AND filter nested
   * {@code depth} levels deep, wrapping an {@code (objectClass=*)} present leaf:
   * <pre>
   *   A0 L ( A0 L ( ... ( 87 0B "objectClass" ) ... ) )
   * </pre>
   * Constructed inside-out so the length fields are exact, which is what
   * {@link RawFilter#decode} reads. (Indefinite-length BER is not used because
   * the OpenDJ reader does not support it.)
   */
  private static byte[] buildNestedAndFilter(final int depth) throws Exception
  {
    // Innermost leaf: presence filter (objectClass=*).
    ByteStringBuilder leafBuilder = new ByteStringBuilder();
    ASN1Writer writer = ASN1.getWriter(leafBuilder);
    LDAPFilter.createPresenceFilter("objectClass").write(writer);
    writer.flush();
    byte[] leaf = leafBuilder.toByteString().toByteArray();
    // Build each AND wrapper prefix (0xA0 + definite length) from the inside out.
    List<byte[]> prefixes = new ArrayList<>(depth);
    int contentLen = leaf.length;
    for (int i = 0; i < depth; i++)
    {
      byte[] lengthBytes = berLength(contentLen);
      byte[] prefix = new byte[1 + lengthBytes.length];
      prefix[0] = TYPE_FILTER_AND; // 0xA0
      System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length);
      prefixes.add(prefix);
      contentLen += prefix.length;
    }
    // Assemble: outermost prefix first ... innermost prefix ... leaf.
    byte[] out = new byte[contentLen];
    int pos = 0;
    for (int i = prefixes.size() - 1; i >= 0; i--)
    {
      byte[] prefix = prefixes.get(i);
      System.arraycopy(prefix, 0, out, pos, prefix.length);
      pos += prefix.length;
    }
    System.arraycopy(leaf, 0, out, pos, leaf.length);
    return out;
  }
  /** Encodes {@code len} as a BER definite-length field (short or long form). */
  private static byte[] berLength(final int len)
  {
    if (len < 0x80)
    {
      return new byte[] { (byte) len };
    }
    int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4;
    byte[] out = new byte[1 + numBytes];
    out[0] = (byte) (0x80 | numBytes);
    for (int i = 0; i < numBytes; i++)
    {
      out[numBytes - i] = (byte) (len >>> (8 * i));
    }
    return out;
  }
}