Y3llowDuck
PowerShell Active Directory LDAP Living Off the Land

Living Off the Land with [adsisearcher]

Windows · PowerShell · no RSAT, no imports · ~6 min read

If you've done any Active Directory work, you've probably reached for the ActiveDirectory PowerShell module, or a tool like BloodHound. Those are great. But they have to be installed, imported, or dropped onto the machine first. [adsisearcher] is already part of Windows. You do not have to install anything. And that's exactly why it's worth knowing. Any domain user can run it with nothing but the PowerShell window that's already open.

[adsisearcher] is a PowerShell type accelerator. That's just a short, friendly name for a longer .NET class, the way [xml] stands in for a big XML type. Here the class is System.DirectoryServices.DirectorySearcher, and it ships with the framework, so it's already sitting on every Windows box. Think of it as a tiny search box wired straight into Active Directory. You hand it a question, it asks the nearest domain controller, and it hands you back the answer.

When you cast a string to [adsisearcher], PowerShell quietly does the boring part for you. It finds the current domain, binds to it using the logon token you already have, and points a searcher at the filter you gave it. No credentials to type, no module to import, no new program on disk.

Let me give you a few examples. Four, to be more specific, so you can get an idea of how powerful yet simple this thing is.

01 — read exactly when a password expires

This one reads the precise date and time a user's password will expire, straight from a domain controller. One line.

powershell — when does j.doe's password expire?
PS C:\> $s=[adsisearcher]"(sAMAccountName=j.doe)";$s.ReferralChasing="None";$s.PropertiesToLoad.Add("msDS-UserPasswordExpiryTimeComputed")|Out-Null;[datetime]::FromFileTime($s.FindOne().Properties["msds-userpasswordexpirytimecomputed"][0]) Wednesday, November 4, 2026 6:17:07 PM

It's four small statements joined by semicolons. Because this is the first one, let me walk through each part. The rest of the post reuses the same moves, so it's worth the two minutes here.

1. $s=[adsisearcher]"(sAMAccountName=j.doe)" binds the searcher to the domain and sets the filter. No credentials, no import. A default domain user has everything needed to run it.

2. $s.ReferralChasing="None" keeps the query on the one domain controller you're already talking to. When a DC can't answer part of a query it hands back a referral pointing at another server, and chasing those means extra binds and extra noise, sometimes a query that hangs on a partition you never cared about. None makes it faster and predictable.

3. $s.PropertiesToLoad.Add("msDS-UserPasswordExpiryTimeComputed")|Out-Null tells the DC to send back just that one attribute instead of every field on the object. The |Out-Null is only there because .Add() returns the new index of the item, and we don't want that number printed. That attribute is special: it isn't stored anywhere. The DC calculates it on the fly from the account's pwdLastSet and whatever password policy applies. Ask for it by name and the DC just does the math for you.

4. [datetime]::FromFileTime($s.FindOne().Properties["msds-userpasswordexpirytimecomputed"][0]) runs the query and fixes the format. FindOne() returns the first match, .Properties["..."] is a collection so [0] grabs the value, and that value is a Windows FILETIME, a big count of ticks since the year 1601. Useless raw. FromFileTime() turns it into a date you can act on.

One small note: I queried the property key in all lowercase, msds-userpasswordexpirytimecomputed. That's not required. The Properties collection is case-insensitive, so any casing works. It just prints its keys back in lowercase, which is the casing you'll see when you enumerate the results yourself, and that's the habit I picked up. And why does an attacker care about an expiry date? It tells you which accounts are about to rotate and which passwords are going stale, so you can time a spray without walking an account into lockout. All from a read that never touches the account.

02 — list a user's group membership

This example is very similar to the previous one, it just has a different attribute. Instead of the expiry time, it asks for memberOf.

powershell — what groups is chepe in?
PS C:\> ([adsisearcher]"(sAMAccountName=chepe)").FindOne().Properties["memberof"] CN=Domain Admins,CN=Users,DC=purview,DC=lab CN=Remote Desktop Users,CN=Builtin,DC=purview,DC=lab

Each line is the full path to a group chepe belongs to. Read the first one again. An account that's in Domain Admins is exactly the kind of thread you pull on, and you found it without touching that account or opening a single admin tool.

One thing for newcomers, so the output doesn't confuse you later: memberOf lists direct groups only. It won't show Domain Users, which is stored separately as the primary group, and it won't expand nested groups. For a first look that's fine. Just know the real membership can run deeper than what prints.

03 — find every password that never expires

This one widens the net. Instead of one user, ask the whole directory for accounts whose password is set to never expire. Those are almost always service accounts, and service accounts are where the good stuff lives.

powershell — passwords set to never expire
PS C:\> ([adsisearcher]"(&(objectCategory=person)(userAccountControl:1.2.840.113556.1.4.803:=65536))").FindAll() | %{ $_.Properties["samaccountname"][0] } svc_sql svc_backup svc_veeam adm_helpdesk

That userAccountControl:1.2.840.113556.1.4.803:=65536 looks awful, but it's just an LDAP bitwise filter asking for the DONT_EXPIRE_PASSWORD flag. You don't have to memorize the number. You have to know the trick exists, because it turns "read one account" into "list every account with a given setting." Swap 65536 for other flag values and you can hunt disabled accounts, smartcard-required accounts, and more.

FindAll() instead of FindOne() returns everything that matches, and the | %{ ... } at the end is shorthand for ForEach-Object, pulling just the account name out of each result so you get a clean list.

04 — check AD accounts description field

Last one, and it's the one that still surprises some people. Ask for every account that has a description set and print it.

powershell — skim every description field
PS C:\> ([adsisearcher]"(description=*)").FindAll() | %{ $_.Properties["samaccountname"][0]+' :: '+$_.Properties["description"][0] } svc_sql :: SQL service account - temp pw Winter2025! guest :: Built-in account for guest access svc_backup :: nightly backup runner, do not disable

The description field is free text that admins fill in for their own convenience, and people still park things like "temp pw Winter2025" in there far more often than they should. It's a plain read, it never touches the accounts, and every now and then it hands you a credential outright. Same one line, a different filter, a different attribute. That's the pattern the whole post is built on.

05 — so just deny LDAP read, right?

Now, the one million dollar question. How do we stop all this? Is that even possible? Can we just take away LDAP read permissions, which is what runs under the hood, to reduce the risk of misuse? It sounds like a plausible option in theory. Unfortunately, it's not that simple.

LDAP read isn't some admin feature you can switch off. It's what the whole domain runs on. Applying Group Policy, logging on, finding a DC, resolving group membership, every Kerberos lookup: all of it is authenticated processes reading AD constantly. Authenticated Users being able to read most of the directory is the default, and it's the default because the alternative is a domain that doesn't work.

● the uncomfortable part
Every domain-joined machine and every logged-on user is, by design, allowed to enumerate users, groups, and most of their attributes. That's not a hole to patch. It's the read model AD was built on in 2000 and still runs on today.

Having said that, you can still harden specific things, and you should: ACL individual sensitive attributes (LAPS' ms-Mcs-AdmPwd is the classic), turn on list-object mode, move sensitive accounts into tightly delegated OUs. But take the password-expiry read from section 01. It won't stop, for one specific reason. msDS-UserPasswordExpiryTimeComputed is a constructed attribute. It isn't stored, so there's no ACL to hang on it. To deny the computed value you'd have to deny read on pwdLastSet, and clients really need to know password age to work. You can't ACL a calculation, and you can't hide its inputs without breaking logon. That's the tell: this isn't a vulnerability with a patch, it's the design working as intended, read from the other side of the table.

And on the machine itself, there's nothing to catch. [adsisearcher] spawns no process, loads no module, writes nothing to disk. It uses System.DirectoryServices, the same .NET stack a thousand legitimate apps use to talk to AD. To the DC it's one more authenticated client asking a normal question.

06 — what defenders can do

As blocking LDAP read permissions is almost impossible, without affecting or breaking normal AD operations, detection becomes the second best option. Here are a few things we can do.

We can turn on Directory Service Access auditing. With SACLs in place, reads against objects generate event 4662 on the DC. It's noisy out of the box, which is exactly why most shops leave it off, but scoped to the right objects it's the closest thing you have to visibility into who is reading what.

Another way: plant honey attributes and honey accounts. A decoy account that looks like a juicy non-expiring service account, with a SACL that screams the moment anyone reads it, turns this quiet enumeration into a loud alert. The attacker's own thoroughness is what trips it. They can't tell the bait from the real thing without reading it, and reading it is the alarm.

Keeping an eye on unusual LDAP traffic is another way. One host suddenly issuing broad LDAP queries, pulling every account with a given flag, or paging through every user object, is a pattern even when each individual read is legitimate. You're not looking for a bad process name. You're looking for a normal API being used at an abnormal scale from somewhere it doesn't belong.

Last but not least, move those service accounts with non-expiring or weak passwords to group managed service accounts, when possible, so the password is 120 characters, rotates on its own, and never lives in a human's head or a config file. If an account can't be a gMSA, give it a really long random password and a tight logon scope. And of course, get the passwords out of description fields today.

07 — wrapping up

So there it is. Four one-liners, no tools installed and no files dropped, just a domain user reading a directory that has to let them read it. That's what makes [adsisearcher] handy, and it's the same reason it's so hard to stop. You won't block the read. What you can do is make what it finds worthless: rotate the service account passwords, move them to gMSAs where you can, and get the secrets out of description fields. And keep an eye on the reads themselves. Monitor for that one host paging through the whole directory or pulling every account with a given flag, because when the read is normal, the pattern is all you get.

If you want to play with this, the old Microsoft Scripting Blog write-up on the [adsisearcher] type accelerator is a good place to start. Spin it up in a lab, change the filter, and see what the directory hands back.