Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail Ckclackatoo PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Security Descriptors

Single-page view · as markdown

Security descriptors

Peios / Peios Security Fundamentals / Security Descriptors

A security descriptor (often just SD) is the policy attached to every protected object in Peios. Every file, every registry key, every IPC socket, every running process, every token — all of them carry an SD that defines the security policy for that one object. Where the token is the "who" of an access decision, the SD is the "what to check against".

A security descriptor has four components, each with its own role, its own evaluation rules, and its own pitfalls. This topic covers each in turn.

The four components #

Every SD has exactly four parts:

ComponentWhat it does
OwnerA single SID identifying who owns the object. The owner has implicit READ_CONTROL and WRITE_DAC rights (with one exception, covered in Ownership).
Primary groupA single SID. Used when CREATOR_GROUP placeholder ACEs are substituted during inheritance. Rarely directly meaningful.
DACL (Discretionary Access Control List)The ordered list of ACEs that decides who can do what to the object. This is the part most people mean when they say "the permissions on a file". Controlled by the owner via WRITE_DAC.
SACL (System Access Control List)The list of system-level policy ACEs — audit rules, the object's integrity label, resource attributes, central access policy references, PIP trust labels. Controlled by anyone with ACCESS_SYSTEM_SECURITY (which is gated by SeSecurityPrivilege).

A given SD can have all four components or some subset. An SD without a DACL is not the same as an SD with an empty DACL — see DACL evaluation for the rule. An SD without an owner is malformed; the access check rejects it.

What an SD does not contain #

Worth knowing what is not in an SD, because these things are often mistakenly attributed to it:

  • The object's content or data. SDs say nothing about what the object is, only about who can use it.
  • The object's type. Whether you are dealing with a file, a registry key, or a process is the object manager's concern. The SD is the same shape regardless; only the access rights it grants are object-type-specific (because the GenericMapping for each type is different).
  • The token of the principal who is trying to access the object. That comes from the caller, not the SD. An SD is policy; tokens are subjects; the access check matches them up.
  • Anything about the parent object. Inheritance is computed at creation time — a child object's SD is its own complete value, not a delegation to the parent. See Inheritance for the eager-evaluation model.

These boundaries matter because the SD's job is small and well-defined. Everything else about an object lives somewhere else: the data is in the data path, the type is in the object manager, the access decision is in the access check.

Where SDs live #

Different object types store SDs in different places, but the format and meaning are the same.

Object typeStorage
Files and directoriesAn xattr (security.peios.sd, or system.ntfs_security on NTFS volumes) on the inode.
Registry keysStored by the registry source.
ProcessesA field on the PSB (process security block).
TokensA field on the token object itself.
Abstract Unix socketsA field on the socket LSM blob, stamped from the binding thread's effective token at bind().
IPC objectsA field on the object's kernel structure.

The binary format — the self-relative layout — is uniform. An SD that was written for a file can be read by a tool that uses the same parser as for a registry key. This consistency is what lets system tools work generically across object types.

Note one important rule: reading or writing the SD xattr directly is denied unconditionally. Even with CAP_DAC_OVERRIDE. All SD access must go through the appropriate KACS API (kacs_get_sd, kacs_set_sd, or the equivalent registry-side APIs). The xattr exists so the SD travels with the file across normal filesystem operations (copy, backup, restore by tools that understand xattrs), but the security boundary is the API, not the xattr layer.

The cost-of-modification asymmetry #

A useful intuition: modifying different parts of an SD requires different rights.

To changeYou need
The DACLWRITE_DAC on the object (the owner always has this unless suppressed by OWNER RIGHTS).
The ownerWRITE_OWNER on the object, plus a restriction on which SIDs you can name as the new owner (covered in Ownership).
The SACLACCESS_SYSTEM_SECURITY — granted by SeSecurityPrivilege rather than by the object's DACL. The DACL never grants this right.

This asymmetry is deliberate. The DACL is what users control to manage their own data. Ownership is a stronger lever — taking ownership is a deliberate administrative act. The SACL is a system-level concern, gated by a privilege held by administrators and audit systems.

How an SD gets created #

There are three ways an object ends up with its SD:

  1. Inherited from a parent. The most common path: when a child object is created, its SD is computed by combining the parent's inheritable ACEs with the creating principal's defaults. The result is stored on the child as a complete SD. The parent is not consulted again at access-check time. See Inheritance.
  2. Explicitly supplied at creation. The creator passes an SD as a parameter (to kacs_open with a create disposition, to a registry key create, etc.). This SD is taken as-is, subject to the rules about what fields the creator may set (the creator must be entitled to name the owner, for instance).
  3. Synthesised by the kernel. For objects whose storage layer cannot hold an explicit SD (an abstract socket bound by a process that did not provide one, a file on a mount with synthesize_ephemeral policy and no parent inheritance), the kernel constructs a default SD from the creating principal's token (default_dacl, owner_sid_index, primary_group_index). The synthesis path is per-object-type but the result is a normal SD.

Once an SD is in place, it is the authoritative policy for the object. Modifying it after creation is a separate operation governed by kacs_set_sd and its access requirements.

The SD as an evaluation target #

When the access check runs on an SD, it is not reading every field. The pipeline does specific things with specific parts of the SD in a specific order. The full algorithm lives in Access decisions; a brief sketch for orientation:

  1. The owner field is consulted to determine implicit rights (and whether they have been suppressed by OWNER RIGHTS).
  2. The SACL is scanned for mandatory integrity labels and PIP trust labels — these gate the access independently of the DACL.
  3. The DACL is walked, ACE by ACE, in order, applying first-writer-wins.
  4. The SACL is scanned again for audit and alarm ACEs that should fire as a result of the access attempt.

Each step has its own page in this topic. The point of mentioning the order here is so that as you read the deeper pages you can see where each component is consulted.

Where to start #

If you want the structural details — what an ACL looks like, what each ACE type means, what bits the access mask uses — read ACLs, ACEs, and access masks.

If you want to understand how a DACL actually decides "allowed" or "denied" — first-writer-wins, ACE ordering, the canonical order, MAXIMUM_ALLOWED — read DACL evaluation.

If you want to know how owner implicit rights work and why OWNER RIGHTS can suppress them, read Ownership and implicit rights.

If you want the inheritance model — how a child's SD is computed from its parent and creator, and why parent changes don't propagate to existing children — read Inheritance.

If you want conditional ACEs — the ABAC-style mechanism where access depends on token claims and resource attributes — read Conditional ACEs.

If you want the SACL specifically — audit, alarm, integrity labels, central access policy references, PIP trust labels — read The SACL.

To read and change a descriptor from a shell — owner, DACL, SACL, label, inheritance — read The sd command.

ACLs, ACEs, and access masks

Peios / Peios Security Fundamentals / Security Descriptors

A DACL and a SACL are both Access Control Lists — sequences of Access Control Entries in a defined order. The two lists have different jobs (the DACL decides access; the SACL records system-level policy and audit), but they share a structure. This page covers that structure, plus the catalog of ACE types and the layout of the 32-bit access mask that almost every ACE carries.

ACL structure #

An ACL is, conceptually, an ordered array of ACEs. The binary form is a short header — a revision byte, the total AclSize, and the AceCount — followed by the ACEs back-to-back; the byte-level layout is owned by the Security descriptors wire format.

The maximum ACL size is 64 KB — the AclSize field is a 16-bit value. An ACL is self-delimiting: a parser walks exactly AceCount ACEs starting after the header and stops when it has consumed AclSize bytes.

The two ACL revisions differ only in what ACE types they are allowed to contain. 0x02 is the basic revision; 0x04 additionally allows object ACEs (the type with property GUIDs) and callback ACEs (the type with conditional expressions). Parsers should accept whichever revision they see; writers should emit the minimum revision necessary for the ACEs they are including.

ACE structure #

Every ACE has a 4-byte header — a one-byte AceType, a one-byte AceFlags, and a two-byte AceSize (always a multiple of 4) — followed by a type-specific body. The body's shape depends on the type. The most common shapes:

  • Single-SID body — Mask (4 bytes) | SID (variable). Used by basic allow/deny/audit/alarm ACEs and by the integrity/scoped-policy/trust-label SACL types.
  • Object ACE body — Mask (4) | Flags (4) | optional ObjectType GUID (16) | optional InheritedObjectType GUID (16) | SID (variable). The flags field controls whether each GUID is present.
  • Callback (conditional) body — same as the corresponding non-callback type, plus a trailing ApplicationData block holding the conditional expression bytecode. See Conditional ACEs.

The detailed byte-level layouts live in the Wire formats reference. For this page, what matters is that every ACE is self-describing — the parser reads the header, dispatches by type, and consumes exactly AceSize bytes.

The ACE type catalog #

There are twenty-one ACE types, split into three families: access-control, audit/alarm, and system-policy. Most SDs you will look at use only a small subset of these — typically ACCESS_ALLOWED, ACCESS_DENIED, SYSTEM_AUDIT, and SYSTEM_MANDATORY_LABEL. The numeric type values are catalogued in ACE types and flags; the tables here describe what each type is for.

Access-control ACE family #

These ACEs participate in the DACL walk.

TypeBodyUse
ACCESS_ALLOWEDSingle SIDGrant the listed rights to the named principal.
ACCESS_DENIEDSingle SIDDeny the listed rights to the named principal.
ACCESS_ALLOWED_COMPOUNDReserved (not used in v0.20).—
ACCESS_ALLOWED_OBJECTObject ACEGrant rights, scoped by property GUID or inherited only by certain child object types.
ACCESS_DENIED_OBJECTObject ACEDeny rights, same scoping.
ACCESS_ALLOWED_CALLBACKSingle SID + expressionGrant rights only if the conditional expression evaluates TRUE.
ACCESS_DENIED_CALLBACKSingle SID + expressionDeny rights when the expression evaluates TRUE or UNKNOWN.
ACCESS_ALLOWED_CALLBACK_OBJECTObject ACE + expressionGrant rights, conditionally, scoped by GUID.
ACCESS_DENIED_CALLBACK_OBJECTObject ACE + expressionDeny rights, conditionally, scoped by GUID.

The most common ACEs you will write are ACCESS_ALLOWED and ACCESS_DENIED — plain "this principal gets these rights" or "this principal does not get these rights". The object variants matter when you are protecting directory-style objects with per-property permissions. The callback variants are how conditional expressions show up in ACEs.

Audit and alarm ACE family #

These ACEs sit in the SACL and decide when access produces an audit event.

TypeBodyUse
SYSTEM_AUDITSingle SIDEmit an audit event when the named principal attempts the listed rights. Flags decide whether success, failure, or both.
SYSTEM_AUDIT_OBJECTObject ACESame, scoped by GUID.
SYSTEM_AUDIT_CALLBACKSingle SID + expressionAudit conditionally. UNKNOWN result emits the event (fail-open for auditing).
SYSTEM_AUDIT_CALLBACK_OBJECTObject ACE + expressionConditional, scoped by GUID.
SYSTEM_ALARMSingle SIDConfigure per-operation continuous audit on the open handle. Each matching operation produces an event.
SYSTEM_ALARM_OBJECTObject ACESame, scoped by GUID.
SYSTEM_ALARM_CALLBACKSingle SID + expressionConditional continuous audit.
SYSTEM_ALARM_CALLBACK_OBJECTObject ACE + expressionConditional, scoped by GUID.

The distinction between AUDIT and ALARM is important: AUDIT fires at the moment of handle creation (one event per access attempt), ALARM configures a per-operation mask on the open handle that fires on every subsequent operation. See The SACL and Auditing for the full story.

System-policy ACE family #

These ACEs sit in the SACL and carry policy other than audit.

TypeBodyUse
SYSTEM_MANDATORY_LABELIntegrity SID + maskThe object's mandatory integrity label and the policy bits that govern who can write/read/execute up.
SYSTEM_RESOURCE_ATTRIBUTEEveryone SID + claim entryA name-value attribute on the object, referenceable as @Resource.<name> in conditional expressions.
SYSTEM_SCOPED_POLICY_IDPolicy SIDA reference to a central access policy that should be evaluated alongside the object's own DACL.
SYSTEM_PROCESS_TRUST_LABELPIP SID + maskThe object's PIP trust label and the explicit allowed mask for non-dominant callers.

These four are how the SACL extends the access decision beyond what the DACL alone can express. Each has its own evaluation rules (covered in the relevant topics — MIC under Access decisions, resource attributes under Resource attributes, CAAP under Central access policies, PIP under Process integrity protection).

ACE flags #

The AceFlags field is a bitmask. Most flags control inheritance — whether and how an ACE on a parent object propagates to children. Two flags control audit firing on SYSTEM_AUDIT and SYSTEM_ALARM ACEs. (Bit values: ACE types and flags.)

FlagEffect
OBJECT_INHERIT_ACEThe ACE inherits to non-container child objects (files, not directories).
CONTAINER_INHERIT_ACEThe ACE inherits to container child objects (directories).
NO_PROPAGATE_INHERIT_ACEThe ACE is inherited by direct children but its OBJECT_INHERIT_ACE and CONTAINER_INHERIT_ACE flags are cleared in the inherited copy — it stops propagating after one level.
INHERIT_ONLY_ACEThe ACE is for inheritance only. The access check skips it; it exists only to be copied to children.
INHERITED_ACESet on ACEs that were created by inheritance, not by an explicit add. Tools use this flag to distinguish "this came from the parent" from "the user set this explicitly".
SUCCESSFUL_ACCESS_ACE_FLAG(Audit/alarm only) Fire on successful access.
FAILED_ACCESS_ACE_FLAG(Audit/alarm only) Fire on denied access.

The inheritance flags compose in non-obvious ways. Combinations you will see often:

FlagsEffect
CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE (CI | OI)Inherit to all descendants (files and directories), recursively.
CONTAINER_INHERIT_ACE (CI)Inherit to descendant containers only.
OBJECT_INHERIT_ACE (OI)Inherit to descendant non-containers only.
CI | OI | INHERIT_ONLY_ACEApply to descendants but not to this object.
CI | OI | NO_PROPAGATE_INHERIT_ACEApply to immediate children only, not grandchildren.

The full inheritance machinery is on Inheritance.

The access mask #

Every ACE that participates in the DACL walk, and every audit/alarm ACE, carries a 32-bit access mask. The mask uses four regions, partitioned by bit position.

RegionBitsPurpose
Object-specific rights0–1516 bits whose meaning depends on the object type. For a file: FILE_READ_DATA, FILE_WRITE_DATA, etc. For a registry key: KEY_QUERY_VALUE, KEY_SET_VALUE, etc. For a process: PROCESS_TERMINATE, PROCESS_VM_READ, etc.
Standard rights16–205 bits shared across all object types: DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE.
Reserved21–23Must be zero.
Special rights24–25ACCESS_SYSTEM_SECURITY — gates SACL read/write. MAXIMUM_ALLOWED — a request flag, not a real right.
Reserved26–27Must be zero.
Generic rights28–31GENERIC_ALL, GENERIC_EXECUTE, GENERIC_WRITE, GENERIC_READ. Abstract — mapped to object-specific rights at evaluation time.

The numeric values for every right live in Access mask bits; the mask's byte layout is in the SD wire format. A few things worth noting about the layout:

  • The same bit means different things on different object types. Bit 0 is FILE_READ_DATA on a file but KEY_QUERY_VALUE on a registry key. The object-specific portion of the mask is reusable across types because the object manager knows what kind of object it is.
  • Standard rights are uniform. DELETE always means delete the object; READ_CONTROL always means read the SD; WRITE_DAC always means modify the DACL; WRITE_OWNER always means change the owner. These bits work identically regardless of object type.
  • Generic rights are abstract. GENERIC_READ does not name a specific right. It is a placeholder that gets mapped to a concrete combination of object-specific and standard rights at the moment of evaluation, using the object type's GenericMapping table.

Generic mapping #

When an access check sees a generic right in either an ACE mask or a requested mask, it consults the object type's GenericMapping table to substitute concrete rights. The mapping is per-type:

Object typeGENERIC_READ maps to
FileFILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE
TokenTOKEN_QUERY | READ_CONTROL
ProcessPROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL

The full GenericMapping tables live in the Constants and catalogs reference. The key idea for now is: generic rights let an ACE author say "give the read permission for this thing" without knowing exactly which bits "read" means for this object type. The access check fills in the concrete bits at evaluation time.

This is also why an ACE that grants GENERIC_READ to a principal will produce different concrete grants depending on which object the ACE is on. The bit pattern in the ACE is the same; the meaning depends on where it lives.

MAXIMUM_ALLOWED is a request, not a right #

MAXIMUM_ALLOWED (bit 25) is special. It is not a right an ACE can grant — it is a marker the caller sets in a desired-access mask when asking "what is the maximum access I could be granted right now?". The access check sees the flag, evaluates the DACL fully, and returns every right the caller could be granted.

MAXIMUM_ALLOWED must not appear in an ACE. It is meaningless there.

Sizes and limits #

The size limits (maximum SD size, maximum ACL size, and the rest) are catalogued in Other constants. A practical SD on a typical object is far below all of them — a few hundred bytes at most. The limits exist for the pathological cases.

Where to go next #

For how the ACEs in a DACL actually decide "allowed" or "denied" — first-writer-wins, ordering, the null/empty distinction — read DACL evaluation.

For the full numeric catalog of ACE types, flags, and access-mask bits, see ACE types and flags.

To read and edit ACLs from a shell, read The sd command.

DACL evaluation

Peios / Peios Security Fundamentals / Security Descriptors

When the access check walks a DACL, it is answering one question, bit by bit: for each right the caller asked for, does this DACL grant or deny it? The mechanism is first-writer-wins — each bit is decided by the first ACE that mentions it, and once decided, no later ACE can override the decision. The walk is in the order ACEs appear in the DACL.

That rule is short. The consequences are not. The order of ACEs in a DACL is semantically load-bearing. A canonical order exists precisely so the rule produces the result you would expect. This page covers the walk, the canonical order, the special cases for null and empty DACLs, and what MAXIMUM_ALLOWED does to the algorithm.

The walk #

The access check starts with two state variables: decided (bits whose grant/deny status is now fixed) and granted (bits that have been granted). Both start empty. It walks the DACL from first ACE to last. For each ACE: skip if the ACE does not match the caller (wrong SID, INHERIT_ONLY_ACE set, conditional expression evaluates UNKNOWN-or-FALSE for allow / FALSE for deny). For an ACCESS_DENIED ACE that matches: any of its mask bits that are not yet in decided are added to decided and not added to granted (they are denied). For an ACCESS_ALLOWED ACE that matches: any of its mask bits that are not yet in decided are added to both decided and granted. After the walk, the access check has its answer: the bits in granted are the rights this DACL would give.

That paragraph is the whole algorithm for an ordinary DACL. Everything else on this page is either a special case (null/empty DACL, MAXIMUM_ALLOWED) or a rule about how to arrange ACEs so the walk produces the right answer.

First-writer-wins, illustrated #

Suppose a DACL contains:

  1. ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA
  2. ACCESS_DENIED Alice FILE_WRITE_DATA

Alice asks for FILE_READ_DATA | FILE_WRITE_DATA. The walk:

  • ACE 1 matches Alice. FILE_READ_DATA and FILE_WRITE_DATA are not yet decided. Both are added to decided and to granted.
  • ACE 2 matches Alice. FILE_WRITE_DATA is already decided. The ACE has no effect.

Result: Alice gets both rights. The deny in ACE 2 came too late.

Now suppose the DACL is in the other order:

  1. ACCESS_DENIED Alice FILE_WRITE_DATA
  2. ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA

The walk:

  • ACE 1 matches Alice. FILE_WRITE_DATA is not yet decided. It is added to decided but not to granted (denied).
  • ACE 2 matches Alice. FILE_READ_DATA is not yet decided — it is added to both. FILE_WRITE_DATA is already decided. The ACE only takes effect on FILE_READ_DATA.

Result: Alice gets FILE_READ_DATA only. The deny in ACE 1 prevented the allow in ACE 2 from granting write.

The two DACLs contain exactly the same ACEs but produce different results. That is what is meant by "order matters".

The canonical ACE order #

Because ordering is load-bearing, Peios defines a canonical order that produces the result a reasonable reader would expect: explicit denies override explicit allows, and explicit ACEs override inherited ACEs.

PositionACE class
1Explicit deny ACEs (set directly on the object).
2Explicit allow ACEs.
3Inherited deny ACEs.
4Inherited allow ACEs.

A DACL written in this order has the following properties:

  • An explicit deny always overrides any allow (explicit or inherited) for the bits it covers.
  • An explicit allow overrides any inherited rule.
  • Inherited denies override inherited allows.

Tools that compose DACLs — a properties dialog, a system utility — are expected to maintain canonical order. The access check itself does not require it; the walk is the same regardless. But a DACL whose ACEs are out of canonical order produces results that may surprise the author. "I added a deny ACE and it had no effect" is almost always a canonical-order problem: the deny was placed after an allow that already decided the bits.

The four classes are distinguished by ACE type plus the INHERITED_ACE flag (0x10). Explicit ACEs have the flag clear; inherited ACEs have it set. The kernel sets the flag automatically when an ACE is created by inheritance.

NULL DACL vs empty DACL #

The two states look identical to a casual reader but mean opposite things.

StateWhat the SD looks likeWhat the access check does
NULL DACLSE_DACL_PRESENT flag is not set in the SD's control flags.Grants every valid right. The walk is skipped.
Empty DACLSE_DACL_PRESENT is set, the DACL exists, but contains zero ACEs.Grants no rights (except owner implicit rights — see Ownership). The walk happens, decides nothing, and the result is an empty granted mask.

Warning

The NULL case is rare and dangerous. It is how you say "this object has no discretionary access control at all — anyone can do anything". You will see it on a few system objects where no DACL would be the right thing. You will never see it on a file you set permissions on.

The empty-DACL case is what you get when you explicitly remove every ACE from a DACL but keep the DACL itself. The intent is "no one has any rights to this object", and the access check honours it.

Tools that print or parse SDs must distinguish the two. Saying "the DACL is empty" without distinguishing between absent-DACL and zero-ACE-DACL is a recipe for misreading an object's policy.

ACE flags that affect the walk #

A few flags change whether or how an ACE participates:

FlagEffect on the walk
INHERIT_ONLY_ACE (0x08)The ACE is skipped entirely during the DACL walk. It exists only to be copied to children at inheritance time.
INHERITED_ACE (0x10)The ACE participates normally. The flag is used to identify inherited ACEs for canonical-ordering purposes, not to change evaluation.
OBJECT_INHERIT_ACE, CONTAINER_INHERIT_ACE, NO_PROPAGATE_INHERIT_ACENo effect on the walk. These flags control inheritance to child objects; the walk on this object ignores them.
SUCCESSFUL_ACCESS_ACE_FLAG, FAILED_ACCESS_ACE_FLAGThese are audit/alarm flags and have no effect on access-control ACEs.

The one to remember is INHERIT_ONLY_ACE. An ACE with that flag set is for inheritance purposes only and does not control access to the object it is on. This is sometimes the source of a confusing "but my ACE is right there in the DACL" — yes, it is, but it is inherit-only, so the walk skips it.

MAXIMUM_ALLOWED: the "what could I get" mode #

Normally the access check stops as soon as every bit in the requested mask is decided. If the caller asked for FILE_READ_DATA | FILE_WRITE_DATA and both bits are decided after three ACEs in a DACL of twenty, the walk stops — there is nothing more to learn.

When the caller sets MAXIMUM_ALLOWED (bit 25) in the requested mask, the access check changes mode. Instead of stopping when the requested bits are decided, it walks the entire DACL and accumulates every bit that any ACE would grant the caller. The returned granted mask is the union of all rights the caller could have gotten.

This is what tools use to display "you have read, write, and delete access to this file" without having to ask for each right separately. It is also what services use when they want to open an object with whatever rights they can get, deferring the actual privilege check to later operations.

Two specific rules about MAXIMUM_ALLOWED:

  • The flag itself is stripped from the requested mask before the walk runs. The actual decided/granted arithmetic is done on the real bits.
  • The granted mask is returned through the same channel as a normal access check; the caller learns what they got back.

MAXIMUM_ALLOWED does not affect first-writer-wins. A deny ACE that decides a bit still decides it. The full-walk mode just means the walk does not exit early.

What the DACL walk does not decide #

A handful of access decisions happen outside the DACL walk:

  • Owner implicit rights are granted before the walk starts, based on the SD's owner field. Covered in Ownership and implicit rights.
  • Privilege-granted rights (SeBackup, SeRestore, SeSecurity, SeTakeOwnership, SeRelabel) are decided before or after the walk, depending on the privilege. The DACL has no say.
  • MIC (mandatory integrity) and PIP (process integrity protection) checks happen before the DACL walk for non-dominant callers, and can pre-decide write or other bits as denied.
  • Restricted token and confinement intersections happen after the DACL walk and can narrow the result.
  • CAAP (central access policies) evaluate alongside the DACL and can further restrict.
  • SACL audit happens after the access decision, based on whatever the DACL walk + the other layers produced.

In other words, the DACL walk is one layer in a longer pipeline. It is the layer most people mean when they talk about "permissions", but it is not the whole story. The full pipeline lives in Access decisions.

Practical tips #

A handful of patterns that come up repeatedly:

  • When a deny ACE seems to have no effect, check whether it appears after an allow ACE that already decided the bit. The fix is to move it earlier (or move the allow later) — i.e. canonicalise.
  • When an inherited ACE seems to override an explicit one, check canonical order. Inherited ACEs should sit after explicit ones; if you find an inherited deny ahead of an explicit allow, the DACL is out of order.
  • When testing access, query the full SD and walk it manually if the answer is surprising. Almost every "why doesn't this work" reduces to either a misordered DACL, a misplaced INHERIT_ONLY_ACE, or a missing flag.
  • When writing a DACL programmatically, build it in canonical order from the start. Adding ACEs at the end and "sorting later" is fragile.

Where to go next #

For the implicit rights the owner gets before the walk starts — and how to suppress them — read Ownership and implicit rights.

For the full pipeline the walk sits inside — MIC, PIP, privileges, narrowing layers — read Access decisions.

To test what a DACL would grant from a shell, read The sd command.

Ownership and implicit rights

Peios / Peios Security Fundamentals / Security Descriptors

Every security descriptor names an owner, a SID associated with the object's access policy. An owner normally receives READ_CONTROL to read the descriptor and WRITE_DAC to change its DACL. Ownership does not automatically grant access to file contents, and it is not a guarantee against locking yourself out.

Before relying on ownership to repair access, inspect the DACL for OWNER RIGHTS ACEs and check the other access layers. For a shell workflow, use Managing file security; this page explains when ownership helps and when it does not.

The implicit rights #

At the start of the normal DACL walk, an owner can receive READ_CONTROL and WRITE_DAC. Those bits are marked granted and decided, so a later DACL ACE cannot reverse them. The grant is limited to valid rights that an earlier policy stage has not already decided; a mandatory denial is not overridden by ownership.

The descriptor specification determines ownership by equality with the caller's user SID or token group SIDs. This ownership relation is distinct from the SE_GROUP_OWNER requirement for assigning a group as a new owner. Allow and deny ACE matching also has its own enabled/deny-only group rules.

The implicit grant covers only descriptor reading and DACL editing. It does not include FILE_READ_DATA, FILE_WRITE_DATA, WRITE_OWNER or ACCESS_SYSTEM_SECURITY. In particular, being the owner does not confer SACL access or permission to transfer ownership.

An owner may be able to repair a DACL that excludes them from data access, but only if WRITE_DAC remains available. The Kernel TRM documents two important limits:

  • A qualifying OWNER RIGHTS ACE suppresses the implicit grant.
  • The grant cannot override rights already decided by mandatory policy. Confinement also performs its secondary walk without an owner-implicit bypass.

OWNER RIGHTS: suppressing the implicit grant #

S-1-3-4, OWNER RIGHTS, lets a policy control the owner's rights through ordinary DACL rules rather than an automatic grant. This can be useful when a service that owns a file should not also be free to rewrite its access policy.

Suppression happens if the DACL contains a non-inherit-only access-control ACE targeting S-1-3-4. The pre-scan tests the ACE's presence, not whether its condition evaluates true. Even a conditional OWNER RIGHTS ACE whose condition later fails suppresses implicit READ_CONTROL | WRITE_DAC.

The owner then receives whatever access the full DACL walk and remaining policy allow, including matching user, group and OWNER RIGHTS ACEs. It is not limited to the OWNER RIGHTS ACE alone. An OWNER RIGHTS allow ACE granting only read-data therefore removes the implicit WRITE_DAC, but another applicable ACE could still grant WRITE_DAC.

OWNER RIGHTS ACEs obey the same first-writer-wins order and allow/deny matching rules as other access-control ACEs. An inherit-only OWNER RIGHTS ACE does not suppress implicit rights on the object it is attached to.

Warning

Changing the owner alone preserves the DACL. If it contains an OWNER RIGHTS ACE that suppresses WRITE_DAC, that suppression applies to the new owner too. Taking ownership is therefore not a reliable way around it. Keep a separately verified, authorized repair path before restricting owner policy access.

Changing ownership #

Changing an owner requires WRITE_OWNER (standard-right bit 19, 0x00080000) on the object. The DACL can grant it, or an applicable privilege can contribute it through the access-check pipeline. Passing that gate and being allowed to name the new owner are separate requirements.

Without SeRestorePrivilege, the new owner must be either:

  • The caller's own user SID, or
  • A group SID on the caller's token carrying SE_GROUP_OWNER.

SeTakeOwnershipPrivilege does not relax this new-owner restriction. With SeRestorePrivilege, the SID constraint is bypassed and any well-formed SID can be assigned; the operation still needs the applicable set-security access path and checks. The kernel validates the change before applying it.

For example, the documented shell form for making the current caller the owner of one file is:

sd owner ./report.txt @self

Use it only when that is the intended ownership policy and the required authority is established. It leaves the DACL in place and does not itself restore file-data or DACL-edit access. Inspect and verify as described in Managing file security.

SeTakeOwnership and recovery limits #

The kernel access-check contract lets SeTakeOwnershipPrivilege supply WRITE_OWNER after the DACL walk when the DACL did not grant it and mandatory policy did not block it. This is a DACL override for one right, not an unconditional grant of access to the object.

It does not bypass:

  • The new-owner SID rule. Use of an arbitrary SID instead requires SeRestorePrivilege.
  • MIC or PIP restrictions. A caller that does not dominate the object's required integrity or trust does not gain an override merely by holding take-ownership.
  • Other narrowing layers, including confinement.
  • OWNER RIGHTS suppression after the owner changes. The unchanged DACL can still withhold WRITE_DAC.

Important

Confirm privilege availability on the deployed build before planning recovery. Older privilege prose says take-ownership and relabel are absent from the published ABI, but the current generated ABI defines their constants. A constant alone does not establish which names authd or a service's privilege configuration accepts, or what the caller's token actually holds. Assigning privileges explains that unresolved distinction; do not assume either universal availability or universal unavailability.

SeRestorePrivilege is also not a generic promise that an arbitrary file handle can repair a descriptor. The set-security contract distinguishes live checks through a path or O_PATH handle from cached rights on an ordinary fd. Verify the supported repair mechanism rather than improvising one from privilege names.

The primary group #

The SD's primary group field is a relic. It exists because the SD format reserves a slot for it and because some legacy code paths consult it, but in Peios its role is almost entirely passive.

The one place the primary group matters is inheritance. When the kernel synthesises a new child SD and an inheritable ACE names CREATOR_GROUP (the well-known SID S-1-3-1), the substitution uses the new child's primary group SID. The primary group is set to the creator's token's primary group at the time of creation.

After inheritance has run, the primary group on the child SD is just a stored value. The access check does not consult it. ACEs do not match it. It exists so the CREATOR_GROUP substitution has a target.

You will rarely need to think about the primary group. The few times it matters are:

  • Programs that read SDs and expect to display the primary group alongside the owner.
  • Legacy POSIX-style applications that map the primary group to a Linux GID for compatibility purposes.
  • Inheritable ACEs that use CREATOR_GROUP to grant access to "the group of whoever created this".

Most of the time, the primary group is set to whatever the creating token had and is then ignored.

Recovery patterns #

SituationWhat to establish before changing policy
You own the file but cannot read its dataWhether you still have READ_CONTROL and WRITE_DAC; inspect OWNER RIGHTS, the DACL and mandatory policy before adding the intended data access.
Files still name a departed user's SIDWhich authorized principal already has policy-edit rights. An ownership change alone does not remove stale ACEs or establish repair authority.
A restore must reproduce original ownersUse an established restore workflow with the required SeRestorePrivilege, live-check access path and descriptor checks; arbitrary owner SIDs are not available to ordinary owner changes.
Policy intentionally limits the ownerPreserve and verify a separate authorized repair route. Neither take-ownership nor a saved SDDL record guarantees rollback.

If no authorized repair route is established, stop and resolve that with the system's policy administrator or documented recovery process. Do not try broad grants, label lowering or raw xattr writes as a substitute for diagnosis.

Where to go next #

  • Managing file security — inspect, choose a bounded change and verify it.
  • The sd command — exact shell syntax.
  • Inheritance — creator placeholders and child descriptors.
  • Privileges — how privilege-held rights differ from ownership.

Inheritance

Peios / Peios Security Fundamentals / Security Descriptors

When a new object is created under a parent — a file in a directory, a registry key under another key — the new object gets its own security descriptor. The kernel computes that SD at creation time by combining inheritable ACEs from the parent's SD with whatever the creator supplied or defaulted. The result is stored on the child as a complete, self-contained SD. From that point on, the access check on the child consults only the child's own SD; the parent is not walked.

This is the eager evaluation model. It has one large consequence: modifying a parent's inheritable ACEs does not propagate to existing children. The children's SDs were computed when they were created and are now their own. Whatever changes you make to the parent affect only objects created after the change. Children that already exist keep the SDs they had.

This page covers the inheritance algorithm — what counts as inheritable, how the merge works, the special placeholders (CREATOR_OWNER, CREATOR_GROUP), and the SE_DACL_PROTECTED and SE_SACL_PROTECTED flags that opt an object out of inheritance entirely.

Eager vs lazy #

Two models are possible:

  • Lazy inheritance: a child has no inherited ACEs of its own; access checks on the child walk the parent SD too. Changes to the parent propagate immediately.
  • Eager inheritance: a child's SD is fully computed at creation; access checks consult only the child. Changes to the parent affect future children only.

Peios uses eager inheritance, like the access-control model it grew out of. The advantages: access checks are local (you only need one SD's worth of data), no walking up directory trees during evaluation, no concurrency issues with "the parent moved between my permission check and the operation". The disadvantage: when you change a parent's permissions, you need a separate sweep to propagate the change to existing children — a tool's job, not the kernel's.

In practice, eager inheritance means: when an administrator changes a directory's DACL and wants the change to apply to existing files, they need a propagation tool. The kernel does not do this automatically and provides no syscall for "re-inherit all children". A propagation tool walks the tree and rewrites child SDs explicitly.

The inheritance flags #

Four ACE flags control how an ACE propagates to children. They are the same four mentioned on the ACLs and ACEs page:

FlagEffect at creation of a child
OBJECT_INHERIT_ACE (OI, 0x01)The ACE inherits to children that are non-containers (files, not directories).
CONTAINER_INHERIT_ACE (CI, 0x02)The ACE inherits to children that are containers (directories).
NO_PROPAGATE_INHERIT_ACE (NP, 0x04)The ACE inherits to direct children but its OI, CI and NP flags are cleared in the inherited copy, so it stops propagating after one level.
INHERIT_ONLY_ACE (IO, 0x08)The ACE is not evaluated on the object it sits on. It exists only to be inherited.

Common combinations:

FlagsMeaning
CI | OIInherit to all descendants, files and directories, recursively. The most common pattern.
CIInherit to descendant directories only.
OIInherit to descendant files only.
CI | OI | IOApply to descendants but not to this object.
CI | OI | NPApply to immediate children only. Grandchildren do not inherit.
(none)This ACE does not inherit. It applies only to this object.

The INHERIT_ONLY_ACE (IO) flag is what lets you say "this rule is for children, not for the parent itself". An IO ACE is invisible to the access check on its own object; the DACL walk skips it. But it is copied to children at creation time.

The merge algorithm #

When a new child is created, the kernel needs to produce the child's SD from three inputs:

  1. The parent's SD. Inheritable ACEs from here will be copied (with adjustments) into the child.
  2. The creator's SD, if one was supplied as a parameter to the create call. This contains explicit ACEs the creator wants on the new object.
  3. The creator's token. Provides defaults (owner, primary group, default DACL) when the creator did not supply specific values.

The merge proceeds as follows:

  1. Compute the child's owner. If the creator supplied an owner in the explicit SD, use it (subject to the same-self-or-owner-group rule from Ownership). Otherwise, use the creator's token's default owner.
  2. Compute the child's primary group. Same rule: explicit SD if present, otherwise the creator's token's default primary group.
  3. Compute the child's DACL. If the creator supplied a DACL, start with its explicit ACEs and append the inheritable ACEs from the parent's DACL — only those whose flags say they should propagate to this kind of child (container or non-container). If the creator supplied no DACL, the child's DACL is the inherited ACEs alone. Only when the parent yields no inheritable ACE at all is the creator's token consulted: the child then gets the token's default_dacl, and if the token has none, a null DACL. The default DACL is a fallback, never a base that inherited ACEs are added to.
  4. Compute the child's SACL. Same algorithm as the DACL, applied to SACL ACEs.

Each inherited ACE goes through a small transformation as it is copied:

  • The INHERITED_ACE flag (0x10) is set in the copy. This marks it as having come from inheritance, not from explicit assignment.
  • If NO_PROPAGATE_INHERIT_ACE was set, the OI, CI and NP flags are cleared in the copy.
  • If the ACE's SID is CREATOR_OWNER (S-1-3-0), the SID is substituted with the new child's owner SID.
  • If the ACE's SID is CREATOR_GROUP (S-1-3-1), the SID is substituted with the new child's primary group SID.

The CREATOR substitution is the mechanism that makes inheritable ACEs portable. A directory's DACL containing CI | OI ACCESS_ALLOWED CREATOR_OWNER GENERIC_ALL means "every file or subdirectory created under here grants full access to whoever owns it". The owner of each child is different; the substitution at creation time fills in the right SID for each one.

Creator SIDs and generic rights can produce two ACEs #

Substitution alone would answer the question for one object and destroy the rule that asked it. A subdirectory would come away holding a concrete SID — its own creator's — with that access over everything beneath it, permanently, and nothing further down would ever resolve against its own creator again.

When a container inherits an ACE that names a creator SID or carries generic rights, and the ACE both applies to the child and propagates further, it receives both halves:

SID and rightsFlags
The rule's answer for this containercreator SID substituted, generic rights mappedINHERITED, no inheritance flags
The rule, carried onwardoriginal SID and generic maskoriginal propagation flags, plus IO and INHERITED

The second is inherit-only, so it grants nothing on the directory itself; it exists to be inherited again further down, where it resolves against whoever creates the next object.

This also applies to registry keys. A generic-rights ACE with an ordinary SID still needs both copies: its effective rights are mapped now, while its original generic mask is preserved for descendants. An ACE with a concrete SID and concrete rights is inherited as one copy.

An ACE that targets files only (OI without CI) passes through a directory as one unresolved, inherit-only copy. Adding NP prevents that copy from reaching files below the directory, so the directory inherits no such ACE.

A non-container receives the resolved ACE alone. A file has no children for the rule to reach, so the copy carries no inheritance flags either.

NO_PROPAGATE_INHERIT_ACE ends the rule as it ends any other: with OI, CI and NP cleared there is nothing to carry, and only the substituted ACE is written.

Note

The inheritance-flag detail matters more than it looks. The substituted ACE keeping OI or CI is exactly how a concrete SID escapes downward — which is why the resolved copy is stripped of them and the unresolved copy is the only thing that propagates.

Sources of an inherited SD, ranked #

Which source wins for each component of the child SD, in order:

ComponentFirst sourceSecond sourceThird source
OwnerCreator's explicit SD (if owner present)Creator's token's default owner(none — must be present)
Primary groupCreator's explicit SD (if group present)Creator's token's default primary group(none — must be present)
DACLCreator's explicit SD (if DACL present), plus the parent's inheritable ACEsThe parent's inheritable ACEs aloneCreator's token's default_dacl, and a NULL DACL if the token has none
SACLCreator's explicit SD (if SACL present), plus the parent's inheritable ACEsThe parent's inheritable ACEs alone(no SACL means no audit policy)

Whenever the parent has inheritable ACEs, they are appended to an explicit DACL or SACL rather than replacing it, so a creator who supplies an explicit DACL gets that DACL plus the inheritable ACEs from the parent. They cannot exclude the parent's ACEs except by setting the protected flag (see below).

The token's default_dacl is a fallback, not a base. It is consulted only when the creator supplied no DACL and inheritance produced nothing, which depends on the filesystem and its explicit subtree policies. The cases that reach it are objects with no parent to inherit from, such as an abstract socket, and children of a container whose descriptor was deliberately written without inheritable ACEs.

Protected ACLs #

There is an opt-out: the SE_DACL_PROTECTED and SE_SACL_PROTECTED flags in the SD's control field.

FlagEffect
SE_DACL_PROTECTED (0x1000)The child's DACL does not accept inheritable ACEs from the parent. Only the explicit ACEs in the creator's SD appear.
SE_SACL_PROTECTED (0x2000)Same, for the SACL.

When a creator sets these flags on the explicit SD they pass to the create call, the resulting child's DACL/SACL is purely what the creator wrote. The parent might have a hundred inheritable ACEs; none of them appear on the child.

Protected ACLs are used when an object should be administered without reference to its container. A service that creates files whose access should be controlled only by the service, not by whoever happens to own the directory it lives in, sets SE_DACL_PROTECTED on each file's explicit SD at creation.

The flags can also be set after creation, via a subsequent kacs_set_sd. Doing so does not retroactively remove previously inherited ACEs — they were copied at creation time and are now the child's own — but it does protect against future re-inheritance if a propagation tool sweeps the tree.

A protected DACL is how a subtree gets its own policy #

The Peios filesystem is a worked example, and a useful one because it is small enough to hold in your head.

dev.peios.fsbase declares protected inheritance scopes for the intended public software trees: /usr, and /lcl/bin, /lcl/sbin, /lcl/libexec, /lcl/lib, /lcl/include, /lcl/share, /lcl/etc and /lcl/conf. SYSTEM and Administrators have full control; Everyone inherits read/execute. Thus newly installed software stays readable to ordinary principals, including on a live image whose lower filesystem synthesises non-inheritable descriptors. This is not a public inheritance policy for all of /var.

PathProtected policy
/homeSYSTEM and Administrators can list it; Everyone can traverse but cannot list. Individual homes receive their own principal-specific descriptor.
/tmp, /var/tmpEveryone can create entries but cannot delete another principal's entries. CREATOR OWNER inheritance makes children private to their creator, SYSTEM and Administrators. /var/tmp retains its persistent semantics.
/var/state/secretsSYSTEM-only, inherited by children. Other consumers require explicit grants.

A protected parent does not retroactively protect an existing child. Runtime credential writers must establish protection before writing; a later rename or a Unix mode such as 0600 does not replace the Peios security descriptor.

Auto-inherit flags #

Two more SD control flags work alongside the protected flags:

FlagEffect
SE_DACL_AUTO_INHERIT_REQ (0x0100)Set by a creator that wants the child to participate in auto-inheritance. Tools that propagate inherited ACEs use this to decide whether to update the child.
SE_DACL_AUTO_INHERITED (0x0400)Set by the kernel on a child whose inherited ACEs have been computed by the auto-inheritance algorithm.

The corresponding SACL pair (SE_SACL_AUTO_INHERIT_REQ, SE_SACL_AUTO_INHERITED) works the same way.

These flags exist so tools can distinguish "this child wants automated propagation" from "this child has its own bespoke DACL the user wrote by hand". A propagation tool sweeping a tree typically updates only children with the auto-inherit flags set, leaving hand-crafted DACLs untouched.

What inheritance does not do #

A handful of things often attributed to inheritance but not done by it:

  • Inheritance does not happen at access-check time. Once a child has its SD, the parent is not consulted on access decisions. Inheritance is purely a creation-time activity.
  • Inheritance does not retroactively update children. A change to a parent's inheritable ACEs is invisible to children created before the change. Propagation requires an explicit sweep.
  • Inheritance does not chain across moves. Moving a file from directory A to directory B does not re-run inheritance against B. The file's SD remains whatever it was. (Copying is a different operation; it creates a new object, which does go through inheritance.)
  • Inheritance does not preserve ACE order. Inheritable ACEs from the parent are appended to the explicit ACEs from the creator. If you want canonical order, the creator needs to either start from the parent's order and intersperse, or run a canonicalisation pass afterwards.

Inheritance and creator-supplied SDs #

A subtle interaction worth knowing: when a creator passes an explicit SD to the create call, the explicit ACEs go before the inherited ones. This means the creator's ACEs sit higher in the DACL than the parent's, and first-writer-wins favours them.

If the parent says "deny X" and the creator says "allow X", the creator's allow wins because it sits first. If the canonical order says the parent's inherited deny should win (because explicit ACEs from the creator should not override the parent's policy), the creator needs to either not supply a competing ACE or arrange the explicit SD to use the protected-ACL flag.

This is one of the reasons creators usually pass no explicit DACL (relying on the default-DACL + parent-inheritance merge) rather than constructing a custom SD. The latter is correct in cases where the creator knows exactly what the child's policy should be; the former is correct in cases where the creator just wants the natural policy of its location.

Where to go next #

For ACEs whose effect is gated by an expression rather than fixed at write time, read Conditional ACEs.

To sweep a tree and propagate inheritable ACEs from a shell, read The sd command.

Private state and data defaults #

The base filesystem gives /var/state, /var/cache, /var/log, /var/crash, /data and /srv protected, inheritable SYSTEM/Administrators full access. Everyone may traverse each container to an explicitly shared child, but may not list it, create entries, or inherit public access into new contents. The reduced initramfs skeleton carries the same policies for its /var branches.

A package or administrator must establish an explicit child scope before a service writes there: grant the service's own SID and any intended readers. Changing a directory descriptor does not recursively rewrite existing children. Public software under /usr and the designated /lcl branches remains readable; /var/tmp keeps its separate creator-private temporary-file policy. The secrets scope stays SYSTEM-only, excluding Administrators unless explicitly granted.

SYSTEM remains trusted across private scopes. A service-specific grant isolates a lower-privilege service from unrelated stores; it does not contain another process that retains SYSTEM identity or equivalent machine-wide privileges.

The credential store under /var/state/lpsd grants the distinct lpsd service account and SYSTEM; it continues to exclude Administrators and other services.

Conditional ACEs

Peios / Peios Security Fundamentals / Security Descriptors

A conditional ACE is an ACE whose effect depends on a boolean expression. Where an ordinary allow ACE says "grant these rights to this principal", a conditional allow ACE says "grant these rights to this principal if this expression is true". The expression can reference attributes of the calling token (the user's department, the device's compliance state), attributes of the object (its classification, its sensitivity), and runtime context supplied by the caller.

Conditional ACEs are how Peios expresses attribute-based access control — ABAC — as opposed to identity-based access control where every rule names a specific principal or group. The conditional model is strictly an extension: every conditional ACE is also keyed to a SID, so it composes naturally with the rest of the DACL. The expression is an extra gate on top.

This page covers the model, the expression language at a conceptual level, and the three-valued logic that makes the model fail safely when an expression cannot be evaluated.

The model #

A conditional ACE is one of the callback ACE types from ACLs and ACEs:

TypeBehaviour
ACCESS_ALLOWED_CALLBACKAllow ACE, gated by expression.
ACCESS_DENIED_CALLBACKDeny ACE, gated by expression.
ACCESS_ALLOWED_CALLBACK_OBJECTAllow ACE, gated by expression, scoped by GUID.
ACCESS_DENIED_CALLBACK_OBJECTDeny ACE, gated by expression, scoped by GUID.
SYSTEM_AUDIT_CALLBACKAudit ACE, gated by expression.
SYSTEM_AUDIT_CALLBACK_OBJECTAudit ACE, gated by expression, scoped by GUID.
SYSTEM_ALARM_CALLBACKAlarm ACE, gated by expression.
SYSTEM_ALARM_CALLBACK_OBJECTAlarm ACE, gated by expression, scoped by GUID.

The body of a callback ACE is the body of the corresponding non-callback ACE — Mask, optional GUIDs, SID — plus a trailing ApplicationData block holding the conditional expression in a binary bytecode format. The bytecode begins with the four-byte magic 0x61 0x72 0x74 0x78 (the ASCII string artx); a callback ACE whose ApplicationData does not start with that magic is treated as having an UNKNOWN expression and falls into the UNKNOWN-handling rules below.

During the DACL walk, the access check evaluates the expression at the moment the ACE is examined. The result is TRUE, FALSE, or UNKNOWN. What happens next depends on the ACE type and the result:

ACE classTRUEFALSEUNKNOWN
Allow callbackACE applies (grants its rights)ACE is skippedACE is skipped
Deny callbackACE applies (denies its rights)ACE is skippedACE applies (denies)
Audit callbackEmit eventSkipEmit event
Alarm callbackConfigure continuous-audit maskSkipConfigure mask

The asymmetry between allows and denies is deliberate. An UNKNOWN result on an allow is skipped (the allow does not get to grant access on incomplete information). The same UNKNOWN on a deny does apply (the deny errs on the side of denying). And UNKNOWN on audit fires the event (the audit errs on the side of recording).

The principle is that UNKNOWN never accidentally grants. It can only fail safely closed, or fail safely open for auditing.

The four attribute namespaces #

Conditional expressions reference attributes through four namespaces, each backed by a different source:

NamespaceSourceSet by
@User.<name>The caller's token's user_claims field.authd at token creation, from the user's directory object.
@Device.<name>The caller's token's device_claims field.authd at token creation, from the machine's directory object.
@Resource.<name>A resource attribute ACE in the object's SACL.The object's administrator, via kacs_set_sd.
@Local.<name>A per-access-check parameter supplied by the caller.Whatever code is making the access check.

So an expression like @User.Department == "Engineering" refers to a claim on the caller's token. @Resource.Classification == "Public" refers to an attribute on the object being accessed. @Local.Time > 9 refers to context the calling code passed in. The four namespaces let an ACE reference the caller, the object, and the runtime situation in one expression.

Two of the namespaces are token-side (caller properties), one is object-side (resource properties), and one is per-call context. They cover the three "where can an attribute come from?" possibilities cleanly.

User and device claims are covered on Claims on a token. Resource attributes are covered on Resource attributes. Local claims are passed by the caller; the API surface lives in the Kernel ABI reference.

The expression language #

The expression bytecode is postfix (reverse Polish notation), evaluated by a stack machine. Literal tokens push values onto the stack; operator tokens pop operands, do their work, and push a result. The final stack must contain exactly one tri-state value, which is the expression's result.

On the wire an expression is bytecode; the textual form, which sd --if accepts, looks like:

@User.Department == "Engineering"

@User.Department == "Engineering" && @Resource.Classification != "Secret"

Member_of({SID-of-AdminGroup})

@Device.Compliance == "Compliant" && @Local.Time >= 9 && @Local.Time <= 17

Exists(@User.ProjectAccess) && @User.ProjectAccess Any_of {"alpha", "beta"}

The operators fall into four families:

FamilyOperators
Relational==, !=, <, <=, >, >=
Set / membershipContains, Exists, Any_of, Member_of, Device_Member_of, Member_of_Any, Device_Member_of_Any, plus Not_* variants
Logical&&, `
Attribute references@User., @Device., @Resource., @Local.

The membership operators reference SID sets — Member_of({S-1-5-21-...}) evaluates TRUE if the caller's token has that SID in its groups. Any_of evaluates TRUE if any value in a multi-valued attribute appears in a literal set. Contains and Exists test for presence rather than equality.

The full operator catalog with bytecode values lives in Wire formats reference.

Value types and comparisons #

Expressions operate on six value types: INT64, UINT64, STRING, SID, BOOLEAN, OCTET. Comparisons follow rules you would expect:

  • Numeric comparisons work across INT64 and UINT64 with sign-aware promotion: a negative INT64 is always less than any UINT64.
  • String comparisons are case-insensitive by default. A claim or attribute can opt into case sensitivity via the CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE flag (see Claims on a token).
  • SID comparisons are byte-exact, same as everywhere else.
  • Octet comparisons are byte-exact.

Boolean coercion of other types happens when an expression treats a non-boolean as a boolean (rarely, but it can happen): a nonzero integer is TRUE, zero is FALSE, NULL is UNKNOWN, SIDs/octets/composites are UNKNOWN. Compositions of types that do not coerce cleanly produce UNKNOWN.

The "missing attribute" case #

The most common UNKNOWN producer is a reference to an attribute the token or object does not carry. @User.ClearanceLevel on a token that has no ClearanceLevel claim evaluates to UNKNOWN — not to NULL, not to FALSE, not to an empty string. UNKNOWN.

This is why the three-valued logic exists. Schemas evolve; not every user object has every defined attribute; the access check needs to behave well when an attribute is missing. The rule "UNKNOWN skips an allow, applies a deny, fires an audit" means a missing attribute can never accidentally upgrade access — at worst, the gated allow does nothing.

Three-valued logic #

The truth tables for &&, ||, and ! over {TRUE, FALSE, UNKNOWN}:

AND (&&) #

TRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

OR (||) #

TRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

NOT (!) #

InputOutput
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

The pattern: a definite value (TRUE or FALSE) combined with UNKNOWN can sometimes determine the result (FALSE && UNKNOWN is FALSE; TRUE || UNKNOWN is TRUE) and sometimes cannot (TRUE && UNKNOWN is UNKNOWN; FALSE || UNKNOWN is UNKNOWN). NOT preserves UNKNOWN.

This means you cannot prove an UNKNOWN attribute is false by negating it. ! Exists(@User.ClearanceLevel) evaluates to UNKNOWN if the attribute is missing, because Exists returns UNKNOWN in that case and the NOT preserves it. The right way to check "the user does not have this attribute" is the Not_Exists operator, which returns TRUE for a missing attribute and FALSE for a present one.

A worked example #

Consider an SD on a sensitive file with this DACL:

  1. ACCESS_DENIED_CALLBACK Everyone (when @Resource.Classification == "TopSecret" && Not_Member_of({Cleared-SID})) — deny everyone when the file is TopSecret and they are not in the Cleared group
  2. ACCESS_ALLOWED Authenticated_Users GENERIC_READ — allow read to authenticated users
  3. ACCESS_ALLOWED Authenticated_Users GENERIC_WRITE — allow write to authenticated users

The resource attribute is set on the file's SACL: Classification = "TopSecret".

User Alice, who is not in the Cleared group, tries to read.

  • ACE 1: matches Alice (Everyone matches all tokens). Expression: Classification == "TopSecret" is TRUE (the file's resource attribute matches), Not_Member_of({Cleared-SID}) is TRUE (Alice is not in the group). Both TRUE, so AND is TRUE. The deny applies. FILE_READ_DATA and friends are decided and not granted.
  • ACE 2: also matches Alice, but FILE_READ_DATA etc. are already decided. The allow has no effect.
  • ACE 3: same.

Alice gets nothing. The conditional deny did its job.

Now user Bob, who is in the Cleared group:

  • ACE 1: matches Bob. Expression: Classification == "TopSecret" is TRUE, Not_Member_of({Cleared-SID}) is FALSE. TRUE AND FALSE is FALSE. The deny does not apply.
  • ACE 2: matches Bob. FILE_READ_DATA not yet decided. Granted.
  • ACE 3: matches Bob. FILE_WRITE_DATA not yet decided. Granted.

Bob gets read and write. The conditional deny knew not to apply to him.

Now suppose the file's classification attribute is missing (administrative mistake):

  • ACE 1: expression Classification == "TopSecret" is UNKNOWN (the attribute does not exist). AND of UNKNOWN with anything: depends on the other operand. Even if Not_Member_of is TRUE, UNKNOWN AND TRUE is UNKNOWN. The deny ACE has UNKNOWN, which applies (denies err on the side of denying). FILE_READ_DATA is decided as denied.
  • ACE 2, 3: same fate; already decided.

Both Alice and Bob get nothing. The missing attribute caused the deny to fire, not the allow to grant. Fail safe.

When conditional ACEs are the right tool #

Conditional ACEs are the right tool when:

  • The set of principals who should have access varies based on attributes that change more often than the SD does. A "members of the Engineering department who joined after 2020" rule is one expression; encoding it as a group membership would require maintaining the group as people join.
  • Access depends on something that is not the principal's identity at all — the resource's classification, the time of day, the integrity of the request.
  • The same DACL needs to express many cases without duplication. One conditional ACE covers what would otherwise be N parallel ACEs.

They are the wrong tool when:

  • The condition reduces to "is this user in this group?". Use a group SID and a plain ACE; it is simpler and faster.
  • The condition is a static identity check at a fixed point in time. Use a SID and a plain ACE.
  • The expression depends on state that changes faster than you can update the SD. Conditional ACEs evaluate at access-check time using the data on the token and object now; if the relevant data is stale, the result is stale.

Most DACLs in practice use no conditional ACEs at all. They appear in the SDs of objects whose access policy is genuinely attribute-driven — central access policies, data-classification-driven sharing, time-bound access. For everything else, plain ACEs are clearer.

Where to go next #

For the object-side attributes that @Resource.* references resolve against, read Resource attributes.

For the token-side attributes behind @User.* and @Device.*, read Claims on a token.

Resource attributes

Peios / Peios Security Fundamentals / Security Descriptors

A resource attribute is a typed key-value attribute attached to an object — much like a claim on a token, but applied to the resource rather than the principal. A file might have Classification = "Internal". A registry key might have Department = "Finance". A process or token might have a Compliance = TRUE attribute. These attributes do not grant or deny access by themselves; they are inputs that conditional ACEs can reference as @Resource.<name>.

Resource attributes are stored in the SACL using the SYSTEM_RESOURCE_ATTRIBUTE_ACE type. They share the binary format used by token claims: same value types, same flags, same case-sensitivity rules. The key difference is location — claims live on a token, resource attributes live on an object's SD.

What a resource attribute looks like #

Each resource attribute carries:

  • A name — a string, conventionally Namespace.Attribute (Classification, Department, Project.Code).
  • A value — typed. One of INT64, UINT64, STRING, SID, BOOLEAN, OCTET. Single or multi-valued, but homogeneous (no mixed-type arrays).
  • A set of flags controlling how the attribute participates in access checks.

The flags are the same set used for claims:

FlagEffect
DISABLED (0x0010)The attribute is invisible to all conditional expressions. Effectively does not exist.
USE_FOR_DENY_ONLY (0x0004)The attribute is invisible to conditions on allow ACEs, but visible to conditions on deny ACEs. Lets an administrator quickly demote an attribute.
MANDATORY (0x0020)The attribute cannot be removed or modified without SeTcbPrivilege. Used for system-maintained attributes.
CASE_SENSITIVE (0x0002)String and octet comparisons against this attribute are case-sensitive. Default is case-insensitive.

These are the same flags described on Claims on a token, applied here to attributes on the resource side.

Storage in the SACL #

A resource attribute lives in a SYSTEM_RESOURCE_ATTRIBUTE_ACE (type 0x12). The ACE body uses a single-SID layout where the SID is always Everyone (S-1-1-0); the SID slot is structural, not semantic — resource attribute ACEs do not match against tokens like access-control ACEs do. The real payload is in the ApplicationData portion, which carries one claim entry in the same format as a token claim.

ACE fieldResource attribute value
AceTypeSYSTEM_RESOURCE_ATTRIBUTE_ACE (0x12)
MaskReserved. Not used in access decisions.
SIDAlways S-1-1-0 (Everyone).
ApplicationDataOne claim entry: name, type, flags, value(s).

A single SACL can hold many resource attribute ACEs — one per attribute. The full ABI layout of the claim entry is in the Wire formats reference.

Two structural notes:

  • The first non-inherit-only ACE for each name wins. If two SYSTEM_RESOURCE_ATTRIBUTE_ACE ACEs in the SACL define the same attribute name, only the first one is used. Duplicates after the first are ignored.
  • Resource attributes are located by type-scan, not position. Unlike DACL ACEs, the order of resource attribute ACEs within a SACL is not load-bearing beyond the first-wins tiebreak. The access check finds them by scanning for the type, not by walking.

How resource attributes are used #

When the access check evaluates a conditional ACE that references @Resource.<name>, the lookup runs:

  1. Scan the object's SACL for a SYSTEM_RESOURCE_ATTRIBUTE_ACE whose attribute name matches <name>.
  2. If found, read the value(s) and use them in the expression.
  3. If not found, the reference produces UNKNOWN. The expression continues with that value, subject to three-valued logic.

The "not found" case is what makes the design tolerant of schema evolution. A conditional ACE that references @Resource.Classification works whether or not the specific object has a Classification attribute — if it has one, the expression uses it; if not, the expression evaluates UNKNOWN at that subexpression, and the three-valued logic handles the rest.

Resource attributes do not need to be referenced to exist. An object can carry attributes that no current ACE references — perhaps the schema is being prepared for future use, or the attributes are read by separate tooling that scans the SACL directly. The access check ignores resource attribute ACEs that no conditional expression names; they are inert from its perspective.

Setting and modifying resource attributes #

Resource attributes are part of the SACL. Modifying them goes through kacs_set_sd with SACL_SECURITY_INFORMATION in the security-information mask, which requires ACCESS_SYSTEM_SECURITY on the object. In practice this means a caller holding SeSecurityPrivilege — the same privilege that gates other SACL modifications.

One additional rule: a resource attribute marked MANDATORY cannot be removed or modified except by a caller holding SeTcbPrivilege. This is the lever for system-maintained attributes — values the kernel or a TCB component sets that ordinary administrators should not be able to overwrite.

Replacing the SACL with a new one that omits a MANDATORY attribute is also rejected; the kernel checks the diff, not just the new value.

Namespacing #

Resource attribute names are arbitrary strings. By convention, attributes use a namespace prefix:

  • Project.Code — the project this resource belongs to.
  • Department.Name — the owning department.
  • Compliance.Status — a compliance classification.

The convention is just a convention. The access check compares names as strings; Project.Code and ProjectCode are different attributes. Establishing a stable naming scheme is the administrator's job.

In the conditional expression, the reference is @Resource.<name> regardless of whether the name itself contains dots:

  • @Resource.Project.Code references an attribute named exactly Project.Code.
  • @Resource.Department references an attribute named exactly Department.

The expression parser treats the dot as part of the attribute name, not as a structural separator.

Resource attributes vs claims vs local context #

Resource attributes sit alongside two other attribute sources that conditional expressions can reference. The three together give the conditional model its expressiveness:

SourceLives onReferenced asSet by
User claimsToken@User.<name>authd, from the user directory object
Device claimsToken@Device.<name>authd, from the machine directory object
Resource attributesObject SACL@Resource.<name>Object administrator, via kacs_set_sd
Local claimsPer AccessCheck call@Local.<name>The caller, passed as a parameter

The four namespaces let a single conditional expression weave together "who is the caller", "what machine are they on", "what is the object's property", and "what is the runtime context". The most common pattern uses two of them — caller and resource — for classification-based access. The remaining two appear when machine identity matters or when the calling code wants to supply runtime context.

What resource attributes are not #

A few things resource attributes look like at a glance but are not:

  • Resource attributes are not part of the data. They are policy metadata, stored in the SACL, separate from the object's content. Reading the object's data does not give you access to the attribute, and vice versa.
  • Resource attributes are not ACEs themselves. They live in SYSTEM_RESOURCE_ATTRIBUTE_ACE entries, but the ACE form is structural — the attribute does not grant or deny anything. The access check skips it during the DACL walk and during the SACL audit walk.
  • Resource attributes do not propagate via inheritance. They are not inherited from a parent object to a child. A child inherits the parent's inheritable DACL/SACL access-control ACEs; resource attribute ACEs do not have inheritance semantics. If you want the same attribute on every file in a directory, you set it on each one (or use tooling that walks the tree).
  • Resource attributes are not searchable through the access check. The access check uses attributes for evaluating expressions, not for filtering objects. There is no "list all objects where @Resource.Classification == 'Public'" API. That would be a job for a separate indexing layer.

Where to go next #

For the SACL these attributes live in — and everything else stored alongside them — read The SACL.

For the principal-side counterpart to resource attributes, read Claims on a token.

The SACL

Peios / Peios Security Fundamentals / Security Descriptors

The SACL — System Access Control List — is the half of a security descriptor that holds system-level policy. Where the DACL decides "who can do what to this object", the SACL covers everything else the kernel needs to know about the object for security purposes: which access attempts to audit, what integrity level the object has, what PIP trust label it carries, which central access policies apply to it, and what attributes it exposes for conditional evaluation.

The SACL is not part of the access-grant decision in the way the DACL is. Most of its entries do not grant or deny anything; they configure policy that runs alongside or after the DACL walk. The two access-related entries — SYSTEM_AUDIT and SYSTEM_ALARM — fire events but do not gate access.

This page covers each type of SACL entry, how the access check consumes it, and the special status of SACL modification.

What the SACL holds #

A SACL is an ACL — same on-wire structure as a DACL, the same AceCount/AclSize header and the same ACE format. But the ACE types it holds are different. While a DACL is dominated by access-control types (ACCESS_ALLOWED, ACCESS_DENIED, callbacks), a SACL holds the system-policy types:

ACE typePurpose
SYSTEM_AUDITFire an audit event when access matches. One-shot at handle creation.
SYSTEM_AUDIT_OBJECTSame, GUID-scoped.
SYSTEM_AUDIT_CALLBACKSame, with a conditional expression gating when the audit fires.
SYSTEM_AUDIT_CALLBACK_OBJECTSame, GUID-scoped and conditional.
SYSTEM_ALARMConfigure per-operation continuous audit on an open handle.
SYSTEM_ALARM_OBJECTSame, GUID-scoped.
SYSTEM_ALARM_CALLBACKSame, conditional.
SYSTEM_ALARM_CALLBACK_OBJECTSame, GUID-scoped and conditional.
SYSTEM_MANDATORY_LABELThe object's mandatory integrity label and policy bits (MIC).
SYSTEM_RESOURCE_ATTRIBUTEA typed key-value attribute on the object (covered separately under Resource attributes).
SYSTEM_SCOPED_POLICY_IDA reference to a central access policy (CAAP).
SYSTEM_PROCESS_TRUST_LABELThe object's PIP trust label and the explicit allowed mask for non-dominant callers.

The numeric type values are in the canonical catalog, ACE types and flags.

A SACL can mix these in any combination. Most SACLs in practice are small — a few audit ACEs, perhaps a mandatory label, sometimes a resource attribute or two. SACLs with every type populated are rare and exist only on the most highly-protected objects.

Audit ACEs: one event per access attempt #

A SYSTEM_AUDIT ACE specifies: when this principal attempts these rights on the object, emit an audit event if the access succeeded (SUCCESSFUL_ACCESS_ACE_FLAG set) or if it failed (FAILED_ACCESS_ACE_FLAG set) or both.

The event fires at the moment the access check completes. One event per matching audit ACE per access attempt. A handle opened with FILE_READ_DATA | FILE_WRITE_DATA against an SD with an audit ACE matching both rights produces one event covering both. A separate handle later, with separate access checks, produces separate events.

Audit ACEs match like other ACEs: the SID in the ACE is compared against the caller's identity. Audit ACE matching uses deny polarity — it considers the broadest identity view, including deny-only groups, so that any identity the caller has that should produce an audit event will produce one even if it would not match an allow ACE.

The callback variants (SYSTEM_AUDIT_CALLBACK, etc.) add a conditional expression. If the expression evaluates TRUE or UNKNOWN, the event fires; if FALSE, it does not. This is why conditional audit fails open — recording something is the safe choice when uncertain.

The full audit model is covered in Auditing.

Alarm ACEs: an event per operation #

SYSTEM_ALARM configures continuous auditing, not one-shot. The ACE's mask is recorded on the open handle as a continuous audit mask. From then on, every operation performed on the handle has its required-access mask compared against the continuous mask; if any bit overlaps, an event fires.

The difference between AUDIT and ALARM is in granularity. AUDIT records "this principal opened this object with these rights". ALARM records "this principal opened this object with these rights, and then performed this specific operation, then this one, then this one". The trade-off is event volume — alarm ACEs on a hot path can produce many events; audit ACEs produce one per handle.

ALARM is the right tool for objects where post-open behaviour is what matters: an audited credential store, a sensitive log file, a security-critical configuration. For most objects, AUDIT is enough.

SYSTEM_MANDATORY_LABEL: the integrity floor #

A SYSTEM_MANDATORY_LABEL ACE sets the object's mandatory integrity level. The ACE has two parts:

  • A SID from the S-1-16-* integrity namespace, whose single sub-authority is the numeric level (S-1-16-8192 for Medium, S-1-16-12288 for High, and so on — any single-sub-authority S-1-16 value is a valid level, compared numerically).
  • A mask containing the MIC policy bits: SYSTEM_MANDATORY_LABEL_NO_READ_UP (0x01), SYSTEM_MANDATORY_LABEL_NO_WRITE_UP (0x02), SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP (0x04).

When the access check evaluates the SD, it scans for a SYSTEM_MANDATORY_LABEL ACE. The first non-inherit-only one is the object's effective label. If none is present, the object's effective integrity is Medium with NO_WRITE_UP (the default).

A caller whose effective token's integrity level is below the object's label cannot perform the right categories blocked by the policy bits. The check runs before the DACL walk. A non-dominant caller's deny is decided before any allow ACE in the DACL has a chance to grant the same right.

The label is independent of identity. Two users at different integrity levels with identical SIDs see the same DACL but different effective access, because the label-vs-token-integrity comparison happens first.

MIC is covered in detail in Access decisions.

SYSTEM_PROCESS_TRUST_LABEL: the PIP gate #

A SYSTEM_PROCESS_TRUST_LABEL ACE opts the object in to process integrity protection (PIP). The ACE has:

  • A SID of the form S-1-19-T-L where T is the PIP type and L is the trust level.
  • A mask specifying the rights that non-dominant callers are explicitly allowed.

PIP enforcement is two-step. First, the access check determines whether the caller's PSB dominates the object's trust label — that is, the caller's pip_type is at least the ACE's type AND the caller's pip_trust is at least the ACE's trust level. If the caller dominates, the trust label imposes no restrictions. If the caller does not dominate, only the rights in the ACE's mask are permitted, and privilege-granted rights are revoked (which is what makes PIP different from MIC).

Objects without a PIP trust label ACE are unprotected — PIP is opt-in. The access check does not impose PIP rules unless the SACL specifically asks for them.

PIP is covered in Process integrity protection.

SYSTEM_SCOPED_POLICY_ID: central access policies #

A SYSTEM_SCOPED_POLICY_ID ACE references a centrally-defined access policy by SID. The policy itself is distributed by authd (from loregd on standalone machines, from the machine's directory source on domain-joined ones) and held in a kernel cache. When the access check sees this ACE, it looks up the referenced policy and evaluates its rules in addition to the object's own DACL — intersecting the result, never widening.

Multiple SYSTEM_SCOPED_POLICY_ID ACEs in one SACL apply multiple policies. The result is the intersection of all of them with the object's DACL.

The mechanism — applies-to expressions, staged vs effective rules, the recovery policy for when a referenced policy is missing — lives in Central access policies. For this page, what matters is that the entry exists, sits in the SACL, and is looked up by SID at access time.

SYSTEM_RESOURCE_ATTRIBUTE: typed properties on the object #

SYSTEM_RESOURCE_ATTRIBUTE carries a typed key-value attribute on the object — referenceable as @Resource.<name> in conditional expressions on this or any other SD. The structural role is described above; the full story is in Resource attributes.

How the SACL is consumed #

The access check consults the SACL twice, at different points:

  1. Before the DACL walk, the check scans the SACL for the MIC, PIP, and scoped-policy ACEs. MIC and PIP pre-decide certain bits as denied for non-dominant callers; scoped policies are noted for later evaluation. Resource attribute ACEs are scanned and indexed so that conditional expressions in the DACL can reference them.
  2. After the DACL walk, the check scans the SACL again for SYSTEM_AUDIT and SYSTEM_ALARM ACEs and decides which events to fire. The MIC, PIP, and scoped-policy ACEs are not consulted again here — their job is done.

This two-pass shape is why the SACL is "structural" rather than "evaluated": its job is to set policy for the DACL walk and to record audit about the DACL walk's outcome, not to participate in the walk itself.

Modifying the SACL #

The DACL is gated by WRITE_DAC, which the owner has implicitly. The SACL is gated by ACCESS_SYSTEM_SECURITY, which the owner does not have implicitly. To modify a SACL, a caller needs:

  • Either SeSecurityPrivilege (which grants ACCESS_SYSTEM_SECURITY on any object), or
  • SeRestorePrivilege (which grants it for specific kacs_set_sd use cases — backup/restore tooling).

WRITE_DAC does not grant ACCESS_SYSTEM_SECURITY. An owner who can rewrite a DACL freely cannot touch the SACL. This is deliberate: the SACL holds audit policy and system-level constraints that the object's owner should not be able to unilaterally remove. An audited object's audit policy is the administrator's, not the user's.

Modifying a SACL that contains a MANDATORY-flagged resource attribute is further restricted: removing or changing a mandatory attribute requires SeTcbPrivilege (covered under Resource attributes).

The LABEL_SECURITY_INFORMATION shortcut #

The kacs_set_sd syscall takes a security_information bitmask saying which SD components to update. Two of the bits are relevant here:

FlagEffect
SACL_SECURITY_INFORMATION (0x08)Update the SACL (all of it). Requires ACCESS_SYSTEM_SECURITY.
LABEL_SECURITY_INFORMATION (0x10)Update only the mandatory integrity label ACE within the SACL.

LABEL_SECURITY_INFORMATION is the right tool when you want to change an object's integrity level without touching its audit policy. Setting an integrity label downward (to a level at or below the caller's own integrity level) is allowed; setting it above requires SeRelabelPrivilege.

The two flags are mutually exclusive in a single kacs_set_sd call. You either replace the entire SACL or update only the label, not both at once.

What the SACL is not #

A few clarifications:

  • The SACL is not for "extra security". It is the kernel's place for system-level policy. Putting more entries in the SACL does not make an object more secure; it just records more policy.
  • The SACL is not where the DACL goes if you forget the DACL. The two lists are structurally distinct in the SD format; an SD with a missing DACL but a populated SACL has a NULL DACL (grant all access) regardless of what the SACL says.
  • The SACL is not visible to ordinary users. Reading the SACL — kacs_get_sd with SACL_SECURITY_INFORMATION — also requires ACCESS_SYSTEM_SECURITY. An owner can read their object's DACL freely but cannot read its SACL without the same privilege required to modify it.

Where to go next #

For the events audit and alarm ACEs produce, and where they go, read Auditing.

For how the SACL's labels and policies fit into the full check pipeline, read Access decisions.

To read and edit a SACL from a shell, read The sd command.

SDDL

Peios / Peios Security Fundamentals / Security Descriptors

A security descriptor is a binary structure, but almost nobody writes one by hand. SDDL — the Security Descriptor Definition Language — is its text form: a single line that names an owner, a group, and the two access control lists.

You meet it wherever a descriptor has to be written down rather than computed:

  • sd set and sd show --sddl
  • mount's --synth-sddl, which supplies the template descriptor for a filesystem that has no native descriptor storage
  • registry seeds, which carry descriptors for the keys they create
  • peinit service definitions, whose ControlSecurity and ServiceSecurity values are descriptors

This article is the canonical list of the codes Peios accepts. The concepts behind them — what a DACL is, how inheritance propagates, what the SACL carries — have their own articles, linked at the end.

The shape of a descriptor string #

Four optional sections, each introduced by a letter and a colon:

O:<sid>G:<sid>D:<acl>S:<acl>
SectionContents
O:The owner SID.
G:The primary group SID.
D:The discretionary ACL — who may do what.
S:The system ACL — auditing, the integrity label, policy references.

A section you omit is absent from the descriptor rather than empty, and the formatter emits the sections in the order above regardless of the order you wrote them. A section may appear only once; a repeat is an error rather than an override.

A bare D: is an empty DACL: it contains no grants. A NULL DACL is written D:NO_ACCESS_CONTROL and imposes no discretionary access restrictions. Omitting D: has the same binary meaning as D:NO_ACCESS_CONTROL: the SE_DACL_PRESENT bit is clear and the DACL offset is zero. A bare D: sets PRESENT and points to a real ACL containing zero ACEs. There are two states, not three.

NO_ACCESS_CONTROL may accompany ACL flags, such as D:PAINO_ACCESS_CONTROL, but cannot accompany ACEs. The same applies to S:: S:NO_ACCESS_CONTROL is a descriptor with no SACL.

The formatter leaves out a PRESENT-clear ACL, just as you can, unless it carries P, AR or AI flags. Those have nowhere else to go, so it writes them with the marker: D:PNO_ACCESS_CONTROL. A descriptor with nothing at all to show formats as D:NO_ACCESS_CONTROL, because SDDL has no empty string. The C, Rust and Go SDKs format identically, and their output always parses back to the same binary descriptor.

So a missing D: in a tool's output means a NULL DACL. Tools that read only some sections of a descriptor, such as reg sd --owner, also leave out the sections they did not read. There, a missing section means only that it was not requested.

ACL flags #

An ACL body may open with one or more flags, in any order, before the first (:

FlagMeaning
PProtected. The ACL does not inherit from its parent.
AIAuto-inherited. The ACL was produced by inheritance propagation.
ARAuto-inherit required. Requests that propagation be applied.

So D:PAI(A;;GA;;;SY) is a protected, auto-inherited DACL with one ACE.

An ACE #

Each ACE is a parenthesised list of six semicolon-separated fields, or seven when the ACE carries a payload:

(type;flags;rights;object_guid;inherit_object_guid;account_sid)
(type;flags;rights;object_guid;inherit_object_guid;account_sid;payload)

The two GUID fields apply only to object ACEs and are left empty otherwise — supplying one on a non-object ACE is an error rather than a value that is quietly discarded. The seventh field carries the condition expression on a callback ACE, or the claim on a resource-attribute ACE.

Reading a real one:

O:SYG:SYD:(A;OICI;GA;;;SY)

Owner SYSTEM, group SYSTEM, and a DACL with one ACE: allow (A), inherited by both containers and objects (OI CI), all access (GA), no object GUIDs, to SYSTEM (SY). That is the descriptor Peios seeds /run with at boot.

ACE types #

CodeACE type
AAccess allowed
DAccess denied
AUSystem audit
OAAccess allowed, object
ODAccess denied, object
OUSystem audit, object
XAAccess allowed, callback — carries a condition
XDAccess denied, callback — carries a condition
XUSystem audit, callback — carries a condition
ZAAccess allowed, callback object
ZDAccess denied, callback object
ZUSystem audit, callback object
ALSystem alarm — continuous auditing
OLSystem alarm, object
XLSystem alarm, callback — carries a condition
ZLSystem alarm, callback object
MLMandatory label — the integrity level
TLProcess trust label
RAResource attribute — carries a claim
SPScoped policy identifier
FLAccess filter — carries a condition

The callback types are how a conditional ACE is spelled: XA is an allow ACE with an expression in its seventh field.

XL and ZL are Peios' own. Windows names no callback alarm, so a string holding one does not read on Windows.

The formatter fails on any other ACE type, such as a compound ACE (0x04) or an unallocated value. It does not leave the entry out, because a string without it would describe a descriptor other than the one held.

ACE flags #

CodeMeaning
CIContainer inherit — child containers receive it.
OIObject inherit — child objects receive it.
NPNo propagate — children receive it, grandchildren do not.
IOInherit only — it does not apply to the object carrying it.
IDInherited — this ACE arrived by propagation.
SAAudit successful access.
FAAudit failed access.

SA and FA are meaningful only on an audit ACE in the SACL.

Access rights #

Generic rights, mapped to concrete rights by the object class:

CodeRight
GAGeneric all
GRGeneric read
GWGeneric write
GXGeneric execute

Standard rights, which mean the same thing on every object class:

CodeRight
SDDelete
RCRead control — read the descriptor
WDWrite DACL
WOWrite owner

File composites:

CodeMaskRight
FA0x001F01FFAll file access
FR0x00120089File read
FW0x00120116File write
FX0x001200A0File execute

Registry-key composites:

CodeMaskRight
KA0x000F003FAll key access
KR0x00020019Key read
KW0x00020006Key write
KX0x00020019Key execute

Directory-object rights, for object ACEs:

CodeRight
CCCreate child
DCDelete child
LCList children
SWSelf write
RPRead property
WPWrite property
DTDelete tree
LOList object
CRControl access

Mandatory-label policy bits, valid on an ML ACE:

CodePolicy
NWNo write up
NRNo read up
NXNo execute up

An all-ASCII-digit rights field is an unsigned decimal mask. For example, 1179817 means 0x001200a9, and 0 means no rights. Leading zeroes are accepted as decimal, not octal. The maximum is 4294967295 (0xffffffff); larger values, signs and non-ASCII digits are rejected. Decimal masks must occupy the whole rights field and cannot be appended to mnemonic codes. This grammar applies to libpeios, legacy libp-go, and the independent peios-go SDK.

A rights field may also be a hexadecimal mask, written 0x followed by the value. It consumes the rest of the field, so it can only come last — but it may follow letter codes rather than replacing them. FRFX0x6 grants file read, file execute, and the two bits 0x6 names, which is how you reach a right that has no two-letter code without giving up the ones that do:

(A;;FRFX0x6;;;WD)

FILE_ADD_FILE and FILE_ADD_SUBDIRECTORY are the usual reason to need it. Writing the whole mask as 0x1200af is equally correct and tells a reader nothing.

SID codes #

An account field takes either a full S-1-… SID or one of these aliases.

Principals and logon types:

CodeSIDPrincipal
WDS-1-1-0Everyone
ANS-1-5-7Anonymous
IUS-1-5-4Interactive
NUS-1-5-2Network logon
SUS-1-5-6Service
RCS-1-5-12Restricted code
WRS-1-5-33Write-restricted code
PSS-1-5-10Principal self
EDS-1-5-9Enterprise domain controllers
AUS-1-5-11Authenticated Users
SYS-1-5-18Local System
LSS-1-5-19Local Service
NSS-1-5-20Network Service
ACS-1-15-2-1All application packages

Inheritance placeholders:

CodeSIDPrincipal
COS-1-3-0Creator Owner
CGS-1-3-1Creator Group
OWS-1-3-4Owner rights

Integrity levels:

CodeSIDPrincipal
LWS-1-16-4096Low integrity
MES-1-16-8192Medium integrity
MPS-1-16-8448Medium-plus integrity
HIS-1-16-12288High integrity
SIS-1-16-16384System integrity

The BUILTIN groups, S-1-5-32-*. These are absolute despite the name: the RIDs are fixed, not allocated per machine.

CodeSIDPrincipal
BAS-1-5-32-544Administrators
BUS-1-5-32-545Users
BGS-1-5-32-546Guests
PUS-1-5-32-547Power users
AOS-1-5-32-548Account operators
SOS-1-5-32-549Server operators
POS-1-5-32-550Printer operators
BOS-1-5-32-551Backup operators
RES-1-5-32-552Replicator
RUS-1-5-32-554Pre-Windows 2000 compatible access
RDS-1-5-32-555Remote desktop users
NOS-1-5-32-556Network configuration operators
MUS-1-5-32-558Performance monitor users
LUS-1-5-32-559Performance log users
ISS-1-5-32-568IIS users
CYS-1-5-32-569Cryptographic operators
ERS-1-5-32-573Event log readers
CDS-1-5-32-574Certificate service DCOM access
RAS-1-5-32-575RDS remote access servers
ESS-1-5-32-576RDS endpoint servers
MSS-1-5-32-577RDS management servers
HAS-1-5-32-578Hyper-V administrators
AAS-1-5-32-579Access control assistance operators
RMS-1-5-32-580Remote management users

SU is worth knowing about: it is the group every token minted for a service logon carries, so it is how you grant something to services as a class rather than naming each one. Membership follows from how a process was started rather than from which account it runs as, so an ordinary user process cannot acquire it.

CO is the one you reach for most often after that. An inheritable ACE naming Creator Owner is rewritten to the new object's owner when a child is created, which is how a container grants each principal rights over what it makes there without granting anything over what anyone else makes.

Two SIDs you might expect have no alias and must be written in full: the null SID S-1-0-0, and the protected-process integrity level S-1-16-20480.

The same two letters mean different things in different fields #

The code tables overlap, and a code is interpreted by the field it appears in rather than by its spelling. The collisions that catch people out:

CodeAs a rightAs an accountElsewhere
WDWrite DACLEveryone
RCRead controlRestricted code
AUAuthenticated UsersACE type: system audit
RARDS remote access serversACE type: resource attribute
FAAll file accessACE flag: audit failed access
SDDelete

So in (A;FA;FA;;;WD) the first FA is an ACE flag, the second is a rights mask, and WD is Everyone rather than write-DACL. Each is unambiguous in place, but none of them reads that way at a glance.

Not everything that parses is emitted #

Reading a descriptor and formatting it again does not always return the string you started with, and this is deliberate rather than a defect.

The directory-object rights (CC through CR) and the mandatory-label bits (NW, NR, NX) occupy the same low bit positions as one another. Which of them a bit means depends on the object class and the ACE type. The ACE type settles it for a mandatory label, so an ML entry's mask comes back as NW, NR and NX. Elsewhere the formatter would have to guess, so it accepts the directory-object codes when parsing and emits none of them, falling back to a hexadecimal mask. KX is dropped for a related reason: it shares a mask with KR, so only one of the two can ever come back.

The formatter also prefers composites to their components, emitting FA rather than the eight codes that add up to it.

Domain- and machine-relative aliases #

The codes DA, DG, DU, DD, DC, SA, EA, RO, CA, PA, CN, AP, KA, EK and RS name principals relative to a domain — domain admins, domain users, and so on — and LA and LG name the local administrator and guest accounts relative to a machine. Peios recognises them so that it can reject them with a message that says what is wrong, and it cannot resolve them: there is no domain or machine SID for them to be relative to. Write the SID you mean instead.

RU reads like one of these and is not: it is BUILTIN\Pre-Windows 2000 Compatible Access, S-1-5-32-554, and resolves like any other BUILTIN group.

Reading a descriptor #

sd show normally renders its selected view for a person to read. Its rights column uses a shorthand of its own — a lone f is all file access (0x001F01FF), not the single bit 0xF. To format the queried owner, group, DACL and integrity-label subset as SDDL:

sd show --sddl /usr/bin/login

The command does not query the full SACL. Formatting cannot recover audit ACEs or other components absent from the query: matching strings are not proof of full descriptor equality, and this output is not a complete backup to replay with sd set. See sd show inspection limits.

Where to go next #

  • Security descriptors — what the parts of a descriptor do.
  • ACLs and ACEs — how the entries you have just learned to spell are evaluated.
  • Inheritance — what CI, OI, NP, IO and ID do to a tree.
  • Conditional ACEs — the expression language behind the callback types.
  • The sd command — reading and writing descriptors on real objects.

Peios Learn — documentation for the Peios project.

Built with Trail.