Directories
44.0 What this chapter gives you#
- You will be able to explain why a directory is a tree rather than a table, and name the 1988 standard that fixed its shape.
- You will be able to read a distinguished name aloud, name its naming attribute, and say why no two children of one parent may share one.
- You will be able to name the four LDAP operations that carry almost all real traffic, and say exactly what a successful bind does and does not prove.
- You will be able to write a search filter that is correct under RFC 4515 and escape untrusted input into it without opening an injection hole.
- You will be able to draw an Active Directory forest with two domains and a global catalogue, and say which queries belong on port 389 and which on 3268.
- You will be able to trace a Kerberos logon from the first request to the service ticket, naming which key encrypts which part of which message.
- You will be able to explain why a service principal name on a human-chosen password is a standing offer of an offline cracking job, and why the same account with a machine-generated password is not.
- You will be able to tell a Golden ticket from a Silver ticket by the key used to forge it, and say what each does to your audit trail.
- You will be able to state what Entra ID kept from Active Directory and what it discarded, and why a cloud directory does not retire the old one.
- You will be able to look at a nested group structure and predict the point at which a user’s Kerberos token stops fitting.
There is a room in most large organizations that nobody visits and everybody depends on. It is not a room any more, of course. It is a set of servers, usually more than four of them, usually older than the people who administer them, and it holds a list of every person, every machine, every printer, every shared folder and every application account the organization has ever admitted to owning. When a laptop wakes up in the morning and asks whether the person typing is allowed to be there, that list answers. When a payroll application asks whether this user may see salaries, that list answers. When somebody leaves and their access is supposed to vanish, that list is the thing that is supposed to forget them.
That list is a directory, and this chapter is about how it works. It matters more than its unglamorous reputation suggests. The last twenty years of identity engineering have been spent building token-based systems — assertions, access tokens, federation agreements — that sit on top of directories and are usually described as replacing them. In practice they mostly translate them. The federation server that issues a token to a cloud application is, nine times in ten, reading a directory to decide what to put in the token. The directory did not go away. It moved one layer down and became invisible, which is a much more dangerous place for a critical system to sit.
The thesis of this chapter is a single sentence: before there were tokens there were directories, and in most large organizations the directory is still the truth. Everything that follows is an attempt to make that sentence uncomfortable in the right way. We will look at where the directory came from, at the protocol we actually use to talk to it, at the enormous Microsoft product that dominates it, at the authentication protocol wired into that product, and at the specific family of attacks that fall out of the design like fruit off a shaken tree. We will end with the smallest and most avoidable of the failures, which is building a directory query out of string concatenation.
We will follow one organization the whole way. Larkfield Group is a logistics company with roughly nine thousand staff across three countries. Its directory is Active Directory. Its forest root domain is larkfield.example, and most of its people live in a child domain called corp.larkfield.example. Anita Bose is a payroll analyst who sits in the Manchester office. Every value in this chapter — her distinguished name, her group memberships, her security identifier, the service account that runs the payroll database, the ticket that gets forged in the attack section — belongs to that one company and that one Tuesday morning, 18 August 2026.
The plain version#
The building with one reception desk#
Imagine a very large office building, twelve floors, several thousand people. It has one reception desk on the ground floor, and behind that desk is a set of card index drawers.
Each drawer holds cards. Each card describes one thing in the building. Most cards describe people: name, job title, telephone extension, which floor they sit on, which team they belong to. Some cards describe rooms. Some describe printers. Some describe the vans in the car park. Some describe the teams themselves, and those cards are lists of names rather than descriptions of a person.
The receptionist can do a small number of things with these drawers, and the real system can do exactly the same small number of things and nothing more.
The receptionist can look you up. You give a description — “everybody in the payroll team on the fourth floor” — and back come all the cards that match. That is a search.
The receptionist can also check one fact without telling you anything else. You ask whether Anita Bose is in the payroll team, and the answer is yes or no: not the card, not the other fields. That is a compare, and it is a genuinely different operation from a search, because it leaks less.
The receptionist can change a card, if you are allowed to. New extension number, new floor, added to a team, removed from a team. That is a modify.
And before any of that, the receptionist asks who you are. You put your own card on the counter and say a password, or you show a pass. If the receptionist is satisfied, everything you do afterwards is done as you, with whatever permissions your card carries. That is a bind, and it is the first thing that happens in every conversation with a directory. It is also the thing that people misunderstand most often, and we will come back to it twice.
Why the drawers are a tree and not one long list#
You might expect the cards to be in one giant alphabetical pile. They are not. They are organized as a tree, like a family tree drawn upside down, and there is a good reason for it that has nothing to do with tidiness.
At the very top there is one entry that stands for the whole organization. Underneath it there are entries for the big divisions. Underneath those there are entries for departments. Underneath those, finally, are the cards for people and machines. Anita’s card is not simply “Anita Bose”. It is “Anita Bose, who is in the Payroll department, which is in the Staff division, which is in Larkfield Group”. Her full address in the tree is the whole path from the top down to her, and that address is unique in the entire organization by construction. Two people called Anita Bose can both exist, as long as they are not both in the same department.
The tree buys three things. It buys uniqueness without a central registry of names, because the path itself guarantees it. It buys delegation: if the German office runs its own branch, you can say “the German administrators may change anything below this point and nothing above it” and mean it precisely, where on a flat list you would have to write a rule, and rules drift. And it buys efficient searching, because if the answer is somewhere in payroll you can start at the payroll branch instead of reading the other eleven floors.
The honest version: the tree also buys you a very large amount of pain, because organizations reorganize and trees do not move gracefully. We will come back to this.
What a card is allowed to say#
There is a rulebook at the reception desk, and it is strict.
The rulebook says what kinds of card exist. There is a kind called “person”. There is a kind called “team”. There is a kind called “machine”. Each kind has a list of fields it must have and a list of fields it may have. A person card must have a surname. A person card may have a telephone number. A person card may not have a field called “tyre pressure”, because that field belongs to the kind called “van”.
The rulebook also says what each field is allowed to contain. A telephone number field holds text of a certain shape. A date field holds a date. A member field holds the full tree address of another card, not a name typed by hand.
And the rulebook says how to compare two values in a field. This sounds like a footnote and it is not. When you ask “is there anyone called anita bose”, does the receptionist match “Anita Bose”? For a name field, yes: names are compared ignoring capital letters and ignoring extra spaces. For a password field, no: passwords are compared exactly. Every field carries its own rule for what counts as equal, and getting that rule wrong is how directories produce answers that are technically correct and practically wrong.
This rulebook is the single most under-appreciated part of a directory. It is why a directory is not a spreadsheet. A spreadsheet lets you type anything into any cell. A directory refuses.
What showing your card at the desk actually proves#
Here is the part that deserves slowing down, because almost everybody gets it wrong the first time. When you bind — when you put your card on the counter and say your password — and the receptionist says “very well”, you have learned exactly one thing: that whoever is standing at the counter knew the password belonging to that card. It is a statement about a secret, not about a person, and it is made by the receptionist about the receptionist’s own records.
You have not learned that the person is present, alive, sober or willing. You have not learned that the account should exist, or that it is allowed to do anything at all. Most importantly, if your application asked the directory to check a password on its behalf, you have not learned that the account has any right to use your application. A successful bind means “this password is the current password for this entry”. Nothing else.
There is a worse case, and it is common enough to be worth its own paragraph. If you send a card name and an empty password, some directories will happily say “very well” and give you the rights of an anonymous stranger. An application that reads that answer as “the password was correct” has just let anybody in as anybody. This is not an exotic bug. It has been in production systems for thirty years, and the specification that governs it says in plain words that servers should refuse such requests by default. We will name the exact section later.
Teams, and the quiet way they became permissions#
The team cards started life as convenience. Rather than write out forty names every time you wanted to send a memo to payroll, you wrote “Payroll” and the receptionist expanded it.
Then somebody noticed that the same trick works for doors. Rather than program every door lock with forty names, program it with “Payroll” and let the lock ask the receptionist. Now the team card is not a convenience. It is a permission. Adding a name to that card opens a door.
Then somebody noticed that teams can contain teams. “All Finance” contains “Payroll” and “Accounts Payable” and “Treasury”. That is useful, and it is where the trouble starts, because now the question “who can open the payroll door” is not a lookup. It is a walk through a graph nobody has drawn, that grew one convenient decision at a time over fifteen years, and that quite often contains loops. In every large organization, group membership became the authorization system by accident, and nobody ever sat down and designed it.
Anita’s Tuesday morning, with real values#
Let us do one morning with actual numbers, and carry them through the rest of the chapter.
It is Tuesday 18 August 2026. At 08:41 Anita Bose sits down at her laptop in Manchester and types her password. The laptop is a member of the corp.larkfield.example domain. It does not check the password itself. It asks the nearest domain controller, a server called dc01.corp.larkfield.example.
The domain controller finds her entry. Her full tree address is:
CN=Anita Bose,OU=Payroll,OU=Staff,DC=corp,
DC=larkfield,DC=example
Read from the right, that says: the example naming space, then larkfield inside it, then corp inside that, then the Staff container, then the Payroll container, then Anita. Read from the left it is the more usual way round for humans: Anita, in Payroll, in Staff, in the corp domain of larkfield.example.
Her short login name is abose. Her internet-style login name is anita.bose@larkfield.example. Both point at the same entry. Her entry also carries a number that is the thing the system actually uses internally, a security identifier, which for her looks like this:
S-1-5-21-3623811015-3361044348-30300820-2617
The long middle section identifies the domain. The 2617 on the end identifies Anita within it. If the name changes, that number does not. If the number changes, she is a different person as far as every file server in the company is concerned, no matter what the name says.
The domain controller confirms the password. Then it does something more interesting than saying yes. It builds a small sealed document listing every team Anita belongs to — not just the ones written on her card, but every one reached by following team-inside-team links — and it hands that document to her laptop inside a sealed pass. Her laptop will show that pass at every door in the building for the next ten hours without ever mentioning her password again.
Anita then opens the payroll reporting site. Her laptop does not send a password there either. It goes back to the domain controller, says “I want to visit the payroll reporting site”, and receives a second sealed pass addressed specifically to that site. The site opens the pass, reads the list of teams inside it, sees Payroll Analysts on the list, and shows her the salary report.
Three things to notice, because all three come back later.
The site never asked the directory anything. It read the list of teams out of the sealed pass it was handed. If somebody can produce a convincing pass, the site will believe it without a single lookup.
The list of teams was fixed at 08:41. If Anita is removed from Payroll Analysts at 09:15, her pass still says she is in it, and the site will still show the salary report, until the pass expires.
The seal is made with a key. Whoever holds that key can write any pass they like, with any list of teams they like, for any person they like.
Where the plain version stops being true#
A card index you can write to is not a card index#
The reception desk analogy makes the directory sound like a reference book. It is not. It is a distributed database with replication, conflict resolution, tombstones for deleted objects, and a rich set of write operations, and every one of those things has consequences the card analogy hides.
There is more than one receptionist. Larkfield has eleven domain controllers. Each holds a full writable copy of the domain’s data. A change written to the Manchester server is not instantly visible on the Frankfurt server. Active Directory replicates changes on a schedule, and within a site the default is that a change is propagated to partners after a short delay, while between sites it follows the site link schedule. During that window, two servers disagree about the truth, and both are behaving correctly.
The honest version: a directory is eventually consistent, and the word “eventually” is doing real work. If you disable an account and then immediately test whether the account can log in, you may well find that it can, because you asked a different server. Every account-disable procedure that has ever been written by somebody who did not know this has a hole in it exactly the width of the replication delay. Chapter 45 deals with the lifecycle consequences of that hole properly.
“The directory knows who you are” is two claims wearing one coat#
The plain version treated authentication and lookup as one activity at one desk. In a real deployment they are frequently different protocols against different services with different failure modes.
A directory can hold your credential and check it. That is one job. A directory can hold facts about you and hand them out. That is a second job. In Active Directory both jobs are done by the same servers, but by different protocols: Kerberos on port 88 does the credential checking, and LDAP on port 389 does the fact-handing. In Entra ID, the cloud successor, the credential checking is done by OAuth and OpenID Connect endpoints and the fact-handing is done by a web API, and there is no LDAP at all.
The honest version: when somebody says “we authenticate against the directory”, ask which of the two jobs they mean. Applications that “authenticate against LDAP” are usually doing something specific and slightly unfortunate, which is taking the user’s password, binding to the directory as that user, and treating a successful bind as a login. That works. It also means the application has handled the user’s real password, which is the thing every modern design is trying to avoid.
Groups are not permissions, and where the line falls is contested#
The plain version said team membership became permission. That is true as a description of practice and false as a description of the mechanism, and the gap is where most access review projects die.
A group is a set of names. It has no meaning until something chooses to care about it. The file server cares because an entry in a folder’s access list mentions the group. The payroll application cares because a line in its configuration mentions the group. The VPN cares because a policy mentions it. None of those facts are visible from the directory. You can read a group’s membership perfectly and still have no idea what the group grants, because the grant lives in the systems that reference it, and there is no index of those references.
Practitioners genuinely disagree here: one camp holds that groups should be pure organizational statements and that authorization belongs in application policy keyed off them, while the other holds that policy engines are one more thing to run and that groups named after what they grant are at least honest. Both positions are defensible, and chapter 46 takes the argument up properly. For this chapter the point is narrower: reading a directory does not tell you what access anybody has.
The tree is not the security boundary you assume#
The plain version made the tree sound like a set of walls. Branch by branch, department by department, with administrators who own their patch.
Inside Active Directory, the organizational unit — the container that looks like a department — is a boundary for delegated administration and for policy, and it is emphatically not a security boundary in the sense people mean. The security boundary in Active Directory is the forest. Not the organizational unit, not the domain.
This is a point Microsoft has stated in its own guidance for many years, and it surprises people every time. A domain administrator in a child domain can, by well-understood routes, reach the forest root. Separate domains within a forest do not isolate an attacker who has compromised one of them. If you need genuine isolation between two parts of an organization, you need two forests, with an explicit trust between them or no trust at all.
The honest version: drawing your organization as a neat tree of departments and assuming each branch is contained is the single most common architectural error in Active Directory design, and it is usually discovered during an incident.
The pass is not checked by the office that issued it#
In the plain version, the site opened a sealed pass and read the team list inside. That is exactly right, and it is the most consequential sentence in this chapter.
A Kerberos service ticket is decrypted by the service using the service’s own long-term key. The service does not phone the domain controller to ask whether the ticket is genuine. It cannot, in the general case, because the whole point of the design is that the service can validate a client without a round trip to a central server. Optional PAC validation exists, and Windows services can be configured to perform it, but the default behaviour for most services is not to.
That single design decision produces most of the attack surface in the second half of this chapter. If you hold the key a ticket is encrypted with, you can write the ticket. If you hold the key the ticket-granting ticket is encrypted with, you can write any ticket for anyone.
A cloud directory is not the same shape#
The plain version drew a tree. Entra ID, which is where most organizations’ cloud identity now lives, is not a tree. It is a flat set of objects in a tenant, with administrative units as a partial and much weaker substitute for organizational units. There are no distinguished names in the LDAP sense, no schema you can extend with your own object classes in the way you could on premises, no Group Policy, no Kerberos and no NTLM.
The honest version: Entra ID is not Active Directory in the cloud. It is a different product with a different data model that happens to be operated by the same company and to synchronize with the old one. Almost every migration project that assumed otherwise has run long. We will be precise about what carried over and what did not.
The technical version#
X.500, the Directory Information Tree, and the protocol nobody could deploy#
The shape of every directory in use today was fixed by a set of standards published by CCITT, the body that became the ITU Telecommunication Standardization Sector. The first edition of Recommendation X.500, titled “Information technology - Open Systems Interconnection - The Directory: Overview of concepts, models and services”, was approved in November 1988. It has been revised repeatedly. The edition in force as of August 2026 is X.500 (10/19), with Amendment 1 approved in October 2024.
X.500 is not one document. It is a series, and the division of labour in that series is worth knowing because the same divisions survive in LDAP.
| Recommendation | What it fixes |
|---|---|
| X.500 | Overview and models |
| X.501 | The information model |
| X.511 | Abstract service definition |
| X.518 | Distributed operations |
| X.519 | Protocol specifications |
| X.520 | Selected attribute types |
| X.521 | Selected object classes |
| X.525 | Replication |
The central abstraction is the Directory Information Tree, universally shortened to DIT: a single hierarchy in which every entry has exactly one parent and is named relative to that parent. An entry is a set of attributes. An attribute is a type with one or more values. The entry’s own name within its parent is called the relative distinguished name, and the sequence of relative distinguished names from the root down to the entry is the distinguished name.
X.500 assumed the world would run the OSI protocol stack. Clients, called Directory User Agents, would speak the Directory Access Protocol to servers called Directory System Agents, over OSI transport. That assumption did not survive contact with the Internet. DAP over a full OSI stack was heavy to implement, heavy to run, and there was very little OSI networking in the places that wanted directories.
The first widely deployed commercial directory built on X.500 concepts was not from Microsoft. Novell shipped NetWare Directory Services with NetWare 4 in 1993, seven years before Active Directory, replacing the per-server “bindery” with a single distributed tree. Novell renamed the product eDirectory in February 2004. Anyone who administered a network in the 1990s met the ideas in this chapter through NDS first.
LDAP as the practical subset#
The Lightweight Directory Access Protocol was the answer to DAP’s weight. RFC 1487, “X.500 Lightweight Directory Access Protocol”, was published in July 1993 by W. Yeong of Performance Systems International, T. Howes of the University of Michigan, and S. Kille of the ISODE Consortium. Its stated aim was “to provide access to the Directory while not incurring the resource requirements of the Directory Access Protocol”. It ran directly over TCP, and it named the port that is still in use: “Server implementations running over the TCP should provide a protocol listener on port 389.”
The design moves that made LDAP lightweight are the reason it won. It dropped the OSI stack and ran on TCP directly. It used a restricted subset of the ASN.1 Basic Encoding Rules rather than the full encoding. It represented distinguished names and search filters as ordinary strings rather than structured encodings, which meant a human could type a query. It reduced the operation set. And it made the client chase referrals rather than requiring the server to chain requests on its behalf.
Version 2 arrived as RFC 1777 in March 1995. Version 3 arrived as RFC 2251 in December 1997 and is the version everything speaks today. The whole specification was then rewritten and reorganized, and the current technical specification is the RFC 4510 series, published in June 2006, which “obsoletes entirely the previously defined LDAP technical specification defined in RFC 3377 (and consisting of RFCs 2251-2256, 2829, 2830, 3771, and 3377 itself)”.
| RFC | Subject |
|---|---|
| 4510 | Specification roadmap |
| 4511 | The protocol |
| 4512 | Directory information models |
| 4513 | Authentication and security |
| 4514 | String form of DNs |
| 4515 | String form of search filters |
| 4516 | The LDAP URL |
| 4517 | Syntaxes and matching rules |
| 4518 | Internationalized string prep |
| 4519 | Schema for user applications |
Two implementation facts anchor the era. The OpenLDAP project was started in 1998 by Kurt Zeilenga, by cloning the reference source from the University of Michigan after that project’s final release in 1996; OpenLDAP 1.0 was announced on 26 August 1998. As of August 2026 the current release is OpenLDAP 2.7.0, dated 6 August 2026. Microsoft shipped Active Directory with Windows 2000 Server on 17 February 2000, and it has been the dominant enterprise directory ever since.
Distinguished names and the rules that bite#
A distinguished name is a sequence of relative distinguished names, written left to right from the entry up to the root, separated by commas. RFC 4514 defines the string form.
Anita’s DN, written properly on one line, is CN=Anita Bose,OU=Payroll,OU=Staff,DC=corp,DC=larkfield,DC=example. The leftmost component, CN=Anita Bose, is her relative distinguished name. CN is the naming attribute, and the rule is absolute: within one parent entry, no two children may have the same relative distinguished name. That is the uniqueness the tree buys you.
A relative distinguished name may be multi-valued, joined with a plus sign, as in CN=Anita Bose+UID=abose. This is legal, rare, and a reliable source of bugs in code that splits DNs on commas.
The characters that must be escaped in a DN are not the same as the characters that must be escaped in a search filter, and confusing the two is the root of a whole class of defects. In a DN, RFC 4514 requires escaping of a leading # or space, a trailing space, and the characters comma, plus, double quote, backslash, less-than, greater-than and semicolon. Escaping is done with a backslash before the character, or with a backslash and two hexadecimal digits.
Attribute types themselves are identified by object identifiers, and the string names we type are aliases for those numbers. A few worth memorizing:
| Name | OID | Meaning |
|---|---|---|
| cn | 2.5.4.3 | Common name |
| sn | 2.5.4.4 | Surname |
| ou | 2.5.4.11 | Organizational unit |
| dc | 0.9.2342.19200300.100.1.25 | Domain component |
| member | 2.5.4.31 | Group member |
The dc attribute deserves a note. It comes from the COSINE schema, not from X.500 proper, and the convention of naming a directory tree after a DNS domain — DC=corp,DC=larkfield,DC=example for corp.larkfield.example — is exactly that, a convention, defined in RFC 2247 and adopted universally. X.500 originally imagined trees rooted in country and organization names, and you can still find directories built as C=GB,O=Larkfield Group. Microsoft chose the DNS-shaped form and that settled the argument.
Object classes, attribute types and schema#
Every entry carries an objectClass attribute, and its values determine what other attributes the entry may and must have. RFC 4512 defines three kinds of object class.
An abstract class cannot be instantiated on its own and exists to be inherited from. top is the abstract class from which everything descends.
A structural class defines what the entry fundamentally is. Every entry has exactly one structural class chain. person, organizationalPerson and inetOrgPerson form such a chain.
An auxiliary class adds attributes to an entry without changing what it fundamentally is. An entry can carry several.
The most widely deployed person class outside Microsoft is inetOrgPerson, defined in RFC 2798 by M. Smith of Netscape in April 2000, with OID 2.16.840.1.113730.3.2.2 and superclass organizationalPerson. Its stated reason for existing is blunt: the X.500 standards “do not define a person object class that meets the requirements found in today’s Internet and Intranet directory service deployments”. It adds the attributes real deployments needed, including mail, uid, displayName, employeeNumber, manager, mobile and jpegPhoto.
Microsoft went its own way. Active Directory’s person class is user, and Microsoft’s own attributes live under the OID arc 1.2.840.113556. Three that appear constantly:
| Attribute | OID | What it holds |
|---|---|---|
| sAMAccountName | 1.2.840.113556.1.4.221 | Short logon name |
| userPrincipalName | 1.2.840.113556.1.4.656 | Internet-style name |
| servicePrincipalName | 1.2.840.113556.1.4.771 | Kerberos service names |
Each attribute type also carries a syntax, which says what the values look like, and matching rules, which say how comparisons are done. RFC 4517 defines these. The one that surprises people is caseIgnoreMatch, the default equality rule for most name-like attributes: cn=anita bose matches CN=Anita Bose, and it also ignores leading, trailing and repeated internal spaces. Directory equality is not string equality, and any application that assumes it is will eventually let two “different” values collide.
The operations, and what a bind actually proves#
RFC 4511 defines the protocol operations. There are ten, and four of them carry nearly all real traffic.
| Operation | Purpose | Failure code seen |
|---|---|---|
| Bind | Establish authorization | 49 invalidCredentials |
| Search | Retrieve entries | 32 noSuchObject |
| Compare | Test one attribute value | 5 compareFalse |
| Modify | Change attributes | 50 insufficientAccess |
The other six are Unbind, Add, Delete, ModifyDN, Abandon and Extended. StartTLS, which upgrades a plain connection to TLS, is an Extended operation, defined in RFC 4511 section 4.14, with OID 1.3.6.1.4.1.1466.20037. That matters because there are two ways to encrypt LDAP: StartTLS on port 389, which is the standardized way, and implicit TLS on port 636, usually called LDAPS, which is a convention that predates StartTLS and was never standardized. Both are in wide use.
A Search request carries eight parameters, and tuning a directory query is almost entirely a matter of getting them right: the base object, the scope (baseObject, singleLevel or wholeSubtree), the alias dereferencing behaviour, a size limit, a time limit, a types-only flag, the filter, and the attributes to return. A search that starts at the top of the tree with wholeSubtree scope and asks for all attributes is the directory equivalent of a full table scan.
Now the important part. RFC 4513 defines the Bind mechanisms, and section 5.1 covers the simple Bind method in three cases that are easy to confuse.
Section 5.1.1 is the anonymous mechanism: a Bind with a zero-length name and a zero-length password. This explicitly establishes an anonymous authorization state, and it is honest about it.
Section 5.1.2 is the unauthenticated mechanism: a Bind with a real distinguished name of non-zero length and a zero-length password. The specification says this “establish[es] an anonymous authorization state”, and adds a warning in as many words: “Unauthenticated Bind operations can have significant security issues. Servers SHOULD by default fail Unauthenticated Bind requests with a resultCode of unwillingToPerform.”
Section 5.1.3 is the name/password mechanism, the one everybody means. It establishes an authenticated authorization state. The specification is equally blunt about its limits: “The name/password authentication mechanism of the simple Bind method is not suitable for authentication in environments without confidentiality protection.”
So here is the precise answer to “what does a bind prove”. A successful name/password simple Bind proves that the requester presented the value currently stored as the credential for that entry, as judged by that server, at that moment. It proves nothing about authorization, nothing about the account’s fitness for your application, and — in the unauthenticated case — nothing at all, while still returning success.
The concrete failure looks like this. An application takes a username and password from a form, builds a DN, binds, and treats result code 0 as a successful login. A user submits an empty password. The library sends a Bind with a non-empty name and an empty password. A server that has not been hardened returns success. The application logs the user in as whoever they named.
Active Directory rejects zero-length passwords on simple binds for this reason, which has saved a great many applications from their own code. That is an implementation detail of Active Directory, not a property of LDAP. Do not rely on it in code that might one day point at OpenLDAP.
When a bind does fail against Active Directory, the error is more informative than the standard allows for, and this is genuinely useful in operations and genuinely useful to attackers. Result code 49 is returned with a diagnostic string containing a hexadecimal data value:
| data | Meaning |
|---|---|
| 525 | User not found |
| 52e | Valid user, wrong password |
| 530 | Not permitted at this time |
| 531 | Not permitted at this machine |
| 532 | Password expired |
| 533 | Account disabled |
| 701 | Account expired |
| 773 | Must change password |
| 775 | Account locked out |
The distinction between 525 and 52e is a username-enumeration oracle handed over in plain text. Whether to suppress it at the application layer is a real trade-off: it makes support harder and it closes a genuine information leak.
The Compare operation deserves its own warning. It returns compareTrue (6) or compareFalse (5) and does not return the attribute value. Against a directory that permits comparison of a password attribute, it is a perfect oracle: an attacker can test password guesses without ever binding, and without generating the failed-bind events your monitoring watches. Active Directory refuses to read or compare unicodePwd at all. Directories that store userPassword should ensure the attribute is neither readable nor comparable by unprivileged binds.
Active Directory: domains, forests, trusts and the global catalogue#
Active Directory Domain Services is documented in the Microsoft Open Specifications document MS-ADTS, “Active Directory Technical Specification”, which as of August 2026 stands at revision 68.0, dated 25 May 2026. It is the authoritative answer whenever a vendor tells you something about AD behaviour that sounds wrong.
A domain is a replication and administration unit with its own account database and its own security identifier prefix. A tree is a set of domains sharing a contiguous DNS namespace. A forest is one or more trees sharing a schema, a configuration and a global catalogue. Larkfield’s forest looks like this:
larkfield.example (forest root)
|
+-----------+-----------+
| |
corp.larkfield.example eu.larkfield.example
(9,000 users) (1,400 users)
Every domain controller holds three naming contexts, which are separately replicated partitions of the directory:
The Domain naming context holds the users, groups and computers of one domain. It replicates only to domain controllers in that domain.
The Configuration naming context holds the description of the forest itself: sites, subnets, site links, services, and the list of partitions. It replicates to every domain controller in the forest.
The Schema naming context holds the class and attribute definitions. It also replicates forest-wide, and it is writable on exactly one server at a time, the Schema Master.
You can see all of this from any client without credentials, because the rootDSE — the unnamed entry at the very top of the server — is readable anonymously by design:
ldapsearch -x -H ldap://dc01.corp.larkfield.example \
-s base -b "" \
namingContexts defaultNamingContext \
schemaNamingContext supportedSASLMechanisms
dn:
namingContexts: DC=corp,DC=larkfield,DC=example
namingContexts: CN=Configuration,DC=larkfield,DC=example
namingContexts: CN=Schema,CN=Configuration,
DC=larkfield,DC=example
namingContexts: DC=DomainDnsZones,DC=corp,
DC=larkfield,DC=example
defaultNamingContext: DC=corp,DC=larkfield,DC=example
schemaNamingContext: CN=Schema,CN=Configuration,
DC=larkfield,DC=example
supportedSASLMechanisms: GSSAPI
supportedSASLMechanisms: GSS-SPNEGO
supportedSASLMechanisms: EXTERNAL
supportedSASLMechanisms: DIGEST-MD5
That single unauthenticated query tells an attacker the forest root domain, the child domain, the presence of integrated DNS, and which authentication mechanisms are on offer. It is the first thing any assessment runs.
Trusts are the relationships between domains. Within a forest, parent-child and tree-root trusts are created automatically, are two-way, and are transitive: corp trusts eu because both trust the root. Trusts to domains outside the forest come in several kinds. An external trust is non-transitive and points at a single domain. A forest trust, available from the Windows Server 2003 forest functional level onwards, is transitive between the two forests. A realm trust points at a non-Windows Kerberos realm. A shortcut trust cuts a path between two domains in the same forest to shorten referral chains.
Two facts about trusts matter more than the taxonomy. SID filtering, which discards security identifiers from the trusted domain that do not belong to it, is applied by default on external and forest trusts, and disabling it to make a migration work re-opens the SID history injection route. And the forest, not the domain, is the security boundary, as Microsoft’s own guidance has said for two decades: a compromise of any domain in a forest should be treated as a compromise of the forest.
The global catalogue solves a problem the partitioning creates. A domain controller in corp knows nothing about the objects in eu. So selected domain controllers are also designated global catalogue servers, and they hold a partial replica of every object in every domain in the forest. Partial means a subset of attributes: those flagged in the schema as members of the partial attribute set. A global catalogue server answers on separate ports.
| Service | Port | Notes |
|---|---|---|
| LDAP | 389 | One domain, referrals |
| LDAP over TLS | 636 | LDAPS, a convention |
| Global catalogue | 3268 | Forest-wide, partial |
| GC over TLS | 3269 | Same, encrypted |
| Kerberos | 88 | TCP and UDP |
| Kerberos passwd | 464 | Password change |
The practical rule: if your query needs objects from more than one domain, or needs universal group membership, or resolves a user principal name at logon, it goes to 3268. If it needs an attribute that is not in the partial attribute set, or needs to write, it goes to 389 against a domain controller of the right domain. Applications that hard-code 389 and then fail intermittently in multi-domain forests are a standing genre of support ticket.
Finally, the numbers. Microsoft’s published limits for AD DS, in the document “Active Directory Domain Services maximum limits and scalability”, give the sizes that actually constrain designs: each domain controller can create almost 2.15 billion objects over its lifetime, and the maximum number of security identifiers creatable over the lifetime of a domain controller is 2,147,483,647 relative identifiers. Microsoft recommends no more than 1,200 domain controllers per domain, and limits the number of Group Policy objects applied to a single account to 999. The maximum number of domains in a forest depends on functional level: 800 at the Windows 2000 level, 1,200 at Windows Server 2003, and 3,000 at the Windows Server 2025 level.
That last row is a reminder that this is not a frozen product. Windows Server 2025 introduced the first new domain and forest functional level since Windows Server 2016 — Windows Server 2019 and 2022 both topped out at the 2016 level — and it brought a 32k database page option and higher object limits with it.
Kerberos inside Active Directory: TGT, service tickets and SPNs#
Kerberos began at MIT’s Project Athena in the 1980s. Version 5 was first specified in RFC 1510 in 1993 and is now defined by RFC 4120, “The Kerberos Network Authentication Service (V5)”, published in July 2005 by C. Neuman, T. Yu, S. Hartman and K. Raeburn, which obsoletes RFC 1510. Microsoft’s extensions are documented in MS-KILE, “Kerberos Protocol Extensions”, which as of August 2026 stands at revision 48.0 dated 14 July 2026, and which explicitly specifies “the Microsoft implementation of the Kerberos Protocol Extensions, as specified in [RFC4120]”.
Every writable domain controller is a Key Distribution Center: two logical services in one process, the Authentication Service and the Ticket-Granting Service, both on port 88. The whole protocol rests on one asymmetry of knowledge. The KDC holds the account database, so it knows the long-term key of every principal, and no two other principals share a key. It can therefore hand out short-lived session keys sealed to each party, so that two principals who have never met end up holding the same session key without having exchanged a secret.
Here is Anita’s Tuesday, in protocol terms.
Anita's laptop dc01 (the KDC)
| |
| 1. AS-REQ + PA-ENC-TIMESTAMP |
|----------------------------------->|
| |
| 2. AS-REP (TGT, session key) |
|<-----------------------------------|
| |
| 3. TGS-REQ (TGT + target SPN) |
|----------------------------------->|
| |
| 4. TGS-REP (service ticket) |
|<-----------------------------------|
|
| reports.corp.larkfield.example
| |
| 5. AP-REQ (service ticket)
|----------->|
| |
| 6. AP-REP (optional, mutual auth)
|<-----------|
Step 1 is the Authentication Service exchange, RFC 4120 section 3.1, with the message format in section 5.4.1. Anita’s laptop derives a key from her password and encrypts the current timestamp with it. This is pre-authentication, and Windows requires it by default. The KDC decrypts the timestamp with its own copy of her key, and a plausible time means the password was right.
Step 2 returns two things: the session key, encrypted under Anita’s long-term key so only she can read it, and the ticket-granting ticket, or TGT, which holds the same session key plus her identity and group memberships, encrypted under the long-term key of a special account called krbtgt. Anita cannot read her own TGT and does not need to. She simply presents it back.
The group memberships sit in a Microsoft-specific structure inside the ticket called the Privilege Attribute Certificate, documented in MS-PAC. Despite the name it is not an X.509 certificate. It lists the user’s relative identifier, primary group and full set of group security identifiers, with signatures over it.
Step 3 is the Ticket-Granting Service exchange, RFC 4120 section 3.3: Anita presents her TGT and names the service she wants by its service principal name. Step 4 returns a service ticket encrypted under the long-term key of the account that owns that name. Step 5 presents that ticket to the service in a KRB_AP_REQ, RFC 4120 section 5.5.1; the service decrypts it with its own key, reads the PAC, and builds an access token, without contacting the KDC at all in the default configuration.
The service principal name is the join between the Kerberos world and the directory. It is a string of the form serviceclass/host:port/servicename, stored in the servicePrincipalName attribute of the account that runs the service. Larkfield’s payroll estate has these:
HTTP/reports.corp.larkfield.example
-> svc-payroll-web
MSSQLSvc/db07.corp.larkfield.example:1433
-> svc-payroll-sql
LDAP/dc01.corp.larkfield.example
-> DC01$ (the computer account)
cifs/fs04.corp.larkfield.example
-> FS04$ (the computer account)
Two rules govern SPNs and both cause outages. An SPN must be unique in the forest; a duplicate makes the KDC refuse to issue tickets and the client falls back to NTLM, which surfaces as “it works for some users and not others”, and you find duplicates with setspn -X. And the SPN the client constructs comes from the name the client typed, not the name the server has, so connecting to reports when the SPN says reports.corp.larkfield.example will not match.
The defaults that govern ticket lifetimes live in Kerberos Policy in the Default Domain Policy, and they have been the same for a long time.
| Setting | Default |
|---|---|
| User ticket lifetime | 10 hours |
| Service ticket lifetime | 600 min |
| User ticket renewal | 7 days |
| Clock skew tolerance | 5 minutes |
The five-minute clock tolerance is why a domain-joined machine with a wrong clock cannot log in at all, and why time synchronization is a security dependency rather than a convenience.
Delegation, and the three ways to let a service act as you#
The reporting site does not just show pages. It queries the payroll database, and the database wants to apply Anita’s own permissions, not the web server’s. So the web server has to act as Anita against SQL Server. Kerberos has three answers to that, and they differ enormously in how much damage they do when the middle server is compromised.
Unconstrained delegation is the oldest and the worst. The account is flagged TRUSTED_FOR_DELEGATION in userAccountControl (bit 0x80000). When a client authenticates to such a server, the client’s Kerberos stack sends a forwarded copy of the client’s own TGT inside the request. The server caches it in memory. It can then use that TGT to request tickets for any service in the domain, as that user. Not just SQL Server. Anything. If the web server is compromised, every user who ever visited it has effectively been handed to the attacker, and there are well-known techniques for coercing a high-value account, including a domain controller, into authenticating to a chosen host in order to harvest its TGT.
Constrained delegation was Microsoft’s fix, specified in MS-SFU. The msDS-AllowedToDelegateTo attribute on the middle account lists the specific service principal names it may impersonate users to. The middle server uses S4U2Proxy to exchange a service ticket presented to it for a service ticket to one of those listed services. A related extension, S4U2Self, lets a service obtain a ticket to itself on behalf of a user who never used Kerberos at all — this is “protocol transition”, enabled by the TRUSTED_TO_AUTH_FOR_DELEGATION bit (0x1000000), and it is more powerful than it looks because it means the middle service can impersonate anyone without that person doing anything.
Resource-based constrained delegation, added in Windows Server 2012, inverts the control. Instead of the middle server listing the resources it may reach, the resource lists the middle servers that may impersonate to it, in the msDS-AllowedToActOnBehalfOfOtherIdentity attribute. This is the better model, because the party bearing the risk sets the policy. It is also, from an attacker’s point of view, a privilege escalation route with a very short path: anyone who can write that one attribute on a computer object can impersonate any user to that computer.
The honest version: delegation configuration is the most frequently misunderstood part of Active Directory, and an audit that lists every account with unconstrained delegation is usually the highest-value hour anyone spends in a first assessment.
Group membership as authorization, and the nested group explosion#
Active Directory groups have two independent properties. A group has a type — security, which can appear in access control lists, or distribution, which cannot and exists only for mail. And a group has a scope, which controls where its members may come from and where it may be used.
| Scope | Can contain | Usable in |
|---|---|---|
| Domain local | Any domain in forest | Own domain |
| Global | Own domain only | Whole forest |
| Universal | Any domain in forest | Whole forest |
The classical design pattern is AGDLP: put Accounts into Global groups, put global groups into Domain Local groups, and grant Permissions to the domain local groups. In a multi-domain forest it becomes AGUDLP, with universal groups in the middle. Larkfield follows it:
Anita Bose
|
+-- GG-Payroll-Analysts (global)
|
+-- UG-Finance-Reporting (universal)
|
+-- DL-PayrollDB-Read (domain local)
|
+-- granted SELECT on payroll DB
The pattern is sound. What breaks is fifteen years of nobody removing anything, of groups added to groups “just for this project”, and of a helpdesk operator closing a ticket by nesting a group that already contains half the company.
The cost is paid in the Kerberos ticket, because every security identifier the user holds goes into the PAC and the PAC travels inside every ticket. Microsoft’s published limits are specific: the maximum recommended size for a Kerberos ticket is 48,000 bytes, the MaxTokenSize registry value has defaulted to 48,000 bytes since Windows Server 2012, and the maximum allowed value is 65,535 bytes. There is also a hard ceiling on membership: “Security principals, such as user, group, and computer accounts, can be members of a maximum of 1,015 groups”, because the access token built for each principal has a size limit “that isn’t affected by how you nest the groups”.
That last clause is the one to underline. Nesting does not save space. The token contains the fully expanded transitive set of security identifiers, not the tree that produced it. A user in three groups that are each in three hundred groups holds nine hundred SIDs.
The symptom is not an error saying “too many groups”. It is intermittent failure: Kerberos works to some services and not others, HTTP requests fail with header-size errors because the base64-encoded token no longer fits in a header, and users report that the problem moves around. Because base64 inflates the ticket, Microsoft advises against raising MaxTokenSize past 48,000 even though the protocol permits 65,535.
Two constructed attributes let you measure the real number rather than guess. tokenGroups returns the fully expanded set of security identifiers for a principal, computed by the domain controller. And the matching rule OID 1.2.840.113556.1.4.1941, LDAP_MATCHING_RULE_IN_CHAIN, walks the nesting chain inside the filter itself:
(member:1.2.840.113556.1.4.1941:=CN=Anita Bose,
OU=Payroll,OU=Staff,DC=corp,DC=larkfield,DC=example)
Run against the groups container, that filter returns every group Anita is in, directly or at any depth. It is expensive, it is not indexed in the ordinary way, and it is the only honest answer to “what does this person actually belong to”.
Two related matching rules complete the toolkit, and both are bitwise tests against userAccountControl. OID 1.2.840.113556.1.4.803 is a bitwise AND, and 1.2.840.113556.1.4.804 is a bitwise OR. The filter (userAccountControl:1.2.840.113556.1.4.803:=2) finds disabled accounts. The filter with the value 4194304 finds accounts that do not require Kerberos pre-authentication, which is a different attack surface entirely and one worth running against your own directory today.
The attacks that follow from the design#
Nothing in this section is a bug. Every one of these attacks is a correct use of the protocol by someone holding a key they should not hold, or by someone exploiting the fact that a service validates a ticket without asking anyone.
Kerberoasting was named and demonstrated by Tim Medin in his talk “Attacking Microsoft Kerberos: Kicking the Guard Dog of Hades” at DerbyCon 4 in September 2014. The logic is three steps and no exploit code.
Step one: any authenticated principal may request a service ticket for any service principal name. That is not a flaw; the ticket-granting service performs no authorization check on the request, because authorization is the service’s business, not the KDC’s.
Step two: the returned ticket is encrypted with the long-term key of the account that owns the SPN. If the negotiated encryption type is RC4-HMAC (etype 23), that key is the NT hash of the password: the MD4 hash of the password in UTF-16LE, with no salt and no iteration.
Step three: the attacker cracks it offline, generating no traffic and no failed logon events. The standard tools are GetUserSPNs.py in Impacket and the kerberoast command in Rubeus, and the output feeds straight into hashcat.
| Hashcat mode | Ticket type |
|---|---|
| 13100 | TGS-REP etype 23 |
| 19600 | TGS-REP etype 17 |
| 19700 | TGS-REP etype 18 |
| 18200 | AS-REP etype 23 |
This works in practice, not merely in theory, because SPNs are most often attached to ordinary user accounts whose passwords were chosen by a human being in 2013 and never changed. Larkfield’s svc-payroll-sql is exactly such an account: a word, a year and an exclamation mark, an MSSQLSvc SPN, and membership of Domain Admins because that was easier than working out the permissions in 2014.
The mitigations follow from the mechanism. Use accounts whose passwords no human chose: group managed service accounts, whose passwords Microsoft documents as 240-byte randomly generated values rotated automatically, by default every 30 days. Computer accounts are safe for the same reason. Where a user account with an SPN is unavoidable, give it a long random password, remove it from privileged groups, and set msDS-SupportedEncryptionTypes so that AES is used, which makes the offline attack vastly slower without making it impossible.
The RC4 question has moved recently and the dates matter. Microsoft published “Beyond RC4 for Windows authentication” on 3 December 2025 and has been staging RC4’s removal as an assumed Kerberos encryption type since. The January 2026 cumulative updates began an audit phase, removing RC4 from the assumed encryption types and adding Windows System event log entries 201, 202, 206 and 207 to identify at-risk interactions. The April 2026 updates moved supported domain controllers to enforcement, with rollback still possible; the July 2026 updates keep enforcement and remove the rollback. The change is tracked as CVE-2026-20833. Separately, DES has been removed outright from Windows Server 2025 and Windows 11 version 24H2, which is why the default DefaultDomainSupportedEncTypes value is 0x27 on older systems and 0x24 on Windows Server 2025. As of Microsoft’s January 2026 guidance there are no plans to remove RC4 from Windows entirely.
The practical consequence for this chapter: Kerberoasting is not dead, but as of August 2026 a fully patched domain no longer hands out RC4-encrypted service tickets by default, and the attack falls back to AES tickets, which crack far more slowly. Weak service account passwords remain weak; the economics have shifted, not the principle.
Golden tickets were demonstrated by Alva “Skip” Duckwall and Benjamin Delpy in their Black Hat USA 2014 talk “Abusing Microsoft Kerberos: Sorry You Guys Don’t Get It”, using Delpy’s tool mimikatz. A Golden ticket is a forged ticket-granting ticket. You need two things: the long-term key of the krbtgt account and the domain’s security identifier. With those you can write a TGT for any principal, with any group security identifiers and any validity period, and the KDC will accept it, because its only test is whether it can decrypt the ticket with the krbtgt key.
Note what that means for your audit trail. The account named in a Golden ticket need not exist. The groups claimed in the PAC need not have that member. The user never authenticated, so there is no event 4768 for a TGT request. Detection has to come from anomalies in the tickets themselves and from behaviour, not from a failed logon.
Recovery is the part people get wrong. Changing the krbtgt password once does not help, because Active Directory retains the current and previous key so that tickets issued moments before the change still validate. The krbtgt password must be reset twice, with enough delay between resets for replication to complete and for outstanding tickets to expire. Doing the two resets back to back invalidates every ticket in the domain at once and produces an outage.
Silver tickets need less. A Silver ticket is a forged service ticket, encrypted with the long-term key of one service account rather than with krbtgt. You compromise one service account, you forge tickets to that one service, and you present them. The KDC is never contacted, so there is no event 4769 for a service ticket request either. The blast radius is one service. The stealth is total.
| Forgery | Key needed | Scope |
|---|---|---|
| Golden ticket | krbtgt key | Whole domain |
| Silver ticket | Service key | One service |
| Kerberoast | None to request | Offline crack |
Microsoft has hardened the PAC twice in response. The November 2021 updates KB5008102 and KB5008380 addressed CVE-2021-42278 and CVE-2021-42287, the pair publicly known as noPac or sAMAccountName spoofing, in which a low-privileged user creates a computer account, renames it to match a domain controller’s name without the trailing dollar sign, and obtains a service ticket as that domain controller. Charlie Clark published a weaponization write-up on 10 December 2021. The fix added the requester’s security identifier to the PAC so the KDC can check that the ticket it is renewing belongs to who it claims.
The November 2022 updates, documented in KB5020805, addressed CVE-2022-37967 by adding new signatures to the Kerberos PAC buffer. Microsoft’s own summary is that the update “address[es] security bypass and elevation of privilege vulnerabilities with Privilege Attribute Certificate (PAC) signatures”, where “an attacker could digitally alter PAC signatures, raising their privileges”. The rollout followed Microsoft’s now-standard pattern: deploy in a non-enforcing state, enable enforcement, then remove the ability to disable it.
There is a related weakness that has nothing to do with tickets and belongs here anyway. An account with the DONT_REQ_PREAUTH flag set (0x400000 in userAccountControl) will receive an AS-REP containing material encrypted with its password-derived key without proving anything first. Anyone who knows the account name can request it. That is AS-REP roasting, hashcat mode 18200, and it requires no credentials at all.
Entra ID and the cloud directory: what changed and what did not#
Azure Active Directory was renamed Microsoft Entra ID. The name change was “publicly announced on July 11, 2023”, the rollout across Microsoft experiences “started on August 15, 2023”, service plan display names changed on 1 October 2023, and most naming changes “were completed at the end of 2023”. Microsoft’s own documentation is explicit that nothing technical changed: “all existing login URLs, APIs, PowerShell cmdlets, and Microsoft Authentication Libraries (MSAL) stay the same”.
What did not change is more interesting, because it was already different. Entra ID was never Active Directory. It shares a vendor and a synchronization pipeline and almost nothing else.
| Capability | AD DS | Entra ID |
|---|---|---|
| LDAP | Yes | No |
| Kerberos and NTLM | Yes | No |
| Organizational units | Yes | No |
| Group Policy | Yes | No |
| OAuth, OIDC, SAML | Add-on | Native |
| Hierarchy | DIT tree | Flat tenant |
The consequences are practical. With no organizational units, delegated administration runs through administrative units and role assignments, and an object may belong to no more than 30 administrative units. With no distinguished names, objects are addressed by a GUID and by user principal name. There is no schema extension in the on-premises sense; extension attributes and custom security attributes fill part of the gap. And with no Kerberos, an application wanting integrated Windows authentication cannot have it from Entra ID directly. Microsoft Entra Domain Services exists to fill that hole: a managed domain that “provides managed domain services such as domain join, group policy, lightweight directory access protocol (LDAP), and Kerberos/NTLM authentication”, synchronized one way from Entra ID, and which Microsoft is clear “is a stand-alone domain and isn’t an extension of an on-premises domain”.
The service limits, published by Microsoft and last updated on 4 August 2026, are worth having to hand because they bite at scale in ways that surprise people who are used to on-premises numbers.
| Limit | Value |
|---|---|
| Objects, no verified domain | 50,000 |
| Objects, verified domain | 300,000 |
| Objects a non-admin may make | 250 |
| Group memberships per user | 2,048 |
| Groups in a SAML assertion | 150 |
| Groups in a JWT | 200 |
| Groups in Conditional Access | 4,096 |
That table contains the modern echo of the Kerberos token bloat problem. A user in more than 2,048 groups may find access blocked. A SAML assertion carries at most 150 group memberships, and filtering is supported only up to 1,000 groups; a JSON Web Token carries at most 200. Applications that make authorization decisions from group claims in a token break at those thresholds, and the recommended workaround — ask Microsoft Graph for the groups instead of reading them from the token — reintroduces the directory lookup that tokens were meant to eliminate. The directory did not go away.
Hybrid identity is where the two worlds meet, and it deserves precision because it is widely described wrongly. Password hash synchronization does not send passwords to the cloud, and it does not send NT hashes either. Microsoft documents the exact transformation. Every two minutes the synchronization agent requests the stored unicodePwd values from a domain controller. It expands the 16-byte MD4 hash to 64 bytes by converting it to a 32-character hexadecimal string and then back to binary as UTF-16. It adds a 10-byte per-user salt. It runs the result through PBKDF2 with “1,000 iterations of the HMAC-SHA256 keyed hashing algorithm”, and transmits the resulting 32-byte hash with its salt and iteration count over TLS. The agent “never has access to the clear text password”.
One hybrid component deserves naming as a risk. Seamless single sign-on works by creating a computer account in Active Directory called AZUREADSSOACC, whose Kerberos key is shared with Entra ID. Anyone who extracts that key can forge Kerberos tickets that Entra ID will accept for any synchronized user. Microsoft’s own documentation says it “highly recommend[s] that you roll over the Kerberos decryption key of the AZUREADSSOACC computer account at least every 30 days”, and in most environments nobody ever has.
Federation between the on-premises directory and cloud applications is chapter 43’s subject and we leave it there.
LDAP injection, and filter construction done safely#
The smallest failure in this chapter is also the most avoidable, and it is still shipping in new code in 2026.
An LDAP search filter is a string with structure. RFC 4515, “LDAP: String Representation of Search Filters”, published in June 2006 by M. Smith and T. Howes, defines that structure. A filter is built from parentheses, the operators &, | and !, attribute names, and assertion values. Here are three of the specification’s own examples, quoted exactly:
(cn=Babs Jensen)
(&(objectClass=Person)(|(sn=Jensen)(cn=Babs J*)))
(o=Parens R Us \28for all your parenthetical needs\29)
The third example is the whole lesson in one line. The parentheses inside the value are written as \28 and \29 precisely because unescaped they would be structure rather than data.
Now the defect. Application code builds a filter by pasting user input into a template:
flt = "(&(objectClass=user)(sAMAccountName=" + name + "))"
conn.search(base_dn, flt, SUBTREE)
Three inputs break it, in increasing order of interest.
The first is a single asterisk. Submit * as the name and the filter becomes (&(objectClass=user)(sAMAccountName=*)), which matches every user account in the search base. No structural manipulation is required at all; the asterisk is a wildcard in the assertion value, and the specification says so. If the application then displays the first result, or counts results, or uses the result to decide anything, it is now doing so over the whole directory. This is the single most common real instance of LDAP injection and it needs exactly one character.
The second is a filter term added to the conjunction. Submit this as the name:
abose)(memberOf=CN=Domain Admins,CN=Users,
DC=corp,DC=larkfield,DC=example
and the assembled filter is syntactically perfect:
(&(objectClass=user)(sAMAccountName=abose)
(memberOf=CN=Domain Admins,CN=Users,
DC=corp,DC=larkfield,DC=example))
The attacker has not widened the search. They have narrowed it, deliberately, into a question: is abose a member of Domain Admins. The application returns one record or none, and the difference is the answer. Repeat with other group names, other attributes, other values, and the application becomes a general-purpose read interface to a directory the attacker cannot otherwise query. This is blind LDAP injection, and the fact that it narrows rather than widens is exactly why it gets missed in review.
The third, most often shown in tutorials, is the attempt to break out of the enclosing & entirely with extra closing parentheses and a second top-level filter. Whether that succeeds depends on the server’s parser and on how the client library handles trailing content, and products differ. The honest version: do not build a threat model on the hope that a parser rejects the malformed case. Some do, some do not, and the two cases above work everywhere.
There is a fourth injection point that filter escaping does not touch at all, because it is not in a filter. Applications that authenticate by binding often assemble the bind DN:
dn = "CN=" + name + ",OU=Payroll,OU=Staff," \
"DC=corp,DC=larkfield,DC=example"
conn.bind(dn, password)
A name containing a comma does not produce an error. It produces a different distinguished name, pointing at a different part of the tree. DN escaping is governed by RFC 4514, and its rules are not the same as RFC 4515’s.
| Context | Escape these |
|---|---|
| Search filter | star, parens, backslash, NUL |
| DN component | comma, plus, quote, backslash |
| DN component also | semicolon, angle brackets |
| DN, position | leading hash, edge spaces |
The filter escapes, from RFC 4515 section 3, are written as a backslash and two hexadecimal digits:
* -> \2a
( -> \28
) -> \29
\ -> \5c
NUL -> \00
So the safe version of the search is not clever. It is boring, which is the point:
def esc_filter(s):
out = []
for ch in s:
out.append({'*': r'\2a', '(': r'\28',
')': r'\29', '\\': r'\5c',
'\x00': r'\00'}.get(ch, ch))
return ''.join(out)
flt = "(&(objectClass=user)(sAMAccountName=%s))" % \
esc_filter(name)
Five rules cover the rest of it. First, prefer a library that builds filters from parts rather than one that takes a string; most mature LDAP libraries offer such an interface, and it removes the defect class rather than mitigating it. Second, escape for the right grammar, because using one escaper for filters, DNs and URLs alike is a bug waiting for the right input. Third, allow-list: a login name is almost always drawn from a small character set, and rejecting anything outside [A-Za-z0-9._-] before the value reaches the directory layer is cheap. Fourth, verify passwords by binding as the user rather than by reading or comparing a password attribute, because a bind produces an event the directory can log, lock out and rate-limit while a Compare produces an oracle that does none of those things. Fifth, bind the search connection as a dedicated, minimally privileged account, because injection is bounded by what the binding identity may read, and an application that searches as a domain administrator has converted a filter bug into a directory dump.
One neighbouring incident is worth a sentence so that it is not confused with this material. The Log4Shell vulnerability, CVE-2021-44228, disclosed on 9 December 2021, allowed attacker-controlled strings to trigger JNDI lookups that could reach an LDAP server and load remote code. It involved LDAP and it was catastrophic, and it is not LDAP injection: nothing was injected into a filter. It was injection into a lookup mechanism that happened to speak LDAP.
Finally, transport. Since Windows Server 2025, Microsoft has changed the defaults here. Microsoft’s documentation states that for Windows Server 2025 and later, “all new Active Directory deployments require LDAP signing by default”, channel binding is set to “When supported”, and channel binding auditing is enabled by default; on Windows Server 2019 and earlier, channel binding defaulted to “Never”. An unsigned, unencrypted simple bind on port 389 sends the password in clear text across the network, and RFC 4513 says so in as many words. If your application does that, the escaping discussion is not your largest problem.
A worked audit: what Anita’s morning actually exposed#
Let us finish the running example by asking, of one small payroll estate, the questions this chapter says to ask. Every value here has appeared already.
The first question is which accounts are roastable: user accounts, not computer accounts, carrying a service principal name.
(&(objectCategory=person)(objectClass=user)
(servicePrincipalName=*)
(!(userAccountControl:1.2.840.113556.1.4.803:=2)))
Larkfield’s answer is four accounts, of which svc-payroll-sql is one. Its password was set on 14 March 2014 and has not changed, its msDS-SupportedEncryptionTypes attribute is unset, and it is a member of Domain Admins. Anybody with any valid account in the domain could request a ticket for MSSQLSvc/db07.corp.larkfield.example:1433 and take it away. Before January 2026 that ticket came back as hashcat mode 13100; after the April 2026 enforcement update, on a patched domain controller, it comes back as mode 19700, and a weekend becomes a much longer project. The account is still wrong. The economics simply changed.
The second question is which accounts skip pre-authentication, because those need no credentials at all:
(&(objectCategory=person)(objectClass=user)
(userAccountControl:1.2.840.113556.1.4.803:=4194304))
The third question is which accounts can impersonate others. Unconstrained delegation is the bit 0x80000, decimal 524288:
(&(objectCategory=computer)
(userAccountControl:1.2.840.113556.1.4.803:=524288))
Larkfield’s answer includes an old print server that nobody has logged into since 2019. Any domain controller or administrator that authenticates to it leaves a usable ticket-granting ticket in its memory.
The fourth question is what Anita actually holds. Not what her card says — what the expanded set is:
(member:1.2.840.113556.1.4.1941:=CN=Anita Bose,
OU=Payroll,OU=Staff,DC=corp,DC=larkfield,DC=example)
Direct memberships: three. Transitive memberships: forty-one. Two of those forty-one came from a group nested in 2018 to solve a printer problem and grant read access to a shared drive that now holds board papers. Neither is visible from her user object, because memberOf shows direct memberships only. Anybody reviewing her access by reading her account will see three groups and approve.
The fifth question is whether any of this is close to a limit. Anita holds forty-one security identifiers, which is far below the ceiling of 1,015 group memberships and far below the 48,000-byte recommended ticket size. A senior operations manager at Larkfield holds six hundred and twelve. Nobody has looked at that number, and nobody will until Kerberos starts failing intermittently for one department.
The pattern in all five answers is the same, and it is the pattern this whole chapter has been circling. The directory is not hiding anything. Every one of these facts is a single query away, readable by an ordinary account, in a system that has been documented in public for twenty-six years. The information is not secret. It is simply unread.
44.98 Common wrong ideas#
Wrong: A successful LDAP bind means the user is allowed to use my application. Right: A name/password simple bind proves only that the requester presented the value currently stored as that entry’s credential, as judged by one server at one moment; authorization is a separate question the directory has not been asked, and the application must still decide whether that account may use it.
Wrong: An organizational unit is a security boundary, so a department in its own OU is isolated. Right: The organizational unit bounds delegated administration and Group Policy and nothing else; the security boundary in Active Directory is the forest, so a compromise of any domain in a forest should be treated as a compromise of all of it.
Wrong: Nesting groups keeps a user’s Kerberos token small. Right: The token holds the fully expanded transitive set of security identifiers, so nesting changes nothing about its size, and Microsoft’s published ceiling of 1,015 group memberships is stated to be a limit that “isn’t affected by how you nest the groups”.
Wrong: Kerberoasting needs elevated privileges, so it only matters after an attacker is already an administrator. Right: Any authenticated principal may request a service ticket for any registered service principal name, because the ticket-granting service performs no authorization check on the request, so one ordinary phished account is the entire prerequisite.
Wrong: Resetting the krbtgt password removes an attacker’s Golden ticket. Right: Active Directory keeps the current and the previous krbtgt key so recently issued tickets keep working, so the password must be reset twice with replication time between the resets, and doing both at once invalidates every ticket in the domain.
Wrong: Entra ID is Active Directory moved to the cloud, so on-premises designs transfer. Right: Entra ID has no LDAP, no Kerberos, no NTLM, no organizational units and no Group Policy; it is a flat tenant reached through OAuth, OpenID Connect, SAML and Microsoft Graph, and Microsoft Entra Domain Services exists as a separate stand-alone managed domain precisely because those capabilities are absent.
Wrong: LDAPS on port 636 is the standard way to encrypt LDAP. Right: The standardized mechanism is the StartTLS extended operation on port 389, defined in RFC 4511 section 4.14 with OID 1.3.6.1.4.1.1466.20037; implicit TLS on port 636 is a widely followed convention that predates StartTLS and was never standardized, which is why product behaviour varies.
Wrong: If I escape user input for LDAP, I am safe wherever I put it. Right: There are three separate grammars: filter values follow RFC 4515 and escape asterisk, both parentheses, backslash and NUL as hexadecimal sequences, distinguished name components follow RFC 4514 and escape comma, plus, quotation mark, backslash, semicolon, the angle brackets, a leading hash or space and a trailing space, and LDAP URLs follow RFC 4516 and then percent-encoding.
Wrong: A service checks with a domain controller that a Kerberos ticket is genuine. Right: The service decrypts the ticket with its own long-term key and reads the group memberships from inside it without contacting a domain controller at all by default, which is exactly why a Silver ticket forged with a stolen service key produces no ticket-request event anywhere.
Wrong: Password hash synchronization uploads passwords, or at least NT hashes, to Microsoft. Right: The agent takes the 16-byte MD4 hash from unicodePwd, expands it to 64 bytes through a hexadecimal round trip, adds a 10-byte per-user salt and runs 1,000 iterations of PBKDF2 with HMAC-SHA256 before sending the 32-byte result over TLS, and Microsoft states that the agent “never has access to the clear text password”.
44.99 Chapter summary in 20 lines#
- A directory is a hierarchical database of an organization’s people, machines and groups, and in most large organizations it is still the authoritative source that token-issuing systems read from.
- The shape of every modern directory was fixed by ITU-T Recommendation X.500, first approved in November 1988, whose central idea is a Directory Information Tree in which every entry has exactly one parent.
- LDAP began as RFC 1487 in July 1993 as a lighter alternative to the X.500 Directory Access Protocol, ran directly over TCP on port 389, and is now specified by the RFC 4510 series of June 2006.
- A distinguished name is the path from an entry to the root, its leftmost component is the relative distinguished name, and no two children of one parent may share one.
- Object classes and attribute syntaxes form a schema the directory enforces, which is why a directory is not a spreadsheet and why every attribute carries its own rule for what counts as equal.
- Ten LDAP operations exist, and four of them carry almost all real traffic: bind, search, compare and modify.
- A successful simple bind proves only that the requester knew the credential stored for that entry, and RFC 4513 section 5.1.2 warns that a bind with a real name and an empty password establishes an anonymous state that servers should refuse by default.
- Active Directory shipped with Windows 2000 Server on 17 February 2000, is specified in MS-ADTS, and splits its data into domain, configuration and schema naming contexts.
- The forest, not the domain and not the organizational unit, is the security boundary, and the global catalogue on ports 3268 and 3269 holds a partial replica of every object in the forest.
- Kerberos issues a ticket-granting ticket encrypted under the krbtgt key and service tickets encrypted under each service account’s key, with defaults of ten hours, ten hours and seven days for renewal.
- Group memberships travel inside the ticket in the Privilege Attribute Certificate, so a service reads them from the ticket rather than the directory, and a membership change does not bite until the ticket expires.
- A service principal name binds a service to the account whose key encrypts its tickets, must be unique in the forest, and must match the name the client actually typed.
- Unconstrained delegation leaves every visiting user’s ticket-granting ticket in a server’s memory, constrained delegation limits the targets by attribute, and resource-based constrained delegation lets the resource decide.
- Nesting does not reduce token size, because the token holds the fully expanded set of security identifiers against a documented ceiling of 1,015 memberships and a recommended maximum ticket size of 48,000 bytes.
- Kerberoasting, named by Tim Medin at DerbyCon 4 in September 2014, exploits the fact that any authenticated user may request a ticket for any service principal name and crack it offline.
- A Golden ticket is forged with the krbtgt key and grants anything in the domain, while a Silver ticket is forged with one service key, works only against that service, and generates no ticket-request event at all.
- Microsoft removed RC4 from Kerberos in stages through 2026, with audit in the January updates, enforcement from April and no rollback after July, tracked as CVE-2026-20833.
- Entra ID, renamed from Azure Active Directory in an announcement of 11 July 2023, has no LDAP, no Kerberos, no organizational units and no Group Policy, and its limits of 150 group claims in a SAML assertion and 200 in a JSON Web Token reproduce token bloat in a new form.
- LDAP injection needs no more than one asterisk to turn a lookup into a directory-wide search, and an injected conjunct turns an application into a blind read oracle.
- Filter escaping, distinguished name escaping and URL escaping are three different grammars, binding as the user beats comparing a password attribute, and the search connection should never bind as an administrator.
Chapter sources: ITU-T Recommendation X.500, “The Directory: Overview of concepts, models and services”, first approved November 1988, edition X.500 (10/19) in force with Amendment 1 of October 2024, with companion Recommendations X.501, X.511, X.518, X.519, X.520, X.521 and X.525; RFC 1487, “X.500 Lightweight Directory Access Protocol”, Yeong, Howes and Kille, July 1993; RFC 1777 (March 1995) and RFC 2251 (December 1997); the RFC 4510 series of June 2006, in particular RFC 4511 (the protocol, StartTLS in section 4.14), RFC 4512 (information models), RFC 4513 (authentication methods, sections 5.1.1 to 5.1.3 on simple bind), RFC 4514 (distinguished names), RFC 4515 (search filters, section 3 on escaping and section 4 for the quoted examples), RFC 4516 and RFC 4517; RFC 2247 on naming trees after DNS domains; RFC 2798, “Definition of the inetOrgPerson LDAP Object Class”, M. Smith, April 2000; RFC 4120, “The Kerberos Network Authentication Service (V5)”, Neuman, Yu, Hartman and Raeburn, July 2005, obsoleting RFC 1510, with the AS exchange in section 3.1, the TGS exchange in section 3.3 and message formats in sections 5.4.1, 5.4.2 and 5.5.1; the Microsoft Open Specifications MS-ADTS revision 68.0 of 25 May 2026, MS-KILE revision 48.0 of 14 July 2026, MS-SFU and MS-PAC; the Microsoft Learn documents “Active Directory Domain Services maximum limits and scalability” for the 1,015 membership ceiling, the 48,000-byte recommended ticket size, the 65,535-byte maximum MaxTokenSize and the 800, 1,200 and 3,000 domain counts, “Active Directory functional levels” for the Windows Server 2025 level, and “LDAP signing for Active Directory Domain Services” for the Windows Server 2025 defaults; Microsoft Security Bulletin MS14-068 of 18 November 2014 and CVE-2014-6324; KB5008102 and KB5008380 of 9 November 2021 for CVE-2021-42278 and CVE-2021-42287, with Charlie Clark’s weaponization write-up of 10 December 2021; KB5020805 of 8 November 2022 for CVE-2022-37967; the Microsoft posts “Beyond RC4 for Windows authentication” of 3 December 2025 and “What is going on with RC4 in Kerberos?” by Will Aftring, 26 January 2026, updated 10 June 2026, for the 2026 RC4 phases, the 0x27 and 0x24 default encryption type values and CVE-2026-20833; the Microsoft Entra documents on the new name (announced 11 July 2023, rollout from 15 August 2023, service plan names from 1 October 2023), service limits as updated 4 August 2026, password hash synchronization, seamless single sign-on and the AZUREADSSOACC account, group managed service accounts, and Microsoft Entra Domain Services; Tim Medin, “Attacking Microsoft Kerberos: Kicking the Guard Dog of Hades”, DerbyCon 4, September 2014; Alva Duckwall and Benjamin Delpy, “Abusing Microsoft Kerberos: Sorry You Guys Don’t Get It”, Black Hat USA 2014; the hashcat example hashes reference for modes 13100, 18200, 19600 and 19700; the OpenLDAP release history, with 1.0 announced 26 August 1998 and 2.7.0 dated 6 August 2026; Novell’s 1993 NetWare Directory Services in NetWare 4, renamed eDirectory in February 2004; and CVE-2021-44228 of 9 December 2021, named only to separate JNDI lookup abuse from LDAP filter injection.