> For the complete documentation index, see [llms.txt](https://www.impacket.wiki/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://www.impacket.wiki/tutorials/ldap-operations.md).

# LDAP Operations In impacket

## What you'll learn

Active Directory is, at its core, an LDAP directory. Every user, group, computer, and policy object is an LDAP entry with attributes you can read and, with the right privileges, write. Impacket includes a full LDAP client that lets you interact with AD the same way its own management tools do.

By the end of this tutorial you will be able to:

* Create an LDAP connection and bind with NTLM (building on what you learned in [Authentication in Impacket](/tutorials/authentication-in-impacket.md))
* Search the directory with filters and retrieve specific attributes
* Interpret common AD attribute encodings (timestamps, SIDs, flags)
* Modify attributes on existing objects
* Add new objects to the directory
* Delete objects from the directory
* Rename and move objects between containers

This gives you complete CRUD (Create, Read, Update, Delete) coverage of Impacket's LDAP implementation.

***

## Prerequisites

Everything from the [Authentication tutorial](/tutorials/authentication-in-impacket.md), plus:

**A domain controller you can query over LDAP.** Every step in this tutorial talks to Active Directory, so a standalone Windows machine will not work. You need a domain-joined lab with at least one DC.

**A domain account with write privileges (for Steps 6 onward).** The search steps work with any authenticated domain user. The modify, add, and delete steps require an account that can write to the directory, typically a Domain Admin or an account with delegated permissions over a specific OU.

| Placeholder | Meaning                             | Example           |
| ----------- | ----------------------------------- | ----------------- |
| `DC_IP`     | IP address of the domain controller | `192.168.56.5`    |
| `DC_HOST`   | Hostname of the DC (for Kerberos)   | `dc01.lab.local`  |
| `DOMAIN`    | The AD domain in FQDN format        | `lab.local`       |
| `BASE_DN`   | The base distinguished name         | `DC=lab,DC=local` |
| `USERNAME`  | A valid domain account              | `labuser`         |
| `PASSWORD`  | Password for that account           | `Password123!`    |

> **Deriving the Base DN from your domain.** The base DN is your domain name split at each dot and prefixed with `DC=`. For example, `lab.local` becomes `DC=lab,DC=local`. The domain `corp.contoso.com` would become `DC=corp,DC=contoso,DC=com`.

Create a new scratch file:

```bash
touch ldap_basics.py
```

***

## Step 1: Connect and bind with NTLM

In the [Authentication tutorial](/tutorials/authentication-in-impacket.md) you learned that Impacket separates connection from authentication. The same pattern applies here, just with a different class.

```python
from impacket.ldap import ldap

dc_ip    = "192.168.56.5"
domain   = "lab.local"
base_dn  = "DC=lab,DC=local"
username = "labuser"
password = "Password123!"

# Create the connection
ldap_conn = ldap.LDAPConnection(url=f"ldap://{dc_ip}", baseDN=base_dn)
print(f"[*] Connected to {dc_ip} over LDAP")

# Bind (authenticate) with NTLM
ldap_conn.login(user=username, password=password, domain=domain)
print(f"[+] Bound as {domain}\\{username}")
```

Run it:

```bash
python3 ldap_basics.py
```

You should find this to be fimilar as just as `SMBConnection` had a `login()` method that accepted passwords or hashes, `LDAPConnection.login()` follows the same signature:

```python
ldap_conn.login(user, password, domain, lmhash='', nthash='')
```

You can pass an NT hash instead of a password here, exactly as you did in the Authentication tutorial. The underlying NTLM exchange is identical, as it will be with every other impacket protocol, with the differences being the carrier application/protocol, in our case here its over LDAP instead of SMB.

> **LDAP vs LDAPS.** The URL `ldap://` connects on port 389 without encryption. For TLS-encrypted LDAP, use `ldaps://` (port 636). We can also use TLS over LDAP using start\_tls but that is not in scope here. In a lab environment, unencrypted LDAP is fine. In production or when testing detection, be aware that LDAP traffic on port 389 is visible over the network unless you seal the LDAP packets, which impacket does by default as it implements SASL

***

## Step 2: Your first search

Now query the directory. The `search()` method returns a list of results that you iterate through, checking each one to see if it is a `SearchResultEntry`. This is the standard pattern used throughout Impacket's own codebase:

```python
from impacket.ldap import ldap 
from impacket.ldap.ldapasn1 import SearchResultEntry

#Dont forget to change to your values!!
dc_ip    = "192.168.56.5"
domain   = "lab.local"
base_dn  = "DC=lab,DC=local"
username = "labuser"
password = "Password123!"

ldap_conn = ldap.LDAPConnection(url=f"ldap://{dc_ip}", baseDN=base_dn)
ldap_conn.login(user=username, password=password, domain=domain)

# Search for all user objects
resp = ldap_conn.search(
    searchFilter="(objectClass=user)",
    attributes=["sAMAccountName", "distinguishedName", "userAccountControl"],
)

for item in resp:
    if not isinstance(item, SearchResultEntry):
        continue

    for attribute in item["attributes"]:
        print(f"  {attribute['type']}: {attribute['vals'][0]}")
    print()
```

Now go ahead and run it. You'll see every user object in the domain printed with its attributes.

### Understanding the response shape

The `search()` method returns a list that contains both result entries and a final `SearchResultDone` message. The `isinstance(item, SearchResultEntry)` check filters to just the actual data. Each entry has an `attributes` sequence, and each attribute has a `type` (the name) and `vals` (the values). This pattern comes directly from how Impacket's own `secretsdump.py` processes LDAP results.

***

## Step 3: Write targeted filters

The filter `(objectClass=user)` returns everything, including computer accounts (which are a subclass of user in AD). LDAP filters let you be precise about what you want, and they are useful for you to explore for your offensive security career.

The syntax uses prefix notation for logical operators:

```python
# Only user accounts, not computers
search_filter = "(&(objectClass=user)(!(objectClass=computer)))"

# Only enabled accounts (exclude UAC bit 0x2 = ACCOUNTDISABLE)
search_filter = "(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"

# Users whose sAMAccountName starts with "admin"
search_filter = "(&(objectClass=user)(sAMAccountName=admin*))"

# All groups
search_filter = "(objectClass=group)"
```

Try each one. Replace the `searchFilter` in your script, re-run, and see how the results change. Building filters is how you control what the server returns, and getting comfortable with the syntax matters more than any single query.

The three operators you need:

```
AND:  (&(condition1)(condition2))
OR:   (|(condition1)(condition2))
NOT:  (!(condition))
```

The `1.2.840.113556.1.4.803` OID in the UAC filter is a bitwise AND matching rule specific to Active Directory. It tests whether a specific bit is set in an integer attribute. You'll use it frequently for `userAccountControl` queries.

***

## Step 4: Search for specific attributes by name

When you need a particular attribute from a result, check the attribute type as you iterate. This is the pattern Impacket uses internally in places like `secretsdump.py` for example:

```python
resp = ldap_conn.search(
    searchFilter="(&(objectClass=user)(!(objectClass=computer)))",
    attributes=["sAMAccountName", "memberOf"],
)

for item in resp:
    if not isinstance(item, SearchResultEntry):
        continue

    sam_name = ""
    groups = []

    for attribute in item["attributes"]:
        if str(attribute["type"]) == "sAMAccountName":
            sam_name = str(attribute["vals"])
        elif str(attribute["type"]) == "memberOf":
            #Since users can be members of multiple groups, we need to iterate through every item in the group membership collection
            groups = [str(v) for v in attribute["vals"]]


    print(f"  {sam_name}")
    for g in groups:
        print(f"    memberOf: {g}")
    print()
```

Notice that `memberOf` is multi-valued and so a user can belong to many groups, so `attribute["vals"]` contains multiple entries. Single-valued attributes like `sAMAccountName` always have one entry in `vals`, but the structure is the same.

***

## Step 5: Decode common AD attribute encodings

Some important attributes don't come back as readable strings. Three encodings appear constantly.

### userAccountControl flags

The `userAccountControl` attribute is an integer bitmask. The raw value `512` means `NORMAL_ACCOUNT`. The value `66048` means `NORMAL_ACCOUNT + DONT_EXPIRE_PASSWORD`. Decode it:

```python
UAC_FLAGS = {
    0x0002: "ACCOUNTDISABLE",
    0x0010: "LOCKOUT",
    0x0020: "PASSWD_NOTREQD",
    0x0200: "NORMAL_ACCOUNT",
    0x10000: "DONT_EXPIRE_PASSWORD",
    0x40000: "SMARTCARD_REQUIRED",
    0x80000: "TRUSTED_FOR_DELEGATION",
    0x100000: "NOT_DELEGATED",
    0x400000: "DONT_REQ_PREAUTH",
    0x1000000: "TRUSTED_TO_AUTH_FOR_DELEGATION",
}

def decode_uac(uac_value):
    return [name for bit, name in UAC_FLAGS.items() if uac_value & bit]

# Example
print(decode_uac(66048))
# ['NORMAL_ACCOUNT', 'DONT_EXPIRE_PASSWORD']
```

### Windows FILETIME timestamps

Attributes like `lastLogon` and `pwdLastSet` store time as the number of 100-nanosecond intervals since January 1, 1601:

```python
from datetime import datetime, timedelta

WINDOWS_EPOCH = datetime(1601, 1, 1)

def filetime_to_str(filetime):
    if filetime == 0 or filetime >= 0x7FFFFFFFFFFFFFFF:
        return "Never"
    seconds = filetime / 10_000_000
    return (WINDOWS_EPOCH + timedelta(seconds=seconds)).strftime("%Y-%m-%d %H:%M:%S")

# Example
print(filetime_to_str(133500000000000000))
# 2024-01-15 12:00:00
```

### objectSid

The `objectSid` attribute is binary and thankfully for us, Impacket provides a class to parse it:

```python
from impacket.ldap.ldaptypes import LDAP_SID

# Inside your search loop, when you encounter objectSid:
# raw_bytes = attribute["vals"][0].asOctets()
# sid = LDAP_SID(raw_bytes)
# print(sid.formatCanonical())  # S-1-5-21-3623811015-...
```

### Putting the decoders to use

Combine them into a richer search:

```python
resp = ldap_conn.search(
    searchFilter="(&(objectClass=user)(!(objectClass=computer)))",
    attributes=["sAMAccountName", "userAccountControl", "lastLogon", "pwdLastSet"],
)

for item in resp:
    if not isinstance(item, SearchResultEntry):
        continue

    name = ""
    uac = 0
    last_logon = 0
    pwd_set = 0

    for attribute in item["attributes"]:
        attr_name = str(attribute["type"])
        attr_val = str(attribute["vals"][0])

        if attr_name == "sAMAccountName":
            name = attr_val
        elif attr_name == "userAccountControl":
            uac = int(attr_val)
        elif attr_name == "lastLogon":
            last_logon = int(attr_val)
        elif attr_name == "pwdLastSet":
            pwd_set = int(attr_val)

    flags = ", ".join(decode_uac(uac))
    print(f"  {name}")
    print(f"    Flags:      {flags}")
    print(f"    Last logon: {filetime_to_str(last_logon)}")
    print(f"    Pwd set:    {filetime_to_str(pwd_set)}")
    print()
```

***

## Step 6: Modify an attribute on an existing object

You've been reading the directory up to this point and so now lets start writing.

**From this step forward, you need an account with write permissions.**

Impacket's `LDAPConnection` has a `modify()` method that takes a DN and a dictionary of changes. The dictionary maps attribute names to a list of `(operation, value)` tuples. Impacket defines four operation constants:

```python
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE
# There is also MODIFY_INCREMENT for integer attributes
```

Replace a user's description:

```python
from impacket.ldap import ldap
from impacket.ldap.ldap import MODIFY_REPLACE
from impacket.ldap.ldapasn1 import SearchResultEntry

dc_ip    = "192.168.56.5"
domain   = "lab.local"
base_dn  = "DC=lab,DC=local"
username = "Administrator"
password = "AdminPass123!"

ldap_conn = ldap.LDAPConnection(url=f"ldap://{dc_ip}", baseDN=base_dn)
ldap_conn.login(user=username, password=password, domain=domain)

target_dn = "CN=labuser,CN=Users,DC=lab,DC=local"

ldap_conn.modify(target_dn, {
    "description": [(MODIFY_REPLACE, "Modified by Impacket tutorial")]
})
print(f"[+] Modified description on {target_dn}")
```

Verify the change by searching for it:

```python
resp = ldap_conn.search(
    searchBase=target_dn,
    searchFilter="(objectClass=*)",
    attributes=["description"],
)

for item in resp:
    if not isinstance(item, SearchResultEntry):
        continue
    for attribute in item["attributes"]:
        print(f"  {attribute['type']}: {attribute['vals']}")
```

### Multiple modifications in one call

You can change several attributes at once by adding more keys to the dictionary:

```python
from impacket.ldap.ldap import MODIFY_REPLACE, MODIFY_ADD

ldap_conn.modify(target_dn, {
    "description":   [(MODIFY_REPLACE, "Updated description")],
    "displayName":   [(MODIFY_REPLACE, "Lab User")],
    "telephoneNumber": [(MODIFY_ADD, "555-0100")],
})
print("[+] Modified three attributes in one request")
```

Each key is an attribute name, and each value is a list of operations to apply to that attribute. A single attribute can even have multiple operations, for example removing one value and adding another on a multi-valued attribute.

***

## Step 7: Add a new object to the directory

Impacket's `add()` method takes three arguments, which are the DN of the new object, a list of object classes, and a dictionary of attributes.

Create an organizational unit:

```python
new_ou_dn = "OU=ImpacketTutorial,DC=lab,DC=local"

ldap_conn.add(
    new_ou_dn,
    ["top", "organizationalUnit"],
    {"description": "Created by the Impacket LDAP tutorial"},
)
print(f"[+] Created OU: {new_ou_dn}")
```

Verify it exists:

```python
resp = ldap_conn.search(
    searchBase=new_ou_dn,
    searchFilter="(objectClass=organizationalUnit)",
    attributes=["distinguishedName", "description", "whenCreated"],
)

for item in resp:
    if not isinstance(item, SearchResultEntry):
        continue
    for attribute in item["attributes"]:
        print(f"  {attribute['type']}: {attribute['vals'][0]}")
```

Now create a user inside that OU:

```python
new_user_dn = "CN=TutorialUser,OU=ImpacketTutorial,DC=lab,DC=local"

ldap_conn.add(
    new_user_dn,
    ["top", "person", "organizationalPerson", "user"],
    {
        "sAMAccountName": "TutorialUser",
        "userPrincipalName": "TutorialUser@lab.local",
        "userAccountControl": 544,  # NORMAL_ACCOUNT + PASSWD_NOTREQD
    },
)
print(f"[+] Created user: {new_user_dn}")
```

> **Why `PASSWD_NOTREQD`?** Active Directory requires that passwords be set through a separate modify operation over LDAPS/LDAP with start\_tls or sign+sealing, not through the Add request itself. Setting UAC to 544 (`NORMAL_ACCOUNT | PASSWD_NOTREQD`) lets the object be created without an immediate password. In a real engagement you would follow up with a password set over the appropriate channel.

### Try it yourself

Search the `OU=ImpacketTutorial` container and confirm both the OU and the user appear. Use the search pattern from Step 2 with a `searchBase` of `OU=ImpacketTutorial,DC=lab,DC=local`.

***

## Step 8: Rename and move objects

Before deleting the objects you created, try renaming and moving them. Impacket's `modify_dn()` method handles both operations:

```python
# Rename the user (change the CN)
ldap_conn.modify_dn(
    "CN=TutorialUser,OU=ImpacketTutorial,DC=lab,DC=local",
    "CN=RenamedUser",
)
print("[+] Renamed TutorialUser to RenamedUser")

# Move the user to the default Users container
ldap_conn.modify_dn(
    "CN=RenamedUser,OU=ImpacketTutorial,DC=lab,DC=local",
    "CN=RenamedUser",
    newSuperior="CN=Users,DC=lab,DC=local",
)
print("[+] Moved RenamedUser to CN=Users")
```

The first argument is the current DN, the second is the new relative distinguished name (RDN), and `newSuperior` specifies a new parent container if you want to move the object. If you omit `newSuperior`, the object stays in the same container and instead just ends up being renamed.

***

## Step 9: Delete objects from the directory

Deletion is the simplest write operation:

```python
# Delete the user we moved
ldap_conn.delete("CN=RenamedUser,CN=Users,DC=lab,DC=local")
print("[+] Deleted RenamedUser")

# Delete the OU
ldap_conn.delete("OU=ImpacketTutorial,DC=lab,DC=local")
print("[+] Deleted ImpacketTutorial OU")
```

Verify by searching for the OU and confirming no results come back:

```python
resp = ldap_conn.search(
    searchFilter="(distinguishedName=OU=ImpacketTutorial,DC=lab,DC=local)",
    attributes=["distinguishedName"],
)

found = any(isinstance(item, SearchResultEntry) for item in resp)
print(f"[+] OU still exists: {found}")
```

> **Note the order here:** LDAP will not delete a container that still has child objects. If you try to delete a non-empty OU, the server returns a `notAllowedOnNonLeaf` error. As a result, you will need to delete all children first, then the parent container/object.

***

## Step 10: Close the connection

```python
ldap_conn.close()
print("[*] Connection closed.")
```

Like `SMBConnection`, always close your connection when you're done to ensure no leaked handles or dangling authentication contexts :)

***

## Review: what you learned

This tutorial covered Impacket's LDAP operations end to end:

1. **`LDAPConnection(url, baseDN)`** creates a connection to a domain controller over LDAP or LDAPS.
2. **`login()`** binds with NTLM, using the same password-or-hash pattern from the Authentication tutorial.
3. **`search()`** queries the directory. Filter for `SearchResultEntry` items, then iterate `item["attributes"]` to read each attribute's `type` and `vals`.
4. **LDAP filters** use prefix notation: `(&(...)(...))` for AND, `(|(...)(...))` for OR, `(!(...))` for NOT.
5. **AD attribute encodings** require decoding: `userAccountControl` is a bitmask, timestamps are Windows FILETIME integers, `objectSid` is binary.
6. **`modify(dn, changes)`** updates attributes using a dictionary of `{attribute: [(operation, value)]}` with `MODIFY_ADD`, `MODIFY_DELETE`, and `MODIFY_REPLACE` constants.
7. **`add(dn, objectClasses, attributes)`** creates new objects with a list of object classes and a dictionary of attributes.
8. **`modify_dn(dn, newrdn, newSuperior)`** renames and moves objects.
9. **`delete(dn)`** removes objects. Children must be deleted before their parent.

Impacket's `LDAPConnection` gives you direct access to every standard LDAP operation. `search()` returns protocol-level ASN.1 objects, but `add()`, `modify()`, `modify_dn()`, and `delete()` accept plain Python types like strings, integers, lists, and dictionaries. Once you know the four CRUD methods and the search result pattern, you can interact with any part of Active Directory and begin building on it with your own.

***

## Where to go next

* **Kerberos authentication over LDAP.** You used `login()` with NTLM here. Go back to the [Authentication tutorial](/tutorials/authentication-in-impacket.md) and try using Kerberos to bind your LDAP connection.
* **Group membership manipulation.** Try adding a user to a group by modifying the group's `member` attribute with `MODIFY_ADD`, then removing them with `MODIFY_DELETE`.
* **The DCE/RPC tutorial** will show you a completely different protocol that also reads and writes Active Directory, but through Microsoft's RPC interfaces instead of LDAP. Understanding both gives you multiple paths to the same data.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://www.impacket.wiki/tutorials/ldap-operations.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
