ldapsearch
ldapsearch turns a directory server into a readable list of objects: which naming contexts exist, which users, groups, and computers are defined, and every attribute each one carries. Reach for it the moment an LDAP port is open and you want to ask the directory direct questions instead of guessing, whether you are enumerating an Active Directory domain, auditing an OpenLDAP homelab, or confirming one user really has that attribute.
Mental model: ldapsearch -x -H ldap://HOST -b BASE_DN FILTER attrs. Auth, server, where, what, and which fields, in that order.
New to LDAP? Core concepts
- Naming context / base DN: where a search starts in the tree, written
DC=lab,DC=local; the Root DSE lists them (-s base -b "") - Distinguished name (DN): the full path to one object,
CN=USERNAME,CN=Users,DC=lab,DC=local - Bind: how you authenticate; anonymous sends nothing, a simple bind sends a bind DN (
-D) and password (-W) - Scope (
-s):basereads just the object,onegoes one level down,subwalks the whole subtree (the default) - Filter: an RFC 4515 query in parentheses,
(objectClass=user)or(&(objectClass=user)(cn=admin*)) - Attributes: the space-separated list after the filter trims which fields return; omit it to get everything the bind can read
- LDAP vs LDAPS vs StartTLS:
ldap://is plaintext on 389,ldaps://is TLS on 636, and-ZZupgrades a 389 session to TLS - Result codes: the
result:line at the bottom (0 Success,49 Invalid credentials,32 No such object) tells you what actually happened
When to reach for ldapsearch (and when not)
Reach for it to enumerate a directory precisely and repeatably: dump domain users, groups, and computers once 389 or 636 is open, confirm which objects and attributes an AD or OpenLDAP instance really holds, verify anonymous binds are disabled, or check that one user, group membership, or attribute exists exactly as expected. The output saves and diffs cleanly.
Reach for something else when you want a parsed summary (nxc ldap in NetExec bundles the common queries), a visual tree to browse (Apache Directory Studio, JXplorer), attack-path mapping from the data (BloodHound), or native queries from a domain-joined Windows box (Get-ADObject, ADExplorer). ldapsearch can bind but it is not a brute-force tool, and repeated failed binds are loud and can lock accounts.
Install, update, verify#
sudo apt install -y ldap-utils # Debian / Ubuntu / Kali
sudo dnf install -y openldap-clients # Fedora / RHEL
brew install openldap # macOS (Homebrew)
sudo apt install --only-upgrade -y ldap-utils # update
ldapsearch -VV # OpenLDAP version and build
command -v ldapsearch # confirm it is on PATH
On Windows run the Linux client inside WSL with the apt command above. Confirm you can actually reach the server with the lightest possible query, the Root DSE, which asks the server to describe itself and touches no user data:
ldapsearch -x -H ldap://TARGET_IP -s base -b "" namingContexts # server self-test, no credentials
Flags you’ll actually use#
Skim these first; the cheat sheet below is these flags combined.
| Flag | Does | Flag | Does |
|---|---|---|---|
-x | Simple auth (use it in labs) | -w | Password on the CLI (scripts only) |
-H | Server URI (ldap://, ldaps://) | -Z / -ZZ | StartTLS on 389 (-ZZ requires it) |
-b | Base DN to search from | -LLL | Clean LDIF, no comments |
-s | Scope: base, one, or sub | -E pr=1000/noprompt | Paged results for big sets |
-D | Bind DN (who you authenticate as) | -s base -b "" | Root DSE (server self-description) |
-W | Prompt for the password | + / * | Operational / all user attributes |
Cheat sheet#
Every block starts simple and adds one piece at a time, so you can stop at the line that answers the question.
# Build up a query: stop at the line you need
ldapsearch -x -H ldap://TARGET_IP -s base -b "" namingContexts # 1. find the base DN
ldapsearch -x -H ldap://TARGET_IP -b "DC=lab,DC=local" -LLL # 2. + dump the subtree (anonymous)
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" -LLL # 3. + authenticate first
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=user)" sAMAccountName -LLL # 4. + filter to just usernames
# Discover the server (no credentials)
ldapsearch -x -H ldap://TARGET_IP -s base -b "" namingContexts # which trees it serves
ldapsearch -x -H ldap://TARGET_IP -s base -b "" "+" # operational attrs (version, SASL mechs)
ldapsearch -x -H ldap://TARGET_IP -s base -b "" supportedSASLMechanisms # just the auth methods
# Bind forms (pick what the server accepts)
ldapsearch -x -H ldap://TARGET_IP -b "DC=lab,DC=local" -LLL # anonymous (no -D)
ldapsearch -x -D "USERNAME@DOMAIN" -W -H ldap://TARGET_IP ... # UPN bind, prompt for password
ldapsearch -x -D "DOMAIN\USERNAME" -W -H ldap://TARGET_IP ... # NetBIOS-style bind DN
ldapsearch -x -D "CN=USERNAME,CN=Users,DC=lab,DC=local" -W ... # full DN bind
# Objects by type (add -D/-W as needed)
... "(objectClass=user)" sAMAccountName # user logon names
... "(objectClass=group)" cn member # groups and their members
... "(objectClass=computer)" dNSHostName operatingSystem # computers + OS string
... "(&(objectClass=user)(sAMAccountName=USERNAME))" # one user, all attributes
... "(&(objectClass=user)(cn=admin*))" # wildcard match on name
# Active Directory hunts
... "(&(objectClass=user)(servicePrincipalName=*))" sAMAccountName servicePrincipalName # SPN accounts
... "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=2))" # disabled accounts
... "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=65536))" # password never expires
... "(objectClass=user)" description # descriptions (sometimes hold secrets)
# Output control
... -LLL # clean LDIF, no comment noise
... "(objectClass=user)" sAMAccountName mail memberOf # only the attributes you name
... -E pr=1000/noprompt "(objectClass=user)" sAMAccountName # page past a size limit
# Transport
ldapsearch -x -H ldaps://TARGET_IP ... # TLS on 636
ldapsearch -x -ZZ -H ldap://TARGET_IP ... # StartTLS upgrade on 389
LDAPTLS_REQCERT=never ldapsearch -x -H ldaps://TARGET_IP ... # skip cert check (labs you own only)
# Save for notes
ldapsearch -x ... "(objectClass=user)" sAMAccountName -LLL | tee output/users.ldif
Command breakdowns#
Each block below explains one task, the exact command, and what to expect back.
Find the base DN with the Root DSE#
ldapsearch -x -H ldap://TARGET_IP -s base -b "" namingContexts
-s base with an empty base DN asks the server to describe itself. Expect one or more namingContexts: lines like DC=lab,DC=local; copy that base DN into every query below. Needs no credentials and touches no user data.
Read the server’s supported controls and versions#
ldapsearch -x -H ldap://TARGET_IP -s base -b "" "+"
The + requests operational attributes: supportedLDAPVersion, supportedSASLMechanisms, supportedControl. Check this to confirm paged results and StartTLS are actually available before you rely on them.
Dump a subtree with an anonymous bind#
ldapsearch -x -H ldap://TARGET_IP -b "DC=lab,DC=local" -LLL
No -D means anonymous. If the server allows it you get every readable object as clean LDIF; often it is locked down and returns almost nothing. Scope it down fast, since a full subtree can run to megabytes.
Authenticate with a simple bind#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W -b "DC=lab,DC=local" -LLL
-D is the bind identity and -W prompts for the password so it stays out of shell history. result: 49 Invalid credentials means the DN or password is wrong; AD may want USERNAME@DOMAIN, DOMAIN\USERNAME, or a full DN.
List every user’s logon name#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=user)" sAMAccountName -LLL
Naming only sAMAccountName trims each object to one line. Pipe it through grep -i '^sAMAccountName:' | awk '{print $2}' for a bare username list you can feed to the next tool.
Look up one user and all its attributes#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(&(objectClass=user)(sAMAccountName=USERNAME))" -LLL
The & combines both conditions so only the matching user returns, with every attribute your bind can read. Swap in (cn=admin*) to wildcard-match a partial name instead.
Map groups to their members#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=group)" cn member -LLL
Returns each group cn with its member DNs. This is how you find who sits in Domain Admins or any privileged group before deciding where to go next.
Enumerate domain computers and their OS#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=computer)" dNSHostName operatingSystem -LLL
Hands you a hostname and OS string per machine. Feeds straight into a target list for the next scan or a stale-OS inventory.
Find service accounts by their SPN#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(&(objectClass=user)(servicePrincipalName=*))" \
sAMAccountName servicePrincipalName -LLL
User objects that carry a servicePrincipalName are the accounts worth noting during an AD review. This only lists them; anything beyond listing is a separate tool’s job.
Filter on userAccountControl bits#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W -b "DC=lab,DC=local" \
"(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=2))" sAMAccountName -LLL
The :1.2.840.113556.1.4.803: matching rule does a bitwise AND against userAccountControl. =2 finds disabled accounts; swap in 65536 for password-never-expires or 4194304 for accounts that skip Kerberos pre-auth.
Read the description field for stashed secrets#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=user)" sAMAccountName description -LLL
Admins sometimes leave passwords or hints in description or info. Treat any value as a lead to verify, never as a confirmed credential.
Query over LDAPS or StartTLS#
ldapsearch -x -H ldaps://TARGET_IP -D "USERNAME@DOMAIN" -W -b "DC=lab,DC=local" -LLL # TLS on 636
ldapsearch -x -ZZ -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W -b "DC=lab,DC=local" -LLL # StartTLS on 389
Both encrypt the bind so the password is not sent in cleartext. For a lab self-signed cert that blocks the handshake, prefix LDAPTLS_REQCERT=never, only on directories you own.
Page past a “size limit exceeded”#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" -E pr=1000/noprompt "(objectClass=user)" sAMAccountName -LLL
Directories cap how many objects one query returns. -E pr=1000/noprompt pulls results in 1000-object pages until the tree is exhausted, with no keypress between pages.
Save output as clean LDIF for your notes#
ldapsearch -x -H ldap://TARGET_IP -D "USERNAME@DOMAIN" -W \
-b "DC=lab,DC=local" "(objectClass=user)" sAMAccountName -LLL \
2>&1 | tee "output/users-$(date +%Y%m%d-%H%M).ldif"
-LLL keeps the output as parseable LDIF and tee shows it while saving it. Record the target and authorization date next to the file, never the actual password.
Reading the output#
Each object starts with a dn: line, then attribute: value lines; -LLL strips the comment noise so the rest parses cleanly.
| You see | Meaning | Do next |
|---|---|---|
result: 0 Success | The query ran | Read the objects listed above it |
result: 49 Invalid credentials | Bad bind DN or password | Recheck the -D format and the password |
result: 32 No such object | Base DN wrong or missing | Re-read namingContexts from the Root DSE |
Size limit exceeded | Directory capped the result set | Add -E pr=1000/noprompt |
| Success but zero objects | Filter matched nothing, or no read rights | Widen the filter, or bind with more privilege |
Lines with :: | Base64-encoded binary or non-ASCII | Decode only if you actually need the value |
Troubleshooting#
| Problem | Cause | Fix |
|---|---|---|
command not found | Not installed / stale PATH | Install ldap-utils; open a fresh shell |
Can't contact LDAP server | Wrong host/port or no route | Confirm TARGET_IP and 389/636; scan first |
ldap_start_tls: Connect error | StartTLS cert not trusted | Trust the CA, or LDAPTLS_REQCERT=never in a lab |
Bad search filter | Unquoted or malformed filter | Quote the filter; check the parentheses balance |
Gotchas#
- Quote the filter. The shell treats
(,),&, and*as special, so(&(objectClass=user)(cn=admin*))must sit inside quotes or it never reaches ldapsearch intact. -xis not optional in practice. Drop it and ldapsearch tries SASL/GSSAPI, which fails on most lab setups with a confusing bind error; nearly every example needs-xfor simple auth.-wleaks,-Wprompts. A password passed with lowercase-wlands in your shell history and inpsoutput; use uppercase-Wunless you are inside a throwaway script.- The attribute list is positional, after the filter.
ldapsearch ... "(objectClass=user)" sAMAccountNamereturns only that field; put a filter-looking string in the attribute slot and you silently get the wrong output with no error. memberOfmisses primary-group membership. In AD the authoritative list lives on the group’smemberattribute, andmemberOfis a back-link that omits primary-group ties like Domain Users, so cross-check both before trusting a membership list.
Pairs with#
nmap confirms 389 and 636 are open and fingerprints the directory before you query it. Once you have usernames and group edges, BloodHound turns them into an attack-path graph and Impacket handles the protocol-specific Active Directory workflows raw LDAP cannot. For a faster parsed summary of the same data reach for NetExec (nxc ldap), and for visual browsing use Apache Directory Studio or JXplorer; keep ldapsearch when you need an exact, scriptable, diffable query. grep, awk, and tee slice the LDIF into tidy username and host lists.