Peios Security Fundamentals
The Peios Security Model.
Single-page view · as markdown
Identity in Peios
Peios / Peios Security Fundamentals / Identity
Every action on a running Peios system happens on behalf of a principal — a user, a service, a machine, or a well-known system actor. Identity is the unit of policy: every access decision starts with "who is asking?", and the kernel answers that question the same way for files, registry keys, processes, sockets, and tokens themselves.
The shape of an identity #
Identity in Peios has four layers, each with a clear job.
| Layer | Role |
|---|---|
| Principal | The thing being identified — a user account, a group, a service, a machine. |
| SID (Security Identifier) | The unique name for a principal. SIDs are hierarchical strings like S-1-5-21-...-1001. |
| Token | The runtime object that carries an identity into the kernel. Every thread has one. |
| Logon session | The authentication event a token belongs to. Sessions link tokens back to "who logged in, when, and how". |
These four words appear throughout the Peios documentation, and they are not interchangeable. A principal is the long-lived thing in the directory; a SID is its name; a token is its instance for one run; a session is what produced that token.
How an identity gets into the kernel #
flowchart LR
A["Principal"] -->|"identity asserted by"| B["Principal source"]
B -->|"verified identity"| C["authd"]
C -->|creates| D["Logon session"]
C -->|mints| E["Token"]
E -->|installed on| F["Process"]
F -->|"presents at every syscall"| G["Access decision"]
When a user signs in, authentication happens in three steps:
- A principal source verifies the credential and asserts who the principal is — the local principal source for this machine's own accounts, or a directory-backed source on a domain-joined machine.
- authd, the authentication authority, creates a logon session: a kernel object that records the authentication event (who, how, when).
- authd mints a token carrying the user's SID, group SIDs, integrity level, privileges, and a reference to the session. The token becomes the user's runtime identity — and its privileges and integrity level come from this machine's policy for the principal, not from the source that identified them.
The client that requested the logon — login on a console, for example — installs the token on the session's first process, and every child process inherits it across fork. Threads that need to act on behalf of someone else (for example, a service handling a user request) can temporarily swap in an impersonation token while keeping their original — see Impersonation.
The token is the authoritative carrier. Any other identity values a process can observe — including the numeric IDs that surface through standard Linux system calls — are derived from the token, never the other way around. See Linux compatibility for how that projection works.
Identity vs authorisation #
An identity by itself grants nothing. A token says who a thread is; the Kernel Access Control Subsystem (KACS) decides what it can do, by comparing the token against each object's security descriptor. The same token will see different rights on different objects, and the same object will grant different rights to different tokens.
The rest of the security documentation is organised around this separation:
- Identity says "who". This topic, plus Tokens and Logon sessions.
- Authorisation says "what". Security descriptors and Access decisions.
If a request fails unexpectedly, you almost always need to look at both sides: which identity made the request, and which access rule rejected it. Pages in both halves cross-link where the two meet.
What identity does not include #
A few things often get bundled with identity that are kept deliberately separate in Peios:
- Privileges are not identity. A token carries a set of privileges, but a privilege gates a specific operation (loading a driver, taking ownership of an object), independent of who you are. See Privileges.
- Integrity level is not identity. It is a separate axis on the token that controls write-up restrictions. Two users at different integrity levels are still two distinct identities; the integrity level is an additional constraint on top.
- Process integrity (PIP) is not identity at all. PIP is a property of the binary the process is running, set by the kernel from the binary's signature. It controls who can inspect or signal the process, independent of who the process is acting as. See Process integrity protection.
These distinctions matter because they fail in different ways. An "access denied" caused by the integrity level looks like an identity problem but is not.
Where to start #
If you want to understand how SIDs are constructed, read SIDs. For the catalog of built-in principals and their fixed SIDs, read Well-known principals.
If you want the typed attributes a token carries beyond identity — the inputs to attribute-based access decisions — read Claims on a token.
If you want the runtime mechanics — how a token is composed, how it survives fork and exec, and how it gets adjusted — read Tokens.
If you are debugging an unexpected denial, the Inspecting tokens, sessions, and processes topic shows how to see the identity attached to any running thread.
SIDs
Peios / Peios Security Fundamentals / Identity
A SID (Security Identifier) is the unique name for a principal. Every user, group, service, machine, and well-known system actor has exactly one SID, and that SID is how every other part of Peios refers to them: ACEs in a security descriptor name SIDs, tokens carry SIDs, audit events log SIDs.
Two SIDs are equal only if their binary encodings match byte-for-byte. There is no normalisation and no case folding. A SID is its bytes.
The string form #
The form you will see in logs, configuration, and almost every administrative tool is the string form:
S-1-5-21-3623811015-3361044348-30300820-1001
The pieces:
| Position | Meaning |
|---|---|
S | Constant marker — every SID string starts with S. |
1 | Revision number. Always 1 in Peios. |
5 | Identifier authority. A small integer naming the SID's overall namespace. |
21-3623811015-3361044348-30300820 | Sub-authorities. A sequence of integers that locate this principal within the authority's namespace. |
1001 | Relative identifier (RID). The last sub-authority. Distinguishes individual principals within a domain. |
A SID has between 1 and 15 sub-authorities. Most have between 1 and 7. The RID is just whatever sub-authority comes last; "RID" is a name for its role in domain SIDs, not a separate field.
When the authority is written in hex #
The identifier authority is a 48-bit value. When it fits in 32 bits — which it does for every authority Peios uses — it is written in decimal. If the upper 16 bits are nonzero, the authority is instead written as a 0x-prefixed 12-digit hexadecimal value:
S-1-0x000000123456-1-2-3
You will not see this form in practice on Peios. It exists for parity with external systems that allocate from the upper authority range.
The binary form #
On the wire (in security descriptors, in tokens, in audit events), a SID is a packed byte structure:
+----+----+----+----+----+----+----+----+
| 01 | NN | AA AA AA AA AA AA |
+----+----+----+----+----+----+----+----+
| sub_1 (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
| sub_2 (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
| ... |
+----+----+----+----+----+----+----+----+
| sub_N (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
The first byte is the revision (0x01), the second the sub-authority count, then the six-byte authority, then the sub-authorities. The canonical byte-level layout is in the wire formats reference, under "SIDs in wire format".
The mixed endianness — big-endian for the authority, little-endian for the sub-authorities — is the one detail worth memorising. The rest of KACS is uniformly little-endian; the SID authority is the exception, for compatibility with the way SIDs travel across federation boundaries.
A SID is between 8 bytes (no sub-authorities, very rare) and 68 bytes (15 sub-authorities) long.
Comparison #
Two SIDs are equal if and only if their binary representations are identical byte-for-byte.
This rule has consequences worth knowing:
- The string form is for humans. Two strings that look equivalent might not encode to the same bytes if one was reconstructed by hand and got a leading zero wrong, or if one used hex authority where decimal would do. Always compare in binary.
- There is no case folding. SIDs do not contain letters in their numeric form, so this rarely matters, but tools that print SIDs alongside resolved names — for example
BUILTIN\AdministratorsforS-1-5-32-544— must compare the SID, not the name. - The kernel only compares for equality. When KACS matches a SID in an ACE against the SIDs in a token, it tests the full encoded bytes for equality — there is no "same domain" or "prefix match" at the access-check level. Userspace tooling can parse SIDs and compare them structurally for its own purposes (listing all users in a domain, filtering by authority, reporting), and is expected to. That parsing is on top of the byte representation; the underlying bytes are still what define identity.
If you are writing code that handles SIDs, store them as their binary form whenever possible and convert to strings only at the UI boundary.
SID patterns you will recognise #
The sub-authorities of a SID encode meaning by convention, not by parse rule. A few patterns appear so often that you will start to recognise them by shape:
| Pattern | Meaning |
|---|---|
S-1-5-32-... | A built-in alias group under the NT Authority — local administrators, users, guests, etc. |
S-1-5-21-W-X-Y-... | A principal in a specific domain. The three numbers after 21 identify the domain; the last sub-authority is the RID. |
S-1-5-5-X-Y | A logon SID. Created per authentication event; lets ACEs target "the specific login session that did this". |
S-1-16-N | An integrity level. Used as a label in a SACL, not in a DACL. |
S-1-19-T-L | A process integrity protection (PIP) trust label. Used in SACLs on objects that opt in to PIP. |
S-1-5-80-h1-h2-h3-h4-h5 | A service SID, derived from a service name. Lets ACEs target a specific service independent of the account it runs under. |
S-1-15-3-h1-...-h8 | A capability SID, derived from a capability name. Used in confinement to grant a specific capability to a sandboxed application. |
The full catalog of named SIDs is in Well-known principals. The derivation rules for service SIDs and capability SIDs — where the hash values actually come from — are documented under their respective topics.
What a SID does not tell you #
A SID identifies a principal but says nothing else about them. From a SID alone you cannot tell:
- Which groups the principal is a member of (the token's group list does that).
- What integrity level the principal runs at (the token's
integrity_levelfield). - What privileges the principal has (the token's privilege bitmask).
- Whether the principal is currently logged in (the logon session does that).
A SID is just the name. Everything else about a principal lives somewhere else — usually on the token that carries the SID at runtime.
Where to go next #
For the catalog of SIDs whose values are fixed by the system — Everyone, SYSTEM, the BUILTIN aliases, and the rest — read Well-known principals.
For the typed attributes that travel alongside SIDs on a token, read Claims on a token.
For how a SID is carried into every access decision at runtime, read Tokens.
Well-known principals
Peios / Peios Security Fundamentals / Identity
A well-known principal is a principal whose SID is fixed by the system rather than allocated when an account is created. Some name a single specific entity (SYSTEM, Anonymous). Some name a category (Everyone, Authenticated Users). Some are placeholders that get rewritten during inheritance (CREATOR OWNER). All of them have the same purpose: they let you write ACEs that target a role without naming a specific user.
You will recognise the common ones quickly; the full catalog exists so you can look up the rest when they appear in an audit log or an ACE.
How to read the catalog #
Each well-known SID has a fixed numeric value, a conventional name, and a defined behaviour during access checks. Some of them are not real principals at all — they are placeholders or labels that the access check treats specially. Where that distinction matters, it is called out.
The catalog is organised by SID authority, because that is how the SIDs are structured and how you will tend to recognise them.
Universal SIDs (S-1-0, S-1-1, S-1-2, S-1-3) #
These authorities are defined globally — not tied to any specific machine or domain — and exist for cross-system meaning.
| SID | Name | Behaviour |
|---|---|---|
S-1-0-0 | Nobody | Matches nothing. Used to write an ACE that can never apply, usually as a deliberate placeholder. |
S-1-1-0 | Everyone | Matches every token, including Anonymous. The broadest possible grant. |
S-1-2-0 | Local | Matches any token created by a local logon (not a network logon). |
S-1-2-1 | Console Logon | Matches a token created by a physical or console session. |
S-1-3-0 | Creator Owner | Placeholder. Inheritable ACEs containing this SID are rewritten to the new object's owner SID when the child is created. Does not match anything at access-check time. |
S-1-3-1 | Creator Group | Placeholder. Same as Creator Owner but for the primary group. |
S-1-3-4 | Owner Rights | Special. When present in a DACL, it suppresses the implicit READ_CONTROL and WRITE_DAC grants that the owner would otherwise receive. See Ownership and implicit rights. |
The placeholder SIDs (S-1-3-0 and S-1-3-1) are the trickiest. They never grant or deny anything at access-check time. Their job is to make inheritable ACEs portable across owners — you write an ACE that grants WRITE to Creator Owner, and when a child object is created, that ACE is rewritten to grant WRITE to whoever owns the child.
NT Authority SIDs (S-1-5) #
This is the largest group of well-known SIDs and where most of the principals you will use day-to-day live.
Logon classifiers #
| SID | Name | Behaviour |
|---|---|---|
S-1-5-2 | Network | Matches a token created by a network logon. |
S-1-5-4 | Interactive | Matches a token created by an interactive logon (console, RDP, SSH). |
S-1-5-6 | Service | Matches a token created for a service. |
S-1-5-7 | Anonymous | The user SID for tokens at the Anonymous impersonation level. |
S-1-5-11 | Authenticated Users | Matches every successfully authenticated token. Excludes Anonymous. |
The pair S-1-1-0 (Everyone) and S-1-5-11 (Authenticated Users) is the most common distinction worth getting right. Everyone includes Anonymous; Authenticated Users excludes it. Anything reachable by Anonymous is reachable by an unauthenticated network peer, which is almost never what you want.
System actors #
| SID | Name | Behaviour |
|---|---|---|
S-1-5-18 | Local System (SYSTEM) | The kernel and TCB services run as SYSTEM. The most privileged identity on the local machine. |
S-1-5-19 | Local Service | Built-in service account with reduced privileges. |
S-1-5-20 | Network Service | Built-in service account that can authenticate outward to remote machines. |
Built-in aliases #
The sub-authority 32 under NT Authority is the "BUILTIN" domain, which holds aliases for common administrative roles. The full alias list includes a few dozen entries; the ones you will see most often:
| SID | Name |
|---|---|
S-1-5-32-544 | BUILTIN\Administrators |
S-1-5-32-545 | BUILTIN\Users |
S-1-5-32-546 | BUILTIN\Guests |
S-1-5-32-551 | BUILTIN\Backup Operators |
Logon sessions and domains #
| Pattern | Meaning |
|---|---|
S-1-5-5-X-Y | A specific logon session. X and Y are derived from the session's LUID. Always carried in the token of any thread belonging to that session. |
S-1-5-21-DA1-DA2-DA3-RID | A principal in a specific domain. DA1-DA2-DA3 identifies the domain; RID identifies the principal within it. |
S-1-5-21-DA1-DA2-DA3-500 | The Administrator account of that domain (RID 500 is reserved). |
S-1-5-21-DA1-DA2-DA3-512 | Domain Admins. |
S-1-5-21-DA1-DA2-DA3-513 | Domain Users. |
S-1-5-21-DA1-DA2-DA3-515 | Domain Computers — the machine accounts in the domain. |
The full set of reserved RIDs under S-1-5-21-* follows administrative convention. Domain-specific principals (your actual users) get RIDs allocated above 1000.
Service SIDs #
A SID derived from a service's name, used to grant a service ACL entries independent of the account it runs under.
| Pattern | Meaning |
|---|---|
S-1-5-80-h1-h2-h3-h4-h5 | A service SID. The five hash values come from the SHA-1 of the UTF-16LE uppercased service name, split into five little-endian 32-bit integers. |
Service SIDs let you say "grant the loregd service read access to this file" without saying "grant SYSTEM read access" or "grant a specific user read access" — both of which would be too broad.
Mandatory integrity labels (S-1-16) #
These are not principals. They are labels carried in a token's integrity level field and in an object's mandatory label ACE.
| SID | Level |
|---|---|
S-1-16-0 | Untrusted |
S-1-16-4096 | Low |
S-1-16-8192 | Medium |
S-1-16-12288 | High |
S-1-16-16384 | System |
These five are the standard, well-known levels. The level is really the SID's single sub-authority as an unsigned integer, compared numerically, so any S-1-16-<n> with exactly one sub-authority is valid — non-standard values such as medium-plus (S-1-16-8448) and protected (S-1-16-20480) appear for Windows interop.
Integrity levels are a vertical axis on top of identity. A user at Medium integrity and the same user at High integrity have the same SID but different integrity labels. See Mandatory integrity control for how the levels interact with access checks.
Process trust labels (S-1-19) #
Like integrity labels, these are not principals. They label processes by signing trust for Process integrity protection.
| Pattern | Meaning |
|---|---|
S-1-19-T-L | PIP trust label. T is the PIP type (0 None, 512 Protected, 1024 Isolated). L is the trust level within that type. |
Common combinations:
| SID | Trust meaning |
|---|---|
S-1-19-0-0 | No PIP protection. Default for unsigned processes. |
S-1-19-512-1024 | Protected, Authenticode. Third-party signed binaries. |
S-1-19-512-2048 | Protected, App. Peios-distributed applications. |
S-1-19-512-4096 | Protected, Peios. Core Peios components. |
S-1-19-512-8192 | Protected, PeiosTcb. Trusted computing base. |
Confinement and capability SIDs (S-1-15) #
These are used by Confinement to label sandboxed applications and the capabilities they have been granted.
Confinement domains #
| SID | Name |
|---|---|
S-1-15-2-1 | ALL_APPLICATION_PACKAGES — matches every confined application in normal confinement mode. |
S-1-15-2-2 | ALL_RESTRICTED_APPLICATION_PACKAGES — matches confined applications in both normal and strict modes. |
Well-known capabilities #
| SID | Capability |
|---|---|
S-1-15-3-1 | internetClient — outbound network. |
S-1-15-3-2 | internetClientServer — inbound and outbound network. |
S-1-15-3-3 | privateNetworkClientServer — LAN/private network. |
S-1-15-3-8 | enterpriseAuthentication — domain credential access. |
S-1-15-3-9 | sharedUserCertificates — certificate store access. |
S-1-15-3-10 | removableStorage — removable media access. |
Derived capability SIDs #
For application-defined capabilities beyond the well-known set, the SID is derived from the capability name:
| Pattern | Meaning |
|---|---|
S-1-15-3-h1-h2-h3-h4-h5-h6-h7-h8 | A derived capability SID. The eight values come from the SHA-256 of the capability name. The same name always produces the same SID. |
Special placeholder SIDs #
A few SIDs never appear as a real principal but are recognised by the access check.
| SID | Name | How it is used |
|---|---|---|
S-1-3-0 | Creator Owner | Substituted at inheritance time. |
S-1-3-1 | Creator Group | Substituted at inheritance time. |
S-1-3-4 | Owner Rights | Suppresses owner implicit rights when present in a DACL. |
S-1-5-10 | Principal Self | A placeholder for "the principal this object is about". The access check substitutes the caller-supplied self_sid parameter for this SID at evaluation time. Used in directory-style objects where an ACE says "the user can modify their own properties". |
These are the cases where a SID in an ACE does not literally mean "match a token whose user SID is this value". They are special-cased by the access check.
Which ones to use in practice #
The catalog above is comprehensive. In practice, day-to-day administrative work touches a much smaller set:
BUILTIN\AdministratorsandSYSTEMfor default protective DACLs.Authenticated Usersfor "any logged-in user should be able to read this".Everyoneonly when you actually mean "including unauthenticated network peers".Creator Ownerin inheritable ACEs that should follow the owner of a child object.Owner Rightswhen you want to suppress the owner's default grants on a sensitive object.- Specific user/group SIDs allocated by the directory (the
S-1-5-21-...-RIDform) for everyone else.
The remaining well-known SIDs — service SIDs, capability SIDs, integrity labels, PIP labels — show up in specialised contexts that have their own topics in these docs.
Where to go next #
For the attributes a token carries beyond fixed identity — the other input to attribute-based access rules — read Claims on a token.
For the exact numeric values in machine-readable form, see the Well-known SIDs reference.
Claims on a token
Peios / Peios Security Fundamentals / Identity
A claim is a typed key-value attribute that carries information about a principal beyond its identity. The user's department, the machine's compliance status, the time-of-day window for which a session is valid — all of these are claims. They are the raw material that conditional ACEs use to make access decisions about more than just "who is this".
Tokens carry two sets of claims:
- User claims — attributes about the user. Populated at authentication time by authd from the user's directory object.
- Device claims — attributes about the machine. Populated similarly from the machine's directory object.
Both sets are read-only after the token is minted. Adjusting a token's privileges or groups does not touch its claims; only re-authentication does.
What a claim looks like #
A single claim has three things:
- A name — a string, such as
DepartmentorClearance. In conditional expressions the name is qualified by the set it belongs to:@User.Department. - A value — typed (see below). May be a single value or an array of values.
- A set of flags that control how the claim participates in access checks.
Value types #
A claim's value is always one of:
| Type | Notes |
|---|---|
INT64 | A signed 64-bit integer. |
UINT64 | An unsigned 64-bit integer. |
STRING | A UTF-16LE string. |
SID | A binary SID. |
BOOLEAN | TRUE or FALSE. |
OCTET | A raw byte string. |
Mixed-type arrays are not allowed. A claim either holds one STRING, three STRINGs, or one INT64 — never two strings and an integer.
Claim flags #
Each claim carries flags that change how it behaves during access evaluation.
| Flag | Effect |
|---|---|
DISABLED | The claim is invisible to all conditional expressions. Effectively, the attribute does not exist. |
USE_FOR_DENY_ONLY | The claim is invisible to conditions on allow ACEs, but visible to conditions on deny ACEs. This is the conservative downgrade — the claim cannot grant new access but can still trigger denials. |
MANDATORY | The claim cannot be removed or modified without the SeTcbPrivilege. Used for claims that the system itself depends on. |
CASE_SENSITIVE | String and octet comparisons against this claim are case-sensitive. Default is case-insensitive. |
The first two flags (DISABLED, USE_FOR_DENY_ONLY) are the security-relevant ones. They give administrators a way to neutralise a claim (for example, for a logon at a lower trust level) without rewriting the user's directory object.
How claims get onto a token #
flowchart LR
A["User object"] -->|directory| B["authd"]
C["Machine object"] -->|directory| B
B -->|at authentication| D["Token"]
D -->|user_claims| E["@User namespace"]
D -->|device_claims| F["@Device namespace"]
When a user authenticates, authd reads:
- The user's directory object — name, group memberships, any attributes the directory schema defines. Anything the schema marks as a claim attribute is copied into the token as a user claim.
- The machine's directory object — same, but for the device. Anything the schema marks as a device claim is copied into the token as a device claim.
Both sets travel with the token for its entire lifetime.
The directory schema decides what is and is not a claim. A claim that is defined in the schema but absent from a specific user object becomes a missing claim on that user's token — not an empty one, not a default value. Conditional expressions distinguish between the two.
Where claims get used #
Claims do nothing by themselves. They are inputs to conditional ACEs — ACEs whose grant or deny is gated by an expression that references attributes.
A conditional expression refers to a claim by namespace and name:
@User.Department— a claim on the caller's token, user-claims set.@Device.Compliance— a claim on the caller's token, device-claims set.@Resource.Classification— an attribute on the object being accessed.@Local.Time— a per-request attribute supplied by the caller of AccessCheck.
The first two are the token claim sets described above. The last two share the expression syntax but hold different kinds of data:
@Resourcevalues come from resource attributes in the object's SACL, not the caller's token. See Resource attributes.@Localvalues are supplied by the caller as a per-access-check parameter. They never live on a token. They exist for runtime context that does not fit into a directory.
The full grammar of conditional expressions — comparison operators, the three-valued logic (TRUE / FALSE / UNKNOWN), the way missing claims affect the outcome — lives in Conditional ACEs.
Missing claims fail closed #
One behaviour is worth knowing before you get to the conditional ACEs page: a missing claim is not a failure. A conditional expression that references a claim the token does not carry evaluates to UNKNOWN. UNKNOWN on an allow ACE means "skip this ACE"; UNKNOWN on a deny ACE means "treat this as deny". So a missing claim never accidentally grants access — it can only fail closed.
This is why USE_FOR_DENY_ONLY exists. It is the deliberate version of the same idea: take a claim that grants access and demote it to a claim that can only revoke access.
What claims are not #
A few things claims look like at a glance but are not:
- Claims are not groups. A group is a SID in the token's group list; group membership is presence-or-absence. A claim is a typed attribute with a value. The two appear in different parts of the token and are matched by different parts of an ACE.
- Claims are not privileges. Privileges are a fixed system bitmask gating specific operations. Claims are administrator-defined attributes with arbitrary names.
- Claims are not the audit log. Claims describe the principal; the audit log describes what the principal did. The audit-event subject record does include the principal's claims (so audit consumers can write rules over them), but a claim is not itself an event.
Where to go next #
For how the token that carries these claims is built, adjusted, and destroyed, read Tokens.
For the expression grammar that consumes claims — operators, three-valued logic, worked examples — read Conditional ACEs.
Tokens
Peios / Peios Security Fundamentals / Tokens
A token is the runtime carrier of identity in Peios. It is a reference-counted kernel object that holds a user SID, group SIDs, privileges, confinement capabilities, and a handful of other policy fields. Every thread on the system has exactly one effective token at any moment, and every access decision starts by reading that token.
If Identity describes who a principal is, the token is the concrete thing that says "this thread is acting as that principal, right now". Tokens appear throughout these docs because they are the input to AccessCheck for files, registry keys, processes, sockets, and the tokens themselves.
What a token contains #
A token is a structured object with around twenty fields. The full field list is covered in Token types and fields; this section sketches the shape so you have a mental model going into the rest of the topic.
| Group | Fields | What they do |
|---|---|---|
| Identity | user_sid, groups, logon_sid, restricted_sids | Who the token represents. |
| Type and level | token_type, impersonation_level, integrity_level, mandatory_policy | What kind of token this is and how it may be used. |
| Defaults | owner_sid_index, primary_group_index, default_dacl | What the token contributes when new objects are created without an explicit security descriptor. |
| Session and provenance | auth_id, source, token_id, created_at, expiration, origin | Where the token came from and which authentication event it belongs to. |
| Privileges | privilege bitmask (present / enabled / used / removed states) | System-wide rights the token holds. |
| Confinement | confinement_sid, confinement_capabilities, confinement_exempt | Sandboxing constraints, when the token is for a confined application. |
| Claims | user_claims, device_claims, device_groups | Typed attributes for conditional ACE evaluation. |
| Projection | projected_uid, projected_gid, projected_supplementary_gids | Linux-compatibility identity numbers, derived from the rest of the token. |
| Audit policy | audit_policy bitmask | Per-token forced auditing flags. |
| Self SD | security_descriptor | The token is itself an object — this is its own SD, governing who can read or adjust it. |
Most fields are set once when the token is minted and never change. A few — the enabled state of privileges, the enabled state of groups, the default DACL — are adjustable at runtime within strict rules. None of the identity fields (the SIDs themselves) ever change. See Token types and fields for the exact rules.
Tokens and threads #
flowchart LR
A["Thread"] -->|primary token| B["Primary token"]
A -->|impersonation token, when set| C["Impersonation token"]
B -->|read by AccessCheck| D["Kernel"]
C -->|read by AccessCheck when in effect| D
Every thread has a primary token — its baseline identity, inherited at fork and shared with every other thread in the process. While the thread is running normally, the primary token is what AccessCheck reads.
A thread may also temporarily install an impersonation token — a second token that overrides the primary for that one thread until it is reverted. Services use impersonation to act on behalf of a client they are handling: accept the connection, impersonate the client, do the work as them, revert. The primary token survives unchanged; only the one thread sees the impersonated identity, and only until it reverts.
Impersonation has its own topic: Impersonation. For this page it is enough to know that a thread can have two tokens at once, and the kernel reads whichever is "in effect" right now.
Where a token comes from #
A token is always minted by code holding SeCreateTokenPrivilege. In a running system that effectively means authd — the authentication authority — which mints tokens once a principal source has verified who is signing in, and peinit, which mints service tokens during boot before authd is up.
The minting flow is straightforward:
- Authentication succeeds, or a service is being launched.
- The minting component gathers what the token will carry: the identity and memberships the principal source asserted, and the privileges and integrity level this machine's policy grants the principal.
- It calls
kacs_create_tokenwith the resolved values. - The kernel returns a token file descriptor with
TOKEN_ALL_ACCESS. - The minting component installs the token on the target process (the new login session's first process, or the service binary) and closes the fd.
From that point on the token lives in the kernel, referenced by every thread of every process that inherits it. The original fd is gone; the only handle into the token is through the threads carrying it (and any other process that opens it via /proc/<pid>/token or kacs_open_process_token).
No other component creates tokens. There is no API for an ordinary process to fabricate an identity for itself. A process can ask the kernel to duplicate a token it already has, or filter one to a more restricted version, but the original always comes from authd or peinit. This is deliberate: it is what makes the kernel's "where did this identity come from?" question always answerable.
Tokens are objects too #
A token is itself one of the objects KACS protects. It has a security descriptor of its own, and operations on it (querying, adjusting, impersonating, duplicating, installing) all go through AccessCheck against that SD.
| Right | Action it gates |
|---|---|
TOKEN_QUERY | Read token information — fields, groups, privileges, etc. |
TOKEN_DUPLICATE | Create an independent copy via DuplicateToken or FilterToken. |
TOKEN_IMPERSONATE | Install as a thread's impersonation token. |
TOKEN_ASSIGN_PRIMARY | Install as a process's primary token. |
TOKEN_ADJUST_PRIVILEGES | Enable, disable, or permanently remove privileges. |
TOKEN_ADJUST_GROUPS | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | Change the default DACL, owner index, or primary group index. |
TOKEN_ADJUST_SESSIONID | Change interactive_session_id (additionally requires SeTcbPrivilege). |
By default, a freshly minted token grants the SYSTEM identity and the creating principal full access, and grants the token's own user identity TOKEN_QUERY and the adjustment rights.
This self-protection is what stops one thread from "stealing" another's identity. Even reading the fields of another process's token requires the PROCESS_QUERY_INFORMATION right on the target process and the relevant token rights on the token itself.
The two token rules to remember #
Almost every confusion about tokens reduces to one of two rules.
Rule 1: A thread always has a token. There is no "no identity" state. A thread that has not been given a more specific token is running on whichever token its process inherited — at the very least, the SYSTEM token from boot. Code that runs before authd is up runs as SYSTEM, which has every privilege. Code that runs after authd is up runs as whichever identity authd assigned. There is no third option.
Rule 2: Identity is the token, not the process. A process does not "have an identity" except through the tokens of its threads. Two threads in the same process can be running as different principals at the same instant, because one is impersonating and the other is not. When you debug an "access denied" question, the answer is in the thread's token, not the process's notional identity.
Where to start #
If you want to understand the token field-by-field — what each value means, when it can change, how it is encoded — read Token types and fields.
If you want to know how a token moves through fork, exec, adjustment, and destruction, read Token lifecycle.
If you are interested in the restricted-token model — the sandbox primitive where a token is intersected with a secondary identity list — read Restricted and write-restricted tokens.
If you need to understand UAC-style elevation — the linked Full/Limited token pair that lets one principal have two tokens for the same session — read Elevation and linked tokens.
To work with tokens from a shell — inspect, adjust, duplicate, restrict, impersonate — read The token command.
Token types and fields
Peios / Peios Security Fundamentals / Tokens
Every token in Peios is classified along several axes at once. A given token is either a primary token or an impersonation token. If it is an impersonation token it has an impersonation level. It is either restricted or not. If it is part of an elevation pair it has an elevation type. These are not types in an inheritance sense — they are independent dials, and a token's behaviour is the product of all of them.
This page walks through each axis, then through the token's fields grouped by what they do.
token_type — primary or impersonation #
This is the first and most important classification.
A primary token is the baseline identity of a process. Every process has exactly one. It is inherited across fork — child processes start with a copy of the parent's primary token — and survives exec. When a thread runs without doing anything special, AccessCheck reads its primary token.
An impersonation token is a thread-local override. A thread installs an impersonation token to act as someone else for a span of time, then reverts back to its primary. While the impersonation is in effect, AccessCheck reads the impersonation token instead of the primary. Other threads in the same process are unaffected.
The two types are not interchangeable. Trying to install a primary token as a thread's impersonation, or vice versa, fails. The kernel's kacs_open_self_token and related syscalls return tokens of the correct type for what they were asked to do.
| Use case | Token type |
|---|---|
| A login session's identity | Primary |
| A server thread acting on behalf of a connected client | Impersonation |
| A service running with the identity authd assigned to it | Primary |
| A thread querying its caller's identity for an audit log entry | Impersonation (at the Identification level — see below) |
impersonation_level — how far an identity may travel #
Every token carries an impersonation level. For primary tokens this is set to a conventional value (Anonymous) and ignored. For impersonation tokens it is what bounds what the impersonator may do.
There are four levels, ordered from least to most permissive:
| Level | What a server may do with this token |
|---|---|
| Anonymous | No identity. The token's user SID is the well-known Anonymous SID (S-1-5-7). The server learns nothing about the client. |
| Identification | The server may inspect the client's identity — read their SIDs, groups, integrity level — but may not use the token for any access check. AccessCheck with an Identification-level token returns ACCESS_DENIED immediately, no matter what. |
| Impersonation | The server may act as the client for all local operations. This is the default, and the level most server-to-client code paths assume. |
| Delegation | Same as Impersonation locally, plus the server may forward the client's credentials to a remote machine over Kerberos. KACS only tracks the level; the actual forwarding is authd's job. |
The level is set by the client, on the socket, before connecting. The server cannot raise it. It can only end up with the level the client granted, or lower if other constraints intervene (see Impersonation for the full two-gate model).
The Identification level is a common source of confusion. A frequent bug — in services not written for Peios — is to take a client's token and try to open files as them, finding that every open returns ACCESS_DENIED for reasons the code cannot diagnose. The cause is almost always that the client connected at Identification level and the server is supposed to be inspecting, not acting. The correct fix is at the client end: ask for Impersonation.
restricted — a secondary identity check #
A restricted token carries two SID lists: the normal groups list and a secondary restricted_sids list. During AccessCheck the kernel runs the DACL walk twice and intersects the results — once with the full identity, once with only the restricted SIDs. A right is granted only if both passes grant it.
The effect is to limit a token's identity-based access to what the restricted SIDs would independently receive. The user is still the same user; they just cannot use group memberships or other SIDs that are not in the restricted list to satisfy an ACE.
A write-restricted token is the same idea, but the intersection applies only to write-category rights. Reads and execute come from the normal pass alone. This is the common case: a sandbox that should be able to read system files but only write into a narrow allowed set.
Both variants are covered in detail under Restricted and write-restricted tokens. For this page it is enough to know that "restricted" is a property of the token, set at creation by FilterToken, and that the kernel handles the second pass automatically.
elevation_type — half of a linked pair #
When a principal has both a normal (Limited) token and an elevated (Full) token — the UAC-style pattern — the two tokens are linked at the session level. Each carries an elevation_type saying which half it is:
| Value | Meaning |
|---|---|
| Default | The token is not part of a linked pair. Most tokens. |
| Full | The elevated half of a linked pair. |
| Limited | The non-elevated half of a linked pair. |
A linked pair is established by an explicit call to KACS_IOC_LINK_TOKENS. The elevation type does not change anything about the token's access rights by itself — it is a label that lets the system locate the partner token when the user requests elevation. The mechanics live in Elevation and linked tokens.
Field mutability classes #
Token fields fall into three classes. Knowing which class a field is in tells you whether you can ever change it after the token is minted.
| Class | Meaning | Examples |
|---|---|---|
| Fixed | Set at creation, never changes. Cannot be adjusted at runtime even by SYSTEM. | user_sid, groups[].sid, logon_sid, restricted_sids, token_id, auth_id, created_at, source, confinement_sid, mandatory_policy. |
| Adjustable | Can be changed at runtime through a specific syscall. | Privilege enabled-state (via AdjustPrivileges), group enabled-state (via AdjustGroups), default DACL / owner index / primary group index (via AdjustDefault), security_descriptor (via kacs_set_sd). |
| One-way | Can be tightened but never loosened. | Privilege presence (a privilege can be removed but not re-added), group USE_FOR_DENY_ONLY (can be set but not cleared). |
This split is the source of one rule worth memorising: the identity in a token cannot change. You cannot relabel a token to a different user. You cannot add a group to a token. You can only adjust how existing identity bits are used — enable or disable a privilege, mark a group as deny-only, narrow the default DACL.
If you need a different identity, you need a different token. Either authd mints a new one for you (with a re-authentication), or you DuplicateToken / FilterToken your existing one into a more restricted copy.
Fields by purpose #
The token's fields, grouped by what they do. None of this is a low-level binary layout — that lives in the Kernel ABI reference topic. This is the conceptual field list.
Identity #
The fields that say who the token represents.
| Field | What it is |
|---|---|
user_sid | The primary identity of the token. The principal's SID. |
groups | An array of SID_AND_ATTRIBUTES — every group the principal is a member of, each with its current enabled/disabled state and other flags. |
logon_sid | The session-specific SID (S-1-5-5-X-Y) for the logon session this token belongs to. Also appears in groups with the SE_GROUP_LOGON_ID flag. |
restricted_sids | Secondary identity list for restricted tokens. Empty on unrestricted tokens. |
Type and level #
| Field | What it is |
|---|---|
token_type | Primary or Impersonation. |
impersonation_level | One of the four levels listed above (only meaningful for Impersonation tokens). |
integrity_level | Normally one of Untrusted / Low / Medium / High / System; technically any numeric S-1-16 level (see MIC). |
mandatory_policy | The token's MIC enforcement flags (NO_WRITE_UP, NEW_PROCESS_MIN). |
Defaults #
These fields contribute to the security descriptor of a new object when the creator does not supply one explicitly.
| Field | What it is |
|---|---|
owner_sid_index | Index into [user_sid, groups[0..N-1]] selecting which SID becomes the default owner. |
primary_group_index | Same idea for the default primary group. |
default_dacl | The DACL applied to new objects when no explicit SD is supplied. |
Session and provenance #
| Field | What it is |
|---|---|
token_id | A unique LUID identifying this token instance. |
auth_id | The LUID of the logon session this token belongs to. |
source | An 8-character name plus a LUID identifying the component that minted the token (e.g. authd). |
created_at | Timestamp of original minting. Copied unchanged by DuplicateToken and FilterToken. |
expiration | Expiry timestamp. Set by authd. Informational only in v0.20 — not enforced by AccessCheck. |
origin | The originating logon session for derived tokens (S4U, network logon). |
modified_id | A counter bumped on every adjustment. Used as a cache-invalidation key. |
Privileges #
Privileges live in a 64-bit bitmask with four states per privilege:
- Absent — not on this token at all.
- Present, disabled — on the token but not currently in effect. May be enabled.
- Present, enabled — in effect; AccessCheck will use it.
- Used — a sticky audit bit set when the privilege has been exercised. Never cleared.
See Privileges for the model in detail and the catalog of named privileges.
Confinement #
| Field | What it is |
|---|---|
confinement_sid | If non-null, the token is for a confined application; its identity for access checks is intersected with this SID. |
confinement_capabilities | The list of capability SIDs the confined application has declared. |
confinement_exempt | Escape hatch — if set, confinement is skipped entirely. |
isolation_boundary | Reserved for future use; not enforced in v0.20. |
See Confinement for the sandbox model.
Claims #
| Field | What it is |
|---|---|
user_claims | Typed attributes about the user, populated from the directory at authentication time. Used by @User.* references in conditional ACEs. |
device_claims | Same idea for the machine. Used by @Device.*. |
device_groups | The machine's group memberships, for compound identity. |
See Claims on a token.
Projection #
These fields hold pre-computed Linux UID/GID values for compatibility. They are derived from the token, never the other way around.
| Field | What it is |
|---|---|
projected_uid | The Linux UID the token's identity maps to (or 65534 if unmapped). |
projected_gid | Same for primary GID. |
projected_supplementary_gids | Same for supplementary GIDs. |
See Linux compatibility for what consumes these.
Audit policy #
| Field | What it is |
|---|---|
audit_policy | A bitmask of per-token forced-audit flags (OBJECT_ACCESS_SUCCESS, OBJECT_ACCESS_FAILURE, PRIVILEGE_USE_SUCCESS, PRIVILEGE_USE_FAILURE). |
When a flag is set, AccessCheck emits the corresponding audit event regardless of the object's SACL.
Self SD #
| Field | What it is |
|---|---|
security_descriptor | The token is itself a protected object; this is its own SD. Governs who may query, duplicate, impersonate, install, or adjust the token. |
Other #
| Field | What it is |
|---|---|
interactive_session_id | The interactive session number. Zero for services. Adjustable only with SeTcbPrivilege. |
elevation_type | Default / Full / Limited, for linked-pair membership. |
Where to go next #
For how these fields are set, shared, adjusted, and destroyed over a token's life — mint, fork, exec, impersonation, adjustment — read Token lifecycle.
For the full rules around impersonation tokens and their levels, read Impersonation.
To read these fields off a live token from a shell, read The token command.
Token lifecycle
Peios / Peios Security Fundamentals / Tokens
A token is reference-counted. It comes into existence when a privileged component mints it, is reference-counted up and down as it gets attached to processes and threads, and is destroyed when the last reference drops. Between mint and destruction it moves through a handful of well-defined transitions. None of them change a token's identity — but each affects how the token is reached, how many references it has, or how its mutable fields are set.
Three ways to mint a token #
A token is always created by code holding the right privilege:
| Operation | Effect | Privilege required |
|---|---|---|
kacs_create_token | Mint a token from scratch using a wire-format specification. | SeCreateTokenPrivilege |
| DuplicateToken | Make an independent copy of an existing token. The copy is a new object; modifying it does not affect the source. | TOKEN_DUPLICATE on the source |
| FilterToken | Make a copy of an existing token with privileges removed, groups marked deny-only, or restricted SIDs added. | TOKEN_DUPLICATE on the source |
kacs_create_token is what authd and peinit use to mint genuinely new identities. The other two — DuplicateToken and FilterToken — produce copies derived from a token a process already has. A process can FilterToken its own primary token down to a more restricted version and install the result as the primary token of a child it is about to launch.
In all three cases the kernel:
- Validates the inputs (well-formed SIDs, no duplicate luids, all index references in range, etc.).
- Allocates a new token object with
refcount = 1. - Assigns a fresh
token_idLUID andmodified_id = 0. - Stamps the
created_attimestamp. - Injects the appropriate logon SID into the groups list (callers must not supply it).
- Returns a token file descriptor with
TOKEN_ALL_ACCESS(for create) or the access mask the caller asked for (for duplicate).
Any validation failure results in no token being created — the operation is all-or-nothing.
Attaching to a process #
A token by itself is just an object the kernel holds. It does not yet identify anyone. To take effect, it has to be attached to a process or thread.
There are three attachment paths:
- Inheritance at fork — the child's primary token is the parent's primary token. The token's reference count goes up by one.
- Installation by a privileged caller —
KACS_IOC_INSTALLon a token fd makes the token the calling process's primary. RequiresTOKEN_ASSIGN_PRIMARYon the token andSeAssignPrimaryTokenPrivilegeon the caller. Used by peinit when launching a service: fork, install the service token on the child, exec the binary. - Impersonation on a thread —
KACS_IOC_IMPERSONATEon a token fd makes the token the calling thread's impersonation token. RequiresTOKEN_IMPERSONATE. The process-wide primary token is unchanged.
A given token can be attached in all three ways at once: shared as the primary of N processes, also held as the impersonation token of M threads. Each attachment is a reference.
Fork, exec, and the primary token #
flowchart LR
A["Parent process (primary token T)"] -->|fork| B["Child process (primary token T)"]
B -->|exec| C["Child process (primary token T, possibly with lowered integrity)"]
Fork copies the parent's primary token pointer into the child. Both processes now share the same token object. Adjustments made by the parent are visible to the child instantly — they share the storage. Adjustments made by either process via AdjustPrivileges etc. affect the shared token.
What does not survive fork is the parent's impersonation. A thread that forks while impersonating gets a child whose primary token is the parent's primary (not the impersonation). The child is not impersonating; its first thread is running on the inherited primary.
Thread clone (CLONE_THREAD) is different: the new thread is part of the same process, so it shares the same primary token. Privilege or group adjustments made by any thread are visible to all of them immediately.
Exec keeps the primary token. The new binary runs as the same identity. One subtle exception: if the token's mandatory_policy has NEW_PROCESS_MIN set and the executable carries a lower integrity label than the token, the kernel creates a copy of the token with integrity lowered to match, replaces the primary with that copy, and drops the original reference. This is the mechanism that prevents Medium-integrity code from running at Medium when its image is labelled Low.
Impersonation is always reverted at exec. A thread that execs while impersonating has its impersonation token released before the new program runs. This is enforced — the new binary cannot inherit an impersonation it did not establish.
Impersonation install and revert #
A thread becomes an impersonator by installing an impersonation token. The two ways to do it:
kacs_impersonate_peer(fd)— extract the peer's identity from a connected Unix socket and install it at the appropriate level. The most common path for services accepting client connections.KACS_IOC_IMPERSONATEon a token fd — install a specific token (for transports that do not carry a peer token, or when the server has obtained a token by some other means).
Either operation has the same effect: the thread now has a primary token (unchanged) and an impersonation token (newly installed). AccessCheck reads the impersonation token from this point.
A thread reverts with kacs_revert. It always succeeds. It drops the impersonation reference and restores the original primary as the effective identity.
If a thread that is already impersonating installs a different impersonation token, the kernel silently reverts the old one first and then installs the new one. There is no nesting.
Impersonation tokens come from one of three sources during the install:
- A peer's identity captured at socket connect time.
- A duplicate of an existing token (DuplicateToken to a token type of Impersonation with the desired level).
- A new mint from authd (rare).
The level on an impersonation token is set by the client at connect time, never raised by the server. The full two-gate model — identity gate plus integrity ceiling — is in Impersonation.
Adjustment in place #
Some fields can be changed at runtime. The operations are:
| Syscall / ioctl | What it changes |
|---|---|
| AdjustPrivileges | Enable, disable, or permanently remove privileges. A reset-all sentinel restores defaults. |
| AdjustGroups | Enable or disable group entries. Cannot target mandatory groups, deny-only groups, the logon SID, or the user SID. |
| AdjustDefault | Modify the default DACL, owner index, or primary group index. |
kacs_set_sd on the token fd | Modify the token's own self-SD. Requires WRITE_DAC on the token. |
All adjustments are atomic — invalid input rolls the whole operation back, no partial change. Each successful adjustment bumps modified_id so caches keyed on token state can invalidate.
Because the token storage is shared across all threads of a process, an adjustment made by one thread is visible to all of them immediately. This includes adjustments to the primary token of a process that has just installed it but not yet had every thread converge — the kernel handles the convergence asynchronously, and during the brief window other threads may still see the old token. This window is small enough not to matter in practice but is worth knowing about if you are writing tests.
Permanent privilege removal #
When a privilege is removed rather than just disabled, it is gone from the token permanently. The token's privilege bitmask loses the present and enabled-by-default bits. The used bit stays — its purpose is auditing, and the fact that the privilege was once exercised is information that should not disappear.
Removal is irreversible by design. There is no API to re-add a removed privilege. The only way back is a different token (a fresh authentication, or a duplicate from a source that still has the privilege).
The same applies to SE_GROUP_USE_FOR_DENY_ONLY set on a group: it can be marked, but never cleared. Marking a group deny-only is a one-way trip down the access ladder.
The default DACL #
Every token carries a default DACL. It is the DACL that gets applied to a new object when:
- The creator (for example a
kacs_openwithCREATEdisposition, or a registry-key create) does not supply an explicit SD. - The object's parent has no inheritable ACEs that fully cover it.
In other words, the default DACL is the fallback. It guarantees that any object a token creates has at least some DACL, even when nothing else supplies one.
The default DACL is adjustable via AdjustDefault. A typical default DACL grants the token's user identity and SYSTEM full access and excludes everyone else. Services that want a more permissive or more restrictive default can change it once at startup.
The default owner and primary group also live on the token, as indices into the [user_sid, groups[0..N-1]] array. They tell the kernel which SID to stamp as owner and which to stamp as primary_group when synthesising a new SD. They are adjustable through the same call.
Reference counts and destruction #
Every attachment is a reference. A token is alive as long as any of these are true:
- Any process has it as a primary token.
- Any thread has it as an impersonation token.
- Any process has it open via a token fd.
- It is part of an established linked pair on a still-existing logon session.
When the last reference drops, the kernel destroys the token: frees the storage, releases the reference on the logon session. If the destroyed token's session loses its last token reference too, the session itself is destroyed and a logon-session-destroyed event is emitted via KMES. See Logon sessions.
A token's expiration field has no effect on the lifecycle in v0.20 — it is stored for future use but not enforced. A token lives until its references drop. Session revocation, when needed, is implemented by userspace (authd) walking /proc/*/token, identifying tokens with the offending auth_id, and killing the holding processes. This is documented under Inspecting security state.
Quick reference: which transitions change what #
| Transition | Identity | Privileges | Groups | Integrity | Refcount |
|---|---|---|---|---|---|
| Fork | Same | Same | Same | Same | +1 (shared) |
| CLONE_THREAD | Same | Same | Same | Same | Same object |
| Exec (same integrity) | Same | Same | Same | Same | Same object |
| Exec (NEW_PROCESS_MIN downgrade) | Same | Same | Same | Lowered | New token |
| Impersonation install | Different (impersonation token) | Different | Different | Different | +1 on impersonation token |
| Impersonation revert | Back to primary | Back to primary | Back to primary | Back to primary | −1 on impersonation token |
| DuplicateToken | Same | Same | Same | Same | New token with its own count |
| FilterToken | Same | Subset | Subset (some deny-only, restricted_sids added) | Same | New token |
| AdjustPrivileges | Same | Changed (within rules) | Same | Same | Same object |
| AdjustGroups | Same | Same | Changed (within rules) | Same | Same object |
| KACS_IOC_INSTALL | Same token, different process | — | — | — | New attachment |
| Process exit | — | — | — | — | −1 |
Where to go next #
For what FilterToken's restricted variants actually do at access-check time, read Restricted and write-restricted tokens.
For the session a token belongs to and what happens when the last token of a session is released, read Logon sessions.
To drive these transitions from a shell — duplicate, restrict, adjust, install — read The token command.
Restricted and write-restricted tokens
Peios / Peios Security Fundamentals / Tokens
A restricted token is the kernel's narrow-the-identity primitive. It is a normal token with an extra list of SIDs attached — the restricted_sids list. During AccessCheck the kernel runs the DACL walk twice: once with the token's full identity, once with only the restricted SIDs. A bit is granted only if both passes grant it.
The effect is to put a ceiling on what the token can reach, expressed as "the rights this token would have if it were just those restricted SIDs". The identity (user, groups, privileges) is unchanged — but the kernel will only honour an ACE if the restricted-only view of the token would have honoured it independently.
Restricted tokens are the building block underneath several patterns: service hardening, sandbox processes, anti-malware quarantine, anything that says "this code should have less authority than the user it is running as".
The two-pass model #
flowchart TD
A["AccessCheck"] -->|with full identity| B["Normal pass (user_sid + groups + ...)"]
A -->|with restricted SIDs only| C["Restricted pass (restricted_sids only)"]
B -->|granted_normal| D["Intersect"]
C -->|granted_restricted| D
D -->|granted = normal ∩ restricted| E["Final granted mask"]
When KACS evaluates AccessCheck for a restricted token, it runs through the same pipeline twice:
- Normal pass. The DACL walk uses the token's
user_sid, all enabled groups, and all the usual rules. Produces agranted_normalmask. - Restricted pass. The DACL walk runs again, but the only SIDs considered for matching are those in
restricted_sids. Theuser_siddoes not match. None of the regulargroupsmatch. Only the entries inrestricted_sidscount. Produces agranted_restrictedmask. - Intersection. The final granted mask is
granted_normal & granted_restricted.
Both passes must agree to grant a right. If the normal pass would grant FILE_WRITE_DATA but the restricted SIDs do not appear in any allow ACE for that right, the bit is dropped.
The restricted SIDs are usually narrow on purpose. A common pattern is to put a single capability SID (for example internetClient) in the restricted list — the token now has authority only on objects whose DACLs explicitly grant access to that capability.
What is not affected #
A few things are explicitly not narrowed by the restricted pass:
- Privileges. Privilege-granted access (SeBackup, SeRestore, SeTakeOwnership, SeSecurity) is restored after the intersection. A restricted token with SeBackup can still read any file the privilege would have granted.
- The default DACL on new objects. A restricted token still creates new objects with its default DACL, derived from its full identity.
- Reading the token's own state. Querying a restricted token does not require both passes; the token's self-SD governs that as normal.
- MIC. Mandatory integrity is evaluated before the DACL walks. A restricted token cannot use the restricted-SID trick to escape integrity rules.
Privileges being orthogonal to the restricted pass is the most important of these. The restricted-token model narrows identity-based access, not capability-based access. If you want to drop privileges too, that is a separate operation — see "Creation" below.
Write-restricted: the common case #
A write-restricted token narrows only the write-category rights. Reads and execute come from the normal pass alone; only write-mapped bits go through the intersection.
The motivation: it is rare to want a sandbox that cannot read anything in the normal world. Sandboxed code usually needs to load shared libraries, read configuration, perhaps look up its own metadata. What it must not do is write — into the user's home directory, into system paths, into any file outside the small set the sandbox explicitly allows.
In a write-restricted token:
| Right category | Where it comes from |
|---|---|
| Read (FILE_READ_DATA, FILE_LIST_DIRECTORY, etc.) | Normal pass only. |
| Execute (FILE_EXECUTE, FILE_TRAVERSE) | Normal pass only. |
| Write (FILE_WRITE_DATA, FILE_APPEND_DATA, WRITE_DAC, etc.) | Intersection of both passes. |
The "category" is determined by which generic right the bit maps to. FILE_WRITE_EA is in the write category; FILE_READ_EA is in the read category; and so on. The generic mapping for each object type defines the partition.
There is a quirk worth noting. When a token is write-restricted, the kernel also sets a user_deny_only flag on the token internally. This causes the token's own user_sid to match only deny ACEs in any pass, never allow ACEs. The motivation is to prevent a token from getting write access on an object simply because its user SID matches an allow ACE — the write-restricted intersection would otherwise be too easy to bypass by writing an ACE that names the user directly. With user_deny_only set, the user SID can still trigger denials but cannot grant.
This is a subtle interaction; most code that uses write-restricted tokens does not need to think about it, but if you are debugging an access denial on a write-restricted token and the user appears in the DACL with an allow ACE, the explanation is here.
Creation #
There is one path to creating a restricted token: KACS_IOC_RESTRICT on a token fd — the ioctl behind the FilterToken operation named elsewhere in this topic. The operation takes:
- A list of privilege LUIDs to remove from the new token.
- A list of group indices to mark
SE_GROUP_USE_FOR_DENY_ONLYin the new token (set once, irreversible). - A list of restricting SIDs to put in the new token's
restricted_sids. - An optional flag enabling write-restricted mode (which also sets
user_deny_only).
The result is a new token fd. The source token is unchanged.
A typical sandbox launcher does something like this:
- Open its own primary token.
- Call
KACS_IOC_RESTRICTto produce a restricted variant: remove every privilege exceptSeChangeNotifyPrivilege, mark unsafe groups deny-only, add the sandbox's allowed capability SIDs as the restricted SIDs, set the write-restricted flag. - Fork.
KACS_IOC_INSTALLthe restricted token on the child.- Exec the sandboxed binary.
The child now runs as the same user, but the user's group memberships are mostly invisible, the privileges are gone, and writes are confined to objects whose DACLs explicitly allow the sandbox's restricted SIDs.
Restricted tokens vs confinement #
Two things in Peios narrow what a token can reach: the restricted-token model on this page, and confinement (covered in Confinement). They look similar at a glance, but they exist for different audiences.
Restricted tokens are a tool for code. A program — a service, a sandbox launcher, an anti-malware engine — uses FilterToken or KACS_IOC_RESTRICT to narrow a token it already has, then runs sensitive work on the result. The decision is made in code, by the program itself, before it hands the restricted token to the constrained operation. Nothing outside the program needs to know about it, and nothing outside the program enforces it — the program is choosing to give itself less authority.
Confinement is a tool for policy. A sysadmin — or a service definition the sysadmin has chosen to deploy — declares that some component runs as a confined application with a specific confinement SID and an enumerated set of capabilities. The kernel enforces that policy whether or not the confined code is aware of it. Confinement is an administrative decision applied from outside the program, and the program cannot opt out of it.
That difference in audience is the reason behind the technical differences:
| Restricted | Confinement | |
|---|---|---|
| Who decides | The program itself, in code | Administrative policy, applied from outside |
| Typical caller | A sandbox launcher, a hardened service, an anti-malware engine | A service manager applying a service definition; a container runtime |
| Storage on the token | restricted_sids list | confinement_sid + confinement_capabilities |
| Where it fires in AccessCheck | Inside the DACL walk, identity-based intersection | After the DACL walk and privileges, absolute intersection |
| Bypassable with a privilege? | Yes — privilege-granted bits survive | No — confinement is absolute |
| Owner implicit rights still apply? | Yes | No |
| Write-only variant available? | Yes (write-restricted) | No |
The technical asymmetry follows from the audience. A program restricting itself is trusting itself to use the primitive correctly — it can drop privileges if it wants to, leave them in if it doesn't, choose what its restricted SID set should be. Confinement is enforced against the code, so it has to be a harder line: privilege exercise and owner implicit rights are exactly the kinds of escape routes a confined application would otherwise reach for.
The two can be combined. A service that runs under a confinement policy and additionally restricts its own internal worker threads sets both. The kernel applies each layer; the final granted mask is the intersection of all of them.
Practical patterns #
A few patterns worth recognising:
- Drop privileges only. Sometimes you want to remove dangerous privileges without restricting identity at all.
KACS_IOC_RESTRICTwith a privilege removal list, an empty deny-only list, and an empty restricted_sids list does this — the result is a token with reduced privileges and no restricted-SID intersection. - Write-restricted with the deny-only user trick. Set the write-restricted flag, leave
restricted_sidscontaining only what you want the sandbox to be able to write to. The token's user SID can still match deny ACEs (so user-targeted denials still work) but cannot match allow ACEs on writes. - Capability-style sandbox. Put one or more capability SIDs (well-known or derived) in
restricted_sids. The sandbox can then reach only objects whose DACLs explicitly grant access to those capabilities, plus whatever its user identity grants in the normal pass. - Anti-malware quarantine. Restrict to a narrow set of well-known SIDs (Everyone, Authenticated Users) and remove all privileges. The result is a token that can reach widely-shared resources but cannot exercise any system-level rights.
All of these are FilterToken / KACS_IOC_RESTRICT applied to the appropriate source token, with different inputs. The kernel does not distinguish between them; they are just patterns of use.
Where to go next #
For the linked Full/Limited token pair — the other derived-token pattern this topic covers — read Elevation and linked tokens.
For the policy-driven counterpart to restricted tokens, read Confinement.
To build a restricted token from a shell, read The token command.
Elevation and linked tokens
Peios / Peios Security Fundamentals / Tokens
A linked token pair is two tokens for the same principal, one elevated (Full) and one not (Limited), associated with each other through their shared logon session. The point of the pair is to give a user a default identity that is not fully privileged, while keeping a second identity available for explicit elevation when the user actually wants to do administrative work.
If you have used UAC on a Windows desktop, this is the same model. The Limited token is what runs the user's shell and most of their software. The Full token is what runs the action they have just been prompted to authorise. The kernel does not decide when to switch — that is a user-space decision, prompted by some authority broker — but the kernel is responsible for keeping the pair linked, locating the partner on demand, and enforcing the rules around who is allowed to see what.
The model #
flowchart LR
A["Logon session"] -->|associates| B["Full token (elevation_type = Full)"]
A -->|associates| C["Limited token (elevation_type = Limited)"]
B <-->|partner| C
Both tokens share the same logon session (auth_id). They have the same user SID, the same logon SID, the same created_at. What differs:
- The Full token has whatever privileges and group memberships the principal is entitled to when running elevated — typically including BUILTIN\Administrators, SeBackup, SeRestore, etc.
- The Limited token is a filtered version — privileges removed, sensitive groups marked
USE_FOR_DENY_ONLY. It is the version the user runs in by default.
Each carries an elevation_type:
| Value | Meaning |
|---|---|
| Default | Token is not part of a linked pair. The vast majority of tokens. |
| Full | The elevated half of a pair. |
| Limited | The non-elevated half of a pair. |
A token's elevation_type is set when it joins a pair, never cleared. If the session is destroyed, the pair linkage is removed but the individual elevation_type values stay until the token objects themselves are freed.
Establishing a pair #
A pair is created by an authority broker — almost always authd, occasionally peinit — using:
KACS_IOC_LINK_TOKENS(elevated_fd, filtered_fd, session_id)
The kernel requires:
SeTcbPrivilegeon the caller.TOKEN_DUPLICATEon both token fds.- Neither token already linked.
- Both tokens part of the same session (
session_id). - The two tokens not the same token.
When the call succeeds, the kernel:
- Records the pair on the session.
- Sets the elevated token's
elevation_type = Full. - Sets the filtered token's
elevation_type = Limited.
Both tokens continue to be valid as independent tokens. The pair linkage is additional state on the session, not a property that bundles the two into one object.
A typical flow during user login — none of which authd does yet, per the note at the top of this page:
- authd authenticates the user.
- authd creates the user's session.
- authd mints the Full token with all the user's entitled privileges and groups.
- authd FilterTokens the Full token down to the Limited version — privileges removed, admin groups deny-only.
- authd calls
KACS_IOC_LINK_TOKENSto link the two. - authd installs the Limited token as the primary of the user's first process.
- The Full token is kept available (via session state or an authority broker process) for later elevation requests.
The user's shell, file manager, web browser, and most of their applications now run on the Limited token. Whenever the user does something administrative, the authority broker — after whatever consent step is appropriate — fetches the Full token via KACS_IOC_GET_LINKED_TOKEN and installs it on the new process.
Looking up the partner #
KACS_IOC_GET_LINKED_TOKEN(token_fd) -> partner_fd
Given a token that is part of a pair, this ioctl returns a handle to the partner. The semantics depend on who is asking:
- With
SeTcbPrivilege, the caller gets a full handle on the partner token —TOKEN_ALL_ACCESS, the actual token object. This is what authority brokers use to perform an elevation: fetch the Full token's fd, install it on a child process. - Without
SeTcbPrivilege, the caller gets a degraded handle — a freshly duplicated Identification-level clone of the partner, opened only withTOKEN_QUERY. The clone is enough to inspect the partner's identity ("am I currently the Limited token? what would the Full version contain?") but cannot be used for any access check and cannot be installed.
The asymmetry is deliberate. Anyone who can prove they hold one half of a pair is allowed to learn something about the other half — that is useful for diagnostics and for user-facing tools that want to display "you are running as Limited; admin rights are available". But actually wielding the elevated identity requires SeTcbPrivilege, the privilege held only by the authority broker that is supposed to gate elevation.
KACS_IOC_GET_LINKED_TOKEN fails with -ENOENT on a token whose elevation_type is Default, or on a token whose pair was destroyed when its session was destroyed.
What the link does not do #
A few clarifications on what linkage does not change:
- It does not unify the two tokens. They remain independent objects. Adjustments to one have no effect on the other. Destroying one does not destroy the other.
- It does not change access decisions. AccessCheck reads whichever token is currently in effect on the thread. The fact that a token has a partner is invisible to the access check — only the broker that calls
KACS_IOC_GET_LINKED_TOKENsees the pair. - It does not require either token to be installed. A pair can exist on a session that has not yet attached either of its tokens to any process. (Unusual but valid.)
- It does not change
auth_id. Both tokens still report the same session ID. The session is the level at which they are paired.
Teardown #
A pair is dissolved when the underlying logon session is destroyed. At that point:
- The pair association is removed from the session.
- Subsequent calls to
KACS_IOC_GET_LINKED_TOKENon either token return-ENOENT. - The individual tokens themselves continue to exist as long as their reference counts hold. Their
elevation_typevalues are unchanged but no longer meaningful.
A session is destroyed when its last token reference drops (see Logon sessions). For a linked pair, that means: both tokens must lose all their attachments — every process running on them must exit, every fd open on them must be closed, every impersonation token derived from them must be reverted.
For the Limited token this happens naturally when the user logs out. For the Full token, the authority broker is responsible for releasing it when the session ends — typically by holding it in a process that exits when the user logs out.
Common patterns #
Default-Limited login. Every interactive login establishes a pair where the user's shell runs as Limited. The Full token is held by the authority broker.
Elevation on demand. When the user invokes an administrative action (a control panel, a sudo-equivalent), the broker prompts for consent, then uses KACS_IOC_GET_LINKED_TOKEN to fetch the Full token and KACS_IOC_INSTALL to install it on the privileged child process.
Service accounts (no pair). Services do not generally need elevation; their tokens are unpaired and elevation_type = Default. A service that needs occasional elevated work is a different pattern — usually IPC to an already-elevated service rather than a linked pair within the same service.
Diagnostics ("am I elevated?"). A process can call KACS_IOC_GET_LINKED_TOKEN on its own primary token (which it always has at least TOKEN_QUERY on). Without SeTcb it gets an Identification-level clone — enough to read elevation_type and groups on the partner and display "elevated rights available". The clone itself cannot be used for anything but inspection.
Where to go next #
For the session object that holds the pair together — and what happens to the pair when it dies — read Logon sessions.
To inspect a token's elevation type and its partner from a shell, read The token command.
The token command
Peios / Peios Security Fundamentals / Tokens
token is the command-line tool for working with tokens directly. It reads a token's contents, adjusts it, produces derived tokens, and drives impersonation — the low-level operations this topic describes, exposed at a shell.
token subcommand [target] [arguments]
$ token # one-line summary of your own token
$ token show --all # every field of your own token
$ token privs --pid 4821 # the privileges on process 4821's token
token is a direct, debug-level tool. Day to day you do not inspect tokens by hand — the system does. token is for diagnosing an access problem, for understanding what identity a process is really running under, and for building and testing identity setups. Run with no subcommand, it prints a one-line summary of your own token.
Choosing which token #
Almost every subcommand operates on a token, and these flags choose which one. With none, the target is your own.
| Flag | Target |
|---|---|
--self | Your own token. The default. |
--real | Your primary token specifically, rather than the effective one — relevant when your thread is impersonating. |
--pid PID | The primary token of process PID. |
--tid TID | The impersonation token of thread TID (used with --pid). |
--peer SOCK_FD | The peer's captured token on a connected socket — see Peer tokens. |
Reading another process's token is itself access-controlled: it succeeds only with the right authority over that process.
Inspecting a token #
show #
token show prints a token's contents. It is the default — bare token is token show --short.
| Flag | Effect |
|---|---|
--short | A one-line summary. |
--all | Every query class — the fullest dump. |
Field accessors #
Each of these prints one part of a token, for when you want just that piece:
| Subcommand | Prints |
|---|---|
user | The user SID — who the token is. |
owner | The default owner SID. |
group | The primary group SID. |
groups | The group list. |
privs | The privileges, with their enabled state. |
caps | The capabilities. |
claims | The user and device claims. |
integrity | The integrity level. |
logon | The logon type and logon SID. |
source | What minted the token. |
origin | The originating session for a derived token. |
stats | Token statistics — IDs, timestamps, the modification counter. |
default-dacl | The token's default DACL. |
query #
token query CLASS performs a raw read of a single named token-info class and prints the result as JSON — the lowest-level inspection route, for tooling.
Changing a token #
adjust #
token adjust mutates a token in place:
| Form | Changes |
|---|---|
adjust privs NAME=STATE … | Enable, disable, or remove privileges. STATE is enabled, disabled, or removed. |
adjust groups IDX=STATE … | Enable or disable groups by their list index. |
adjust default --dacl SDDL | Replace the token's default DACL. Also --owner-idx / --group-idx. |
adjust session ID | Replace the token's session id. |
restrict #
token restrict produces a restricted token — a more limited variant of a token.
| Flag | Effect |
|---|---|
--drop-privs MASK|NAMES | Privileges to drop. |
--deny IDX,… | Group indices to mark deny-only. |
--restrict SID,… | The restricting SIDs to apply. |
duplicate #
token duplicate (alias dup) copies a token, optionally changing its --type (primary or impersonation), its impersonation --level, or its --access mask.
link and linked #
token link joins two tokens as an elevation pair — a full token and its filtered counterpart — given their file descriptors and a session id. token linked shows a token's elevation-linked counterpart, if it has one. See Elevation.
Impersonation #
| Subcommand | Effect |
|---|---|
impersonate | Begin impersonating the target token on the calling thread. With a trailing -- command …, run that command under the impersonating token. |
revert | Drop any active impersonation on the calling thread. |
See Impersonation for the model these drive.
Creating tokens #
| Subcommand | Effect |
|---|---|
create SPEC | Create a token from a binary token-spec (SPEC is a file, or - for standard input). |
install SPEC | Create a token from a spec and install it as the caller's primary token. |
Creating and installing tokens is a privileged operation, reserved for the components that legitimately mint identity.
Output options #
| Flag | Effect |
|---|---|
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known, falling back to raw. |
--json | Emit JSON instead of human-readable output. |
token and the inspection surfaces #
token is the convenient front-end. Underneath, it reads the same kernel surfaces and rules described in Inspecting tokens — that page covers the query mechanism, the access rules for reading another process's token, and what cannot be inspected.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error. |
| non-zero | The operation failed — no such target, an access denial, or a bad spec. |
Logon sessions
Peios / Peios Security Fundamentals / Logon Sessions
A logon session is the kernel object that records one authentication event. It exists from the moment a principal successfully signs in to the moment every token derived from that sign-in is released. Every token references exactly one session through its auth_id field, and the session ties together everything that came out of that sign-in: the primary tokens, the impersonation tokens derived from them, the linked Full/Limited pair when one is established.
Sessions exist to answer two questions cleanly:
- "Did all of these tokens come from the same sign-in?"
- "When this principal logs out, what should we tear down?"
The first matters for audit and for revocation. The second matters because logging out has to do more than just close a shell — it has to release Kerberos tickets, drop linked-pair associations, fire the right events. Sessions are the unit at which those teardowns happen.
What a session contains #
A logon session is a small kernel object — fewer fields than a token, no privileges or groups of its own. Its job is to identify the event, not the principal.
| Field | What it is |
|---|---|
session_id | A unique LUID identifying the session. Same as the auth_id on every token belonging to the session. |
logon_type | What kind of sign-in produced this session — Interactive, Network, Service, Batch, etc. See Logon types. |
user_sid | The principal who signed in. The same SID appears in user_sid on every token of the session. |
auth_package | A string identifying the authentication mechanism (e.g. "Kerberos", "NTLM", "local"). Informational; not used by AccessCheck. |
created_at | Timestamp of the sign-in. |
logon_sid | A per-session SID derived from the session_id. See below. |
The session is the unit of identity provenance. If you want to answer "where did this token come from?", the path is: token → auth_id → session → logon_type, auth_package, created_at, user_sid. Everything an audit log needs to reconstruct a sign-in is reachable from the session.
The logon SID #
Every session has its own SID, called the logon SID:
S-1-5-5-X-Y
Where X and Y are the high and low 32 bits of the session's LUID. The logon SID is unique to the session — two different logins by the same user produce two different logon SIDs, because they have different session IDs.
Every token of the session carries its logon SID in two places: the dedicated logon_sid field, and as an entry in the groups list with the SE_GROUP_LOGON_ID flag set. Both views point at the same value. The flag exists so the access check can identify the logon SID without needing to know its specific value.
The logon SID is what lets you write an ACE that targets "the specific sign-in that did this" rather than "this user". Common uses:
- Per-session temporary objects. Grant access to the logon SID only. When the session ends, the SID becomes unreachable (no future token will ever carry it), so the object is effectively cleaned up at logout from an access-control perspective.
- Service-to-user isolation in shared sessions. Two services running for the same user but in different sessions can use the logon SID to keep their per-session state apart even though the user SIDs are identical.
- Audit anchoring. An audit event records the logon SID alongside the user SID. The two together let an investigator distinguish "this user did this" from "this specific sign-in did this".
The logon SID cannot be disabled (SE_GROUP_LOGON_ID is a mandatory-equivalent flag), and it cannot be removed from the token. It is intrinsic to the token.
Sessions and tokens #
A session can have many tokens. Every primary token of a process belonging to the session, every impersonation token derived from one of those primaries, every duplicated or filtered copy that shares the same auth_id — all of them are tokens of that session.
flowchart LR
A["Logon session, auth_id=42"] --> B["Primary token (user's shell)"]
A --> C["Primary token (child process)"]
A --> D["Impersonation token (service handling this user)"]
A --> E["Filtered token (restricted sandbox of B)"]
A --> F["Full token (linked pair)"]
A --> G["Limited token (linked pair)"]
The kernel keeps a reference count of how many tokens belong to a session. When the count drops to zero — every token of the session has been released — the session itself is destroyed and a logon-session-destroyed event is emitted.
The implication is that destroying a session is not a primitive operation. There is no kacs_destroy_session syscall. You destroy a session by ensuring every token belonging to it is released, which usually means killing every process running on those tokens. Session revocation — what authd does when an administrator forces a logout — is implemented this way in user space.
Bootstrap sessions #
Two sessions exist before authd is up, created by the kernel during boot:
| Session | ID | Purpose |
|---|---|---|
| SYSTEM session | 0 | The session of the SYSTEM token, attached to init and inherited by every process until authd assigns real tokens. |
| Anonymous session | 998 | The session of the Anonymous token, used as the user SID for Anonymous-level impersonation. |
Both are created by direct kernel initialisation — they do not go through kacs_create_session. They are also never destroyed during a running system's lifetime: the SYSTEM token always exists somewhere, and the Anonymous token is a singleton.
You will see these IDs in audit logs and /sys/kernel/security/kacs/sessions listings. They are not bugs.
Sessions are not durable #
A session exists in the kernel only while it has active tokens. There is no on-disk session table. The session ID space is not stable across boots — after a reboot, the same user signing in again gets a different session ID, and a new logon SID.
This matters in two practical ways:
- You cannot reference a session across a reboot. An ACL entry that targets the logon SID of "the previous boot's session 5" is meaningless after the next boot. The SID format will look syntactically valid, but no token will ever match it.
- Per-session state should be in memory, not on disk. Anything intended to survive a single sign-in should be keyed on the user SID, not the logon SID.
Where to start #
If you want the catalog of logon types — what Interactive, Network, Service, Batch, NewCredentials, and the rest each mean — read Logon types.
If you want the creation, destruction, and revocation mechanics — including the logon-session-destroyed event and what authd does for forced logout — read Session lifecycle.
If you want to see which sessions are currently active on a running system, read Inspecting tokens, sessions, and processes.
To work with sessions from a shell — list them, see their processes, create and destroy them — read The logonse command.
Logon types
Peios / Peios Security Fundamentals / Logon Sessions
Every logon session carries a logon_type — a single number, set by authd at session creation, that classifies the nature of the sign-in. The type is informational from KACS's point of view: AccessCheck does not branch on it. But it is recorded in every audit event that references the session, and it is what audit consumers and SIEM tools use to distinguish "the user logged in at the console" from "the user logged in over the network" from "a service started under this account".
There are six types in v0.20.
The six types #
The numeric values are also catalogued in Other constants.
| Value | Name | What it means |
|---|---|---|
| 2 | Interactive | The user signed in at the console, an SSH session, or some other interactive channel. The most common type for human users. |
| 3 | Network | The user authenticated to access network resources without an interactive sign-in. Typical for SMB, RPC, federated services. |
| 4 | Batch | A scheduled job. The principal is logged in to run a task at a specific time, not by a user actively present. |
| 5 | Service | A service started under a specific principal. Used for the long-lived service-account model. |
| 8 | NetworkCleartext | A network logon where the credential was transmitted in cleartext over the wire. The session is otherwise a Network session; the type distinction exists for audit. |
| 9 | NewCredentials | A session created to use different credentials when reaching out to remote resources, while keeping the local identity unchanged. The local thread continues to act as its primary token; outbound network requests carry the alternative credentials. |
Values 1, 6, 7, and 10 onward are reserved and not used in v0.20.
When each type is used #
Interactive #
The default for any sign-in where a human is at the keyboard. Console logins, SSH connections, terminal services, the lock-screen unlock — all Interactive.
You will see Interactive in audit logs whenever a real user begins a session. If your audit policy distinguishes "human did something" from "automation did something", the Interactive type is the marker for the human side.
Network #
Used when a principal authenticates only to access a network resource — for example, a remote user mounting a file share. The session exists on the server, the user's identity is established there, but the user has no interactive presence on the machine. The session's tokens are typically short-lived (one connection's worth of work).
A Network session is what an inbound SMB connection produces, what an authenticated RPC call produces, what a federated service request produces. It is also what a host typically sees when a user reaches a service on it that is acting on the user's behalf.
Batch #
Scheduled tasks. The principal is "logged in" only in the sense that the system mints a token for the user so the task can run as them. There is no human presence. Once the task finishes, the session ends.
Batch sessions are useful for auditing scheduled work — distinguishing "the user typed this command" from "the user's scheduled task did this command".
Service #
The session under which a service runs. The service account signs in once at service start; the session lives for as long as the service runs. Most services on a Peios machine are Service-type sessions.
This is the type you will see most often in the session listing at /sys/kernel/security/kacs/sessions on a system without active interactive users — every running service contributes one Service-type session.
NetworkCleartext #
Used when the authentication protocol exposed the credential in cleartext on the wire. The semantic effect is identical to Network — it is a network sign-in producing a Network-style session — but the type difference exists so audit policy can flag the cleartext exposure.
You should rarely see this in normal operation. Its presence in an audit log is worth attention; it usually indicates that an older or misconfigured protocol is in use.
NewCredentials #
The unusual one. A NewCredentials session does not replace the calling thread's identity. Instead, it represents "I want to keep being myself locally but use these other credentials for outbound network calls". The thread's effective token retains the local user's SIDs and privileges for everything KACS evaluates locally, but any outbound credential-using request carries the alternative principal.
The pattern matters in environments where a user has local rights on one machine and different rights on another, and wants to keep both available without switching sessions.
Where the type appears #
The logon type is stamped in three places you will care about:
- In the session itself, retrievable through
/sys/kernel/security/kacs/sessionsand the session inspection APIs. - In every token's session — via the token's
auth_idfield, which points to the session, which holds the type. - In audit events — every audit event that includes a subject also implicitly identifies the subject's session, and audit consumers can use that to retrieve the logon type for the event.
The type is not part of any token's SID list. There is no "Interactive" SID that gets added to a token's groups based on the logon type. The well-known SIDs like S-1-5-4 (Interactive) and S-1-5-2 (Network) are added to a token's groups by authd based on the logon type, but they are independent token attributes from that point on. A token with the Interactive group SID has it because authd put it there; the session's logon_type is the input that drove that decision.
This independence is intentional. A token can in principle carry the Interactive group without belonging to an Interactive-type session (rare, but legal). Audit and ACL evaluation use the group SID; provenance uses the session's type.
What the type does not affect #
AccessCheck does not read the logon type. The DACL walk, the MIC check, the PIP check, the restricted/confinement passes — none of them branch on logon_type.
If you want different access for "Interactive users" vs "Network users", you write ACEs that reference the well-known group SIDs (S-1-5-4, S-1-5-2, etc.). Those SIDs end up in the token because of the logon type, but the access check matches on the SID, not the type.
The type matters at the seams: when authd is making decisions about which groups to add to a token, when the audit subsystem is recording provenance, when a SIEM correlates events across sessions. Inside the access check itself, the type is invisible.
Where to go next #
For how a session is created, destroyed, and forcibly revoked, read Session lifecycle.
For the well-known group SIDs (S-1-5-4 Interactive, S-1-5-2 Network, and the rest) that authd derives from the logon type, read Well-known principals.
To see the logon type of each active session from a shell, read The logonse command.
Session lifecycle
Peios / Peios Security Fundamentals / Logon Sessions
A session's life is bracketed by two kernel events: a successful kacs_create_session call that brings it into existence, and the implicit drop of its last token reference that destroys it. There is nothing in between — no resize, no rename, no kernel-side timeout. Sessions are simple objects whose lifecycle is driven entirely by the tokens attached to them.
This page covers the three phases that matter: creation, destruction, and revocation (the userspace pattern for forcing a session to end before the user logs out voluntarily).
Creation #
A session is created by kacs_create_session. The call requires SeTcbPrivilege, so in practice the only callers are authd (every interactive and network sign-in) and peinit (services launched during boot before authd is available).
The call takes a wire-format specification with three fields:
| Field | Meaning |
|---|---|
logon_type | The type — Interactive, Network, Service, Batch, etc. See Logon types. |
auth_package | A string identifying the authentication mechanism (informational). |
user_sid | The principal who signed in. |
The kernel:
- Validates the inputs (well-formed SID, recognised logon type, sane string lengths).
- Allocates a session object with a fresh
session_idLUID. - Stamps
created_at. - Derives the logon SID
S-1-5-5-X-Yfrom the session ID. - Initialises the session's token reference count at zero.
- Returns the new session ID to the caller.
The session now exists but has no tokens. It is in a transient state: any subsequent kacs_create_token call that references this session_id in its auth_id field bumps the count, and the session is "live". If no token is ever created against the session — vanishingly rare — the session stays at refcount zero and is reaped after a brief grace period.
The grace period is what avoids a race: authd's flow is "create session, then mint primary token referencing it". Between those two calls the session has no tokens, and a strict "destroy when refcount hits zero" rule would tear it down before authd's second call. The kernel solves this by only triggering destruction on a refcount that transitions from positive to zero, not on a refcount that has been zero since creation. After a successful first attachment, normal destruction rules apply.
The boot sessions #
Two sessions exist without ever passing through kacs_create_session:
| Session | ID | Created by |
|---|---|---|
| SYSTEM session | 0 | Direct kernel init |
| Anonymous session | 998 | Direct kernel init |
Both are constructed in early boot, before any process exists. The SYSTEM session is attached to the kernel's bootstrap SYSTEM token, which init inherits and which propagates through every process until authd assigns real tokens. The Anonymous session backs the well-known Anonymous token used by Anonymous-level impersonation.
Neither is ever destroyed during the running system's lifetime. They are reference-counted like any other session, but their references never drop to zero — the SYSTEM token always has at least one attachment somewhere, and the Anonymous token is a kernel-internal singleton.
Destruction #
A session is destroyed when its token reference count, having been positive, drops to zero. The kernel:
- Removes the session from the session table.
- Tears down any linked-pair association it was holding (see Elevation and linked tokens).
- Emits a
logon-session-destroyedevent through KMES.
The event carries enough information for consumers to clean up downstream state:
| Field | What it is |
|---|---|
session_id | The destroyed session's ID. |
user_sid | The principal. |
logon_type | The type. |
auth_package | The auth package string. |
created_at | The session's creation timestamp. |
The most important consumer of this event is authd itself. authd subscribes to logon-session-destroyed because it needs to release session-scoped state of its own: Kerberos tickets, cached directory data, any per-session credentials it has been holding. Without the subscription, authd would have no way to know that a session it created is gone.
Other consumers (audit pipelines, accounting tools, session-aware services) may also subscribe. The event is fire-and-forget — there is no acknowledgement, no retry, no replay. A consumer that misses an event misses it.
What ends a session #
A session ends only when every reference to it drops. The references are:
- Every primary token attached to a process with
auth_id = session_id. - Every impersonation token currently installed on any thread.
- Every token fd open on a process's behalf.
- The linked-pair association, while it exists.
Practically, this means a session ends when:
- Every process running on a token of the session has exited.
- Every thread that was impersonating a token of the session has reverted or exited.
- Every fd held on a token of the session has been closed.
- The linked pair, if any, has been dissolved.
The fourth condition is satisfied automatically when the session reaches refcount zero — the kernel dissolves the pair as part of destruction. The first three are user-space's responsibility.
For an ordinary logout, this happens naturally: the user's shell exits, child processes exit, the authority broker process closes its hold on the Full token. Once all of those happen, the kernel sees refcount zero, fires the event, frees the session.
Revocation: there is no kernel call #
There is no syscall to forcibly end a session. No kacs_destroy_session, no kacs_kill_session. The session model is reference-counted, and the only way to end one is to ensure every reference drops. (The one destroy syscall that exists, kacs_destroy_empty_session, is a rollback primitive for empty sessions only — it refuses with -EBUSY any session that still has live tokens. This is what logonse destroy wraps.)
Forced logout — an administrator deciding that user X should not be signed in any more — is therefore a userspace operation. The pattern, implemented by authd:
- Walk
/proc/*/tokento find tokens whoseauth_idmatches the target session. - For each matching token, identify the process holding it.
- Send the appropriate signal to terminate the process (typically SIGTERM with a grace period, then SIGKILL if needed).
- Continue until no processes hold any token of the session.
Once the last process exits, the session reaches refcount zero, the event fires, and the user is logged out.
The kernel cooperates by exposing auth_id through token query interfaces (specifically the TokenStatistics query class via KACS_IOC_QUERY) and by providing the per-PID token handles at /proc/<pid>/token. authd uses both to do the walk.
There are two reasons the kernel does not provide a direct revoke:
- No graceful path. A "kill the session" syscall would need to choose between killing every process holding any of its tokens (loss of work, possible data corruption) and merely refusing future operations, which leaves running processes with stale identity. User-space can stage the teardown — send SIGTERM, wait, escalate — in a way the kernel cannot.
- Reference-counted identity is the simpler model. The rule "a session exists if and only if a token exists referencing it" has one fewer transition than "a session exists if and only if its tokens exist AND no revocation has been requested". Fewer transitions, fewer corner cases.
The cost is that revocation is observable: a thread can detect that its session is about to die (its parent process getting a TERM) before the kernel sees the session as gone. Anything that wants to enforce immediate revocation needs to design around that — typically by minimising the work a thread can do between receiving a signal and actually exiting.
Inspecting active sessions #
The kernel exposes the active session list at /sys/kernel/security/kacs/sessions. The file is a text listing, one session per line:
session_id=42 user_sid=<hex-encoded SID> logon_type=2 auth_package=<hex-encoded name> created_at=...
Lines are stable in their leading fields; new fields may be appended in a future version, so consumers must ignore unknown trailing fields. The file's own SD grants read to BUILTIN\Administrators and SYSTEM only.
This is the canonical way to enumerate sessions. Other tools — eventd consumers, who-equivalents, session monitors — can read it directly or through helpers that wrap it. See Inspecting tokens, sessions, and processes.
Where to go next #
For the token-side half of the same story — the references whose rise and fall drive a session's life — read Token lifecycle.
To list, create, and destroy sessions from a shell, read The logonse command.
The logonse command
Peios / Peios Security Fundamentals / Logon Sessions
logonse is the command-line tool for logon sessions — the kernel's records of authentication events that this topic describes. It lists the active sessions, shows which processes belong to one, creates and destroys sessions, and (as a related low-level job) sets a process's mitigation flags.
logonse subcommand [arguments]
$ logonse list
$ logonse show 4711
logonse is a low-level administrative and debugging tool. It requires a subcommand: list, show, create, destroy, or psb.
Listing sessions #
logonse list #
Enumerates the active logon sessions, and the process IDs in each.
$ logonse list
session 0 pids: [1, 2, 14, 22]
session 4711 pids: [820, 844, 901]
logonse show #
Shows the process IDs that belong to one session.
$ logonse show 4711
A caveat on list and show #
There is no syscall that enumerates logon sessions, and logonse does not read the kernel's sessions file (whose SD restricts it to Administrators and SYSTEM). logonse list and logonse show work by walking the running processes and reading each one's token to find which session it belongs to. That has two consequences worth knowing:
- It is best-effort. A session that has no running process — held alive only by a token file descriptor somewhere — will not appear, because there is no process to find it through.
- It is a snapshot under change. Processes start and exit while the walk runs, so the result is a close approximation of the moment, not a locked one.
For routine "who is signed in" use this is fine. For an authoritative listing, the kernel's own sessions surface — described in Inspecting sessions — is the source of record.
Creating and destroying sessions #
logonse create #
Creates a new logon session for a user, described by a logon type, an authentication-package name, and the user's SID.
$ logonse create --logon-type interactive --auth-package Negotiate --user-sid S-1-5-21-...-1001
| Flag | Meaning |
|---|---|
--logon-type TYPE | The kind of logon: interactive, network, batch, service, network-cleartext, or new-credentials. |
--auth-package STR | The name of the authentication package that vouched for the logon. |
--user-sid SID | The user the session belongs to, as an S-1-… SID or an SDDL alias such as BA. |
On success logonse prints the new session's id. Creating a session is a privileged operation — minting authentication records is reserved for the components that legitimately do so.
logonse destroy #
Destroys a session — but only an empty one, with no tokens still referencing it.
$ logonse destroy 4711
A session with live tokens cannot be destroyed this way; its tokens must go first. See Session lifecycle.
Setting process mitigation flags #
logonse psb #
logonse psb sets the mitigation flags in a process's Process Security Block.
$ logonse psb --pid 4821 --mitigations 0x1c0
| Flag | Meaning |
|---|---|
--pid PID | The process to act on. |
--mitigations MASK | The mitigation bitmask to apply, in hexadecimal or decimal. |
This subcommand is about process hardening rather than logon sessions — it lives in logonse because both deal with low-level per-process kernel state. For what the mitigation flags mean and how they behave, see Process mitigations.
Output options #
| Flag | Effect |
|---|---|
--json | Emit JSON instead of human-readable output. Accepted by every subcommand. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error, or the operation failed. |
Signing in
Peios / Peios Security Fundamentals / Signing In
Every sign-in on Peios is a conversation with an authority over a Unix socket at /run/logon.sock. The protocol is PGSS Logon, and it is a conformance requirement rather than an implementation detail: a system that does not offer it, at that path, with these semantics, is not Peios.
Knowing its shape is worth the few minutes it takes. It explains why a login prompt behaves the way it does, why a wrong password and an unknown account are indistinguishable, and what you would have to build to add a new way of signing in.
Three roles #
The authority listens on the logon socket. It decides whether a sign-in succeeds, and it is the only thing on the system that mints tokens. On a stock Peios system that is authd. There is at most one on a running system — two would make "who authenticated this?" unanswerable.
The client connects and speaks on the principal's behalf. login is a client. So is any greeter, remote access daemon or kiosk agent you might write. A client renders the prompts it is asked to render, returns the answers, and installs the token it is given. It is not trusted, and nothing it sends is taken as fact.
A principal source knows who exists and verifies credentials. It asserts an identity and can do nothing else — it cannot mint a token, grant a privilege or create a session, because the protocol it speaks has no message for any of those. lpsd is the local one. See managing local principals.
The conversation #
A client opens a connection and sends one LogonStart, naming the principal, the kind of session it wants, and the credential types it is able to collect. The authority may then ask for credentials — as many rounds as the source needs — and each request carries prompts the client renders without interpreting. The conversation ends when the authority grants or denies.
The capability list is a statement about the client, not a demand. Declaring that you can collect a password does not mean one will be asked for; declaring that you can collect nothing says you are able to complete a sign-in that needs no interaction, and nothing else. An authority faced with that either completes the sign-in or denies it. That is the mechanism behind passwordless accounts and console autologon.
A connection carries exactly one conversation, so the connection is the conversation's identity. There is no correlation identifier to forge.
The authority is not a process factory #
A grant carries a token as a file descriptor, a session identifier, and a profile. It carries nothing about what should happen next, because nothing about what happens next is the authority's concern.
The client installs the token itself and proceeds. login makes the token its own and then execs your shell, rather than the authority forking anything. This keeps the most privileged process on the system out of the business of launching programs: authd never learns about terminals, environments or session leadership, and stays small enough to audit.
It also means a client can obtain a token for a purpose the authority never anticipated.
Authentication and derivation are separate #
Two acts, deliberately distinguished.
Authentication establishes that the principal is who they claim. Its output is an identity and nothing more.
Derivation constructs the token — which SIDs it carries, which privileges, at what integrity level, with what projected identifiers. Its inputs are the authenticated identity and this machine's policy.
The separation is what stops an identity established elsewhere from carrying entitlements onto this machine with it. A directory can tell this machine that you are a member of Domain Admins; whether that membership carries SeLoadDriverPrivilege here is answered here, every time, by the authority applying local policy. An authority never accepts a token, a privilege set or an integrity level from another party, whatever its trust level.
See privileges for what that policy contains.
What the authority does not trust #
Nothing a client sends. Three consequences are worth knowing because you will see them from the outside:
Peer identity comes from the socket. The authority reads the connected peer's token from the kernel rather than believing anything in a message, because there is no field a client could put its identity in that it could not also lie in. SO_PEERCRED is not used: it returns the projected uid, and on Peios many principals project to the same number.
The logon type is a proposal. Only the caller knows whether a connection is an interactive shell or a batch command, so the caller says — and the authority constrains that against what the verified peer is permitted to request. See logon types.
The identifier is a claim until authentication succeeds, and tty and remote_host are never more than that. A client that lies about its remote host is not prevented from doing so by this protocol.
A wrong password and an unknown account look identical #
An authority never distinguishes an unknown principal from a bad credential — not by denial code, not by the text it returns, and not by timing. There is one denial for both.
lpsd holds to the timing half by verifying an unknown name against a decoy: a real verifier over a password nobody knows, so both outcomes cost one full derivation. Returning early for a name it did not recognise would make "does this account exist?" answerable by anyone who can time a sign-in.
Account existence is not a secret, and Peios does not pretend otherwise — it is answered plainly on a different socket, /run/ident.sock, which is how ls -l turns an owner into a name. What the logon socket refuses to do is let you learn it by guessing credentials. See resolving names.
Where to start #
- The
logincommand — the terminal client, and how a live image signs in without being asked anything. - Logon sessions — the kernel object a successful sign-in creates.
- Managing local principals — the accounts being signed in to.
The protocol is specified rather than merely documented: PGSS §2 is Logon, and PSPU §2 is PSI, the interface principal sources speak. Read those if you are writing a client or a source of your own.
The login command
Peios / Peios Security Fundamentals / Signing In
login is the terminal client for signing in. It collects an identifier, renders whatever credential prompts the authority sends, installs the token it is granted, and replaces itself with your shell.
login [name] [options]
login --try <name> [options]
login --try-no-password <name> [options]
It does not know what a password is. It renders the prompts it is asked to render and returns the answers, so adding a new credential type — a one-time code, a smartcard — changes the authority and leaves login alone.
Naming a principal, or not #
With no name, login asks for one:
Username: alice
Logging in as alice
Password:
With a name, the first prompt is skipped. The Logging in as line comes from the authority rather than from login, so it will gain a realm once authorities have them.
A principal who needs no credential is signed in immediately, with no prompt at all — see passwordless accounts.
--try and --try-no-password #
Both attempt a named principal and fall back to an ordinary prompt rather than failing. They differ in what they offer to collect.
--try <name> attempts the principal with login's full capabilities. A passwordless principal is signed in immediately; one with a password is prompted for it.
--try-no-password <name> attempts the principal while declaring that it can collect nothing, so only a principal who needs no credential can succeed. This is what a console autologon uses.
Neither flag asserts that anyone is authenticated. The authority still decides, and understating what you can collect only denies you prompts you could have rendered — so running either by hand gains you nothing you did not already have.
| Passwordless | Has a password | No such principal | |
|---|---|---|---|
login alice | signed in | prompts | user alice does not exist |
login --try alice | signed in | prompts, then falls back if wrong | falls back silently |
login --try-no-password alice | signed in | falls back silently | falls back silently |
A fallback restarts the sign-in from Username:, so you are never locked to the principal that was attempted.
When a fallback explains itself #
login says why it fell back only if something was already on your terminal.
A --try that reached a password prompt has interrupted you, and dropping to Username: without a word would read as a fault — you typed a password and got asked for a name. So it reports first:
$ login --try alice
Logging in as alice
Password:
login: Password incorrect
Username:
A --try-no-password that was refused before anything was rendered has interrupted nobody, and stays quiet. That is what keeps a line about a failed autologon off the console of every machine where the principal simply has a password.
Falling back is limited to denials another principal could survive. A failure of the authority itself is reported and login exits, because offering a prompt that cannot work either would spin a console.
Where existence is checked #
login asks /run/ident.sock whether a principal exists, not the logon socket. The logon socket will not tell it — an authority never distinguishes an unknown principal from a bad credential — while the identity socket answers plainly, which is what it is for.
If that lookup cannot be answered, login attempts the sign-in anyway rather than reporting an absence. An unreachable source reported as "no such user" would turn an outage into a fact.
Autologon on a console #
A console that signs in on its own is a passwordless principal plus --try-no-password. On a live image, peinit starts:
/bin/login --try-no-password peios
The same service definition suits an image where peios has a password: the attempt is refused before anything is rendered, and an ordinary prompt appears. No second seed, and no conditional configuration.
To turn autologon off, give the principal a password with lps password. To move the prompt to another terminal, change the service's TTYPath — see controlling services.
Options #
| Option | Effect |
|---|---|
--try <name> | Attempt name, falling back to a full prompt. |
--try-no-password <name> | Attempt name collecting nothing, falling back to a full prompt. |
-p | Keep the inherited environment instead of building a fresh one. |
-h <host> | Record the remote peer for a sign-in originated on its behalf. Unverified. |
-H | Accepted and ignored. Suppresses the hostname banner on other systems. |
-- | Treat everything after as a name, not an option. |
-f is not implemented. On Linux it means "trust me, they are already authenticated", gated only by the caller being root. On Peios that decision belongs to the authority, taken from the verified peer, so accepting a flag here would put an authentication bypass in an unprivileged process.
The environment your shell starts in #
Without -p, login builds a fresh environment: HOME, SHELL, USER, LOGNAME, PATH=/bin, and TERM carried through from its own. The shell's argv[0] gets a leading dash, which every shell reads as "this is a login shell, run the profile files".
The home directory comes from the profile the authority sent, and a missing one is not fatal. login reports it and starts you in /. Refusing to proceed over an absent directory would turn a cosmetic problem into being locked out, and creating it would put directory provisioning inside the one program that has to keep working when everything else is broken.
Exit status #
login does not return on success — it replaces itself with your shell, so what you see afterwards is the shell's exit status.
| Code | Meaning |
|---|---|
1 | A usage error, a denied sign-in, or the shell could not be started. |
Managing local principals
Peios / Peios Security Fundamentals / Managing Local Principals
Signing in on Peios involves three separate things, and it is worth being able to name them before you administer any of them.
The authority is the process that mints tokens. On a stock Peios system that is authd. It listens on /run/logon.sock, it holds the privilege to create tokens, and it is the only thing on the system that does. When you type a password at a login prompt, login is talking to authd.
A principal source is a process that knows who exists. It verifies credentials and answers the question "who is this?" — and nothing else. It cannot mint a token, cannot grant a privilege, and cannot create a session, because the protocol it speaks gives it no way to say any of those things.
lpsd is the local principal source: the one that holds this machine's own accounts. It is where jack lives, and it is what you are administering when you run lps.
Why the split #
It would be simpler to have one daemon that both checks passwords and mints tokens. The split exists because those two jobs have very different risk profiles.
Checking a credential means parsing input that came from outside — from a login prompt, a network connection, a smartcard reader. That is the code most likely to have a defect in it. Minting a token is the most privileged operation on the system.
Putting them in one process means a defect in the first becomes a compromise of the second. So authd mints and cannot verify; lpsd verifies and cannot mint. A completely compromised lpsd can lie about the accounts it holds, and that is the whole of what it can do — it cannot elevate anyone, cannot forge a token, and cannot claim identities that are not its to claim.
The division runs through what a source is even able to say. A source states who someone is: their SID, their memberships, their POSIX identifiers, where their session starts. It never states how much this machine trusts them — privileges and integrity levels are authd's, decided from local policy, and there is no message with which a source could ask for one.
That local policy is a registry key, one record per principal, and it is where you go to change what an account may do as opposed to who it is: assigning privileges.
How they find each other #
lpsd connects to authd, not the other way round. That keeps authd — the process holding the token-minting privilege — free of any reason to open an outbound connection to anything.
At boot, lpsd starts after authd, connects to /run/psi.sock, registers itself, and only then reports itself ready. That ordering means anything that starts after lpsd — login, a greeter, a remote access daemon — finds a system that can genuinely authenticate, rather than one where the processes merely exist.
If you see lpsd failing to start, the usual causes are that authd is not running, or that this machine's configuration does not permit lpsd to register. The second is deliberate: a source must be explicitly allowed to assert identity, and an unconfigured machine permits none.
More than one source #
lpsd is the only source on a stock machine, but nothing about the design assumes it is alone. A directory-backed source can register alongside it, holding domain accounts while lpsd holds local ones, and authd routes each logon to whichever source owns the name.
Each source declares the SID namespace it is authoritative for, and authd confines it there. That is what stops a directory deciding who administers your machine, and equally what stops a local source handing out domain identities.
The same confinement applies to numbers. A source is given a range of POSIX identifiers and counts inside it, and authd adds the base — so a source's principals land in its own range whatever the source sends, and the numbers below every range, uid 0 among them, belong to authd alone. lpsd starts at 1,000,000; a directory source would be given somewhere well clear of it.
That is why a uid on a Peios machine is larger than you might expect, and why moving a source's range is a configuration change rather than a migration: nothing a source stores was ever an absolute number.
Seeing a principal, rather than a number #
Signing in is one half. The other is that everything on the system can turn an identifier back into a name — ls -l showing an owner, id showing a group.
That goes to authd too, on a socket of its own, and for a reason worth knowing: a source counts its POSIX identifiers relative to a range it is never told the base of, so only the authority can work out who a uid belongs to. See resolving names.
Where to start #
- The local store — what
lpsdkeeps, and where. - Resolving names — how a name, a SID or a number becomes a principal.
- Creating accounts — the first one, and every one after.
- The
lpscommand — the full reference.
The protocols themselves are specified rather than merely documented: PGSS §2 is Logon, and PSPU §2 is PSI, the interface sources speak. Read those if you are writing a principal source of your own.
The local store
Peios / Peios Security Fundamentals / Managing Local Principals
lpsd keeps everything it knows in one file:
/var/state/lpsd/principals
It is read once at startup and held in memory. It changes only when an administrator changes it.
What is in it #
The machine's domain SID. Every local principal is numbered under it, and it is generated — from the kernel's random number generator — the first time lpsd runs on a machine that has no store.
That last point matters more than it sounds. Two machines built from the same image do not share a domain, so the security descriptors written on one do not grant access to the other's users. An image cannot ship a domain, because the domain does not exist until first boot.
The domain is always of the form S-1-5-21-A-B-C. That shape is structural — the store holds three numbers under a fixed prefix, so it cannot express a domain of any other kind. It cannot claim S-1-5-32 (BUILTIN) or any well-known namespace, whatever is written into it.
The principals. For each: a relative identifier (RID), a Unix ID, the canonical spelling of the name, whether the account is enabled, a password verifier, the groups it belongs to, which of them is primary, a home directory, a shell, a display name, and any claims.
A principal's SID is the domain plus their RID. jack with RID 1000 in domain S-1-5-21-A-B-C is S-1-5-21-A-B-C-1000.
RIDs start at 1000 and are never reused. Not even after an account is deleted. That is deliberate and it is the property that makes deletion safe to offer at all: a reissued RID would give a new person the SID of an old one, silently inheriting every access the descriptors on this machine still grant them. It is the one identity mistake that cannot be undone by fixing the account afterwards.
The local groups. A group created here is an object: a RID from the same counter, a name, and a Unix ID. Sharing the counter with principals is what stops a group ever colliding with a user's SID.
Well-known groups — BUILTIN\Administrators, Everyone, Authenticated Users — are not stored. Their SIDs are the same on every Peios machine, so a stored copy could only ever drift from the real one, and provisioning would freeze whatever the table said that day. lpsd resolves their names from a table in its own code.
Unix IDs #
Linux programs call getuid and getgroups and have never heard of a token, so every token carries POSIX numbers alongside the SIDs. Those numbers begin here.
A principal's Unix ID is their RID, and a group's is its RID. One object, one number — so a uid in a log tells you the RID without a lookup.
The numbers stored here are relative. lpsd knows nothing about where its range sits: authd adds a base from the registry before the number reaches a token, so jack with RID 1000 stores 1000 and signs in as uid 1001000 when the base is 1,000,000.
That indirection is not bookkeeping. It is what stops a principal source reaching uid 0 or another source's numbers — authd reserves everything below the sources' ranges for its own, and refuses a relative number that runs past the end of a range rather than wrapping it. A source can only ever express numbers inside the range it was given.
It also means moving a range is a registry edit rather than a rewrite: nothing stored here was ever absolute.
lps list and lps show display the effective number, with the base already added, because that is the one an operator will see everywhere else.
The same indirection is why getpwuid has to reach authd rather than lpsd: the arithmetic that made a number absolute happened in authd, and only authd can run it backwards. See resolving names.
Profiles and claims #
The profile — home directory, shell, display name — is not identity. No security descriptor names a home directory and no token carries one. It is stored here because this is where an administrator sets it, and it travels to login on the logon reply so a session can start without a second lookup.
Claims are named, typed attributes that conditional ACEs read: a descriptor can grant access to anyone whose Department is Engineering without naming principals individually. They are fixed on a token when it is minted, so a claim set now takes effect at the principal's next sign-in.
What is not in it #
Passwords. What is stored is a verifier — an argon2id hash — and a verifier is deliberately not password-equivalent. You cannot authenticate with it, and it cannot be used to answer a challenge.
That is the property that makes stealing the store meaningfully weaker than knowing the passwords. It is also why Peios does not use challenge-response schemes: those require storing something a response can be recomputed from, which is exactly what this avoids.
Each verifier carries the cost parameters it was created with, so raising them applies to passwords set afterwards while existing accounts keep working.
Well-known group definitions. The store defines the groups this machine creates, and records memberships of everything else as SIDs. BUILTIN\Administrators exists on every Peios machine whether or not this store mentions it; lpsd only records that jack is in it.
Privileges and integrity levels. What a principal may do is not stored here at all. authd decides it, from local policy keyed on the SIDs a token ends up carrying — a registry key rather than this file, so that it stays readable by an administrator and this file can stay readable by nobody. A principal source says who someone is; it has no way to say how much this machine trusts them. See assigning privileges.
The descriptor #
The store file is owned by LocalSystem and its DACL grants LocalSystem and nothing else — not even BUILTIN\Administrators.
That is narrower than almost anything else on the system, and it is on purpose. This is the machine's password material, and the list of principals entitled to read it should be as close to empty as the system permits. An administrator who genuinely needs the file can take ownership, which is an act that leaves a trail; a read granted by the DACL does not.
It is also why lps talks to lpsd over a socket rather than editing the file. A tool that wrote the file would need that descriptor widened to whatever the tool runs as, undoing the one thing it exists to do.
A file, not a database #
The store is serialised whole and replaced atomically: written to a temporary file, flushed, then renamed over the old one.
A few hundred principals, rewritten when an administrator changes an account, is not a workload that needs a write-ahead log. And because the swap is a rename, a reader sees the old file or the new one and there is no third outcome — which means there is no recovery code, and code that does not exist cannot be wrong.
The file carries a checksum. Not to catch a half-written file, which atomic replacement makes impossible, but because a corrupted store that decoded short would present as an account quietly no longer existing.
Missing versus corrupt #
These are treated as opposite outcomes, and the distinction is the most important behaviour in this page.
No store at all means an unprovisioned machine. lpsd generates a domain, writes an empty store, and carries on.
A store that will not read is fatal. lpsd refuses to start.
Collapsing the two — "cannot read it, so make a new one" — would turn a flipped bit into every account on the machine silently ceasing to exist, and then reappearing under a new domain with different SIDs, orphaning every security descriptor that named them. Refusing to start is loud, reversible, and leaves the evidence intact.
If lpsd reports that the store is corrupt, do not delete it. Take a copy first: it is the only record of your machine's identity.
Creating accounts
Peios / Peios Security Fundamentals / Managing Local Principals
Creating an account #
Run lps add with no arguments and it asks for what it needs:
$ lps add
Name: alice
Full name [optional]: Alice Chen
Additional groups [comma-separated, optional]: Administrators
Primary group [the daemon's default]:
Home directory [the daemon's default]:
Shell [the daemon's default]:
Password for alice:
Again:
name alice
full name Alice Chen
primary group (the daemon's default)
home (the daemon's default)
shell (the daemon's default)
groups Administrators
Create this principal? [Y/n]:
created alice with RID 1002
An empty answer takes the default, and nothing is sent until you confirm. Or say it all on one line:
$ lps add alice --group Administrators
Anything you supply is not asked for, so either style works and they mix freely.
Groups are named however you like — Administrators, a local group's name, or a literal SID such as S-1-5-32-544. lpsd resolves it.
You need to be an administrator to do this. See the lps command for the full surface.
What a new account gets #
| RID | The next one. Never chosen, never reused. |
| uid | The RID, plus this machine's base — so RID 1000 signs in as 1001000. |
| Primary group | Authenticated Users, unless you say otherwise. |
| Home | /home/<name> |
| Shell | /bin/sh |
No per-user group is created. That Linux convention exists so a file's group ownership means something, and under KACS it means nothing — access is decided by the token, not by the mode bits. A group per user would be ceremony with a RID attached.
The home directory is not created either. lps records the path; nothing makes the directory. A principal whose home is missing still signs in, starting in /, and login says so.
Accounts with no password #
lps add <name> --no-password creates a principal who signs in without being asked for anything.
The sign-in is otherwise completely ordinary. lpsd still vouches for the principal, authd still mints the token and still applies this machine's privilege and integrity policy to it, and the session that results is indistinguishable from one reached by typing a password. Nothing is bypassed; there is simply nothing to collect.
It is a property of the account. Nothing ties it to a particular terminal, so the principal can be signed in at any logon prompt the machine offers — the console today, a remote one later. If what you want is "this console signs in automatically", a passwordless account is how Peios spells it, and the breadth is the cost.
An empty password is not the same thing and is refused. See the lps command.
Give the first account Administrators #
On a stock machine the tree-wide security descriptor grants LocalSystem and BUILTIN\Administrators, and nothing else.
An account without Administrators can traverse to a file it names exactly, and can execute it — but it cannot list a directory, because nothing bypasses that check. The symptom is a shell that appears to work until you type ls.
That is a limit of the descriptor the system ships with rather than anything about the account. Until per-subtree descriptors exist, an ordinary non-administrative account on a stock image is not comfortable to use.
Where the first account comes from #
A machine with no store provisions one at first boot, and it is empty — lpsd generates the domain, because a source with no domain can answer nothing, but it does not invent accounts.
So something has to create the first one, and there is a chicken-and-egg problem: lps talks to lpsd, so lpsd must already be running, which means this cannot be done by an early-boot script. Autorun scripts run before any service has started.
The answer is a oneshot service, ordered after lpsd, that runs lps like anything else would. On a development image that service exists and creates a known account. On a production image it does not, and an administrator creates the first account themselves.
On a development image #
Images built for development ship two things: a script that creates a known account, and a registry seed defining the oneshot service that runs it. If your image has them, it boots with an account already present, and lps list will show it.
On a live image that account has no credential at all, and it is an administrator. The script creates it with lps add peios --group Administrators --no-password, so anyone who reaches a logon prompt on that machine can become it — and the console signs in as it automatically, without asking anything.
That is deliberate rather than an oversight. A live ISO is an unauthenticated medium: anyone holding it can boot it and read everything on it, so a password would be a formality rather than a boundary, and one printed in the image at that. What it is not is a posture to carry anywhere else.
Give it a password the moment the machine becomes something you care about, which turns it into an ordinary account and stops the console signing in on its own:
$ lps password peios
New password for peios:
Again:
set the password for peios
Better still, build an image without the seed. See On an image without one below.
On an image without one #
lpsd starts, provisions an empty store, and logs that no principals exist and no logon can succeed until one is created. The login prompt will appear and nothing will satisfy it.
That is the correct behaviour rather than a fault, but it does mean you need a way in. Create the first account from a console session that is already SYSTEM — lps accepts LocalSystem as well as administrators, precisely for this case.
Disabling rather than deleting #
$ lps disable guest
disabled guest
A disabled account keeps its RID, its SID, and its group memberships. It simply cannot sign in. Re-enable it with lps enable.
Prefer this to lps remove. A removed principal's SID keeps appearing in the security descriptors of everything they owned, and because RIDs are never reused nothing will ever hold that SID again — the files become owned by a principal that no longer resolves.
You cannot lock yourself out #
lps refuses to remove the last enabled administrator, to disable them, or to take Administrators away from them.
$ lps disable jack
lps: jack is the only principal who can administer this machine; disabling them would leave no way back in
The check counts only enabled administrators, so a disabled standby account does not license removing the working one.
This guard exists because there is currently no offline repair. lps reaches the store only through lpsd, and lpsd only authenticates — so a machine with no enabled administrator has no way back short of editing its disk from another system.
Grouping people together #
For anything beyond the built-in groups, create one:
$ lps group create developers
created the group developers with RID 1001
$ lps group add alice developers
added alice to developers
A local group is a real object with its own SID and gid, so it can be named in a security descriptor and it shows up in getgroups. Deleting one is refused while anybody is still in it.
Changing your own password #
Not yet. lps password is an administrator resetting somebody else's.
Changing your own password will go over PGSS Logon rather than through lps, so that it works the same way whichever source holds your account — a domain principal will change their password exactly as a local one does. Until then, an administrator resets it for you.
Where to go next #
For the full command surface — every flag, the group and claim subcommands, and the exit statuses — read The lps command.
For what the store holds and why RIDs are never reused, read The local store.
The lps command
Peios / Peios Security Fundamentals / Managing Local Principals
lps administers the local principal store — this machine's own accounts and groups, held by lpsd.
lps subcommand [arguments]
It is a client and nothing more. It holds no state, opens no store, and has no privilege of its own: every command is a request over /run/lpsd/admin.sock that lpsd decides whether to honour. lpsd must be running.
You must be a member of BUILTIN\Administrators — enabled, not deny-only — or be LocalSystem.
Naming a group #
Anywhere lps takes a group, you may write it three ways:
lps group add jack Administrators # a well-known name
lps group add jack BUILTIN\\Administrators # the same, qualified
lps group add jack developers # a local group
lps group add jack S-1-5-32-544 # a literal SID
Names are matched case-insensitively, and lpsd resolves them, not lps. The tool carries no copy of the well-known table and does not know this machine's domain — a second copy of the machine's identity scheme would be free to disagree with the first.
A well-known name always wins over a local group of the same name, which is why you cannot create a local group called Administrators.
Inspecting #
lps list #
Every principal, with RID, uid, state, and how many groups each is in.
$ lps list
NAME RID UID STATE GROUPS
jack 1000 1001000 enabled 1
guest 1001 1001001 disabled 0
A - in the UID column means nothing numbers that principal — authd has assigned this machine no identifier range, and they will sign in as nobody. See the local store.
lps show <name> #
One principal in full.
$ lps show jack
name jack
display name Jack Palfrey
rid 1000
sid S-1-5-21-2847362817-1094533892-3310298447-1000
uid 1001000
state enabled
primary group Authenticated Users [S-1-5-11]
home /home/jack
shell /bin/sh
groups Administrators [S-1-5-32-544]
developers [S-1-5-21-2847362817-1094533892-3310298447-1001] (gid 1001001)
claims Department (string) = "Engineering"
Groups show a name where this machine knows one, and always show the SID. The two are kept side by side deliberately: the name is what you recognise, the SID is what a security descriptor actually holds, and the moment they disagree is exactly when you need to see both.
Only local groups show a gid. lpsd numbers the groups it owns and nothing else — Administrators and Authenticated Users are numbered by authd, from a table below every source's range, so lpsd genuinely does not know their gid and does not guess. To see the numbers a running session actually holds, read Groups: in /proc/self/status.
lps domain #
This machine's domain SID — the namespace every local principal and local group is numbered under.
$ lps domain
S-1-5-21-2847362817-1094533892-3310298447
Generated at first boot and unique to this machine.
Creating and removing principals #
lps add [name] [options] #
Creates a principal. Run with no arguments, it asks for what it needs:
$ lps add
Name: alice
Full name [optional]: Alice Chen
Additional groups [comma-separated, optional]: Administrators, developers
Primary group [the daemon's default]:
Home directory [the daemon's default]:
Shell [the daemon's default]:
Password for alice:
Again:
name alice
full name Alice Chen
primary group (the daemon's default)
home (the daemon's default)
shell (the daemon's default)
groups Administrators, developers
Create this principal? [Y/n]:
created alice with RID 1002
Any option you supply is not asked for, so scripted invocations keep working unchanged:
| Option | Effect |
|---|---|
--group <group> | A membership. Repeatable. |
--primary-group <group> | The group that becomes the POSIX gid. |
--home <path> | Home directory. Must be absolute. |
--shell <path> | Login shell. Must be absolute. |
--display-name <text> | A human's name for a human to read. |
--disabled | Create it unable to sign in. |
--no-password | Create a principal that authenticates with no credential at all. See below. |
--no-prompt | Fail rather than ask for anything missing. |
Nothing is sent until you confirm, so answering n creates nothing.
lps never prompts when standard input is not a terminal. A prompt down a pipe would consume the next line of whatever is driving the tool. See From a script below.
The RID is allocated by lpsd and reported back. You do not choose it, and the uid follows from it.
--no-password #
Creates a principal who signs in without being asked for anything. No password is collected, including in the interactive flow — an operator who has said the account needs no credential is not then asked to invent one.
$ lps add kiosk --group Administrators --no-password --no-prompt
created kiosk with RID 1003
This is a property of the account, not of a terminal. Nothing scopes it to the console: the principal can be signed in at any logon prompt the machine offers, now or later. That is the right posture for a live image, where the medium is unauthenticated anyway and anyone holding it can read everything on it. It is the wrong posture for almost anything else.
An empty password is not a way to spell this, and lps refuses one:
$ lps add alice
...
Password for alice:
Again:
lps: an empty password is not a password: pass --no-password to create a
principal that authenticates without one
The two produce accounts that behave differently — an empty password is still collected, still prompted for, and still has to be answered — so having both spellings would mean two things that look identical at creation and diverge at every sign-in. lpsd refuses an empty password too, so the rule holds however the request arrives.
lps remove <name> #
Deletes a principal.
The RID is not reclaimed. Files the principal owned keep naming a SID that now resolves to nobody, and nothing will ever hold that SID again. lps disable is usually what you wanted — see creating accounts.
Enabling and disabling #
lps enable <name> / lps disable <name> #
A disabled principal keeps everything except the ability to sign in.
Both are refused if they would leave the machine with no enabled administrator.
Passwords #
lps password <name> #
Sets a principal's password, prompting twice.
$ lps password jack
New password for jack:
Again:
set the password for jack
This is an administrator resetting somebody else's password. Changing your own will go over PGSS Logon instead, so that it works identically whichever source holds your account.
An empty password is refused here for the same reason it is refused at creation. Giving a passwordless principal a password works and makes them an ordinary account; there is currently no command that takes one away again, so a principal created with --no-password can gain a credential but not shed one.
From a script, lps reads a single line from standard input when it is not attached to a terminal, and does not ask for confirmation:
printf '%s\n' "$password" | lps add alice --group Administrators --no-prompt
Profiles #
lps set <name> [options] #
Changes part of a profile, leaving the rest alone.
$ lps set jack --shell /bin/bash --display-name "Jack Palfrey"
updated jack
| Option | Effect |
|---|---|
--home <path> | Home directory. Absolute. |
--shell <path> | Login shell. Absolute. |
--display-name <text> | Empty clears it. |
--primary-group <group> | Which group projects to the POSIX gid. |
None of this is identity — no security descriptor names a home directory, and the token carries none of it. It is what login needs in order to start a session, and it reaches login on the logon reply itself.
lps does not create the home directory. It records the path. A principal whose home does not exist still signs in, starting in /, and login says so.
The primary group need not be a membership: authd adds it to the token if it is missing, so the order of two commands does not matter.
Groups #
Local groups are objects with a name, a RID and a gid — unlike well-known groups, which exist on every Peios machine and are not stored here.
lps group list #
$ lps group list
NAME RID GID MEMBERS SID
developers 1001 1001001 2 S-1-5-21-2847362817-1094533892-3310298447-1001
MEMBERS counts principals in this store, which is the only count lpsd can answer for.
lps group create <name> / lps group delete <name> #
$ lps group create developers
created the group developers with RID 1001
Deleting is refused while anyone is still a member, or while the group is anybody's primary group. Either would leave a record pointing at something that no longer exists.
lps group add <name> <group> / lps group remove <name> <group> #
$ lps group add alice Administrators
added alice to Administrators
Removing BUILTIN\Administrators is refused if it would leave no enabled administrator.
Claims #
Claims are named, typed attributes carried on the token and read by conditional ACEs. They are how a security descriptor can say anyone in Engineering rather than naming principals one by one.
lps claim set <name> <claim> <type> [value]... #
$ lps claim set jack Department string Engineering
set the claim Department on jack
$ lps claim set jack Level int64 7
set the claim Level on jack
| Type | Values |
|---|---|
int64 | Signed integers |
uint64 | Unsigned integers |
boolean | true/yes/1 or false/no/0 |
string | Text |
sid | SIDs, in S-1-… form |
octet | Hexadecimal, even length |
A claim may hold several values, and may hold none — lps claim set jack Department string empties it, which is different from removing it.
Claim names are matched case-insensitively, so setting department replaces Department.
lps claim remove <name> <claim> #
$ lps claim remove jack Department
removed the claim Department from jack
A claim reaches a principal at their next sign-in. A token's claims are fixed when it is minted, so setting one changes nothing for a session already running.
Exit status #
| Code | Meaning |
|---|---|
| 0 | Succeeded |
| 1 | lpsd refused the request, or could not be reached |
| 2 | The command line was wrong |
The split lets a script distinguish "I called this incorrectly" from "it was refused".
Common failures #
cannot reach lpsd … it does not appear to be running — lpsd is not up. It exits if it cannot reach authd, or if the store will not read; check its logs before restarting it.
permission denied — you are not an administrator, or the socket's descriptor does not admit you. See the administrative socket.
there is no principal named … — names are case-insensitive, so this means the account genuinely is not there. lps list will show what is.
there is no group named … and it is not a SID — the name matched no well-known group and no local one, and does not parse as a SID. lps group list shows the local ones.
The administrative socket
Peios / Peios Security Fundamentals / Managing Local Principals
lpsd listens on:
/run/lpsd/admin.sock
lps is its only client today. If you are writing tooling against it, or diagnosing why a command was refused, this page is what you need.
Who may use it #
Two ways to satisfy the check, and lpsd reads the connecting process's token to decide:
- membership of
BUILTIN\Administrators, enabled; or - being
LocalSystem.
LocalSystem is admitted deliberately rather than as a convenience. At first boot there is no administrator yet — that is the problem the bootstrap service exists to solve, and it runs as LocalSystem.
The word enabled is load-bearing. A group that is present in a token but marked deny-only contributes to denials and grants nothing, so lpsd requires the enabled attribute. A deliberately restricted token that merely mentions Administrators does not qualify.
What decides, and what does not #
The peer's token decides. lpsd asks the kernel who is on the other end of the connection. Nothing in any message contributes to that answer, and nothing a client sends could.
A KACS descriptor is defence in depth. lpsd stamps one on its runtime directory and socket, admitting LocalSystem and BUILTIN\Administrators.
That descriptor is load-bearing in a way worth knowing about: /run is seeded with a descriptor admitting LocalSystem alone, and everything created under it inherits that. Without lpsd stamping its own, an administrator running lps would be refused by KACS before a byte was exchanged.
The Unix mode decides nothing. KACS grants every managed process the capabilities that override the DAC check, so file modes do not gate anything on Peios. The socket's mode is permissive to say so rather than to imply a control that is not operating.
Shape of the protocol #
One request per connection: lps connects, sends one request, reads one answer, and disconnects. There is no session and no state carried between commands.
That is partly the shape of the tool — a command does one thing and exits — and partly a bound on a real hazard. lpsd serves administrative requests on the same thread as logons, so a client that connected and then said nothing would stall every logon behind it. One request under a deadline bounds that.
The authorization check happens before anything is read, so an unauthorised connection costs a token read and a refusal rather than the full timeout. An open socket cannot be used to stall logons.
Durability #
A change is applied in memory, written to disk, and only then reported as done. If the write fails the change is rolled back and the command reports failure.
So a command that reports success has persisted. A command that reports failure changed nothing — not "possibly changed something", but nothing: lpsd keeps a snapshot and restores it.
The protocol itself #
The wire protocol is PLPS, sharing its codec and header layout with PGSS Logon (PGSS §2) and PSI (PSPU §2). It is not itself specified, because unlike those two it is one daemon's administrative interface rather than a contract anyone else implements.
If you are writing tooling, prefer driving lps and parsing its output over speaking the protocol directly. The command's surface is stable; the protocol's is not promised to be.
See also #
- The
lpscommand — the client this socket serves. - Managing local principals — the authority/source split behind the design.
Resolving names
Peios / Peios Security Fundamentals / Managing Local Principals
Every program that prints a name for a file's owner is asking the same question: who is this? On Peios that question goes to authd, over a socket of its own:
/run/ident.sock
Separate from /run/logon.sock, but the same standard — PGSS §2 defines both. What separates them is who calls: a handful of things ever originate a logon, where every process on the system resolves names. A filesystem walk is millions of lookups, and it must not be able to fill the queue an administrator needs in order to sign in.
Why the authority, and only the authority #
A principal source counts POSIX identifiers relative to a range it is never told the base of. authd adds the base — so jack, stored by lpsd as 1000, signs in as uid 1001000.
That arithmetic exists in exactly one place, and only that place can run it backwards. No source can answer "who is uid 1001000", because no source knows what 1001000 means. It is not that the authority is a convenient place to put this; it is the only party that can do it at all.
The same holds for names. A bare name may exist in more than one source, and which one wins is a property of the machine rather than of any source in it.
One answer, systemwide #
Whatever asks — a Peios tool, a Linux program through getpwnam, a future graphical console — the question goes to the same place and gets the same answer.
There is deliberately no way to arrange otherwise. glibc's identity databases are patched to reach the authority and nothing else, there is no /etc/passwd behind them, and PAM does not exist on Peios at all. Every route by which another system lets identity come from somewhere the authority cannot see has been closed rather than configured.
That is worth more than it sounds. If two components resolved names separately they could disagree, and a program acting on one principal's behalf while its access is checked against another is a confused-deputy bug rather than a cosmetic inconsistency. It also means a name resolves identically at a logon and at an ls -l, because the same code answers both.
The search order #
A name nobody qualified is resolved by asking sources in a configured order, stopping at the first that answers.
The order is a value on each source's allowlist entry:
Machine\Software\Authd\Sources\lpsd
SearchOrder REG_DWORD 1000
Lower is consulted first. Sources that share a value are ordered by name. A source that sets nothing resolves at 1000, which leaves room to place a new source either side of the ones already there without renumbering them.
It is deliberately explicit. Resolving in the order sources happened to register would let a slow disk change which principal a name refers to.
Bare in, qualified out #
You may look a principal up by a bare name. What comes back always carries the SID as well as the name.
So a tool can always tell which principal it got, compare two answers for identity, and record something unambiguous in a log. Ambiguity is permitted in what you ask; it is not permitted in what the authority answers.
Reserved characters #
A principal or group name may not contain @, \, /, : or ,, may not begin or end with a space, and must be printable ASCII.
Each of those is a separator somewhere a name ends up — a path, a passwd record, a group member list — and the damage is done by whatever reads it later, so it cannot be prevented at the point of display. @ and \ are reserved for qualified names (jack@local), which do not exist yet: reserving them now is what stops an account literally called jack@local colliding with the syntax the day it arrives.
The ASCII restriction defers confusables and normalisation rather than getting them wrong. jack spelled with a Cyrillic а renders identically and is a different principal, and the same name in NFC and NFD is two byte sequences a comparison calls two people. Relaxing this later is safe; tightening it later would mean renaming accounts.
Absent is not the same as missing #
Three answers, and the difference between the last two is the one worth knowing:
| Answer | Means |
|---|---|
| Found | Here is the principal. |
| Not found | Every source that could have answered was asked, and there is no such principal. |
| Unavailable | A source that could have answered did not. |
A source that is configured and not running does not simply drop out of the order. If lpsd has crashed, jack resolves to unavailable — not to a directory's jack further down the order, which would be a different principal with a different SID, and every security descriptor granting the local one would stop applying to the person signing in under that name.
It also matters because not found is safe to remember and unavailable is not. A cache told "no such user" during an outage would keep saying so long after the outage ended.
Groups you cannot list #
Ask who is in a local group and you get its members. Ask who is in Everyone and you get nothing — and that is the correct answer rather than a limitation.
Membership comes in three kinds:
Recorded. Local groups, and BUILTIN\Administrators. A source holds the edges, so it can list them. (Note that being well-known has nothing to do with it — Administrators is well-known and perfectly enumerable.)
Stapled. Everyone, Authenticated Users. Nothing records who is in them; authd adds them to every token it mints. Membership is a rule, not data, so there is no list to produce.
Session-scoped. Interactive, Network, Batch, Service. These are not properties of an account at all, but of a logon: jack is in Interactive at the console and not in it over the network. No static answer exists even in principle, which is why they have no POSIX group id either.
The asymmetry that falls out is the useful one. Which groups is this principal in is cheap and always works. Who is in this group is best-effort, and a source is entitled to decline it — listing a directory group can mean listing an entire organisation.
Every lookup is live #
There is no cache yet. Each lookup is a fresh round trip to the source that holds the answer.
That is a deliberate first step rather than an oversight: a cache is invisible on both sides of the wire, so adding one later changes nothing about how any of this behaves. What it means today is that listing a very large directory is slower than it will be.
Sources already declare what a cache will need — whether they can push a change notification, and how long an answer may be held — so the contract is in place before the thing that uses it.
Where to go next #
For how Linux programs reach this resolution through getpwnam and friends, read Name service switch.
For where the local principals being resolved actually live, read The local store.
Impersonation
Peios / Peios Security Fundamentals / Impersonation
Impersonation is the mechanism that lets one thread temporarily act as another principal. The thread installs a second token — an impersonation token — and from that moment until it reverts, every access decision on that thread reads the impersonation token instead of the thread's primary. Other threads in the same process are unaffected. The primary token is preserved untouched and resumes its role the instant the thread reverts.
Impersonation exists because of one stubborn fact: most code that handles user requests does not run as the user. A storage service runs as a service account, a database runs as postgres, the registry daemon runs as loregd. When a real user asks one of these services to do something, the work has to be evaluated against the user's rights, not the service's. Impersonation is how that swap happens cleanly.
The two-token model #
A thread can have two tokens at once:
flowchart LR
A["Thread"] -->|primary token, always| B["Primary token, the service's identity"]
A -->|impersonation token, while impersonating| C["Impersonation token, the client's identity"]
While the impersonation token is installed, every AccessCheck on that thread reads it. The primary is invisible until revert. Other threads in the same process still read their own primary tokens — impersonation is strictly per-thread.
Two consequences worth knowing up front:
- The process's identity does not change. Other processes inspecting this one still see the primary token as the process's identity. Tools like
ps,/proc/<pid>/token, and audit events that record the process identity all report the primary. Only the thread's own view is overridden. - The PSB does not change. A thread impersonating a higher-trust token does not get higher process integrity protection. PIP is a property of the binary running, not of the impersonation token. See Process integrity protection for the why.
A canonical server flow #
The simplest case — one OS thread, one request, start to finish — follows a flow like this:
flowchart LR
A["Client connects"] --> B["Server accepts"]
B --> C["Server calls kacs_impersonate_peer(fd)"]
C --> D["Server does the work"]
D --> E["Server calls kacs_revert()"]
E --> F["Server handles next request"]
- Client connects. Before connect, the client may have called
kacs_set_impersonation_levelon the socket to bound how far its identity may travel (see Impersonation levels). - Server accepts. The kernel captures the client's identity onto the socket at connect time.
- Server impersonates. A call to
kacs_impersonate_peer(fd)installs the captured identity onto the calling thread, at the effective level determined by the two-gate model. - Server does the work. Every access check on this thread now runs against the client's identity. File opens succeed if the client could open the file; registry reads succeed if the client could read; and so on.
- Server reverts.
kacs_revert()drops the impersonation token. The thread is back to its primary identity for the next request.
A server that handles many concurrent clients impersonates separately per thread. A server that handles requests serially impersonates and reverts in a loop. Either way, the impersonation is always a bracketed operation — install, do work, revert.
Just-in-time impersonation #
The canonical flow above assumes one OS thread is dedicated to one request from start to finish. That model fits a thread-per-connection server, but it does not fit most modern application runtimes. In Go, a goroutine can be scheduled onto any of the runtime's worker threads, and may move between them at any await point. In Rust's async runtimes, in Java's virtual threads, in Node's event loop — wherever you have M:N scheduling, the "impersonate at the top of the request, revert at the bottom" pattern is unsafe. The impersonation is installed on whichever OS thread happened to be running when the call was made; the request may continue on a different OS thread an instant later.
Most real Peios applications use just-in-time impersonation instead: capture the client's identity as a token fd at request start, hold the fd in the request context, and install it on the current OS thread only for the brief moment of the access-requiring action, reverting immediately after.
flowchart LR
A["Accept connection"] --> B["Capture peer fd"]
B --> C["Service-identity work"]
C --> D["Install token, one action, revert"]
D --> E["More service-identity work"]
E --> F["Install token, one action, revert"]
F --> G["..."]
G --> H["Close token fd"]
The sequence:
- At accept, the server calls
kacs_open_peer_token(fd)to capture the client's identity as a token fd. The fd is stored in the request context — request struct, goroutine-local, whatever the runtime provides. - Most of the request is processed as the service's own identity. Decoding, dispatching, internal bookkeeping, response framing — none of these need the client.
- When the code reaches the one operation that requires the client — opening a user-controlled file, reading the user's home directory, anything where the access decision must be the client's, not the server's — it installs the impersonation token on the current OS thread (
KACS_IOC_IMPERSONATE), does the single operation, and callskacs_revertimmediately. - The request continues at service identity. Further per-request impersonations follow the same install-do-revert pattern.
- At end of request, the captured token fd is closed.
This pattern has two real advantages over the canonical flow:
- It is safe under multiplexed threading. The impersonation is installed and reverted within a tight, synchronous block of code that does not yield to the runtime, so the runtime cannot move the work to another OS thread mid-impersonation. The token fd in the request context travels with the request regardless of which thread is currently executing it; only the install-do-revert window actually pins a thread.
- It minimises the time spent impersonated. Bugs where the wrong code runs as the wrong identity — a logging call between impersonate and the real work, an error path that forgets to revert, an unrelated background task pre-empting on the same thread — are bounded by the same tight block. Most of the request is unambiguously the service's identity; only the access-requiring call is the client's.
The cost is a small amount of boilerplate at every impersonation point. The performance cost is negligible: install and revert are pure in-kernel operations with no allocation and no IPC.
If you are writing a new service for Peios in a modern runtime, this is the pattern to start from. The canonical flow above is the simple-case reference; just-in-time is the realistic default.
Why not just run the server as the user? #
The reason impersonation exists rather than "spawn one server process per user" is the cost. Long-lived services that handle many users — a file server, a database, the registry daemon — would otherwise need one process per concurrent session. Impersonation lets a single long-lived service process handle requests for arbitrary users by swapping identities per thread, paying only the cost of an in-kernel token install per request.
The trade-off is that the server has to be careful. Code paths that touch user-controlled state are expected to be running impersonated; code paths that touch service-internal state (its own configuration, its own logs) are not. Mixing these up is the most common bug category in services written for this model: a request handler that opens a user-controlled path while still running as the service account, or a maintenance routine that touches the service's own state while still impersonating the last client.
The pattern that minimises this is to make impersonation the default state of a request thread and revert only at well-defined boundaries (logging, internal bookkeeping, between requests).
What impersonation does not do #
A few things impersonation looks like at a glance but is not:
- It does not invent new authority. The impersonation token carries the client's privileges, integrity (capped at the server's own — see The two-gate model), groups, and SIDs intact, and the impersonating thread can exercise any of them on the client's behalf. What impersonation does not do is grant more than the client had, raise integrity above the server's ceiling, or change the binary's PIP.
- It does not give the server arbitrary identity. The server can only assume identities the kernel has handed it — through a captured peer token, or through a token fd the server already possessed by some other path. There is no API to fabricate an impersonation token from a SID string.
- It does not grant inherent access. Impersonating a user is not a master key to the user's data. The user's home directory, registry keys, and in-flight tokens are reachable if and only if the user themselves has access — the access check still runs to decide. Impersonation is the mechanism for evaluating that check as the user; it is not the access itself.
- It does not survive exec. A thread that execs while impersonating has its impersonation token released before the new program runs. Impersonation is intra-program state; the new binary cannot inherit it.
Where to start #
If you want to understand the four impersonation levels — Anonymous, Identification, Impersonation, Delegation — and what each one permits, read Impersonation levels.
If you want the rules that decide what level a server actually ends up with — the identity gate, the integrity ceiling, and the silent downgrade behaviour — read The two-gate model.
If you want the concrete mechanics — peer token capture from sockets, the difference between kacs_impersonate_peer and kacs_open_peer_token, and the explicit-fd variant — read Peer tokens and capture.
Impersonation levels
Peios / Peios Security Fundamentals / Impersonation
Every impersonation token carries an impersonation level — one of four values, ordered from least to most permissive. The level is the answer to "how much may a server do with this identity?". Each level admits all the operations of the levels below it and additionally allows specific extra things.
There is one rule worth pinning before the catalog: the level is set by the client, not the server. A client calls kacs_set_impersonation_level on the socket before connect() to declare the maximum level a server may use. The kernel records the level on the socket. When the server later impersonates the peer, the level cannot exceed what the client set. A server cannot ask for a higher level; it can only end up at the level the client granted or lower.
The four levels #
The numeric values are also catalogued in Other constants.
| Value | Name | What a server may do |
|---|---|---|
| 0 | Anonymous | Nothing identity-related. The impersonation token has the Anonymous SID as its user; the server learns nothing about the actual client. |
| 1 | Identification | Inspect the client's identity. The server may read the client's SIDs, groups, integrity level, and privileges — but the token must not be used for AccessCheck. Any access check using an Identification-level token is denied immediately, no matter what the DACL says. |
| 2 | Impersonation | Act as the client for all local operations. This is the default and the level most server-to-client code paths assume. |
| 3 | Delegation | Identical to Impersonation locally, plus the client's credentials may be forwarded to a remote machine over Kerberos. The kernel only records that Delegation was granted; the actual forwarding is authd's responsibility. |
The default — what a socket carries if the client never sets a level — is Impersonation. This default exists because the vast majority of clients want their server to be able to act on their behalf; making the no-action case the conservative one would push every client into boilerplate.
Anonymous #
The least useful level, and not as common as you might expect. An Anonymous impersonation token has:
- A user SID of
S-1-5-7(the well-known Anonymous SID). - The Everyone group, but not Authenticated Users.
- Untrusted integrity.
- No privileges.
A server impersonating an Anonymous client effectively has no identity at all. It can act, but every access check is evaluated as Anonymous — which usually fails because almost every interesting object's DACL grants nothing to Everyone-but-not-Authenticated-Users.
You will mostly see Anonymous in two cases:
- A client explicitly opted out of identity disclosure. Some protocols want the server to act on a request without knowing who issued it. The client calls
kacs_set_impersonation_level(ANONYMOUS)on its socket; the server impersonates and gets Anonymous. - A connection that was never authenticated. A network peer that connected without going through any auth step is, from the server's point of view, Anonymous. The token reflects this honestly.
Identification #
The most misleading level. Identification gives the server enough of the client's token to inspect identity but explicitly forbids using it for access decisions. AccessCheck with an Identification-level token returns ACCESS_DENIED immediately, before reading the DACL.
The intended use is for servers that need to know who is asking — for audit logging, for routing decisions, for displaying a username — without actually wanting to act as them. A web server that logs which user made each request but always reads files as itself wants Identification.
The pitfall: a server that thinks it is doing Impersonation but the client set the level to Identification finds every file open returning ACCESS_DENIED for reasons that look nothing like an identity problem. The DACL is fine; the user has rights to the file; the server's primary identity has rights to the file. Yet every access fails. The cause is almost always that the client connected at Identification level and the server is supposed to be inspecting, not acting.
The fix is at the client end. If your server has to act on behalf of the user, the client must connect at Impersonation level (or accept the default — which is already Impersonation).
There is one specific construction that always produces an Identification-level token: KACS_IOC_GET_LINKED_TOKEN called without SeTcbPrivilege returns an Identification-level clone of the partner token. The clone is for inspection only — it cannot be installed and would fail every access check if it were. See Elevation and linked tokens.
Impersonation #
The level you will use almost all of the time. An Impersonation-level token can act as the client for any local operation:
- Open files as the client.
- Read and modify registry keys as the client.
- Connect to other local services as the client (and have those services impersonate the same client — see "Identity cascading" in Peer tokens and capture).
- Be the subject of any AccessCheck call.
What an Impersonation-level token cannot do is cross the machine boundary with the client's identity. If the impersonating service makes a network request that requires authentication, that request goes out using the service's credentials, not the client's. The client's identity stays on this machine.
That last point is the dividing line between Impersonation and Delegation.
Delegation #
Delegation extends Impersonation to remote calls. A server at Delegation level can act as the client locally and forward the client's identity to a remote machine over Kerberos. The remote service receives a token that represents the original client, not the intermediary.
The mechanism is entirely in authd. KACS records that the impersonation token was granted Delegation; the kernel does nothing further. When the impersonating server makes an outbound network request, authd consults the token's level. If it sees Delegation, authd performs the Kerberos credential forwarding (constrained or unconstrained, depending on the domain policy). If it sees only Impersonation, authd authenticates the outbound request as the server's own identity.
The split — KACS records the flag, authd acts on it — keeps the kernel out of the Kerberos business. It also means Delegation is meaningful only on domain-joined machines where authd has Kerberos configured. On a standalone machine, Delegation is recorded but has no effect, since there are no remote services to forward to.
What sets the level #
Three things determine the level a server actually ends up with:
- The client's request, set on the socket before connect via
kacs_set_impersonation_level. Default is Impersonation if the client says nothing. - The two-gate model, applied at impersonation time: the identity gate and the integrity ceiling. Either can cause the granted level to be silently downgraded. See The two-gate model.
- The transport. Some Unix-socket variants — SOCK_DGRAM, socketpair, pre-existing pipes — do not carry a peer token. Servers using these transports cannot use
kacs_impersonate_peerand must use the explicit-fd ioctl instead. See Peer tokens and capture.
The first sets the maximum. The second can lower it. The third is about the mechanism for getting the token at all.
Inspecting the granted level #
A server that has just impersonated can read its own effective token to check what level it actually got. Reading the impersonation_level field of the effective token (via KACS_IOC_QUERY on the thread's token fd) returns the level in effect.
This is worth doing at runtime if your code branches on level. The "client asked for Impersonation, but the integrity ceiling silently downgraded to Identification" case is silent by design — the impersonation call succeeds and the work proceeds, but every access check on the downgraded token will fail. Code that wants to detect the downgrade before doing access-requiring work should query the level first and skip work for which the level is insufficient.
Where to go next #
For how the kernel decides the level a server actually ends up with — and why the downgrade is silent — read The two-gate model.
For how a server gets hold of a client's identity in the first place, read Peer tokens and capture.
The two-gate model
Peios / Peios Security Fundamentals / Impersonation
When a server impersonates a client, it does not automatically get the level the client granted on the socket. The kernel runs two independent gates first. Each gate can lower the effective level. The granted level is the minimum the two gates permit.
The two gates are:
- The identity gate — is the server allowed to impersonate this particular user's identity?
- The integrity ceiling — may the impersonation token's integrity level exceed the server's own?
Each gate is checked independently. Failing either one is silent: the impersonation call still succeeds, the level is silently capped, and the server only finds out by inspecting the resulting token. The kernel deliberately does not raise an error, because the requested level is the maximum — any lower level is a valid outcome.
There is one case where the kernel refuses outright with -EPERM rather than downgrading. That case is covered below.
Gate 1: identity #
The identity gate asks: does the server have authority to impersonate this specific user? It has two ways to pass:
- The server's primary token has the same user SID as the client. A process can always impersonate its own user, regardless of privileges.
- The server's primary token holds
SeImpersonatePrivilege. The privilege explicitly grants the right to assume any user's identity.
If neither is true, the identity gate fails. The token still gets installed, but the effective level is capped at Identification — the server can inspect the client's identity but cannot use the token for any access check.
The split is deliberate. A service whose job is handling user requests — a file share, a database, the registry daemon — holds SeImpersonatePrivilege and can impersonate any user. A process running as user X can act as user X without any special privilege. Code that holds neither — a random user-mode process trying to install some other user's token — is restricted to inspection only.
SeImpersonatePrivilege is one of the few privileges that authd grants liberally to services in the role-policy assignment. Almost every service that handles user requests has it. The privilege is what distinguishes "code authorised to act as users" from "code that happened to receive a token fd".
Gate 2: integrity ceiling #
The integrity ceiling asks: does the impersonation token's integrity level exceed the server's own primary token's integrity level?
If it does — the client has High integrity, the server has Medium — the effective impersonation level is silently capped at Identification. The token still installs, and the client's integrity label may be preserved as inert identity metadata, but the token cannot pass any access check.
The motivation is concrete. Without the ceiling, a Medium-integrity service that captured a High-integrity client's token would suddenly be operating at High integrity for the duration of the request. That breaks the integrity model — a process's effective ceiling should be bounded by what it would have had on its own.
The ceiling is always enforced, and it caps the level, not the token's integrity label. SeImpersonatePrivilege does not bypass it. There is no privilege that does. A service that needs to act on behalf of a High-integrity client must itself run at High integrity.
Composition: the minimum permitted #
The two gates are independent. Each one decides what level it permits:
| Identity gate result | Integrity ceiling result | Granted level |
|---|---|---|
| Pass at full level | Token's integrity is at or below server's | The level the client granted on the socket. |
| Pass at full level | Token's integrity exceeds server's | Identification. The ceiling downgrades the level; the token installs but no AccessCheck will pass. |
| Fail (no same-SID, no SeImpersonate) | Token's integrity is at or below server's | Identification. Token installs, server can inspect, but no AccessCheck will pass. |
| Fail | Token's integrity exceeds server's | Identification. Both gates independently cap to the same level. |
The granted level is the minimum the two gates permit. Neither gate fails the operation; both can downgrade it. The downgrade is silent.
The silent downgrade #
This is the consequence worth memorising: impersonation never errors on policy failure. It silently downgrades.
A server that calls kacs_impersonate_peer(fd) and gets a successful return does not know whether the resulting token has the level it expected. Both an Impersonation-level token and an Identification-level token can come out of the same call, with no error in either case. The only way to tell is to query the token after install.
Code that wants to detect a downgrade has to do something like:
- Call
kacs_impersonate_peer(fd)(or the explicit-fd variant). - Open the thread's effective token.
- Read
impersonation_levelviaKACS_IOC_QUERY. - Branch on the result: proceed if Impersonation or Delegation, log-and-skip if Identification or Anonymous.
The reason for the silent design is that the gates are bounded by policy and the policy is the operator's decision. A service may legitimately want to handle requests from clients regardless of whether it can fully impersonate them — recording who asked for what even when it cannot act as them is useful. Making the downgrade an error would force every server to either bypass the failure or refuse to handle the request, neither of which is the right default.
The one hard-deny case #
There is exactly one situation where impersonation fails with an error rather than downgrading: a restricted token attempting to impersonate an unrestricted token of the same user.
The kernel refuses with -EPERM. The impersonation does not happen, the thread does not get a new token, the call returns an error.
The reason is sandbox-escape prevention. A process running on a restricted token — a sandbox process, an anti-malware quarantine — is supposed to have narrowed identity. If that process could impersonate an unrestricted version of the same user, the restriction would be trivially escapable: capture any token fd belonging to the same user (which the sandbox could obtain via socket peer capture or other means), impersonate, and suddenly the sandbox is running with full user identity.
The hard-deny applies specifically to the same-user case. A restricted process can still impersonate a different user's token, subject to the normal gates. It just cannot use impersonation as a way to "promote" itself back to the unrestricted version of its own identity.
This is the only impersonation case that errors. Every other policy failure is silent.
Double impersonation #
A thread that is already impersonating can install a second impersonation token. The kernel handles this by silently reverting the first impersonation before installing the second — there is no nesting.
The gates are evaluated against the primary token, not the current impersonation. Whatever the thread was previously impersonating does not affect what it can impersonate next. The primary's identity and privileges (notably SeImpersonatePrivilege) are what the kernel reads to decide both gates.
When the gates do not apply #
The gates fire on impersonation install. They do not fire on:
- Reverting.
kacs_revertalways succeeds and does not consult the gates. - Reading a token.
kacs_open_peer_tokenorkacs_open_thread_tokenreturns a token fd without impersonating; the gates only run when the token is actually installed on a thread. - Process token operations. Opening another process's primary token (
kacs_open_process_token) is governed by the process SD and PIP dominance, not the impersonation gates.
The gates are specifically about installing a foreign identity on a thread. Reading information about a token is a different operation governed by different checks.
The principle behind the model #
The two-gate model is the kernel's enforcement of one rule: a thread can never end up at a higher effective trust level than it could have reached on its own.
The identity gate makes sure the impersonation has authority behind it: either the same user, or the explicit SeImpersonatePrivilege that says "this service handles users by design". The integrity ceiling makes sure the impersonation cannot raise the server's effective integrity. Together they bound impersonation to what the server's own primary token would have permitted.
Everything else is a consequence. The silent downgrade exists because the requested level is a maximum, not a promise. The restricted-token hard-deny exists because that one case is the only way a same-user impersonation could increase authority rather than decrease it. The PIP exclusion (covered in Process integrity protection) is the same rule applied to the binary's trust label: PIP is a property of the binary, not of an identity, and impersonation does not change which binary is running.
Where to go next #
For the capture mechanism the gates run against — how a client's identity gets onto a socket and into the server's hands — read Peer tokens and capture.
For the restricted tokens behind the one hard-deny case, read Restricted and write-restricted tokens.
Peer tokens and capture
Peios / Peios Security Fundamentals / Impersonation
When a client connects to a server over a connected Unix socket, the kernel captures the client's identity onto the socket at connect time. The server can later impersonate that identity by calling kacs_impersonate_peer(fd) on the connection — no further negotiation, no credential exchange, no token transfer in the message stream. The token is held by the kernel and tagged to the socket from the moment of connect.
This is the primary way servers acquire impersonation tokens. Almost every local IPC in Peios — registry access, service-to-service calls, user-to-daemon requests — goes through it. The model is simple enough that most services need to know only one syscall (kacs_impersonate_peer) and one cleanup call (kacs_revert).
This page covers the capture mechanism, the two different syscalls for using a captured peer token, the transports that do and do not carry peer tokens, and how identity cascades when a service that is itself impersonating connects to a third service.
Capture at connect time #
flowchart LR
A["Client thread, primary token T_C"] -->|"kacs_set_impersonation_level (optional)"| B["Socket"]
A -->|connect| C["Server socket"]
B -.->|captured at connect| D["Peer token on server side, derived from T_C at the client-requested level"]
When the client calls connect() on a Unix stream or seqpacket socket, the kernel:
- Reads the client's effective token (impersonation if set, primary otherwise).
- Reads the impersonation level the client requested on the socket (default Impersonation).
- Constructs a peer token derived from the client's token, at the requested level.
- Attaches the peer token to the server-side socket.
The peer token is held by the kernel for the life of the socket. The client's identity travels into the kernel at connect, not at impersonate. By the time the server calls kacs_impersonate_peer, the work of capturing identity is already done.
This timing matters in one specific way: the captured identity is the client's identity at the moment of connect. If the client changes identity afterwards (impersonates a third party, drops privileges, anything), the change does not affect the peer token on the existing connection. The peer token is a snapshot. Servers that want to track changing client identity across a long-lived connection need to renegotiate (typically by having the client reconnect).
Two ways to use a peer token #
The kernel exposes two operations on a peer token:
kacs_impersonate_peer(fd)— the combined operation. Captures the peer's identity from the socket and installs it on the calling thread, at the level the two-gate model permits. The most common path.kacs_open_peer_token(fd)— the inspect-and-store operation. Returns a token fd to the peer's identity without installing it. The fd carriesTOKEN_QUERY | TOKEN_IMPERSONATEaccess — enough to read the token and to install it later viaKACS_IOC_IMPERSONATE, but not enough to duplicate or adjust it.
The two operations exist because servers have different needs:
- Direct request handling. A request thread that wants to impersonate, do work, and revert all in one tight synchronous block calls
kacs_impersonate_peerand thenkacs_revert. There is no reason to hold the token fd around. - Just-in-time impersonation. A server that wants to keep the client's identity available across a request but only install it at the moment of each access-requiring action — the just-in-time pattern covered on the overview page — calls
kacs_open_peer_tokento capture the token fd at request start, stores it in the request context, and installs it on the current OS thread (KACS_IOC_IMPERSONATE) just before each action that needs the client's identity, reverting immediately after. This is the right pattern for any server using a multiplexed runtime (Go, Rust async, Java virtual threads, Node), and is the recommended default for new services. - Deferred work across threads. A request handler that hands off work to a thread pool cannot reasonably keep its own thread impersonating while another thread does the work — impersonation is per-thread. The same
kacs_open_peer_tokenmechanism lets it capture the token fd, pass it to the worker, and have the worker install it before doing the access-requiring operation. - Inspection without action. A logging or audit thread that wants to record the client's identity without doing any work as them calls
kacs_open_peer_token, queries the token viaKACS_IOC_QUERY, and closes the fd. No impersonation is ever installed.
The "capture once, install per action" pattern is the main reason for separating the two operations. It is what makes safe impersonation possible under M:N threading and what bounds the time a thread is actually impersonated to the smallest window that does the work.
kacs_revert and the explicit-fd variant #
A thread reverts impersonation with kacs_revert(). It always succeeds. It drops the impersonation token reference and restores the primary as the effective identity.
There is also KACS_IOC_IMPERSONATE — an ioctl on a token fd, not on a socket. This is the explicit-fd variant. The caller passes the fd of a token they have obtained by some other means (DuplicateToken, kacs_open_peer_token, etc.) and the kernel installs it on the calling thread, running the two-gate model as usual.
kacs_impersonate_peer is essentially kacs_open_peer_token followed by KACS_IOC_IMPERSONATE, fused for the common case where you do not need the token fd to outlive the impersonate-and-revert cycle.
Transports that carry peer tokens #
Peer token capture works on Unix sockets that have a real connect step. Specifically:
| Transport | Peer token? |
|---|---|
SOCK_STREAM Unix socket | Yes. Captured at connect. |
SOCK_SEQPACKET Unix socket | Yes. Captured at connect. |
SOCK_DGRAM Unix socket | No. No connect step; no point at which to capture. |
socketpair(2) | No. The two ends share an origin; there is no "client" and "server". |
Pipes (pipe(2), named FIFOs) | No. No socket framework. |
| TCP sockets | No. Peer is potentially remote; KACS does not capture network identities. |
Servers using a transport that does not carry a peer token cannot use kacs_impersonate_peer. They have to obtain a token fd by some other path and use KACS_IOC_IMPERSONATE to install it. Common patterns:
- Token fd passed over the connection. A datagram protocol can include a token fd in an
SCM_RIGHTSmessage; the receiver gets the fd and impersonates from it. The token fd has whatever access the sender opened it with, bounded by what the sender held. - Out-of-band capture. A service that knows the peer's PID can call
kacs_open_process_token(pidfd)to get the peer's primary token (subject to the process SD and PIP dominance checks). Less common; usually only the loopback-like patterns inside the TCB work this way.
The lack of peer tokens on datagram and pipe transports is intentional, not an oversight. Those transports are fundamentally connection-less or pre-existing; capturing a "peer" identity that may not exist is ill-defined.
Identity cascading #
A service that is impersonating client A may itself need to call a third service. When it does, the connection it opens carries A's identity, not the service's own.
flowchart LR
A["Client A"] -->|connect| B["Server S1, impersonating A"]
B -->|connect to S2 while impersonating| C["Server S2"]
C -.->|peer token sees A, not S1| D["S2 can impersonate A"]
The mechanism: the impersonation token is the thread's effective token, and the effective token is what the kernel reads when capturing peer identity at connect. So when S1, while impersonating A, calls connect() on a socket to S2, the kernel captures A's identity onto S2's side of the socket. S2 can then kacs_impersonate_peer and get A.
This cascading is automatic and is what makes the local-IPC ecosystem work cleanly. A user makes one request to a service, and that service makes downstream requests to other services on the user's behalf — registry, file system, audit logging — and each of those downstream services sees the user as the peer. No explicit token forwarding is required.
The cascading is bounded by the impersonation level the original client granted. If A connected at Impersonation level, S1 captures A at Impersonation; when S1 connects to S2, the captured identity on S2's side is bounded by what S1's effective token currently is — which is A at Impersonation. The level does not get re-extended by cascading.
The same mechanism reaches the network boundary only at Delegation. If A connected at Delegation level, the captured identity on cascaded local connections is still A at Delegation, and outbound network calls from S1 carry A's Kerberos credentials (via authd; KACS only records the flag). Without Delegation, network calls from S1 go out as S1, not as A.
What the server has to do #
There are two server-side shapes, depending on which pattern (canonical synchronous or just-in-time) the server is using. Both start at the same point — the kernel has captured the client's peer token onto the socket at connect time — and diverge after accept.
Canonical synchronous flow (one OS thread, one request, start to finish):
- Accept the connection. Standard
accept()on the Unix socket. The kernel has already captured the client's peer token onto this socket. - Impersonate the peer. Call
kacs_impersonate_peer(fd). The kernel runs the two-gate model and installs the resulting token. - (Optional) Inspect the granted level. Read the thread's effective token's
impersonation_level. If it is below what you expected, the client's request, the identity gate, or the integrity ceiling has lowered it. - Do the work. Every access check on this thread is now against the impersonation token.
- Revert. Call
kacs_revert(). The thread is back to its primary identity. - Handle the next request. Either on the same connection (loop back to step 2 if the client may re-impersonate per request) or on the next connection.
Just-in-time flow (the right shape for any modern runtime — Go, Rust async, etc. — and the recommended default):
- Accept the connection. As above.
- Capture the peer token fd. Call
kacs_open_peer_token(fd)and store the returned token fd in the request context. The fd carriesTOKEN_QUERY | TOKEN_IMPERSONATE. - (Optional) Inspect the captured token. Query the captured fd to verify the identity and level before doing any work.
- Do most of the request as the service. Decoding, dispatch, internal bookkeeping, response framing — all run as the service's own identity.
- For each access-requiring action, install the captured token on the current OS thread (
KACS_IOC_IMPERSONATEon the stored fd), do the single action, callkacs_revert()immediately. Keep the impersonation window as tight as possible. - Close the captured fd at end of request.
A server that handles concurrent connections per thread runs whichever flow it uses in each thread, in parallel. The impersonation state is strictly per-thread; nothing shared.
Common mistakes #
A few patterns that bite first-time service authors:
- Forgetting to revert. A thread that finishes a request without calling
kacs_revertcontinues to impersonate the previous client. The next request handled on that thread uses the wrong identity. Makekacs_revertpart of your between-requests cleanup, unconditionally. - Reverting too early. A thread that reverts before all of the work is done — perhaps because an inner function is doing cleanup that runs as the service identity — will fail access checks on the parts that needed to remain impersonated. Keep the impersonation scope wide enough to cover the whole user-facing operation.
- Holding a peer token fd across exec. Token fds are file descriptors and survive exec by default (unless O_CLOEXEC was set). But the impersonation state of the thread does not survive exec — it is reverted automatically. If your service execs another binary, the new binary starts with no impersonation, even if the old code was impersonating.
- Assuming the level you asked for is the level you got. The two-gate model can silently downgrade. Code that branches on level should query, not assume.
Where to go next #
For what the impersonated identity is checked against once the server starts acting as the client, read Security descriptors.
To drive impersonation from a shell — impersonate a peer, revert, inspect the effective token — read The token command.
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:
| Component | What it does |
|---|---|
| Owner | A single SID identifying who owns the object. The owner has implicit READ_CONTROL and WRITE_DAC rights (with one exception, covered in Ownership). |
| Primary group | A 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 type | Storage |
|---|---|
| Files and directories | An xattr (security.peios.sd, or system.ntfs_security on NTFS volumes) on the inode. |
| Registry keys | Stored by the registry source. |
| Processes | A field on the PSB (process security block). |
| Tokens | A field on the token object itself. |
| Abstract Unix sockets | A field on the socket LSM blob, stamped from the binding thread's effective token at bind(). |
| IPC objects | A 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 change | You need |
|---|---|
| The DACL | WRITE_DAC on the object (the owner always has this unless suppressed by OWNER RIGHTS). |
| The owner | WRITE_OWNER on the object, plus a restriction on which SIDs you can name as the new owner (covered in Ownership). |
| The SACL | ACCESS_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:
- 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.
- Explicitly supplied at creation. The creator passes an SD as a parameter (to
kacs_openwith 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). - 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_ephemeralpolicy 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:
- The owner field is consulted to determine implicit rights (and whether they have been suppressed by OWNER RIGHTS).
- The SACL is scanned for mandatory integrity labels and PIP trust labels — these gate the access independently of the DACL.
- The DACL is walked, ACE by ACE, in order, applying first-writer-wins.
- 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.
| Type | Body | Use |
|---|---|---|
ACCESS_ALLOWED | Single SID | Grant the listed rights to the named principal. |
ACCESS_DENIED | Single SID | Deny the listed rights to the named principal. |
ACCESS_ALLOWED_COMPOUND | Reserved (not used in v0.20). | — |
ACCESS_ALLOWED_OBJECT | Object ACE | Grant rights, scoped by property GUID or inherited only by certain child object types. |
ACCESS_DENIED_OBJECT | Object ACE | Deny rights, same scoping. |
ACCESS_ALLOWED_CALLBACK | Single SID + expression | Grant rights only if the conditional expression evaluates TRUE. |
ACCESS_DENIED_CALLBACK | Single SID + expression | Deny rights when the expression evaluates TRUE or UNKNOWN. |
ACCESS_ALLOWED_CALLBACK_OBJECT | Object ACE + expression | Grant rights, conditionally, scoped by GUID. |
ACCESS_DENIED_CALLBACK_OBJECT | Object ACE + expression | Deny 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.
| Type | Body | Use |
|---|---|---|
SYSTEM_AUDIT | Single SID | Emit an audit event when the named principal attempts the listed rights. Flags decide whether success, failure, or both. |
SYSTEM_AUDIT_OBJECT | Object ACE | Same, scoped by GUID. |
SYSTEM_AUDIT_CALLBACK | Single SID + expression | Audit conditionally. UNKNOWN result emits the event (fail-open for auditing). |
SYSTEM_AUDIT_CALLBACK_OBJECT | Object ACE + expression | Conditional, scoped by GUID. |
SYSTEM_ALARM | Single SID | Configure per-operation continuous audit on the open handle. Each matching operation produces an event. |
SYSTEM_ALARM_OBJECT | Object ACE | Same, scoped by GUID. |
SYSTEM_ALARM_CALLBACK | Single SID + expression | Conditional continuous audit. |
SYSTEM_ALARM_CALLBACK_OBJECT | Object ACE + expression | Conditional, 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.
| Type | Body | Use |
|---|---|---|
SYSTEM_MANDATORY_LABEL | Integrity SID + mask | The object's mandatory integrity label and the policy bits that govern who can write/read/execute up. |
SYSTEM_RESOURCE_ATTRIBUTE | Everyone SID + claim entry | A name-value attribute on the object, referenceable as @Resource.<name> in conditional expressions. |
SYSTEM_SCOPED_POLICY_ID | Policy SID | A reference to a central access policy that should be evaluated alongside the object's own DACL. |
SYSTEM_PROCESS_TRUST_LABEL | PIP SID + mask | The 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.)
| Flag | Effect |
|---|---|
OBJECT_INHERIT_ACE | The ACE inherits to non-container child objects (files, not directories). |
CONTAINER_INHERIT_ACE | The ACE inherits to container child objects (directories). |
NO_PROPAGATE_INHERIT_ACE | The 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_ACE | The ACE is for inheritance only. The access check skips it; it exists only to be copied to children. |
INHERITED_ACE | Set 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:
| Flags | Effect |
|---|---|
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_ACE | Apply to descendants but not to this object. |
CI | OI | NO_PROPAGATE_INHERIT_ACE | Apply 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.
| Region | Bits | Purpose |
|---|---|---|
| Object-specific rights | 0–15 | 16 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 rights | 16–20 | 5 bits shared across all object types: DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE. |
| Reserved | 21–23 | Must be zero. |
| Special rights | 24–25 | ACCESS_SYSTEM_SECURITY — gates SACL read/write. MAXIMUM_ALLOWED — a request flag, not a real right. |
| Reserved | 26–27 | Must be zero. |
| Generic rights | 28–31 | GENERIC_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_DATAon a file butKEY_QUERY_VALUEon 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.
DELETEalways means delete the object;READ_CONTROLalways means read the SD;WRITE_DACalways means modify the DACL;WRITE_OWNERalways means change the owner. These bits work identically regardless of object type. - Generic rights are abstract.
GENERIC_READdoes 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 type | GENERIC_READ maps to |
|---|---|
| File | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE |
| Token | TOKEN_QUERY | READ_CONTROL |
| Process | PROCESS_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:
ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATAACCESS_DENIED Alice FILE_WRITE_DATA
Alice asks for FILE_READ_DATA | FILE_WRITE_DATA. The walk:
- ACE 1 matches Alice.
FILE_READ_DATAandFILE_WRITE_DATAare not yet decided. Both are added todecidedand togranted. - ACE 2 matches Alice.
FILE_WRITE_DATAis 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:
ACCESS_DENIED Alice FILE_WRITE_DATAACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA
The walk:
- ACE 1 matches Alice.
FILE_WRITE_DATAis not yet decided. It is added todecidedbut not togranted(denied). - ACE 2 matches Alice.
FILE_READ_DATAis not yet decided — it is added to both.FILE_WRITE_DATAis already decided. The ACE only takes effect onFILE_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.
| Position | ACE class |
|---|---|
| 1 | Explicit deny ACEs (set directly on the object). |
| 2 | Explicit allow ACEs. |
| 3 | Inherited deny ACEs. |
| 4 | Inherited 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.
| State | What the SD looks like | What the access check does |
|---|---|---|
| NULL DACL | SE_DACL_PRESENT flag is not set in the SD's control flags. | Grants every valid right. The walk is skipped. |
| Empty DACL | SE_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. |
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:
| Flag | Effect 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_ACE | No 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_FLAG | These 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/grantedarithmetic 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 single SID identifying the principal responsible for the object's access policy. The owner has authority over the object's DACL: they can change it, including granting or revoking access for themselves and others.
That authority is implemented by two implicit rights granted outside the DACL walk: READ_CONTROL (read the SD) and WRITE_DAC (modify the DACL). The owner gets these regardless of what the DACL says. An owner cannot accidentally write a DACL that locks themselves out of their own object — the implicit rights are the floor.
This is the "you cannot lock yourself out" guarantee. It is also why every SD must have an owner: an object with no owner has no fallback authority, and the access check rejects an SD without one.
The implicit rights #
When the access check starts evaluating a DACL, it first checks whether the caller's token represents the SD's owner. If it does, the caller is granted READ_CONTROL and WRITE_DAC before the DACL walk begins. Both bits go into the granted mask and into decided, so no later ACE — allow or deny — can change them.
"Represents the owner" means the caller's user_sid matches the SD's owner SID or the caller's token has the owner SID as a group with the SE_GROUP_OWNER flag set. The flag is on the group, not on the token globally; only groups specifically marked as eligible to act as owner count. This lets a token represent multiple potential owners (the user themselves, plus the administrative group they belong to that owns shared objects).
The implicit grant is just READ_CONTROL and WRITE_DAC. Nothing else. The owner of a file does not automatically get FILE_READ_DATA or FILE_WRITE_DATA — those rights come from the DACL like any other. An owner who has not written themselves into their own DACL can read and modify the policy but cannot read or write the data without first adding an allow ACE.
This split is what makes the model work. The owner controls policy; the DACL controls data access. An owner can lose all data-access rights to their own object (perhaps because every ACE was a deny against them) and still recover, because WRITE_DAC is implicit and lets them rewrite the DACL.
OWNER RIGHTS: suppressing the implicit grant #
Sometimes you want an object where the owner is not allowed to be a backdoor. A sensitive log file owned by a service should not let the service rewrite its own audit policy. A configuration object owned by a user should not let that user override administrator-set restrictions.
The mechanism is the well-known SID S-1-3-4 — OWNER RIGHTS. When any ACE in the DACL names OWNER RIGHTS, the kernel suppresses the implicit grant of READ_CONTROL | WRITE_DAC. The owner gets whatever the ACE says they get, no more.
The rule is more precise than that. Suppression happens when:
- An ACE in the DACL names
S-1-3-4as its SID, and - The ACE has
INHERIT_ONLY_ACEclear (it is a real, evaluated ACE, not an inherit-only placeholder).
If any such ACE is present, the owner's implicit rights are suppressed regardless of what the ACE grants. An OWNER RIGHTS allow ACE granting only FILE_READ_DATA means the owner can read but not write the DACL — they have lost WRITE_DAC entirely.
During the DACL walk, the OWNER RIGHTS SID matches the caller if they are the owner. So an ACCESS_ALLOWED ACE on S-1-3-4 grants those rights to whoever happens to own the object. The ACE composes with the rest of the DACL like any other ACE; first-writer-wins still applies.
The two pieces — suppress implicit, then evaluate normally — let you write DACLs that say:
- "The owner gets exactly read and read-attributes, nothing else, including not WRITE_DAC."
- "The owner has the same rights as any other authenticated user; no special privileges."
- "The owner has no access at all; only specific named principals can touch this object."
Changing ownership #
Ownership is itself a property the access check protects. To change an SD's owner, the caller must hold WRITE_OWNER on the object. By default, that right is in the standard rights section of the access mask (bit 19, value 0x00080000), and a DACL can grant it like any other right.
Once a caller has WRITE_OWNER, they cannot just name any SID as the new owner. The kernel applies one of two rules, depending on the caller's privileges.
Without SeRestorePrivilege, the new owner SID must be either:
- The caller's own
user_sid, or - A SID present in the caller's
groupslist with theSE_GROUP_OWNERflag set.
This restriction is what stops a user with WRITE_OWNER from "transferring" an object to someone else and washing their hands of it. You can take ownership yourself (assuming you have WRITE_OWNER or SeTakeOwnershipPrivilege), but you cannot hand the object to an arbitrary third party.
With SeRestorePrivilege, the restriction is bypassed. The new owner can be any well-formed SID. This is what backup and restore tools use to restore an object whose original owner is not present on the current system.
The kernel's kacs_set_sd enforces both rules at the time of the change — there is no "set owner to anyone, validate later" path.
SeTakeOwnership: an unconditional grant #
SeTakeOwnershipPrivilege is a privilege that grants WRITE_OWNER on any object, regardless of the DACL. A caller holding the privilege can take ownership of anything they can reach — a file whose DACL grants them nothing, a registry key with no allow ACE for them at all.
The privilege does not bypass:
- The "new owner must be self or a SE_GROUP_OWNER group" rule. The caller still has to name themselves (or one of their owner-eligible groups) as the new owner. To name a different principal as the new owner, the caller additionally needs
SeRestorePrivilege. - MIC. A lower-integrity caller cannot take ownership of a higher-integrity object even with the privilege.
- PIP. A non-dominant caller cannot take ownership of an object protected by a higher PIP trust label even with the privilege.
SeTakeOwnership is the lever that makes administrative recovery possible. An object whose DACL has accidentally locked everyone out can still be reached by an administrator with the privilege, who can take ownership, then use the implicit WRITE_DAC to rewrite the DACL. Without the privilege, such an object would be permanently inaccessible.
The privilege is granted very narrowly — typically only to BUILTIN\Administrators and the SYSTEM account. Most service accounts and ordinary users do not have it.
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_GROUPto 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 #
A few patterns for when ownership is the lever you need:
- Lost access to your own file. You wrote a DACL that denies you. You are still the owner; implicit
WRITE_DAClets you rewrite the DACL and add back what you need. - Inherited a mess of objects from a departed user. Their tokens are gone, but their SIDs are still in the DACLs. An administrator with
SeTakeOwnershipcan take ownership of each object (subject to the same-self-or-owner-group rule for the new owner) and then rewrite the DACLs. - Restoring backups. A backup tool with
SeRestorePrivilegecan set arbitrary owners during restore, reproducing the original SDs even when the original principals are not present on the current system. - Locking the owner out deliberately. Use an
OWNER RIGHTSACE — explicitly grant the owner what you want them to have (perhaps nothing) and the implicit grant ofREAD_CONTROL | WRITE_DACwill be suppressed. Use with care; this is what makes objects administratively unrecoverable except viaSeTakeOwnership.
Where to go next #
For how the CREATOR_OWNER and CREATOR_GROUP placeholders get substituted when children are created, read Inheritance.
For how SeTakeOwnership and SeRestore fit the wider privilege model, read Privileges.
To view and change an object's owner from a shell, read The sd command.
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:
| Flag | Effect 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 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:
| Flags | Meaning |
|---|---|
CI | OI | Inherit to all descendants, files and directories, recursively. The most common pattern. |
CI | Inherit to descendant directories only. |
OI | Inherit to descendant files only. |
CI | OI | IO | Apply to descendants but not to this object. |
CI | OI | NP | Apply 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:
- The parent's SD. Inheritable ACEs from here will be copied (with adjustments) into the child.
- 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.
- The creator's token. Provides defaults (owner, primary group, default DACL) when the creator did not supply specific values.
The merge proceeds as follows:
- 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.
- Compute the child's primary group. Same rule: explicit SD if present, otherwise the creator's token's default primary group.
- Compute the child's DACL. Start with explicit ACEs from the creator's SD (or, if the creator did not supply a DACL, the creator's token's
default_dacl). Then append inheritable ACEs from the parent's DACL — but only those whose flags say they should propagate to this kind of child (container or non-container). - 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_ACEflag (0x10) is set in the copy. This marks it as having come from inheritance, not from explicit assignment. - If
NO_PROPAGATE_INHERIT_ACEwas set, theOIandCIflags are cleared in the copy. (NP itself is also typically cleared.) - 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.
Sources of an inherited SD, ranked #
Which source wins for each component of the child SD, in order:
| Component | First source | Second source | Third source |
|---|---|---|---|
| Owner | Creator's explicit SD (if owner present) | Creator's token's default owner | (none — must be present) |
| Primary group | Creator's explicit SD (if group present) | Creator's token's default primary group | (none — must be present) |
| DACL | Creator's explicit SD (if DACL present) | Creator's token's default_dacl | (no DACL means NULL DACL) |
| SACL | Creator's explicit SD (if SACL present) | (no fallback) | (no SACL means no audit policy) |
Plus, in every case, inheritable ACEs from the parent are appended to whatever DACL/SACL was chosen as the base. The parent's contributions never replace; they always extend.
This means: 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).
Protected ACLs #
There is an opt-out: the SE_DACL_PROTECTED and SE_SACL_PROTECTED flags in the SD's control field.
| Flag | Effect |
|---|---|
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 (or the creator's default_dacl) 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.
Auto-inherit flags #
Two more SD control flags work alongside the protected flags:
| Flag | Effect |
|---|---|
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.
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:
| Type | Behaviour |
|---|---|
ACCESS_ALLOWED_CALLBACK | Allow ACE, gated by expression. |
ACCESS_DENIED_CALLBACK | Deny ACE, gated by expression. |
ACCESS_ALLOWED_CALLBACK_OBJECT | Allow ACE, gated by expression, scoped by GUID. |
ACCESS_DENIED_CALLBACK_OBJECT | Deny ACE, gated by expression, scoped by GUID. |
SYSTEM_AUDIT_CALLBACK | Audit ACE, gated by expression. |
SYSTEM_AUDIT_CALLBACK_OBJECT | Audit ACE, gated by expression, scoped by GUID. |
SYSTEM_ALARM_CALLBACK | Alarm ACE, gated by expression. |
SYSTEM_ALARM_CALLBACK_OBJECT | Alarm 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 class | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| Allow callback | ACE applies (grants its rights) | ACE is skipped | ACE is skipped |
| Deny callback | ACE applies (denies its rights) | ACE is skipped | ACE applies (denies) |
| Audit callback | Emit event | Skip | Emit event |
| Alarm callback | Configure continuous-audit mask | Skip | Configure 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:
| Namespace | Source | Set 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:
| Family | Operators |
|---|---|
| Relational | ==, !=, <, <=, >, >= |
| Set / membership | Contains, 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
INT64andUINT64with sign-aware promotion: a negativeINT64is always less than anyUINT64. - String comparisons are case-insensitive by default. A claim or attribute can opt into case sensitivity via the
CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVEflag (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 (&&) #
| TRUE | FALSE | UNKNOWN | |
|---|---|---|---|
| TRUE | TRUE | FALSE | UNKNOWN |
| FALSE | FALSE | FALSE | FALSE |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |
OR (||) #
| TRUE | FALSE | UNKNOWN | |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | UNKNOWN |
| UNKNOWN | TRUE | UNKNOWN | UNKNOWN |
NOT (!) #
| Input | Output |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |
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:
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 groupACCESS_ALLOWED Authenticated_Users GENERIC_READ— allow read to authenticated usersACCESS_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_DATAand friends are decided and not granted. - ACE 2: also matches Alice, but
FILE_READ_DATAetc. 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_DATAnot yet decided. Granted. - ACE 3: matches Bob.
FILE_WRITE_DATAnot 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 ifNot_Member_ofis TRUE, UNKNOWN AND TRUE is UNKNOWN. The deny ACE has UNKNOWN, which applies (denies err on the side of denying).FILE_READ_DATAis 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:
| Flag | Effect |
|---|---|
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 field | Resource attribute value |
|---|---|
| AceType | SYSTEM_RESOURCE_ATTRIBUTE_ACE (0x12) |
| Mask | Reserved. Not used in access decisions. |
| SID | Always S-1-1-0 (Everyone). |
| ApplicationData | One 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_ACEACEs 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:
- Scan the object's SACL for a
SYSTEM_RESOURCE_ATTRIBUTE_ACEwhose attribute name matches<name>. - If found, read the value(s) and use them in the expression.
- 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.Codereferences an attribute named exactlyProject.Code.@Resource.Departmentreferences an attribute named exactlyDepartment.
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:
| Source | Lives on | Referenced as | Set by |
|---|---|---|---|
| User claims | Token | @User.<name> | authd, from the user directory object |
| Device claims | Token | @Device.<name> | authd, from the machine directory object |
| Resource attributes | Object SACL | @Resource.<name> | Object administrator, via kacs_set_sd |
| Local claims | Per 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_ACEentries, 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 type | Purpose |
|---|---|
SYSTEM_AUDIT | Fire an audit event when access matches. One-shot at handle creation. |
SYSTEM_AUDIT_OBJECT | Same, GUID-scoped. |
SYSTEM_AUDIT_CALLBACK | Same, with a conditional expression gating when the audit fires. |
SYSTEM_AUDIT_CALLBACK_OBJECT | Same, GUID-scoped and conditional. |
SYSTEM_ALARM | Configure per-operation continuous audit on an open handle. |
SYSTEM_ALARM_OBJECT | Same, GUID-scoped. |
SYSTEM_ALARM_CALLBACK | Same, conditional. |
SYSTEM_ALARM_CALLBACK_OBJECT | Same, GUID-scoped and conditional. |
SYSTEM_MANDATORY_LABEL | The object's mandatory integrity label and policy bits (MIC). |
SYSTEM_RESOURCE_ATTRIBUTE | A typed key-value attribute on the object (covered separately under Resource attributes). |
SYSTEM_SCOPED_POLICY_ID | A reference to a central access policy (CAAP). |
SYSTEM_PROCESS_TRUST_LABEL | The 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-8192for Medium,S-1-16-12288for High, and so on — any single-sub-authorityS-1-16value 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-LwhereTis the PIP type andLis 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:
- 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.
- After the DACL walk, the check scans the SACL again for
SYSTEM_AUDITandSYSTEM_ALARMACEs 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 grantsACCESS_SYSTEM_SECURITYon any object), or SeRestorePrivilege(which grants it for specifickacs_set_sduse 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:
| Flag | Effect |
|---|---|
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_sdwithSACL_SECURITY_INFORMATION— also requiresACCESS_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.
The sd command
Peios / Peios Security Fundamentals / Security Descriptors
sd is the command-line tool for working with security descriptors on files. Everything this topic describes — owners, DACLs, ACEs, the SACL, integrity labels, inheritance — sd is how you read it and change it from a shell.
sd subcommand path [arguments]
$ sd show ./report.txt
$ sd allow ./report.txt alice:read
$ sd owner ./report.txt BA
Where ls -l shows a file's owner and a summary, and cp --preserve carries a descriptor across, sd is the tool that edits the descriptor directly.
The subcommands #
| Group | Subcommand | Does |
|---|---|---|
| Inspect | show | Print the descriptor on a path. |
check | Simulate an access check against the path. | |
| DACL | allow | Add an allow rule for one or more principals. |
deny | Add a deny rule for one or more principals. | |
remove | Drop every DACL rule for the named principals. | |
| Auditing | audit | Add an audit rule to the SACL. |
unaudit | Drop every SACL rule for the named principals. | |
| Ownership | owner | Set the descriptor's owner. |
group | Set the descriptor's group. | |
| Integrity | integrity | Set the mandatory integrity label. |
| Inheritance | inherit | Turn inheritance protection on or off. |
reset | Drop the file's own rules and re-inherit from the parent. | |
propagate | Push inheritance down to descendants. | |
| Wholesale | set | Replace the entire descriptor at once. |
Naming a principal #
Wherever a subcommand takes a PRINCIPAL, it accepts any of:
| Form | Example | Meaning |
|---|---|---|
@self | @self | The user SID of the token running sd. |
@owner | @owner | A placeholder that the access check substitutes with the file's own owner. |
| A well-known label | Everyone, Administrators, LocalSystem | A named built-in principal. |
| A two-letter alias | WD, BA, SY | The short alias for a well-known principal. |
| A raw SID | S-1-5-32-544 | Any SID, written out in full. |
See SIDs for what these are.
Naming permissions #
Wherever a subcommand takes PERMS, several notations are accepted, and may be mixed:
| Form | Example | Meaning |
|---|---|---|
| Single letters | rwx, r, m | r read, w write, x execute, d delete, m modify, f full, c change-permissions, o take-ownership. |
| Words | read,write, modify | The same set, spelled out. |
| Fine-grained names | read-data,append,traverse | Individual low-level rights, for precise rules. |
| Raw hex | 0x1F01FF | An access mask written directly. |
Run letters together (rwx) or separate names with commas (read,write,execute).
Inspecting #
sd show #
Prints the descriptor on a path — the owner, the group, the DACL, the SACL.
$ sd show ./report.txt
| Flag | Effect |
|---|---|
--sddl | Render the descriptor as an SDDL string. |
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known. |
--all | Verbose — decode every flag and show raw masks alongside. |
--json | Emit JSON. |
sd check #
Simulates an access decision: "would this access be allowed?" — without performing it.
$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
| Argument / flag | Effect |
|---|---|
PERMS | The access to test for. |
--pid PID | Check against process PID's token instead of your own. |
--explain | Show why the decision came out as it did — the rule-by-rule walk. |
sd check is the first thing to reach for when an access is denied and you do not know why. --explain walks the descriptor the same way the kernel does.
Changing the DACL #
The DACL is the list of allow and deny rules. These three subcommands edit it.
sd allow and sd deny #
Add an allow (or deny) rule for one or more PRINCIPAL:PERMS pairs.
$ sd allow ./report.txt alice:read bob:rw
$ sd deny ./report.txt Everyone:write
| Flag | Effect |
|---|---|
--flags LIST | ACE inheritance flags — CI container-inherit, OI object-inherit, NP no-propagate, IO inherit-only; none clears them. |
--if EXPR | Make it a conditional rule, applied only when EXPR is true. |
--replace | Drop any existing rules for this principal and kind first, instead of appending. |
--recursive, -r | Apply to every descendant of the path. |
Remember that a deny rule, when it matches, wins over any allow — see DACL evaluation.
sd remove #
Drops every DACL rule — allow and deny — for the named principals.
$ sd remove ./report.txt bob carol
| Flag | Effect |
|---|---|
--allow-empty | Permit the result to be a present-but-empty DACL, which denies everyone. Without this, sd refuses to produce one. |
--recursive, -r | Apply to every descendant. |
Auditing #
sd audit and sd unaudit #
The SACL holds audit rules — see The SACL. sd audit adds one; sd unaudit drops every SACL rule for the named principals.
$ sd audit ./secrets.db Everyone:write:failure
An audit spec is PRINCIPAL:PERMS:WHEN, where WHEN is success, failure, or both — which outcomes to log. sd audit takes the same --flags, --if, --replace, and -r options as sd allow.
Ownership #
sd owner and sd group #
Set the descriptor's owner or group SID.
$ sd owner ./report.txt alice
$ sd group ./report.txt Administrators
Both take a single PRINCIPAL and accept -r. Changing an owner is itself an access-controlled act — see Ownership.
Integrity #
sd integrity #
Sets the file's mandatory integrity label.
$ sd integrity ./report.txt high
The level is one of untrusted, low, medium, medium-plus, high, system, protected. The standard catalog is five levels (untrusted, low, medium, high, system); medium-plus (RID 8448) and protected (RID 20480) are non-standard Windows-compatibility levels — the kernel compares any S-1-16-<rid> numerically.
| Flag | Effect |
|---|---|
--policy BITS | The label's policy bits, comma-separated: NW no write-up, NR no read-up, NX no execute-up. |
--recursive, -r | Apply to every descendant. |
For what the label does, see Mandatory integrity control.
Inheritance #
sd inherit #
Turns inheritance protection on or off — the + mark ls -l shows.
$ sd inherit off ./report.txt # lock the file; stop inheriting
$ sd inherit on ./report.txt # let it inherit from its parent again
inherit on lets the file inherit rules from its parent directory. inherit off protects the file — it keeps its current rules and stops tracking the parent.
| Flag | Effect |
|---|---|
--strip-inherited | When turning protection off, also drop the inherited rules already on the file. |
--recursive, -r | Apply to every descendant. |
sd reset #
Drops the file's own explicit rules and rebuilds its DACL purely from what the parent directory hands down — returning the file to "inherits everything".
sd propagate #
Pushes this directory's inheritable rules down into its descendants, refreshing what they inherit. Use it after changing a directory's rules so the children pick up the change.
See Inheritance for the full model.
Replacing the whole descriptor #
sd set #
Replaces the entire descriptor in one step.
$ sd set ./report.txt 'O:BAG:BAD:P(A;;FA;;;BA)(A;;0x1200a9;;;BU)'
| Argument / flag | Effect |
|---|---|
SDDL | The new descriptor as an SDDL string. - reads it from standard input. |
--binary FILE | Instead of SDDL, read the raw descriptor bytes from FILE (- for standard input). |
--components LIST | Override which parts of the descriptor (owner, group, DACL, SACL) the operation writes. |
Common flags #
These apply across the subcommands:
| Flag | Effect |
|---|---|
--recursive, -r | Apply the change to every descendant of the path. |
--no-follow-symlinks, -P | Operate on a symbolic link itself, not the file it points to. |
--json | Emit JSON instead of human-readable output. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for sd check, the access would be allowed. |
| non-zero | The operation failed, the path was unreachable, or — for sd check — the access would be denied. |
Privileges
Peios / Peios Security Fundamentals / Privileges
A privilege is a system-wide right carried on a token. Where group membership decides "is this principal allowed to do things by virtue of who they are", a privilege decides "is this principal allowed to do this specific operation, regardless of who they are". Loading a kernel module, taking ownership of an object, changing the system clock, reading any file for backup — each is gated by a specific privilege, granted to principals whose role legitimately requires it.
Privileges sit on the token alongside the user SID and group list, but they are evaluated separately from identity. The access check reads a privilege bitmask, not a group membership. An ACE in a DACL cannot match a privilege the way it matches a SID. Privileges are their own axis of authority.
What a privilege is #
A privilege has three properties:
- A name — a specific string like
SeBackupPrivilege. Most privilege names start withSeand end withPrivilege; the convention is shared across the catalog. - A LUID — a 64-bit identifier the kernel uses internally to reference the privilege. Privileges occupy specific bit positions in the 64-bit privilege bitmask on every token.
- A specific operation it gates — every privilege has a defined effect: which kernel operation, which AccessCheck pathway, which capability it controls.
Privileges are global. The catalog is fixed at build time; there is no API to define new privileges at runtime. A token either has a specific named privilege or does not. The number of distinct privileges in v0.20 is in the low tens.
Where privileges come from #
A token's privileges are decided at one moment: when the token is minted. That happens either:
- During boot, when the kernel constructs the SYSTEM token (which holds every privilege).
- When authd authenticates a principal and constructs their token, applying the privilege policy authd has loaded for the principal's role.
- When peinit mints a token for a service it is launching.
- When existing code creates a derived token via DuplicateToken (preserves privileges) or FilterToken (removes the listed privileges).
There is no path to gain a privilege after a token is minted. AdjustPrivileges can enable or disable a privilege the token already has, and can permanently remove a privilege, but cannot add one that was not present at creation. A token's privilege bitmask is at most what was put there at the start.
The privilege policy authd consults — which principals get which privileges — is local to the machine, and lives in the registry under Machine\Generic\Authn\Policy, one record per principal. authd reads it at every logon. See assigning privileges for the record format and the rules.
That it is local is the design rather than an implementation detail. A principal source — the local store, or a directory — says who someone is: their SID, their memberships, their POSIX identifiers. It never says how much this machine trusts them, and the protocol it speaks gives it no way to. So a directory can tell this machine that you are a member of Domain Admins; whether that membership carries SeLoadDriverPrivilege here is this machine's answer, not the directory's.
From the kernel's point of view the policy is invisible: it only ever sees the resulting token.
The four states of a privilege #
At any moment, each privilege on a token is in one of four states:
| State | Meaning |
|---|---|
| Absent | The privilege is not on this token. The token's bitmask has the relevant bit clear in both the "present" and "enabled" fields. |
| Present, disabled | The privilege is on the token but not currently in effect. The access check ignores it. The token may enable it via AdjustPrivileges. |
| Present, enabled | The privilege is on the token and is in effect. AccessCheck will use it where applicable. |
| Used | The privilege has been exercised at least once. A sticky audit bit; never cleared. |
The states layer rather than replace. A privilege that has been used remains in whatever present/enabled state it was in; the "used" bit is recorded alongside, for auditing.
Disabling rather than removing is the common pattern. A service runs most of the time with sensitive privileges present but disabled, enabling them only at the moment they need to be exercised and disabling them afterwards. The reason: the access check ignores disabled privileges entirely, so the service is not at risk of accidentally exercising a privilege it had not meant to use.
The full lifecycle — transitions, AdjustPrivileges semantics, FilterToken-based permanent removal, the "used" bit — lives in Privilege lifecycle.
Privileges in the access check #
Privileges interact with the access check in three ways:
Direct gating #
Some operations are gated directly by a privilege held at the moment of the call, with no DACL involved.
SeLoadDriverPrivilegegates kernel module loads. No DACL applies; either you have the privilege enabled or the load fails.SeSystemtimePrivilegegatessettimeofday.SeCreateTokenPrivilegegateskacs_create_token.
These privileges are direct. The check is "is this privilege enabled on the calling token" and nothing else.
Influencing the DACL walk #
A handful of privileges, when enabled, change what the DACL walk would conclude. These are the AccessCheck-influencing privileges, covered in Privilege categories:
SeSecurityPrivilegegrantsACCESS_SYSTEM_SECURITY(SACL read/write) regardless of the DACL.SeTakeOwnershipPrivilegegrantsWRITE_OWNERon any object.SeBackupPrivilegegrants read access to any object (with the backup intent flag set).SeRestorePrivilegegrants write access and ownership-change rights (with the restore intent flag).SeRelabelPrivilegepermits raising an object's integrity label above the caller's own.
These privileges produce grants that no DACL would have produced. They are recorded in the access check's audit state so audit events can record "this access was granted by privilege X" rather than appearing as a DACL grant.
Intent-gated privileges #
Two of the influencing privileges — SeBackupPrivilege and SeRestorePrivilege — only fire when the caller passes a specific intent flag to AccessCheck. Without the flag, the privileges might as well not be present. This avoids accidentally exercising a backup privilege when doing a normal read.
The intent model is covered in Intent-gated privileges.
Privileges vs groups #
The split between privileges and group membership is one of the deliberate design decisions of the model:
| Group membership | Privileges | |
|---|---|---|
| What it grants | Whatever ACEs name the group | A specific operation, named directly |
| Where it lives | The token's groups field | The token's privilege bitmask |
| How it is matched | ACE SID matches token group SID | Privilege code is consulted in specific kernel paths |
| Who manages it | Whoever holds the accounts (a directory, or lpsd) and the per-object administrator (DACL entries) | This machine's administrator, in local policy |
| Granularity | Group-wide; the same group across all objects | Per-operation; the same privilege across all objects |
A user can be in a group and still not hold a privilege the rest of the group has, or vice versa, because the two are managed independently — and, more than that, they are managed by different parties. Memberships come from whoever holds the account; privileges come from this machine.
The classical example: BUILTIN\Administrators is a group, and being in it grants whatever ACEs in DACLs name the group. But the dangerous privileges — SeLoadDriver, SeBackup, SeRestore — are not automatic by group membership. Local policy specifies which administrators get which privileges, and it does so by naming principals: a record for one account's SID can grant it privileges the rest of Administrators does not have, or withhold ones they do.
This separation matters because the failure modes are different. A misconfigured DACL grants too much group access; a misconfigured privilege policy grants too much system-level authority. Both are fixable in different places.
Privileges and inheritance #
Group memberships propagate down through identity (you remain in your groups until your token changes). Privileges work the same way: a token holds the privileges authd minted it with, and the bitmask is preserved through fork, exec, and inheritance into child processes — exactly like the rest of the token.
Privileges do not propagate to ACEs or objects. There is no "privilege-bearing" ACE; no SD can be annotated with a privilege requirement; no object can demand "you must have SeBackup to open me". The privilege model and the SD model do not overlap that way. They interact only through the access check, when an influencing privilege is enabled on the caller's token and the AccessCheck pipeline decides whether to use it.
Where to start #
If you want the lifecycle in detail — the present/enabled/used/removed transitions, what AdjustPrivileges actually does, how FilterToken removes a privilege permanently — read Privilege lifecycle.
If you want to understand intent-gated privileges — why SeBackup and SeRestore require an explicit flag and how that flag is passed — read Intent-gated privileges.
If you want to see how privileges are organised — the kernel-standalone group, the AccessCheck-influencing group, the application-level group, the reserved group — read Privilege categories.
If you want the full catalog of named privileges with their numeric LUIDs and one-line descriptions, that is in the Constants and catalogs reference.
Privilege lifecycle
Peios / Peios Security Fundamentals / Privileges
A privilege's state on a token is more than just "yes, the token has it". The four-state model — absent, present-disabled, present-enabled, used — exists so a service can hold a sensitive privilege most of the time without exercising it, enable it briefly when it actually needs it, disable it again afterwards, and have all of those transitions auditable.
This page covers each transition: how privileges get added (only one moment), how they get enabled and disabled (many times), how they get permanently removed (one-way), and what the "used" bit means for auditing.
The four states #
From the Privileges overview:
| State | Bitmask representation | Meaning |
|---|---|---|
| Absent | present bit clear | Not on the token. AccessCheck cannot use it. |
| Present, disabled | present set, enabled clear | On the token but not in effect. AccessCheck ignores it. |
| Present, enabled | present set, enabled set | In effect. AccessCheck will use it where applicable. |
| Used (overlaid) | used bit set, independent of the others | Has been exercised at least once. Sticky. |
The bitmask is a 64-bit word. Each privilege occupies a fixed bit position (its LUID determines which). The token actually carries four independent 64-bit words: present, enabled, enabled_by_default, and used. Most code only cares about the first two.
enabled_by_default records the initial state — what was enabled at token creation. The kernel uses it to implement the "reset to defaults" sentinel in AdjustPrivileges.
Adding a privilege: only at creation #
There is exactly one moment when a privilege can be added to a token: when the token is being created. Specifically:
- During boot, the kernel constructs the SYSTEM token with every privilege present and enabled.
- When authd calls
kacs_create_token, the wire-format specification names the privileges to include and which of them should start enabled. - When peinit calls
kacs_create_tokenfor a service.
DuplicateToken preserves the privilege bitmask of the source — every present bit, every enabled bit, every used bit copies into the new token. The duplicate has the same privileges as the source.
That is the entire list of paths. There is no syscall, no ioctl, no privileged API to add a privilege to an existing token. A token's present bitmask is at most what it was at creation; it can only shrink over time.
This rule is one of the load-bearing parts of the security model. It is what makes "the principal's authority is fully expressed by their token" true. If a process could acquire new privileges at runtime, the kernel would need to track which privilege-acquisition paths exist and protect each one. By making creation the only entry point and authd the only entity that can use it, the kernel concentrates the privilege-policy decision into one component, audited at one moment.
Enabling and disabling: AdjustPrivileges #
The most common runtime operation on privileges is enabling or disabling them. The syscall is AdjustPrivileges (also reachable as the KACS_IOC_ADJUST_PRIVS ioctl on a token fd).
The caller passes an array of kacs_priv_entry records, each containing:
- A LUID identifying which privilege to adjust.
- An attributes value telling the kernel what to do with it.
The attributes value is one of:
| Value | Effect |
|---|---|
0 | Disable the privilege. Clears the enabled bit; leaves present set. |
SE_PRIVILEGE_ENABLED (0x02) | Enable the privilege. Sets the enabled bit. The privilege must already be present. |
SE_PRIVILEGE_REMOVED (0x04) | Permanently remove the privilege. Clears present, enabled, and enabled_by_default. Does not clear used. Irreversible. |
KACS_PRIV_RESET_ALL_DEFAULTS (0x80000000) | Sentinel value, used with LUID 0. Resets every privilege on the token to its enabled_by_default state. |
The operation is atomic: every entry in the array is validated first, and if any one is invalid (a LUID for a privilege not present on the token, an unknown attributes value, a duplicate LUID), the entire call fails and no changes are made. There is no partial success.
The call returns the previous state of every privilege touched, as a bitmask. Callers that want to do scoped privilege enable — turn on a privilege, do work, turn it off — read the return value to know what state to restore to. The pattern:
- Save previous state by calling AdjustPrivileges to enable the desired privilege.
- Do the work.
- Call AdjustPrivileges again with the saved state to restore.
If the privilege was already enabled before step 1, step 3 leaves it enabled. If it was disabled, step 3 restores it to disabled. Either way the work runs with the privilege enabled and the original state is preserved.
What AdjustPrivileges cannot do #
AdjustPrivileges cannot:
- Add a privilege. A LUID for a privilege not present on the token is rejected. The token must already have the privilege; AdjustPrivileges only changes its enabled state or removes it.
- Resurrect a removed privilege. Once
SE_PRIVILEGE_REMOVEDis applied, the privilege is absent. A subsequent attempt to enable it fails (the privilege is no longer present). - Toggle the
usedbit. The kernel setsusedautomatically when a privilege is exercised. Userspace cannot clear it. - Set
enabledwithoutpresent. The kernel rejects this — a privilege cannot be enabled if it is not present on the token. The "enabled but absent" state is not representable.
The right to call AdjustPrivileges is gated by TOKEN_ADJUST_PRIVILEGES (0x0020) on the target token. A thread can always adjust its own token's privileges (the default token SD grants this right to the token's user identity); adjusting another process's token requires PROCESS_QUERY_INFORMATION on that process, TOKEN_ADJUST_PRIVILEGES on the token, and the appropriate PIP dominance.
The "used" bit and audit #
When AccessCheck or any other kernel path actually exercises a privilege on a token, the kernel sets the corresponding bit in the token's used bitmask. Once set, the bit remains set for the life of the token. Nothing — not AdjustPrivileges, not FilterToken, not DuplicateToken into a fresh token — clears it.
The bit is for auditing. It answers the question "did this token, at some point in its existence, exercise this privilege?". A security audit can read a token's used bitmask to see which privileges have been engaged so far. A privilege that is present but never used is materially different from one that has been used; the used bit records the difference.
This is why removal does not clear used. If a privilege has been exercised, removing it later does not erase the fact. The audit trail remains.
DuplicateToken copies used from the source. A duplicate of a token that has used a privilege is itself marked as having used it, even though the duplicate has never actually exercised it. The rationale: a duplicate inherits the source's audit history; if the source has used a privilege, anything derived from it is suspect of having access to that privilege's effects.
If you need a fresh, audit-clean token, you need a freshly-minted token (a new authentication, or a new kacs_create_token call). Duplicates carry history.
Permanent removal #
SE_PRIVILEGE_REMOVED permanently removes a privilege from a token. After removal:
presentis clear.enabledis clear.enabled_by_defaultis clear.usedremains whatever it was (still set if the privilege was exercised before removal).
The removal is irreversible. There is no syscall to re-add. The only way back to a token with the privilege is to start with a different token — typically by FilterToken from a source that still has the privilege, or by a fresh authentication.
Removal is the right tool for:
- Service hardening. A service that holds privileges by default but only needs them at startup can remove them after the startup phase. Removing rather than disabling makes the privilege unrecoverable for the remainder of the service's lifetime — even if an attacker compromises the service later, the privileges are gone.
- Sandbox launchers. Code that forks a child to run untrusted work removes every dangerous privilege from the child's token before exec. The child has no way to get them back.
- One-way demotion. A process starting with high authority that wants to demote itself to a lower-privileged identity, irrecoverably, removes the privileges it no longer wants. This is stronger than disabling: a disabled privilege can be enabled by code running on the token (assuming
TOKEN_ADJUST_PRIVILEGES); a removed privilege cannot.
The one-way nature is the point. The model is built around the assumption that authority shrinks. A removed privilege is gone for the same reason a disabled group can be marked use-for-deny-only and not un-marked: each is a deliberate restriction the kernel will not let userspace undo.
FilterToken and bulk removal #
For removing multiple privileges at once — typically during sandbox creation — FilterToken is the appropriate API. Where AdjustPrivileges with SE_PRIVILEGE_REMOVED removes one privilege from the calling token, FilterToken produces a new token with the listed privileges removed.
The difference matters: FilterToken does not modify an existing token. It creates a copy with the listed adjustments (privileges removed, groups marked use-for-deny-only, restricted SIDs added) and returns the new token. The original is unchanged.
This is the right pattern for sandbox launchers, which want to filter a token down to a restricted version for a child process without affecting their own running identity. AdjustPrivileges with SE_PRIVILEGE_REMOVED is the right pattern for code that wants to demote itself.
FilterToken is covered in Token lifecycle and Restricted and write-restricted tokens.
The reset-to-defaults sentinel #
A token records enabled_by_default separately from enabled. The two diverge as the token's privileges get enabled and disabled at runtime; the default record stays as it was at creation.
The KACS_PRIV_RESET_ALL_DEFAULTS sentinel — a kacs_priv_entry with luid = 0 and the sentinel attributes value — tells AdjustPrivileges to copy enabled_by_default back into enabled. Every privilege that was enabled at creation is now enabled again; every privilege that was disabled at creation is now disabled again.
The sentinel is useful for code that does "enable a few privileges, do work, restore". Instead of saving the prior state and restoring it explicitly, the code can simply reset to defaults — which is the right behaviour as long as nothing else has changed the defaults (which nothing should, since defaults are immutable at creation).
The reset sentinel only affects enabled. It does not bring back removed privileges. It does not clear the used bit. It is a cheap way to "go back to how this token started" within the set of operations that still make sense.
Field mutability summary #
To pin the entire model on one table:
| Field | Mutability |
|---|---|
present | Only ever clears (via SE_PRIVILEGE_REMOVED). Set only at token creation. |
enabled | Toggled freely between 0 and the corresponding present bit. |
enabled_by_default | Immutable after token creation. |
used | Set by the kernel when the privilege is exercised. Never clears. |
The asymmetric mutability is the model's spine. Authority can decrease at runtime but not increase. State for auditing accumulates. Defaults are fixed. The "easy" mutation — toggle enabled — is the only one that goes both ways, and it does not affect what the token fundamentally is.
Where to go next #
For the two privileges that require an intent flag on top of being enabled, read Intent-gated privileges.
For bulk privilege removal as part of building a sandbox token, read Restricted and write-restricted tokens.
Intent-gated privileges
Peios / Peios Security Fundamentals / Privileges
Most privileges work the same way: if they are enabled on the calling token at the moment of the call, the kernel uses them; if not, it does not. AdjustPrivileges manages enabled state; the access check reads it. There is no other input.
Two privileges are special: SeBackupPrivilege and SeRestorePrivilege. Both are present-and-enabled on the tokens of backup-and-restore tools. Both grant access that the DACL alone would not. And both will refuse to do anything unless the calling code passes a specific intent flag to AccessCheck — BACKUP_INTENT for SeBackup, RESTORE_INTENT for SeRestore.
Without the intent flag, the privilege is invisible to the access check. The token has it. The kernel knows it has it. But the privilege does not participate in the access decision. The DACL walk runs as if the privilege were not present.
This page covers why intent gating exists, how the flags are passed, and what each privilege actually does when its intent flag is set.
Why intent gating exists #
The privileges in question grant very broad access. SeBackup grants read on any object regardless of the DACL. SeRestore grants write, ownership change, and SACL access on any object. They are designed for backup-and-restore tools — programs that legitimately need to bypass discretionary access control to do their job.
The problem: if these privileges were always in effect when enabled, every access check on a token holding them would benefit from them. A backup tool that opens a configuration file as part of its initialisation — for entirely ordinary reasons — would silently exercise SeBackup, even though the configuration file's normal DACL would have granted the access anyway. The audit trail would record privilege exercises that were operationally meaningless. Worse, a buggy code path in the tool that did something unintended would silently get backup-level access to the unintended object.
Intent gating solves this by making the privilege opt-in per call. The tool's main backup loop sets BACKUP_INTENT on every AccessCheck call it makes; everything else does not. The privilege fires only where the tool deliberately asked for it. Audit events for privilege exercise are accurate (they only appear where the tool intentionally invoked the privilege), and bugs in non-backup code paths cannot accidentally exercise SeBackup.
The split is between "enabled" (the token can use the privilege) and "intent" (the caller wants to use the privilege right now). Both must be true.
The intent flags #
The flags are passed to AccessCheck as part of the privilege_intent parameter. They are independent bits:
| Flag | Effect |
|---|---|
BACKUP_INTENT (0x01) | Tells AccessCheck that the caller wants SeBackupPrivilege to participate, if present and enabled. |
RESTORE_INTENT (0x02) | Tells AccessCheck that the caller wants SeRestorePrivilege to participate. |
A caller can set both, one, or neither. The flags are not exclusive — a backup-and-restore tool might do a restore-with-verification operation that wants both privileges to fire.
Without either flag, the corresponding privilege is stripped from the access check's view of the token for this call. It is not removed from the token; the token still has it; the next call from the same code can set the flag and use it. The strip happens only for the current AccessCheck.
The flags are at the AccessCheck API surface. Lower-level open and access paths (the file system's open, the registry's read) translate higher-level semantics into AccessCheck calls; whether they pass the flags depends on whether they were told to. Most do not; an opener of a file in the ordinary course of work does not pass BACKUP_INTENT.
What SeBackupPrivilege does #
With BACKUP_INTENT set and SeBackup enabled, AccessCheck grants the caller read-category rights on the object regardless of the DACL:
- For files:
FILE_READ_DATA,FILE_READ_ATTRIBUTES,FILE_READ_EA, andREAD_CONTROL. - For registry keys: the read-category rights on the key (the specific values depend on the registry's GenericMapping).
- For tokens:
TOKEN_QUERYandREAD_CONTROL.
The grant happens after the DACL walk has run. Specifically, the walk produces a granted mask for whatever the DACL alone would grant; the privilege then adds the read-category bits to that mask without consulting the DACL. If the DACL already granted some of them, the privilege grant is redundant; if the DACL granted nothing, the privilege adds them.
The grant is recorded in the access check's audit state. An audit event for this call records "the read rights came from SeBackupPrivilege", distinguishing privilege-granted access from DACL-granted access. Audit consumers that care about privilege use can filter by this marker.
SeBackup does not grant write access. A backup tool that needs to write its output also reads but cannot use SeBackup for that — write is SeRestore's territory.
What SeRestorePrivilege does #
With RESTORE_INTENT set and SeRestore enabled, AccessCheck grants the caller write-category rights and metadata-modification rights regardless of the DACL:
- Write rights (
FILE_WRITE_DATA,FILE_APPEND_DATA,FILE_WRITE_ATTRIBUTES,FILE_WRITE_EAfor files; corresponding rights on other object types). DELETE.WRITE_OWNERandWRITE_DAC.ACCESS_SYSTEM_SECURITY(SACL read/write).
Plus, separately, SeRestore bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction during kacs_set_sd. A restore tool can set the owner of a restored object to any well-formed SID, not just the caller's own. This is what lets a backup restore reconstitute an object's original owner even when the original principal is not present on the running system.
Like SeBackup, the grant is recorded in audit state — write rights granted by SeRestore are distinguished from those granted by the DACL.
The reason SeRestore also grants ACCESS_SYSTEM_SECURITY (which would normally require SeSecurityPrivilege) is the same: a restore operation needs to set the SACL as part of reconstituting the object's policy. Forcing the tool to also hold SeSecurity would be redundant; SeRestore folds that authority in.
What the flags do not do #
Two clarifications:
- The flags do not grant privileges. A token that does not have SeBackup gets nothing from setting BACKUP_INTENT. The flag tells the kernel "use this privilege if I have it"; it cannot conjure a privilege the token lacks.
- The flags do not enable disabled privileges. A token that has SeBackup present but disabled gets nothing from BACKUP_INTENT, just as it would get nothing without the flag. The privilege must be enabled for AccessCheck to consider using it. Intent is on top of enabled, not in place of it.
The state machine is: token has the privilege AND token has the privilege enabled AND caller has set the intent flag = AccessCheck uses the privilege. Any one of the three missing means it does not.
Why only these two privileges #
The intent-gating model exists specifically for privileges that grant broad, blanket access. SeBackup grants read on everything; SeRestore grants write on everything. The risk of accidental exercise is high enough to be worth a per-call gate.
Other privileges that influence the access check — SeSecurityPrivilege (SACL access), SeTakeOwnershipPrivilege (WRITE_OWNER), SeRelabelPrivilege (raising integrity) — are not intent-gated. They are scoped enough that the "just check if enabled" model is appropriate. SeSecurity only fires when accessing the SACL; SeTakeOwnership only when changing the owner; SeRelabel only when changing the integrity label — none of which is done by accident.
The split is between "this privilege only fires when you do something specific anyway" (no intent needed) and "this privilege would fire on every access if we let it" (intent required). SeBackup and SeRestore are the only two privileges in the second class.
Calling pattern #
A typical backup tool's loop:
- Token at startup has
SeBackupPrivilegepresent and enabled. (Granted by authd's privilege policy to backup-role principals.) - For each object to back up:
- Call AccessCheck with
BACKUP_INTENTset inprivilege_intent. The kernel grants the read-category rights via SeBackup if the DACL would not. - Read the object.
- Call AccessCheck with
- Other operations the tool does (reading its own configuration, writing log output, opening its output file) call AccessCheck without
BACKUP_INTENT. These run through the DACL like any other access. The privilege is not exercised.
A typical restore tool's loop is the mirror:
- Token at startup has
SeRestorePrivilegepresent and enabled. - For each object to restore:
- Call AccessCheck with
RESTORE_INTENTset. The kernel grants write-category rights via SeRestore. - Write the object's contents.
- Set its SD via
kacs_set_sd, including owner. The "any well-formed SID can be the owner" rule is in effect because RESTORE_INTENT was set on the AccessCheck that produced the WRITE_OWNER grant.
- Call AccessCheck with
- Other operations run normally without the flag.
The pattern is symmetric. Both privileges work the same way; both follow the same intent rule.
What about privileges in audit events #
Audit events for privilege exercise carry enough information to distinguish:
- The privilege that was exercised.
- The bits it contributed to the final granted mask.
- Whether those bits survived to the final access decision (i.e., were not stripped by some later layer like restricted-token intersection or confinement).
A backup tool with BACKUP_INTENT set on every AccessCheck call produces clean audit events: one privilege-use event per backup-flavoured access, none for incidental accesses. This is the audit trail intent gating was designed to produce. Without the flag, the privilege would fire on every access from the same token, and the audit would be unable to distinguish "I exercised SeBackup because I'm a backup" from "I exercised SeBackup because the file would have been readable to me anyway".
The full audit model lives in Auditing.
Where to go next #
For the four functional categories the rest of the privileges fall into, read Privilege categories.
For exactly where SeBackup and SeRestore fire during a check — and which layers can still strip their grants — read Privileges in the pipeline.
Privilege categories
Peios / Peios Security Fundamentals / Privileges
The privileges in Peios fall into four functional categories. Each category has a different relationship to the kernel and to the access check. Knowing which category a privilege is in tells you what it does, where it fires, and whether to expect it to participate in the DACL walk.
This page is organised around the four categories. The full per-privilege catalog — name, LUID bit, one-line description for every privilege — is in Constants and catalogs.
The four categories #
| Category | What the privileges do | Examples |
|---|---|---|
| Kernel-standalone | Gate specific kernel operations directly. Not consulted during the DACL walk. | SeLoadDriverPrivilege, SeSystemtimePrivilege, SeCreateTokenPrivilege |
| AccessCheck-influencing | Cause the access check to grant access the DACL alone would not. Consulted at specific points during the access pipeline. | SeSecurityPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege, SeRelabelPrivilege |
| Application-level | Defined for the directory and authd to interpret. The kernel records them on the token but does not enforce them itself. | SeSyncAgentPrivilege, SeEnableDelegationPrivilege, SeMachineAccountPrivilege |
| Reserved | Present in the catalog for ABI parity but not used in v0.20. | SeCreatePagefilePrivilege, SeUndockPrivilege, SeTimeZonePrivilege |
The categorisation is functional, not structural. A token's privilege bitmask does not partition by category; all bits live in the same 64-bit word. The category is a property of how each individual privilege is consumed.
Kernel-standalone privileges #
These are the largest category. A kernel-standalone privilege gates a specific kernel operation: the kernel checks at the entry point whether the caller's effective token has the privilege enabled, and refuses the operation if not. The DACL walk is not involved.
Representative members:
| Privilege | What it gates |
|---|---|
SeCreateTokenPrivilege | kacs_create_token. Token minting. Held only by authd and peinit. |
SeAssignPrimaryTokenPrivilege | Installing a token as another process's primary. Used by peinit. |
SeImpersonatePrivilege | Impersonating any user (when not running as the same user). Held by every service that handles user requests. |
SeTcbPrivilege | "Act as part of the TCB" — a catch-all for operations that should only happen in trusted code. Required for KACS_IOC_LINK_TOKENS, kacs_set_caap, mount-policy changes (policy=synth-*, which author security descriptors), and a handful of other system operations. It also satisfies every check SeManageVolumePrivilege satisfies, since the TCB may do anything a volume manager may. |
SeLoadDriverPrivilege | Loading and unloading kernel modules. Held only by peinit on its primary token; explicitly stripped via FilterToken from every other service. |
SeManageVolumePrivilege | Mounting, unmounting and reshaping the mount tree, including mount policy (policy=synth-*). Granted to Administrators. The most powerful privilege routinely granted outside the TCB — see the warning below. |
SeShutdownPrivilege | Local shutdown and reboot. |
SeRemoteShutdownPrivilege | Shutdown from a remote connection. Requires SeShutdown as well. |
SeDebugPrivilege | Inspecting another process regardless of its SD. Crucially, it does not bypass PIP dominance — a SeDebug holder can bypass an unrelated process's SD but still cannot cross a PIP boundary. |
SeSystemtimePrivilege | Setting the system clock. |
SeIncreaseBasePriorityPrivilege | Raising another process's scheduling priority or setting its CPU affinity. |
SeIncreaseQuotaPrivilege | Overriding resource limits for a process. |
SeLockMemoryPrivilege | Locking pages in physical memory (mlock/mlockall). |
SeAuditPrivilege | Writing entries to the audit log. |
SeProfileSingleProcessPrivilege | Cross-task profiling of a specific other process (perf_event_open). Own-task profiling needs no privilege. |
SeSystemProfilePrivilege | System-wide profiling (perf_event_open with pid == -1): per-CPU, all-task, kernel-mode events. |
SeBindPrivilegedPortPrivilege | Binding to TCP/UDP ports below 1024. A Peios-specific privilege. |
SeChangeNotifyPrivilege | Bypassing traverse checks during path resolution. Granted to every principal by default; rarely the answer to a question. |
SeCreateSymbolicLinkPrivilege | Creating symbolic links. Granted to every principal by default. |
For all of these, the calling pattern is the same: the kernel reads the calling token's privilege bitmask at the entry point of the gated operation; if the privilege is not enabled, the operation fails with the appropriate error.
The privileges in this category are mostly held in narrow ways. The most dangerous of them — SeCreateToken, SeTcb, SeLoadDriver — appear on only a handful of TCB token holders. Most other services hold a small, role-specific subset.
AccessCheck-influencing privileges #
These privileges, when enabled, change what the access check itself decides. They produce grants the DACL alone would not.
| Privilege | What it does |
|---|---|
SeSecurityPrivilege | Grants ACCESS_SYSTEM_SECURITY on any object (SACL read/write). Also gates kernel-standalone audit-system operations. |
SeTakeOwnershipPrivilege | Grants WRITE_OWNER on any object regardless of the DACL. Subject to MIC and PIP — does not bypass those. |
SeBackupPrivilege | Grants read-category rights on any object when BACKUP_INTENT is set. Intent-gated. |
SeRestorePrivilege | Grants write, metadata, and ownership-change rights on any object when RESTORE_INTENT is set. Intent-gated. Also bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction. |
SeRelabelPrivilege | Permits setting an object's mandatory integrity label above the caller's own. Also acts as the kernel-standalone gate for kacs_set_sd with LABEL_SECURITY_INFORMATION set to a higher label. |
These privileges fire at specific points in the access pipeline:
SeSecurityPrivilegeis consulted when AccessCheck seesACCESS_SYSTEM_SECURITYin the requested mask.SeBackupPrivilegeandSeRestorePrivilegeare consulted near the start of AccessCheck, but only if the corresponding intent flag is set inprivilege_intent. See Intent-gated privileges.SeTakeOwnershipPrivilegeis consulted after the DACL walk: if the walk did not grantWRITE_OWNERand the mandatory policy did not block it, the privilege grants it.SeRelabelPrivilegeis consulted bykacs_set_sdwhen the caller is trying to set an integrity label.
When any of these privileges contributes to the final granted mask, the access check records the fact so audit events can attribute the grant correctly. Audit consumers can distinguish "the user got read access because the DACL allowed it" from "the user got read access because they hold SeBackup and asked to use it".
Application-level privileges #
These privileges are defined for authd, the directory, and federation services to interpret. The kernel records them on the token (so authd has somewhere to put them) but does not enforce them itself.
| Privilege | What it does |
|---|---|
SeSyncAgentPrivilege | Lets the holder read all directory objects regardless of per-object permissions. Used by directory replication agents. |
SeEnableDelegationPrivilege | Lets the holder mark a principal as trusted for delegation in the directory. |
SeMachineAccountPrivilege | Lets the holder add computer accounts to the domain. |
From the kernel's point of view, these are token attributes that no kernel path checks. They appear on the token's privilege bitmask; AdjustPrivileges can enable, disable, or remove them like any other privilege; but no AccessCheck path consults them. Their effect happens entirely in user-space services that read the token and act on its privilege bitmask themselves.
The kernel still enforces the present/enabled/removed/used state machine for these privileges as it does for others — AdjustPrivileges treats them identically. The application-level distinction is about who consumes them, not how they are stored or transitioned.
What SeManageVolumePrivilege is actually worth #
Mounting is an administrative act rather than a TCB one, which is why this
privilege exists separately: without it no administrator could mount anything,
and peios-install could not run outside a SYSTEM shell.
But be clear about its reach before granting it more widely. Its holder may:
- mount a filesystem whose synthesised security descriptors it chooses
(
policy=synth-*with--synth-sddl), and - mount over an existing path.
Together those are enough to author policy on a subtree and to shadow a system path — so in the hands of a determined holder it is a route to authority approaching the TCB's.
This is a deliberate design decision, not an oversight. The narrower
alternatives — permitting only filesystems that carry their own descriptors, or
constraining synthesis to a fixed system template — were considered and
rejected, because the FAT ESP carries no descriptors of its own and must be
mounted synth-ephemeral for an installation to work at all.
Treat SeManageVolumePrivilege as sitting beside SeLoadDriverPrivilege in
sensitivity, not beside SeChangeNotifyPrivilege.
Mounting passes three separate checks, and the privilege covers all
three: may_mount() (may I reshape my namespace), mount_capable() (may
this context create a superblock), and KACS's set_mount_policy (may I
choose how descriptors are synthesised). They were found one at a time by
driving a real image — each fix moved the failure to the next gate — which
is worth knowing if a fourth ever appears.
Reserved privileges #
A handful of privileges appear in the catalog for binary compatibility with the spec lineage but have no implementation in v0.20:
| Privilege | Why it is reserved |
|---|---|
SeCreateGlobalPrivilege | No per-session object namespaces in Peios. |
SeCreatePagefilePrivilege | Folded into SeTcbPrivilege. |
SeCreatePermanentPrivilege | No Linux equivalent. |
SeIncreaseWorkingSetPrivilege | Linux does not gate memory-residency hints. |
SeTrustedCredManAccessPrivilege | Reserved for future secrets infrastructure. |
SeSystemEnvironmentPrivilege | Replaced by SDs on EFI variable files under FACS. |
SeTimeZonePrivilege | Linux does not gate timezone changes. |
SeUndockPrivilege | Server OS; not applicable. |
A reserved privilege's LUID position in the bitmask is allocated, but no kernel path consults it. They are placeholders that keep the bitmask layout stable for future use. A reserved name is not in the privilege vocabulary at all, so a policy record naming one is dropped with a warning rather than granting anything.
Default-grant privileges #
Two privileges deserve a special note: SeChangeNotifyPrivilege and SeCreateSymbolicLinkPrivilege are granted to every principal on a stock machine. The reason is that they are needed for almost every program to function normally — without SeChangeNotifyPrivilege, a process cannot traverse a directory to reach a file, so a token lacking it cannot so much as start a shell; without SeCreateSymbolicLinkPrivilege, a process cannot create the symlinks that build systems and packaging tools depend on.
They are granted by the shipped policy — a record for Everyone — rather than being built into authd, so both are visible and both can be taken away. See assigning privileges. SeChangeNotifyPrivilege alone is additionally authd's compiled floor, applied when a machine has no policy key at all, because a machine that cannot start a shell cannot be repaired from a console.
Their effect is broad-but-uninteresting: on a stock machine every token has them, so any reasoning about access that does not explicitly involve their absence can ignore them. They are mentioned here for completeness; they will rarely be the answer to a question about who can do what.
A token can have these stripped by FilterToken if a sandbox wants to operate without them. Doing so creates a token that cannot traverse arbitrary directories — useful in a tightly confined sandbox, not useful much elsewhere.
How to find the catalog #
The four-category model on this page is the conceptual structure. The byte-level catalog — every privilege name, its LUID bit position, its one-line effect — lives in Constants and catalogs. Cross-reference between the two when you need to look up a specific privilege.
The naming convention is uniform: every privilege starts with Se and ends with Privilege. The middle is descriptive: SeLoadDriver, SeBackup, SeChangeNotify. There are no privileges outside this convention.
Where to go next #
For the per-privilege reference — every name, LUID bit position, and one-line effect — see the Privilege catalog.
For how the AccessCheck-influencing category actually participates in a check, read Access decisions.
Assigning privileges
Peios / Peios Security Fundamentals / Privileges
A principal source says who someone is — their SID, their memberships, their POSIX identifiers. It never says how much this machine trusts them. Privileges and integrity are local policy: authd's alone, decided here, and there is no message with which a source could ask for one.
That policy lives in the registry:
Machine\Generic\Authn\Policy
authd reads it at every logon, not once at startup. A policy change takes effect the next time someone signs in, rather than the next time you restart the one daemon on the system that is most disruptive to restart.
One record per principal #
Each subkey is a principal, and holds everything this machine grants them:
Machine\Generic\Authn\Policy
DeniedPrivileges REG_MULTI_SZ ["SeDebugPrivilege"]
\Everyone
Privileges REG_MULTI_SZ ["SeChangeNotifyPrivilege"]
\Administrators
Privileges REG_MULTI_SZ ["SeBackupPrivilege", "SeRestorePrivilege"]
Integrity REG_SZ "High"
Owner REG_SZ "Administrators"
DefaultDacl REG_SZ "D:(A;;GA;;;SY)(A;;GA;;;BA)"
Keyed by principal rather than by privilege on purpose. One key shows the totality of a principal's authority — reg ls on \Administrators answers "what can an administrator do on this machine?" completely. Authority scattered across twenty per-privilege values is authority nobody audits: a right granted somewhere unexpected does not surface when you look at the principal, and you would have to know to check every other place.
It is also the only shape that holds more than privileges. Integrity, the default owner and the default DACL live on the same record, and logon rights will when they arrive.
Naming a principal #
A subkey's name is either a well-known name or a literal SID:
\Administrators
\S-1-5-21-2847362817-1094533892-3310298447-1000
Names are matched case-insensitively. The recognised ones are SYSTEM, Everyone, Authenticated Users, Administrators, Users, Guests, Local Service, Network Service, and the logon types Interactive, Network, Batch, Service and Anonymous.
Two limits are worth knowing before you hit them:
BUILTIN\Administrators cannot be a subkey name. A backslash is the registry's path separator, so the qualified spelling is unrepresentable. Write the bare name.
A local group's name does not resolve. authd cannot know what developers means without asking lpsd, and policy must never depend on a principal source being up — that would make "what may this principal do" unanswerable exactly when a source is broken. Name local groups by SID, which lps group list will show you.
A subkey that is neither a known name nor a parseable SID is ignored with a warning. It is worth checking for that warning after editing: a record naming nobody sits in the key looking authoritative and applying to no one.
If the key exists, the key is the whole policy #
This is the most important behaviour on the page.
authd carries a compiled-in floor, but it applies only when the key is absent entirely. It is not merged in value by value. So a policy you write is the complete policy: anything not granted below is not granted.
| State | What a principal gets |
|---|---|
| Key absent | The compiled floor — Everyone gets SeChangeNotifyPrivilege, and nothing else |
| Key present | Exactly what the records say |
| Key present but unreadable | Nothing, and a loud log line |
The alternative — a compiled default that each record replaces — fails in the wrong direction. An administrator who writes one record believing they have locked the machine down would still be handing out whatever a compiled table said for every principal they did not mention, from a table they cannot read. That is a security failure, and a silent one. Failing towards less privilege makes things stop working, which is far easier to diagnose than authority you did not know you were granting.
An unreadable key is treated as granting nothing rather than as absent, for the same reason: reading it as absent would silently restore every privilege an administrator may have deliberately removed, at the one moment nobody can check.
What a machine ships with #
The defaults are registry data, not compiled in — authd-policy.reg, shipped to /usr/share/regim/ and applied if the image opts in. So they can be read, edited, and replaced wholesale by an image that wants a different policy, rather than being invisible inside a binary.
A stock machine grants:
| Principal | Privileges | Integrity |
|---|---|---|
Everyone | SeChangeNotifyPrivilege, SeCreateSymbolicLinkPrivilege | (Medium, by default) |
Administrators | the operational set below, plus both of the above | High |
The operational set is SeBackupPrivilege, SeRestorePrivilege, SeShutdownPrivilege, SeRemoteShutdownPrivilege, SeSystemtimePrivilege, SeSecurityPrivilege, SeLoadDriverPrivilege, SeImpersonatePrivilege, SeIncreaseQuotaPrivilege, SeIncreaseBasePriorityPrivilege and SeProfileSingleProcessPrivilege.
SeDebugPrivilege is deliberately not granted. It writes to any process regardless of integrity label, so it is the single privilege that most directly defeats the integrity boundary, and ordinary administration does not need it. Add it when a machine genuinely does.
Three privileges are never granted by the shipped policy and should not be added: SeCreateTokenPrivilege mints any identity without going near authd, which would make every other control here decorative; SeTcbPrivilege and SeAssignPrimaryTokenPrivilege belong to the trusted computing base.
Note that Administrators is granted SeChangeNotifyPrivilege directly, not only through Everyone. Privileges accumulate across every SID on a token, so that repetition is insurance: if someone edits the key and drops the Everyone record, ordinary users lose the ability to traverse a directory — but administrators keep working and can repair it.
How the values compose #
Privileges accumulate. A token holds the union of every record naming a SID it carries — the principal's own, and each of their groups. A group marked USE_FOR_DENY_ONLY contributes nothing.
DeniedPrivileges wins. Listed on the Policy key itself rather than on a record, it is applied after the union, so no record you have not read can defeat it. It is on the parent key so it cannot collide with a principal who happens to be called Denied.
Integrity, Owner and DefaultDacl are single values, so they cannot accumulate. The principal's own record wins outright; otherwise the groups decide:
- Integrity takes the maximum across the groups that name one.
OwnerandDefaultDaclhave no ordering to compare, so two groups naming different values is a misconfiguration: it logs a warning and falls back to the default rather than picking by whichever the registry enumerated first. Two groups naming the same value is not a conflict.
The user's record winning outright is what makes a principal possible to lower. Under a plain maximum, a guest whose own record said Low would still come out Medium the moment any group they belonged to named Medium. The consequence is worth stating plainly: a group cannot impose an integrity floor on a member. If Administrators names High and a member's own record names Low, that member gets Low.
Writing each value #
Privileges — REG_MULTI_SZ #
Full ABI names, SeBackupPrivilege rather than SeBackup, matched case-sensitively. A name this build does not recognise is dropped with a warning and the rest of the list still applies — a policy written for a newer Peios should not lose the privileges it spelled correctly.
An empty list is meaningful: it grants nothing, and is different from the value being absent.
Integrity — REG_SZ or REG_DWORD #
A tier name, matched case-insensitively:
| Name | Value |
|---|---|
Untrusted | 0 |
Low | 4096 |
Medium | 8192 |
High | 12288 |
System | 16384 |
Or a raw REG_DWORD. The kernel compares integrity numerically and any value is legal, so the numeric form reaches levels between the tiers — 8193 sits just above Medium. Use the name unless you need that.
A principal no record names gets Medium. That default is compiled in and is not affected by the key existing, because unlike a privilege it is not a grant: every token must carry some level to be valid at all.
Owner — REG_SZ #
Which principal owns objects this token creates. Names a principal, exactly like a subkey name does; authd converts it to the index the token actually carries, which is a number meaningful only within one token and different on the next logon.
Absent — the ordinary case — means objects are owned by their creator.
The case this exists for is a shared administrative estate: objects an administrator creates being owned by Administrators rather than by the individual, so they remain manageable when that person's account goes away.
DefaultDacl — REG_SZ #
The DACL objects this token creates inherit when nothing else supplies one, written as SDDL:
D:(A;;GA;;;SY)(A;;GA;;;BA)
SDDL rather than raw bytes because the whole argument for policy living in the registry is that an operator can read it. Conditional ACEs are preserved, so D:(XA;;GA;;;WD;(@USER.Department == "Engineering")) works and keeps its condition.
A value that does not parse is dropped with a warning and the system default applies — a typo costs the customisation, not the session.
Locking a machine down #
To forbid a privilege regardless of what any record says:
Machine\Generic\Authn\Policy
DeniedPrivileges REG_MULTI_SZ ["SeDebugPrivilege", "SeLoadDriverPrivilege"]
That is one edit, in one place, that a record you have not read cannot defeat. Removing the privilege from each record individually relies on having found them all.
What this cannot express yet #
Whether a principal may sign in at all. Policy decides what a session gets, not whether one happens. lps disable covers the blunt case; per-logon-type restriction — allowed over the network but not at the console — is not built.
Two privileges the kernel enforces but nothing can name. SeTakeOwnershipPrivilege and SeRelabelPrivilege are honoured by the access check and appear in audit records, but are absent from the published ABI, so no policy can grant them. SeSystemProfilePrivilege is in the same position on current builds. Until that is resolved, a Peios machine has no way to grant take-ownership — which means the documented escape hatch for a file whose DACL excludes you is not currently reachable.
Seeing what a token actually got #
$ token show --all
[privileges] lists what the token holds and integrity shows the label. A privilege the tool cannot name appears as <privilege bit N> rather than being omitted, so the list is always complete even when the name table is not.
See also #
- Privileges — the model these records feed.
- The token command — reading the privileges and integrity a live token actually carries.
- Managing local principals — the accounts these records apply to.
Access decisions
Peios / Peios Security Fundamentals / Access Decisions
AccessCheck is the function the kernel calls every time something is about to happen to a protected object. The caller has a token; the object has a security descriptor; the caller wants some set of rights. AccessCheck takes all three and produces an answer: which of those rights are granted, which are not, and whether to fire any audit events as a result.
The function is not a single comparison. It is a pipeline of layers, each of which can constrain the result. A right that the DACL would have granted can be removed by a later layer (restricted-token intersection, confinement, central access policy). A right that the DACL would not have granted can be added by an earlier layer (privilege grants). Following an access decision through is following each layer in order.
This page walks the pipeline from start to finish, names every stage, and points each one at the topic that covers it in depth.
The shape of an access check #
flowchart LR
A["Token (caller)"] --> X["AccessCheck"]
B["Security descriptor (object)"] --> X
C["Desired mask (caller asked for)"] --> X
D["Optional inputs: privilege intent, self SID, local claims, object type list, PIP from PSB"] --> X
X --> R["Granted mask + audit emissions + continuous audit mask + staging mismatch flag"]
Inputs:
| Input | Source | What it contributes |
|---|---|---|
| Token | The calling thread's effective token. | Identity (user SID, groups, restricted SIDs), token-level state (integrity level, mandatory policy, privileges, confinement). |
| Security descriptor | The object being accessed. | Owner, primary group, DACL, SACL — everything the policy on the object says. |
| Desired mask | The caller. | The 32-bit access mask of rights the caller wants. May include MAXIMUM_ALLOWED to ask "what could I have?". |
privilege_intent | The caller. | Flags for backup and restore intent — see Intent-gated privileges. |
self_sid | The caller. | The SID that PRINCIPAL_SELF (S-1-5-10) should resolve to during the DACL walk. Used by directory-style objects. |
local_claims | The caller. | Per-call attributes in the @Local.* namespace, available to conditional ACE expressions. |
| Object type list | The caller (optional). | A tree of property GUIDs for per-property evaluation. See "Object type list" below. |
| PIP type and trust | The PSB of the calling process. | Used by the PIP step to compare against any trust label on the object. |
| Object audit context | The caller. | An opaque blob included in any audit events emitted for this call, identifying the object to audit consumers. |
Outputs:
| Output | Meaning |
|---|---|
| Granted mask | The 32-bit access mask of rights the caller actually gets. |
| Audit emissions | Any audit events that fired (SACL audit ACEs, privilege-use, token-forced policy events). |
| Continuous audit mask | For file-like objects, the per-operation audit mask to cache on the open handle. See The SACL. |
| Staging mismatch flag | Set when a staged CAAP policy would have produced a different result. See Central access policies. |
The function does not return a boolean. It returns a granted mask, and the caller is responsible for comparing that mask against what was requested. "Access denied" is what happens when the granted mask does not contain all the requested rights — but partial grants are visible to the caller, and MAXIMUM_ALLOWED callers specifically want to know everything they would have gotten.
The pipeline, step by step #
The pipeline runs in a defined order. Each step either pre-decides bits, modifies the running state, or both. The kernel implements it as a 15-step procedure; this section names each step, says what it does, and points to where it is covered in detail.
| Step | Name | What it does |
|---|---|---|
| 0 | Impersonation level gate | If the effective token is Impersonation-type at the Identification level, deny everything immediately. The token may be inspected but not used for access decisions. |
| 1 | Input validation | The SD must be present, must have an owner, must have a primary group. Object type list must be well-formed. Malformed inputs are rejected with appropriate errors. |
| 2 | Generic mapping | Generic rights in the desired mask (GENERIC_READ, GENERIC_WRITE, etc.) are expanded to object-specific bits using the object's GenericMapping table. MAXIMUM_ALLOWED is stripped and remembered as a mode. |
| 3 | Effective privileges | Backup and restore privileges are stripped from the privilege set unless the caller passed the corresponding intent flag. See Intent-gated privileges. |
| 4 | Privilege grants | Pre-decide bits that are granted by privilege regardless of the DACL: ACCESS_SYSTEM_SECURITY via SeSecurity, backup-read bits via SeBackup, write/metadata bits via SeRestore. See Privileges in the pipeline. |
| 5 | Pre-SACL walk | Scan the SACL for the mandatory integrity label (MIC) and the PIP trust label. Pre-decide write/read/execute bits as denied for non-dominant callers. Resource attributes are extracted and indexed for conditional expressions. See Mandatory integrity control and Process integrity protection. |
| 6 | Virtual group injection | OWNER RIGHTS (S-1-3-4) and PRINCIPAL_SELF (S-1-5-10) are injected into the token's group view if the caller owns the object or matches self_sid respectively. See Ownership. |
| 7 | Tree initialisation | If the caller passed an object type list (per-property evaluation), the pipeline's decided/granted state is seeded per node. Skipped for ordinary calls. |
| 8 | Normal DACL evaluation | Grant owner implicit rights (READ_CONTROL, WRITE_DAC) unless suppressed by OWNER RIGHTS. Walk the DACL using first-writer-wins, applying ACE flags, propagating per-property results for object type lists. See DACL evaluation. |
| 9 | Post-DACL SeTakeOwnership | If the walk did not grant WRITE_OWNER and the mandatory policy did not block it, SeTakeOwnershipPrivilege grants it now. See Ownership. |
| 10 | Restricted token pass | If the token is restricted, the DACL is re-evaluated using only the restricted SIDs, and the result is intersected with step 8's result. Privilege-granted bits from step 4 are restored after the intersection. See Restricted and write-restricted tokens. |
| 11 | Confinement pass | If the token is confined, the DACL is re-evaluated against the confinement SID set and the result is absolutely intersected. Privileges do not bypass confinement. See Confinement. |
| 12 | CAAP evaluation | Each SYSTEM_SCOPED_POLICY_ID ACE in the object's SACL is resolved and evaluated. Each rule's DACL is intersected with the running grant. Staging mismatches are recorded. See Central access policies. |
| 13 | Privilege-use audit emission | For each privilege that contributed bits, an audit event is emitted if the token's audit_policy requested it (success or failure variant depending on whether the bits survived). |
| 14 | SACL audit walk | Walk the object's SACL and all collected CAAP effective SACLs for SYSTEM_AUDIT* and SYSTEM_ALARM* ACEs. Emit audit events; compute continuous-audit mask. See Auditing. |
| 14b | Token audit policy | The token's audit_policy field can force audit events independently of any SACL ACE. Success and failure flags emit policy-forced events. |
| 15 | Result computation | The final granted mask is granted & mapped_desired. The function returns this mask, plus the audit emissions, the continuous-audit mask, and the staging mismatch flag. |
The numbering is the kernel's; you will see it in audit and error messages.
Reading the pipeline #
A few observations about the shape that are worth pinning before reading the deeper pages:
Pre-DACL layers can pre-decide bits as denied. MIC and PIP do this for non-dominant callers. A bit pre-decided as denied stays denied; no later step can grant it. The owner of an object that has a high mandatory label cannot read it from a lower-integrity session even though the DACL would grant the read.
Pre-DACL layers can pre-decide bits as granted. Step 4 (privilege grants) does this. Bits granted by privilege are decided before the DACL walk runs; the walk's first-writer-wins applies to undecided bits, so a privilege grant is effectively immune to a later DACL deny.
The DACL walk is the central act. Steps 0–7 set up state for it; steps 9–12 narrow what it produced. The walk itself, governed by DACL evaluation, is where most of the visible policy lives.
Narrowing layers (restricted, confinement, CAAP) only remove access, never add. A right that the DACL plus privileges granted may be lost in steps 10–12. A right that those did not grant cannot be added by the narrowing layers.
Audit fires last and observes everything. By the time the audit walk runs (step 14), the granted mask is final. Audit ACEs can compare against the requested mask, the granted mask, and the difference, and decide whether to emit. Token-forced audit policy can emit even when no SACL ACE matched.
A staging mismatch is informational, not corrective. Step 12 evaluates both the effective CAAP rules (which affect the granted mask) and the staged CAAP rules (which do not). If they would have produced different results, the function reports it; it does not change the granted mask.
Two AccessCheck variants #
The kernel exposes two AccessCheck syscalls. They differ in what they return:
| Syscall | What it does |
|---|---|
kacs_access_check | The common case. Returns a single granted mask for the whole object. |
kacs_access_check_list | The per-property variant. Requires an object type list and returns a separate granted mask and status per node in the tree. Used by directory-style objects with property-level access control. |
The pipeline is the same for both. The list variant just tracks per-node state through every step, so an object type list with twelve nodes produces twelve granted masks at the end, one per node.
For most objects (files, registry keys, tokens), the regular variant is the right one. Per-property is meaningful only when the object has properties that can be granted independently — for example a directory object with named attributes.
Object type list, briefly #
Object ACEs in a DACL carry a property GUID. When the caller passes an object type list, AccessCheck evaluates each ACE against each node of the tree, propagating per-property grants and denials according to the tree structure (a grant on a parent set flows to its children; a denial flows up; sibling grants intersect).
The tree itself is provided by the caller as a preorder-flat array of kacs_object_type_entry records. The array must be well-formed: starts at level 0, no level gaps, no duplicate GUIDs, and so on. Malformed lists are rejected at step 1.
The full mechanics of object ACEs and the object type list are in ACLs, ACEs, and access masks. Most code never builds an object type list — they exist for the directory-object case specifically.
Where to start #
If you want MIC in depth — what an integrity level actually does to access, how the policy bits work, how the SD's mandatory label drives it — read Mandatory integrity control.
If you want to know exactly which bits each AccessCheck-influencing privilege grants and where in the pipeline that grant happens, read Privileges in the pipeline.
If you want the narrowing layers — restricted-token pass, confinement pass, CAAP — in detail, read Narrowing layers.
If you are debugging an unexpected denial right now, read Debugging a denial.
Mandatory integrity control
Peios / Peios Security Fundamentals / Access Decisions
Mandatory integrity control is the layer of the access check that gates rights based on a numeric trust axis called the integrity level. Where the DACL asks "is this principal allowed?", MIC asks "is the caller trusted enough to do this regardless of the DACL?". A non-dominant caller — one whose integrity is lower than the object's — has certain rights pre-decided as denied, before the DACL is even looked at.
The integrity axis is independent of identity. Two threads, same user, can run at different integrity levels and see different effective access. An interactive shell at Medium and an elevated session at High are the same person, separated by integrity. MIC enforces that separation.
MIC fires in step 5 of the access check pipeline — pre-DACL. Its decisions are immutable from that point. The DACL walk, the restricted-token pass, none of them can undo a MIC denial.
The integrity levels #
In practice there are five standard integrity levels, strictly ordered — treat integrity as an enum even though, technically, it is not one (see below). These five are the levels anyone normally works with (canonical value catalog: Other constants):
| Level | SID | RID | Typical use |
|---|---|---|---|
| Untrusted | S-1-16-0 | 0 | Tokens for highly-sandboxed processes. The lowest level. |
| Low | S-1-16-4096 | 4096 | Tokens for confined applications, anti-malware quarantines. |
| Medium | S-1-16-8192 | 8192 | The default for interactive sessions and most services. |
| High | S-1-16-12288 | 12288 | Elevated administrative sessions. The Full token in a UAC-style linked pair. |
| System | S-1-16-16384 | 16384 | The kernel, peinit, authd, and the rest of the TCB. |
The comparison is the obvious one: Untrusted < Low < Medium < High < System. A caller dominates an object if the caller's integrity level is at least the object's level. Otherwise the caller is non-dominant and MIC's policy bits decide what they cannot do.
Technically, though, the level is not an enum. An integrity level is simply the single sub-authority of an S-1-16 mandatory-label SID, read as an unsigned integer, and levels are compared numerically. Any S-1-16-<n> SID with exactly one sub-authority is a valid level; only a SID with the wrong identifier authority (not S-1-16) or more than one sub-authority is malformed and rejected. The kernel does not check a label SID against a hardcoded list — an unusual value is interpreted purely by its numeric position, and the five standard RIDs are spaced by 4096 to leave room between them. This mostly matters for Windows interop, where non-standard levels appear: medium-plus (S-1-16-8448) sits just above Medium, and protected (S-1-16-20480) sits above System. Treat the five named levels as the working model; just don't assume they are the only values a label can carry.
Where the object's label lives #
An object's mandatory integrity label is in its SACL, as a SYSTEM_MANDATORY_LABEL_ACE. The ACE has:
- A SID from the integrity namespace (an
S-1-16SID with one sub-authority — normally one of the five above, occasionally a non-standard level such asmedium-plusorprotected). - A mask containing the MIC policy bits.
When the access check looks at an SD, it scans the SACL for SYSTEM_MANDATORY_LABEL_ACE entries that are not inherit-only. The first such ACE is the object's effective label. If there is none, the object's effective label is Medium with NO_WRITE_UP — that is the default for an unlabelled object.
The Medium-with-NO_WRITE_UP default exists to protect default objects from lower-integrity callers without requiring every object to carry an explicit label. An unlabelled file on a standard filesystem is automatically protected against writes from a Low-integrity sandboxed process; the sandbox cannot scribble on system data even though no one explicitly marked the data with a label.
The SACL ACE also lives there for a reason: changing the integrity label is a privileged operation (gated by SeRelabelPrivilege for label-raising and by the standard SACL access rule for label-lowering). Stored as a SACL ACE, the label is automatically under SACL access control — the owner of an object cannot lower its integrity label without SeSecurityPrivilege or SeRestorePrivilege. This is exactly the right gate; a user should not be able to weaken the protections on their object just because they own it.
The MIC policy bits #
The mask of a SYSTEM_MANDATORY_LABEL_ACE contains MIC policy bits that say what non-dominant callers cannot do (canonical values: ACE types and flags):
| Bit | Value | Effect on non-dominant callers |
|---|---|---|
SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x01 | Read-category rights are denied. |
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x02 | Write-category rights are denied. |
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x04 | Execute-category rights are denied. |
The names use "up" because the non-dominant caller is below the object. A "no write up" rule denies a Medium caller from writing to a High object — the caller would be writing "up" to higher integrity.
The bits compose. A label with all three set blocks all access categories from non-dominant callers. A label with only NO_WRITE_UP allows non-dominant callers to read but not write. The most common combination by far is NO_WRITE_UP alone — it is the default, and it captures the most important integrity rule (lower-integrity code should not be able to modify higher-integrity data).
Read-up and execute-up restrictions are less common. They appear on objects whose existence or contents should be hidden from lower-integrity callers entirely.
How MIC fires in the access check #
flowchart LR
A["Token integrity level"] --> M["MIC compare"]
B["Object integrity label (or Medium/NWU default)"] --> M
M -->|caller dominates: pass| C["Continue to DACL walk"]
M -->|caller does not dominate: apply policy bits| D["Pre-decide affected bits as denied"]
D --> C
At step 5 of the pipeline:
- Extract the object's integrity label (or apply the Medium-with-NO_WRITE_UP default if there is no label ACE).
- Compare the token's
integrity_levelagainst the object's label. - If the token's level is at least the object's level, MIC is satisfied. Nothing happens; the pipeline proceeds.
- If the token's level is below the object's, look at the label's policy bits. For each bit set, take the corresponding category of rights from the desired mask and mark those bits as decided-and-denied. The bits enter the pipeline's
decidedset but notgranted.
The categories — read, write, execute — are defined by the object's GenericMapping. For a file, the read category is the set of bits that map from GENERIC_READ (FILE_READ_DATA, FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE). Same idea for the write and execute categories.
By the time the pipeline reaches the DACL walk in step 8, the bits MIC denied are already decided. The walk processes the remaining bits; the denied ones are skipped.
The "token mandatory policy" field #
A separate but related field: every token carries a mandatory_policy bitmask. It uses two flags:
| Flag | Value | Effect |
|---|---|---|
NO_WRITE_UP | 0x01 | The MIC rule is active for this token's accesses. |
NEW_PROCESS_MIN | 0x02 | At exec, if the executable's integrity label is lower than the token's, the token's integrity is lowered to match. |
Both flags are set at token creation and cannot be changed at runtime. A process cannot relax its own MIC policy — the kernel rejects any AdjustPrivileges-style call that would. The reasoning: a Medium-integrity process should not be able to silently turn off its own write-up restriction.
NO_WRITE_UP is set on essentially every token authd issues. It is the active state of MIC enforcement, and it is worth understanding that it is load-bearing rather than a formality: a token without it carries an integrity level that nothing consults. The access check returns before comparing labels at all, so such a token holds a level that is pure decoration — it neither restricts the token nor protects anything from it.
Where a token's level comes from #
The object's label comes from its SACL, as above. The token's level is decided once, when the token is minted, and never changes afterwards.
For a logon token that is local policy — a per-principal record in the registry that authd reads at every sign-in, keyed on the SIDs the token ends up carrying. A principal source has no way to influence it, deliberately: how much a machine trusts someone is not a fact about them that a directory could know. A principal no record names gets Medium. See assigning privileges for the record format.
For service tokens, peinit decides. For the SYSTEM token, the kernel does.
NEW_PROCESS_MIN is set on tokens that want exec to enforce integrity boundaries. When set, exec of a binary marked at a lower level produces a token whose integrity is lowered. This is what prevents a High-integrity shell from launching a Medium-labelled binary as High-integrity — the resulting process is Medium, regardless of who launched it.
What MIC does not constrain #
A surprisingly important set of things.
MIC does not constrain privilege grants. Step 4 of the pipeline (privilege grants) runs before MIC, and the bits it pre-decides as granted survive MIC. A caller holding SeBackup with BACKUP_INTENT set gets read-category rights via the privilege regardless of MIC. The reason: privileges are explicit, intentional, and audited — they are not subject to the same "block by default" treatment as the DACL.
The exception is PIP. Where MIC ignores privilege grants, PIP strips them. A non-dominant PIP caller loses privilege-granted rights, not just DACL-granted ones. PIP is the stricter integrity-style enforcement; MIC is the looser one. See Process integrity protection.
MIC does deny WRITE_OWNER sought via SeTakeOwnership. Unlike the step-4 privileges, SeTakeOwnership fires post-DACL (step 9) and only on bits not already mandatorily decided — and MIC pre-decides WRITE_OWNER as denied for every non-dominant caller. A tool that needs ownership of higher-integrity objects must hold SeRelabelPrivilege (the one privilege that punches WRITE_OWNER through MIC) or use SeRestorePrivilege with RESTORE_INTENT, whose step-4 grant precedes MIC.
MIC does not lower integrity automatically on access denial. A non-dominant caller blocked by MIC just gets the relevant bits denied. The token's integrity is not adjusted. The same token can succeed at accessing a lower-integrity object on the next call.
MIC is asymmetric. A non-dominant caller is constrained; a dominant caller is not. There is no "no write down" — a High-integrity process writing to a Medium object faces no MIC restriction. This sometimes surprises people. The model is "lower integrity cannot touch higher", not "integrity levels cannot interact at all".
Integrity and impersonation #
The two-gate model in Impersonation refuses to let a server act as a client of higher integrity. A Medium-integrity service that captures a High-integrity client's peer token gets an Identification-level impersonation token — it can inspect the identity, but no access check will pass.
This is enforced unconditionally. SeImpersonatePrivilege does not bypass it. A High-integrity client connecting to a Medium service should not be able to make the Medium service operate at High — that would void the MIC model for the service.
The implication for MIC enforcement: a non-dominant caller cannot escape MIC by impersonating someone with higher integrity. The integrity ceiling downgrades such a token to Identification, so it can never be used to act at a higher integrity than the server's own.
Setting an integrity label #
The label on an object is changed via kacs_set_sd with LABEL_SECURITY_INFORMATION (or SACL_SECURITY_INFORMATION for the whole SACL). The rules:
- Lowering a label (to a level at or below the caller's own integrity) is permitted. No special privilege required beyond the normal SACL access rule (
SeSecurityPrivilegeorSeRestorePrivilege). - Raising a label (to a level above the caller's own integrity) requires
SeRelabelPrivilege. The privilege is rare; only the TCB and specific labelling services hold it.
The asymmetry is the obvious one: you can always demote an object to your own level or lower, but you cannot promote an object beyond what you yourself can reach. This prevents a Medium administrator from labelling an object High and then locking themselves out from below — they cannot label objects at a level they could not access.
For LABEL_SECURITY_INFORMATION specifically, the call is mutually exclusive with SACL_SECURITY_INFORMATION in the same kacs_set_sd invocation. You either update the integrity label only, or you update the whole SACL.
MIC vs PIP in one paragraph #
MIC and PIP look superficially similar. Both compare a level on the caller against a level on the object. Both block non-dominant access. The differences are crucial:
- MIC uses the effective token's
integrity_level. PIP uses the PSB'spip_typeandpip_trust. Impersonation changes the effective token but not the PSB; MIC respects impersonation, PIP does not. - MIC is a one-axis comparison. PIP is two axes (type and trust), both of which must dominate.
- MIC ignores privilege grants. PIP strips them. A privilege can rescue access from MIC; it cannot rescue access from PIP.
- MIC has a default (Medium/NO_WRITE_UP). PIP has no default. Objects without a
SYSTEM_PROCESS_TRUST_LABEL_ACEare not PIP-protected; objects without aSYSTEM_MANDATORY_LABEL_ACEget the default MIC treatment.
The two layers complement each other. MIC is the "user identity is at this trust level" axis; PIP is the "the binary running this process has this trust level" axis. An access can be blocked by either, allowed by both, or — in the rare cases where a privilege bridges MIC but not PIP — allowed by MIC and blocked by PIP.
Where to go next #
For the privileges that can carry an access past MIC — and where each fires in the pipeline — read Privileges in the pipeline.
For the binary-trust axis MIC is so often compared with, read Process integrity protection.
Privileges in the pipeline
Peios / Peios Security Fundamentals / Access Decisions
Five privileges in the catalog change what AccessCheck decides. Each one fires at a specific step of the pipeline. Each one grants a specific set of bits. Each one is bypassed by some layers and not by others. Understanding which is which is what lets you reason about access decisions that involve privileges — when a privilege rescued an access, when it could have but did not, when it would have been irrelevant.
This page covers each of the five AccessCheck-influencing privileges in pipeline order: where it fires, what it grants, what bypasses it, and how it shows up in audit.
The intent model itself — why BACKUP_INTENT and RESTORE_INTENT exist as separate gates — is on Intent-gated privileges. This page assumes that material.
The five privileges, at a glance #
| Privilege | Fires at step | Grants | Intent-gated? |
|---|---|---|---|
SeSecurityPrivilege | 4 | ACCESS_SYSTEM_SECURITY | No |
SeBackupPrivilege | 4 | Read-category rights | Yes (BACKUP_INTENT) |
SeRestorePrivilege | 4 | Write-category rights, WRITE_OWNER, WRITE_DAC, DELETE, ACCESS_SYSTEM_SECURITY | Yes (RESTORE_INTENT) |
SeTakeOwnershipPrivilege | 9 | WRITE_OWNER | No |
SeRelabelPrivilege | (not AccessCheck — kacs_set_sd) | Setting an integrity label above the caller's own | No |
Steps 4 and 9 are both privilege-grant steps in the access check pipeline (see Access decisions overview). The difference is when in the pipeline they run. Step 4 is pre-DACL — privileges that fire there decide bits before the DACL walk happens. Step 9 is post-DACL — SeTakeOwnershipPrivilege fires only if the DACL did not already grant WRITE_OWNER.
SeRelabelPrivilege does not fire inside AccessCheck at all. It is consulted by kacs_set_sd when the caller is trying to change an object's integrity label above their own. It is listed here because, like the others, it modifies what would otherwise be a denial decision.
SeSecurityPrivilege — SACL access #
ACCESS_SYSTEM_SECURITY (bit 0x01000000) gates reading and writing the SACL. The DACL never grants this right — there is no ACE you can write to allow non-privileged users SACL access. The only grant of ACCESS_SYSTEM_SECURITY comes from a privilege.
SeSecurityPrivilege, when enabled on the calling token, grants ACCESS_SYSTEM_SECURITY at step 4 of the pipeline if it appears in the desired mask. The grant is unconditional in the sense that no DACL or MIC bit can deny it. PIP can: a non-dominant PIP caller will lose this bit along with the other privilege-granted bits.
In addition to its access-check role, SeSecurityPrivilege is also a kernel-standalone privilege that gates several Linux capability checks (CAP_AUDIT_CONTROL, CAP_MAC_ADMIN, CAP_AUDIT_READ). The same privilege wears two hats.
SeRestorePrivilege also grants ACCESS_SYSTEM_SECURITY in its specific use cases (restoring an SD via kacs_set_sd). The two privileges are not equivalent: a holder of SeSecurity can read or write any SACL freely; a holder of SeRestore can do it as part of a restore operation but the rest of the access-check pipeline treats them differently.
In audit, an ACCESS_SYSTEM_SECURITY grant attributable to SeSecurityPrivilege is recorded as such. A consumer parsing audit events can distinguish "SACL was modified because the DACL granted it" (cannot happen) from "SACL was modified because the caller held the privilege" (always the cause).
SeBackupPrivilege — read-category, intent-gated #
SeBackupPrivilege is the read-bypass privilege. With BACKUP_INTENT set in privilege_intent and the privilege enabled on the token, AccessCheck at step 4 grants the read-category bits of the desired mask regardless of the DACL.
The "read category" is determined by the object's GenericMapping. For a file: FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL. For a registry key: the corresponding read rights on a key. For a token: TOKEN_QUERY | READ_CONTROL.
The grant is pre-decided in step 4. By the time the DACL walk runs in step 8, the read-category bits are already decided as granted, and the walk only processes the remaining (write, execute, ownership) bits. If the DACL would have granted some read bits anyway, the privilege grant is redundant; if the DACL would have granted none, the privilege grant is the source.
The intent flag is what activates the privilege. Without BACKUP_INTENT, step 4's SeBackup processing is skipped; the privilege has no effect on this call. The intent must come from the caller — there is no kernel default. See Intent-gated privileges.
SeRestorePrivilege — write-category, intent-gated #
SeRestorePrivilege is the write-bypass privilege. With RESTORE_INTENT set and the privilege enabled, AccessCheck at step 4 grants the write-category bits plus several adjacent rights:
- Write-category bits (
FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EAfor files; corresponding bits for other types). DELETE.WRITE_OWNERandWRITE_DAC.ACCESS_SYSTEM_SECURITY(so a restore can rewrite the SACL).
That last bit is the reason a restore tool does not also need to hold SeSecurityPrivilege — SeRestore folds the SACL-write authority into the restore-flavoured grant.
Beyond the access-check grant, SeRestore has one more effect: it bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction in kacs_set_sd. A holder of SeRestore can set an object's owner to any well-formed SID, not just their own. This is what lets a restore reconstitute an object's original owner even when the original principal is not present on the running system.
Like SeBackup, the privilege is intent-gated. Without RESTORE_INTENT, it is skipped at step 4 and has no effect.
SeTakeOwnershipPrivilege — WRITE_OWNER, post-DACL #
SeTakeOwnershipPrivilege is special because of when it fires: step 9, after the DACL walk. The reasoning: the DACL might already grant WRITE_OWNER to the caller, in which case the privilege is unnecessary. The pipeline runs the walk first, then checks: did the walk grant WRITE_OWNER? If yes, nothing to do. If no, and the caller holds SeTakeOwnership, grant it now.
This timing means SeTakeOwnership produces a different audit signature than the step-4 privileges. The "this right was granted by SeTakeOwnership" event fires only when the DACL would not have granted WRITE_OWNER anyway. If you see SeTakeOwnership in an audit log, you know the caller actually needed the privilege for that access; if the DACL had been sufficient, the privilege would not have shown up.
SeTakeOwnership does not bypass MIC. A Medium-integrity caller cannot take ownership of a High-integrity object even with the privilege, because MIC will have pre-decided WRITE_OWNER as denied at step 5 (before SeTakeOwnership runs at step 9). The privilege only fires on bits not already decided.
It also does not bypass PIP — same reasoning, PIP fires before step 9 and can pre-decide WRITE_OWNER as denied for non-dominant PIP callers.
The "new owner must be self or SE_GROUP_OWNER group" rule applies to taking ownership via this privilege. The privilege grants the right to write the owner field, not freedom from validation. To set the owner to an arbitrary SID, the caller additionally needs SeRestore.
SeRelabelPrivilege — raising the integrity label #
SeRelabelPrivilege is the integrity-label privilege. It does two things:
- During
kacs_set_sdwithLABEL_SECURITY_INFORMATION(orSACL_SECURITY_INFORMATION), it allows the caller to set the integrity label to a level above their own. Without the privilege, an object's label can be lowered (to at or below the caller's level) but not raised. - It bypasses the standard MIC check on
WRITE_OWNERwhen the caller is operating on an object with a higher integrity label — specifically, the privilege "punchesWRITE_OWNERthrough MIC" for non-dominant callers, so a relabelling tool can adjust ownership as part of a relabel even if the object is currently at a higher integrity than the tool.
SeRelabelPrivilege is one of the rarest privileges. It is held only by tools that legitimately need to set system-level integrity labels — typically TCB labelling utilities. Almost no general-purpose code holds it.
What privileges do not bypass #
A handful of layers ignore the privilege-grant step entirely.
Confinement. Confinement pass (step 11) intersects the running grant with what the confinement SID set would grant. Privilege-granted bits are not preserved through this intersection. A confined token with SeBackup enabled can still be confined out of read access; the privilege is gone.
This is the major difference between confinement and the restricted-token pass. Restricted-token intersection (step 10) preserves privilege-granted bits — they are restored after the intersection. Confinement does not. The reason: confinement is policy applied to the code from outside, and a confined application should not be able to escape its sandbox by exercising a privilege. See Confinement.
CAAP. Central access policies, evaluated at step 12, can narrow what privileges have already granted. A CAAP rule whose DACL does not grant the right will strip it from the running grant. Privilege-granted bits are not immune.
PIP. Non-dominant PIP callers have their privilege-granted bits stripped in step 5 (PIP processing). This is unique to PIP — MIC is content to leave privilege-granted bits alone, but PIP actively revokes them. The reason is the same as the confinement reason: PIP is about isolating untrusted binaries from trusted ones, and a privilege that bypassed PIP would defeat the model.
A useful mnemonic: privileges survive restricted-token narrowing; they do not survive confinement, CAAP, or PIP. The first is the privilege-friendly narrowing layer; the others are privilege-blind.
Privilege-use audit #
Step 13 of the pipeline is the privilege-use audit emission. For each privilege that contributed bits in steps 4 or 9, the kernel checks the token's audit_policy and emits an audit event accordingly.
The events fall into two flavours:
- Successful privilege use. The privilege contributed bits and those bits survived to the final granted mask. Emitted when
PRIVILEGE_USE_SUCCESS(0x04) is set inaudit_policy. - Failed privilege use. The privilege contributed bits but they did not survive — stripped by some later layer (confinement, CAAP, PIP). Emitted when
PRIVILEGE_USE_FAILURE(0x08) is set.
The failed-use case is what tells you "a privilege was exercised but was unable to actually grant access". This is auditable separately from a plain "access denied" — the privilege got far enough to fire and then got stripped.
The events themselves are documented in Auditing.
Reading a granted mask back #
A practical concern: when an access check returns a granted mask that includes a right, how do you know whether the right came from the DACL or from a privilege?
You cannot tell from the granted mask alone. The bit is the same regardless of source. The way to tell is the audit log: privilege-use audit events identify which privilege contributed which bits. If you see in the audit log that SeBackup granted FILE_READ_DATA on a specific access, you know the DACL did not.
In code, the way to verify is to issue the access check twice: once with the privilege intent flag (or with the privilege enabled), and once without. The difference in granted mask is what the privilege contributed.
For most code paths, this introspection is unnecessary — the granted mask is whatever it is, and the caller acts on it. But for auditing, debugging, or testing pipelines that exercise privileged paths, the distinction matters.
What goes in audit #
The privilege-use audit event for each fire records:
- The name of the privilege (e.g.
SeBackupPrivilege). - The bits the privilege contributed pre-narrowing.
- The bits that survived to the final granted mask.
- Whether the use was successful (bits survived) or failed (bits were stripped).
This is enough for an audit consumer to reconstruct what happened. A success event with non-empty surviving bits shows what the privilege ended up granting; a failure event with empty surviving bits shows what the privilege tried to grant and lost. Together they tell the story of where the access landed.
Where to go next #
For the three layers that can strip privilege-granted bits after the fact, read Narrowing layers.
For the privilege-use events themselves and where they end up, read Auditing.
Narrowing layers
Peios / Peios Security Fundamentals / Access Decisions
The access check has three layers that narrow the running granted mask: the restricted-token pass at step 10, the confinement pass at step 11, and the central access policy evaluation at step 12. Each runs after the DACL walk has produced an initial grant. Each is a strict intersection — they can only remove bits from the grant, never add. A right granted by some earlier layer (the DACL or a privilege) may be lost in these passes. A right not granted by an earlier layer cannot be created by them.
This page covers the three layers in pipeline order, the rules for what does and does not bypass each one, and how they compose when more than one is active.
The order, and why it matters #
| Step | Layer | What it intersects against |
|---|---|---|
| 10 | Restricted-token pass | A DACL walk using only the token's restricted_sids |
| 11 | Confinement pass | A DACL walk using only the token's confinement SID set |
| 12 | CAAP evaluation | The effective DACLs of each referenced central access policy |
The order is fixed. The restricted pass runs first because it is identity-flavoured — the kernel is asking "would the restricted SIDs alone have been granted this?". Confinement runs next because it is policy applied to the code as a whole, and the inputs to the confinement check are bigger than just the SIDs. CAAP runs last because it can layer multiple policies on top of everything.
A key consequence of the order: a right that survives all three is granted. A right denied by any one is denied. Composition is conjunction — every active narrowing layer must permit the right for it to remain in the final granted mask.
Restricted-token pass #
If the token is not restricted (its restricted_sids list is empty), this step is skipped entirely. If it is, the kernel runs the DACL walk a second time, using only restricted_sids as the matching set. The user_sid does not participate. The groups list does not participate. Only entries in restricted_sids count.
The result is granted_restricted — what the DACL would grant if the token had only its restricted SIDs. This mask is intersected with the running grant from step 8: granted = granted & granted_restricted.
Privileges bypass this pass. After the intersection, bits that were granted by privileges in steps 4 or 9 are restored to the running grant. The reason is that privileges are explicit and authorised independently of identity-based narrowing — a restricted token holder with SeBackup enabled and BACKUP_INTENT set can still exercise the privilege.
The write-restricted variant narrows only write-category bits. Reads and execute come from the step-8 grant alone. The bits in the write category are determined by the object's GenericMapping — for files, that is whatever GENERIC_WRITE maps to. See Restricted and write-restricted tokens for the full mechanics, including the user_deny_only flag that write-restricted tokens carry.
The restricted-token pass is the program's narrowing layer. It is set up in code: a sandbox launcher FilterTokens its own token down to a restricted version before launching the constrained work. The code is choosing to give itself less authority. Privileges, being explicit, are not subject to this self-imposed narrowing.
Confinement pass #
If confinement_sid is null on the token, this step is skipped. If it is set, and confinement_exempt is false, the kernel runs the DACL walk a third time, against the confinement identity:
- The user SID is replaced with
confinement_sid. - The groups list is replaced with the
confinement_capabilitieslist. - Group attributes are ignored — presence-based matching only.
- Owner implicit rights are not applied (the confined application is not the owner).
The result is granted_confinement — what the DACL would grant to a "fresh" caller whose entire identity is the confinement SID plus the declared capabilities. This is intersected with whatever the running grant is at this point.
Privileges do not bypass this pass. Bits granted by privileges in steps 4 or 9 are subject to the confinement intersection like any other bit. A confined token with SeBackup enabled and BACKUP_INTENT set can have its privilege grant stripped by confinement.
This is the major difference from the restricted-token pass. Restricted tokens trust their own author to use privileges responsibly; confinement does not trust the confined code at all. Confinement is policy applied to the application, not by it, so the application cannot escape via privilege exercise. See Confinement.
The confinement_exempt flag opts a specific token out of the confinement check. It is set very rarely — only by tools that legitimately need to step outside their own confinement.
CAAP evaluation #
Central access policies are the third narrowing layer. The mechanism: each SYSTEM_SCOPED_POLICY_ID ACE in the object's SACL references a policy by SID. The kernel looks up each policy in its policy cache (populated by authd) and evaluates the policy's rules.
A policy contains zero or more rules, each with:
- An optional applies-to expression — a conditional expression evaluated against the object's resource attributes. If the expression evaluates UNKNOWN or FALSE, the rule is skipped for this object. If it evaluates TRUE (or is absent), the rule applies.
- An effective DACL — a DACL that gets evaluated through a recursive sub-AccessCheck against the calling token. The result is intersected with the running grant.
- An optional effective SACL — audit ACEs that are collected for the eventual audit walk.
- Optional staged DACL / staged SACL — same content, but evaluated in parallel for testing purposes without affecting the actual grant.
For each applicable rule, AccessCheck recursively evaluates the effective DACL using a synthetic SD (where the DACL is the policy's DACL, the SACL has SYSTEM_SCOPED_POLICY_ID ACEs stripped to prevent recursion, and the object's owner/group are preserved). The result is intersected with the running grant.
Privileges do not bypass CAAP — bits granted by privileges are subject to CAAP narrowing like every other bit. The reason: CAAP is administrator-imposed policy, layered on top of object-level DACLs, and a privilege that bypassed it would defeat the model.
Staging mismatches are surfaced separately. If the staged DACL would have produced a different result, the access check sets the staging_mismatch flag in its output. The actual granted mask uses the effective DACL only — the staged DACL never affects what the caller gets. The flag is informational, for policy testing and rollout.
The recovery policy is the kernel's fallback when a referenced policy SID is not in the cache. Rather than fail-open (grant everything as if the policy were absent) or fail-closed (deny everything), the kernel applies a hardcoded recovery policy that grants GENERIC_ALL to BUILTIN\Administrators, SYSTEM, and OWNER_RIGHTS. This way, administrative access survives a missing-policy condition; ordinary access does not.
The full mechanism — applies-to expressions, staged vs effective semantics, distribution from authd — lives in Central access policies. For this page, what matters is that CAAP is the last narrowing layer, that it is privilege-blind, and that it produces the staging-mismatch signal.
Composition #
When more than one narrowing layer is active, they compose in pipeline order:
flowchart LR
A["Step 8 grant (DACL + step-4 privileges)"] -->|step 9| B["+ SeTakeOwnership if needed"]
B -->|step 10| C["intersect with restricted pass (if restricted)"]
C -->|step 4/9 privileges restored| D["intermediate grant"]
D -->|step 11| E["intersect with confinement pass (if confined)"]
E -->|step 12| F["intersect with each CAAP rule"]
F --> G["final granted mask"]
A token can be all three at once: restricted, confined, and accessing an object with CAAP references. Each layer fires; the running grant is intersected three times; the final grant is what survives all of them. Each is conservative — they can only remove. A right that survives is one that all three layers permitted, on top of whatever the DACL and privileges produced.
A token can be one and not the others. A non-restricted, non-confined token accessing a CAAP-bound object faces only step 12. A restricted but not confined token accessing a non-CAAP object faces only step 10. The skipped steps are no-ops.
Practical examples #
Restricted token, no confinement, no CAAP. A sandbox launcher created a restricted version of its own token and ran the workload as that. The workload's accesses go through the DACL walk normally, then through the restricted pass (intersection against restricted_sids), then through confinement (no-op, token is not confined), then CAAP (no-op, object has no policy). Privilege-granted bits from steps 4/9 are restored after the restricted pass — the workload can still exercise its privileges.
Confined application, no restricted SIDs, no CAAP. A service was launched with confinement policy attached by the service manager. The accesses go through the DACL walk normally, then skip the restricted pass (no restricted_sids), then through confinement (intersection against the confinement identity). Privilege-granted bits are subject to the intersection — if the confinement DACL would not have granted them, they are lost.
Plain DACL'd object with CAAP, ordinary token. A file with a SYSTEM_SCOPED_POLICY_ID ACE in its SACL. The DACL walk produces an initial grant. Restricted and confinement passes are no-ops. CAAP looks up the referenced policy, evaluates each applicable rule, intersects each rule's effective DACL with the running grant. The final grant is the DACL-walk result narrowed by every applicable CAAP rule.
All three. A restricted, confined token accessing a CAAP-bound object. Every narrowing layer is active. The final grant has to survive all of them. This is rare in practice — restricted and confined are both belts-and-braces approaches — but it is what the model permits.
What the narrowing layers do not do #
A few clarifications that come up:
- They do not run on objects that have no SACL. Restricted tokens and confinement narrowing depend on the token, not on the SACL. The DACL walk happens; the restricted/confinement pass runs based on token state. Only CAAP requires SACL ACEs to fire.
- They do not change the DACL itself. The narrowing layers compute a result, not a rewritten DACL. The same DACL on the same object produces different results for a normal token vs a restricted one vs a confined one — and the DACL itself is unchanged.
- They do not affect audit. Audit ACEs in the SACL fire based on the final granted mask, not on what was granted by the DACL walk alone. An audit ACE that fires on success will fire only if the right survives all narrowing layers and ends up granted. A failure ACE fires only if the right would have been granted by some earlier layer but was stripped by a later one.
- They do not affect outputs other than the granted mask. Restricted-token and confinement passes do not produce their own audit events. CAAP produces the staging-mismatch flag and contributes effective SACL ACEs to the audit walk; it does not produce a separate "CAAP narrowed this" event.
Why three different layers? #
A reasonable question: if all three are intersections, why not just one? The reason is that they are intersections against different things, set by different audiences, with different bypass rules.
| Layer | Set by | Intersects against | Privileges bypass? |
|---|---|---|---|
| Restricted token | The application (in code) | The application's own restricted SID list | Yes |
| Confinement | The system administrator (policy) | The confinement SID + declared capabilities | No |
| CAAP | The directory administrator (policy) | Centrally-defined access rules | No |
The three sources of narrowing — the application, the local administrator, the directory — produce different layers because they answer different questions. "Does this code restrict its own authority?" is a programming concern; "is this service running in a sandbox?" is a deployment concern; "is this resource under enterprise data-protection policy?" is an organisational concern. Each layer handles its question.
The privilege-bypass split is the most consequential difference. Restricted tokens are application-internal and the application is trusted to use privileges responsibly. Confinement and CAAP are external policy and the bypassed code is not trusted; privileges must not be an escape route.
Where to go next #
For the systematic walk when an access comes back denied and you need to know which layer did it, read Debugging a denial.
For the token-side mechanics of the restricted pass, read Restricted and write-restricted tokens.
Debugging a denial
Peios / Peios Security Fundamentals / Access Decisions
A call returned ACCESS_DENIED, or a file open failed with EACCES, or a registry read came back empty. The access check decided "no", and you need to know which layer of the pipeline produced the denial. What follows is the systematic walk.
The pipeline has many steps, but the practical answer almost always falls in one of six categories. This page covers them in the order to check, with the inspection mechanics for each.
The six places a denial can come from #
Before stepping through, the list of candidates:
- The token cannot be used. The thread is impersonating at the Identification level (step 0).
- MIC denied it. The caller's integrity level is below the object's mandatory label, and the policy bits cover the requested category (step 5).
- PIP denied it. The caller's process trust label does not dominate the object's, and the requested right is outside the explicitly-allowed mask (step 5).
- The DACL did not grant it. The walk produced an empty result for the requested bits (step 8).
- A narrowing layer stripped it. Restricted-token pass, confinement, or CAAP removed bits the DACL had granted (steps 10–12).
- A privilege was needed and not present, or present and not enabled, or present and missing its intent flag. Steps 4 and 9 did not pre-decide the bits as granted.
For each of these, this page covers what to look at and what the finding means.
Find the thread and its tokens #
Almost every investigation starts with finding the thread that made the call and inspecting its tokens. The mechanics:
- The thread's primary token:
cat /proc/<pid>/task/<tid>/tokenproduces a query-only handle (or usekacs_open_thread_tokenprogrammatically). The handle can be queried withKACS_IOC_QUERYfor fields includinguser_sid,groups,integrity_level,privileges,restricted_sids,confinement_sid,auth_id, andimpersonation_level. - The thread's effective token (impersonation if installed, primary otherwise):
cat /sys/kernel/security/kacs/selfwhen run from the thread itself; for another thread, the same/proc/<pid>/task/<tid>/tokenpath returns the effective token.
For a non-self thread, you need PROCESS_QUERY_INFORMATION on the process and PIP dominance. For /proc/self/token and /sys/kernel/security/kacs/self, the kernel always permits inspection.
With the effective token in hand, several denials are immediately diagnosable:
impersonation_level == Identification— the thread is at the wrong impersonation level. AccessCheck on this token denies everything at step 0. Either the client did not request a higher level on the socket, or the two-gate model silently downgraded the impersonation. See The two-gate model.integrity_levelis low and the object is high — possible MIC denial; check the object's label next.restricted_sidsis non-empty — the token is restricted. The denial may be from the restricted-token pass; check what SIDs are in the restricted list.confinement_sidis set andconfinement_exemptis false — the token is confined. The denial may be from the confinement pass.- A privilege you expected to be enabled is in the
presentset but not theenabledset — the privilege is not active; AccessCheck cannot use it. - A privilege you expected to be on the token is absent entirely — authd did not include it; this is a privilege-policy question, not an access-check question.
Find the object and its SD #
Read the security descriptor of the object:
- For files:
kacs_get_sdon the path. Returns the SD as a self-relative binary blob. - For registry keys: the equivalent registry API on the key.
- For tokens: query the token handle with the appropriate KACS_IOC_QUERY class.
- For processes: query the PSB.
Parse the SD into its four components (owner, group, DACL, SACL).
The most useful things to read:
- The owner SID. Does the caller match? If yes, owner implicit rights apply (unless suppressed — see next).
- OWNER RIGHTS suppression. Is there an ACE on
S-1-3-4(OWNER RIGHTS) in the DACL? If so, the owner's implicitREAD_CONTROL | WRITE_DACis suppressed; the owner gets only what that ACE grants. - The DACL. What ACEs are present, in what order? Are there any
INHERIT_ONLYACEs that look like they should grant access but are actually skipped during the walk? - The mandatory integrity label. Look for a
SYSTEM_MANDATORY_LABEL_ACEin the SACL. The SID indicates the object's integrity level; the mask indicates the policy bits. - The PIP trust label. A
SYSTEM_PROCESS_TRUST_LABEL_ACEin the SACL marks the object as PIP-protected. - CAAP references.
SYSTEM_SCOPED_POLICY_ID_ACEentries point to central access policies that contribute to the decision. - Resource attributes.
SYSTEM_RESOURCE_ATTRIBUTE_ACEentries. Relevant if any of the DACL's conditional ACEs references@Resource.*.
Walk the pipeline #
With token and SD in hand, walk the pipeline. The questions, in order:
Impersonation level (pipeline step 0) #
Is the effective token an impersonation token at the Identification level? If yes, that is the denial. AccessCheck stops at step 0; no further evaluation occurs. The fix is at the client end: either the client requested Identification (and should have requested Impersonation), or the two-gate model downgraded a higher request to Identification (consult The two-gate model).
MIC (pipeline step 5) #
Is the object's effective mandatory label at a higher level than the token's integrity_level?
If yes, look at the label's policy bits. For the bits set, the corresponding rights are pre-decided as denied:
NO_READ_UP→ read-category bits are denied.NO_WRITE_UP→ write-category bits are denied (the default for unlabelled objects).NO_EXECUTE_UP→ execute-category bits are denied.
If the requested right falls in a denied category, MIC is the denial. The fix is to either raise the token's integrity (typically by re-authenticating with elevated rights via UAC-style flow) or lower the object's label (requires SeRelabelPrivilege for raising, normal SACL access for lowering). Note that a privilege grant from step 4 (SeBackup, SeRestore) survives MIC — if MIC blocked write but the caller had SeRestorePrivilege with RESTORE_INTENT, the write may still succeed.
PIP (pipeline step 5) #
Does the object have a SYSTEM_PROCESS_TRUST_LABEL_ACE? If so, look at its SID's type and trust levels and compare to the calling process's PSB.
For dominance: both pip_type and pip_trust on the caller must be at least the ACE's values. If not, only the rights in the ACE's mask are permitted, and privilege-granted bits are revoked (this is the difference between PIP and MIC).
If PIP denied a right, the fix is to run the caller as a more-trusted binary (PIP is about the binary's signature, not about the caller's identity). See Process integrity protection.
Privileges (pipeline steps 4 and 9) #
Did the caller need a privilege to get the right? For example, the right is ACCESS_SYSTEM_SECURITY (SACL access), or the DACL granted nothing and the caller would need SeBackup to read.
Check:
- Is the privilege present on the token?
- Is it enabled?
- Did the caller pass the appropriate intent flag (
BACKUP_INTENT,RESTORE_INTENT)?
If any of those is no, the privilege did not fire. The fix depends on which:
- Privilege absent → authd's privilege policy does not grant this privilege to this principal. A policy-level change, not an access-control change.
- Privilege present but disabled → the calling code should enable it via AdjustPrivileges before the operation.
- Privilege enabled but no intent flag → the calling code should pass the flag in
privilege_intentto AccessCheck.
The DACL walk (pipeline step 8) #
Run the DACL walk manually, paying attention to:
- Ordering. Is the DACL in canonical order (explicit deny, explicit allow, inherited deny, inherited allow)? An out-of-order DACL produces surprising first-writer-wins results.
INHERIT_ONLYACEs. Are there ACEs that look relevant but have theIOflag set? They are skipped during the walk.- The matching identity. Does the caller match the SIDs in the ACEs? Remember:
- The token's
user_sidmatches. - Each group in
groupswithSE_GROUP_ENABLEDset matches for allow ACEs. - Groups with
SE_GROUP_USE_FOR_DENY_ONLYset match for deny ACEs only. - The well-known SIDs Everyone, Authenticated Users, etc. match according to their semantics.
OWNER RIGHTSandPRINCIPAL_SELFmatch per the virtual group injection rules.
- The token's
- Conditional ACEs. If any ACE has a conditional expression, evaluate it against the token's claims, the object's resource attributes, and the local claims (if any). A conditional that should evaluate TRUE but evaluates UNKNOWN (because a referenced attribute is missing) does not grant access for an allow ACE.
After the walk, the result is the bits the DACL would grant. If the requested right is missing from this result, the DACL is the denial.
Narrowing layers (pipeline steps 10–12) #
If the DACL granted the right but the final result does not have it, a narrowing layer stripped it. Check each:
- Restricted-token pass. If
restricted_sidsis non-empty, walk the DACL using only those SIDs. If the restricted-only result lacks the right, the restricted pass stripped it. (Privileges restored after this pass — privilege-granted bits are immune.) - Confinement. If the token is confined, walk the DACL using
confinement_sidandconfinement_capabilities. If that result lacks the right, confinement stripped it. (Privileges are not restored — they can be lost here.) - CAAP. If the SACL has
SYSTEM_SCOPED_POLICY_ID_ACEentries, look up each referenced policy and evaluate its rules. If any rule's effective DACL would not have granted the right, CAAP stripped it. The recovery policy applies if a referenced policy is missing from the kernel cache.
Audit (pipeline steps 13–14) #
If the steps above did not produce a clear answer, check the audit log. The access check emits audit events that record:
- The requested mask, the granted mask.
- Which privileges contributed (and whether they survived).
- The matched ACE (for SACL-driven audits).
- The subject and process.
A privilege-use event with success=false tells you a privilege fired and was stripped — that points at confinement/CAAP/PIP. An access-audit event with success=false and a DACL-walk trigger tells you the DACL did not grant. The audit trail is often the fastest way to localise a denial.
A compact checklist #
When a denial happens, in order:
- Find the thread (
tid,pid). - Read the effective token. Note
user_sid,integrity_level,restricted_sids,confinement_sid,impersonation_level, and which privileges are present-and-enabled. - Read the object's SD. Note the owner, the DACL contents (with attention to order and
INHERIT_ONLY), the mandatory label, the PIP label, and any CAAP references or conditional ACEs. - Walk the pipeline mentally against these inputs.
- Check the audit log if the manual walk does not point at a single layer.
Most denials are diagnosed at step 4 with one of the six categories listed at the top. The remainder are either edge cases (a conditional ACE evaluating UNKNOWN because of a missing claim, a CAAP policy with a misconfigured applies-to expression) or programming bugs (the wrong token installed, an incorrect intent flag, a hand-built SD with bad ordering).
When to use the access-check syscall directly #
For complex investigations, calling kacs_access_check directly is the precise tool. The syscall takes the token, the SD, the desired mask, and all the optional parameters (privilege_intent, self_sid, local_claims, object_audit_context, pip_type/pip_trust). It returns the granted mask, the continuous-audit mask, and the staging-mismatch flag.
You can call it from a debugging tool with the exact inputs the failing code used and see what comes back. The granted mask tells you which bits ended up granted; the audit emissions tell you which layers fired. A denial that is inscrutable from logs becomes visible from a manual access-check invocation with a known set of inputs.
This is how authoritative diagnoses work for the hard cases: reproduce the access check inputs, call the syscall manually, examine the output.
Where to go next #
For the inspection surfaces this walk relies on — reading tokens, sessions, and process state on a live system — read Inspecting tokens, sessions, and processes.
To rehearse an access check from a shell against a real file and token, read The sd command.
Central access policies
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy — CAAP for short — is access policy defined once, centrally, and applied to many objects without per-object configuration. Where a DACL says "this is the policy for this object", a CAAP says "this is the policy for every object that references this policy". Objects reference a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in their SACL, naming the policy's SID. When the access check evaluates the object, it looks up each referenced policy and runs its rules in addition to the object's own DACL.
The model is conservative by design: CAAP never widens access — it only narrows. Whatever the object's DACL would have granted, a CAAP can take away. Nothing a CAAP says can let a caller through that the DACL would have denied. This makes CAAP safe to layer: you can apply a policy to many objects without worrying about it accidentally granting access that the underlying ACLs intended to refuse.
This page covers what CAAP is, when it makes sense to use, and the model at a conceptual level.
What CAAP solves #
The problem CAAP exists for: organisations whose access rules span many objects in a way that does not fit the per-object DACL model. Examples:
- "Files classified Top Secret can only be read by users in the Cleared group, regardless of what the file's individual DACL says."
- "Database records belonging to the EU customer base require GDPR-compliant access rules, applied uniformly."
- "Engineering documents must be readable only on devices with up-to-date security software."
In each case, the rule is defined once at the organisational level. Each object that the rule applies to does not need to know the rule itself; it just needs to reference the policy by SID. The policy's content is stored centrally (in the directory or registry, distributed by authd) and applied at access-check time.
If you tried to express these rules directly in object DACLs, you would have to update every covered object whenever the rule changed. With CAAP, the policy is updated once and the next access check on any referencing object sees the new version.
How an object references a policy #
An object opts into a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in its SACL. The ACE has a single SID — the policy's identifier. Multiple SYSTEM_SCOPED_POLICY_ID_ACE entries in one SACL apply multiple policies; each is looked up independently and each contributes its own restriction to the access check.
The SACL is where the reference lives because the policy reference is system-level information — not something the object's owner should be able to override unilaterally. Modifying the SACL requires ACCESS_SYSTEM_SECURITY, which is privilege-gated (see The SACL). An object owner can rewrite the DACL freely; they cannot add or remove CAAP references without administrative authority.
Inherit-only SYSTEM_SCOPED_POLICY_ID_ACE entries are skipped during evaluation, just like other inherit-only ACEs. Their purpose is to propagate the policy reference to child objects at creation time.
The model in one paragraph #
A CAAP is a named bundle of rules. Each rule has:
- An applies-to expression (optional) — a conditional expression evaluated against the object's resource attributes. If it evaluates to TRUE, the rule applies to this object; if to FALSE or UNKNOWN, the rule is skipped. If the expression is absent, the rule applies to every object that references the policy.
- An effective DACL — a DACL whose rules are run against the calling token via a recursive sub-AccessCheck. The result is intersected with the running grant from the rest of the pipeline. A bit granted by the rule's DACL is preserved; a bit not granted is dropped.
- An effective SACL (optional) — audit ACEs merged into the access check's SACL walk, so the policy can contribute its own audit rules.
- A staged DACL and staged SACL (both optional) — proposed replacements for the effective DACL and SACL, evaluated in parallel without affecting access. Used for policy testing. See Staged policies.
The full rule structure is in Policies and rules. The evaluation mechanics — how rules compose, where they fire in the access pipeline, the no-recursion rule — are in Evaluation.
The narrowing-only guarantee #
Every CAAP rule's effective DACL is intersected with the running grant. The intersection is conjunctive: only bits granted by both sides remain. A bit the rule's DACL would not grant is dropped, regardless of how the object's own DACL had decided it.
This makes CAAP safe by construction. You cannot accidentally write a CAAP that grants something. The most permissive CAAP rule possible would be one whose effective DACL grants GENERIC_ALL to Everyone — and even that does not grant anything to a caller whose underlying DACL did not. The intersection is the floor of safety: CAAP can only ever restrict, never relax.
The implications:
- Adding a CAAP to an existing object cannot increase access. Worst case, the new policy contributes nothing additional. Best case, it restricts access in the intended way.
- A misconfigured CAAP cannot grant. It can only deny too aggressively. A bug in a policy's effective DACL means objects become harder to reach, not easier.
- The order of CAAP evaluation does not matter. Intersection is commutative. Two CAAP rules on one object produce the same final grant whether you evaluate rule A then rule B or B then A.
This is the property that makes CAAP suitable as a deployment-wide policy mechanism. Administrators can layer policies without coordinating because the layers can only narrow.
Multiple policies, multiple rules #
A single SACL can include several SYSTEM_SCOPED_POLICY_ID_ACE entries, each referencing a different policy. Each policy can contain several rules. All of them apply. The running grant from the rest of the pipeline is intersected against each rule's effective DACL in turn. The final grant is what survives every applicable rule across every applicable policy.
In practice, a single object is usually covered by zero or one policy. Multiple policies on the same object means the policies cover orthogonal axes — "Classification" plus "Compliance", say — where each axis is a separate policy. The CAAP author keeps each policy's rules focused on one axis and lets layering produce the combined effect.
Check-at-open semantics #
CAAP changes are applied at the next access check. Already-open handles do not see CAAP changes. A handle opened ten minutes ago has its granted mask cached on the file descriptor; the access check that granted it was the one that mattered, and a CAAP update afterwards has no effect on that handle.
This is the standard check-at-open model that File access (FACS) uses for all of access control. A user wanting to be sure a CAAP change has taken effect must close existing handles and reopen.
The implication for policy rollout: updating a CAAP does not immediately revoke access from sessions that already have open handles. If the policy update is meant to be enforced retroactively, the deployment needs an out-of-band step — typically session-revocation or service-restart — to drop the existing handles.
The CAAP catalog lives in the kernel #
The kernel maintains a policy cache keyed by policy SID. The cache is populated by authd at startup, and updated by kacs_set_caap calls when policies change. At access-check time, looking up a referenced policy is a constant-time lookup against this cache.
The cache is empty at boot. Until authd populates it, every CAAP reference resolves to "policy not found" and the recovery policy fires (covered in Distribution and recovery). This is why authd populates the cache early in boot, before user-facing services start.
Where to start #
If you want the structure of a policy — the rule layout, applies-to expressions, effective and staged DACL/SACL — read Policies and rules.
If you want to know how CAAP fires in the access pipeline and the rules for composition, read Evaluation.
If you want to understand the testing model — staged policies that evaluate in parallel without affecting access — read Staged policies.
If you want the operational story — how policies get into the kernel, who calls kacs_set_caap, what happens when a referenced policy is missing — read Distribution and recovery.
Policies and rules
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy is a structured bundle: a version byte, a count of rules, and the rules themselves. Each rule is independently scoped — its applies-to expression decides whether this rule fires on this object — and contributes its own access restriction and audit rules. A policy with three rules can apply to one object via one rule, to another object via two rules, and to a third object via none, depending on the objects' resource attributes.
This page walks through the structure: what a policy contains, what a rule contains, and how the parts fit together. The mechanics of how the policy is actually evaluated during AccessCheck live in Evaluation.
A policy in shape #
A policy in its on-wire form is a versioned binary blob:
| Field | Meaning |
|---|---|
| Version | A single byte. v0.20 uses 0x01. Any other value is rejected at ingestion. |
| Rule count | A 32-bit little-endian count of the rules that follow. Bounded to 256 rules per policy. |
| Rules | Length-prefixed sequence — one entry per rule. |
A policy is not a thing the kernel walks through ACE by ACE. It is a list of rules, each evaluated as its own unit. Two rules in the same policy do not influence each other; either both apply (their applies-to expressions both evaluate TRUE) or one applies or neither.
The kernel holds the parsed policy in its policy cache, keyed by the policy's SID (the SID is established at the time the policy is pushed via kacs_set_caap — see Distribution and recovery).
A rule in shape #
Each rule is a small structured record:
| Field | Meaning |
|---|---|
applies_to | Optional. A length-prefixed conditional expression in the same bytecode used by callback ACEs. If present, the expression must evaluate TRUE for this rule to apply to a given object; if absent, the rule applies to every object that references the policy. |
effective_dacl | A length-prefixed binary DACL. Required (a rule with no DACL would do nothing). Maximum 65,535 bytes. |
effective_sacl | Optional. A length-prefixed binary SACL contributing audit and policy ACEs. |
staged_dacl | Optional. A proposed replacement for effective_dacl, evaluated in parallel for testing. |
staged_sacl | Optional. Same idea for effective_sacl. |
Length-prefixed means each part starts with a 32-bit count of bytes followed by that many bytes. An absent field has length zero. The wire-format details are in the Wire formats reference; for this page, what matters is the structure.
A rule with no applies-to expression and only an effective_dacl is the most common shape. It says "apply this DACL to every object that references this policy".
The applies-to expression #
The applies_to expression is a conditional-ACE bytecode expression — the same model as conditional ACEs in DACLs and SACLs (see Conditional ACEs). The expression has access to four namespaces:
@User.<name>— claims on the calling token.@Device.<name>— claims on the calling token's machine identity.@Resource.<name>— resource attributes on the object being accessed.@Local.<name>— per-call attributes the caller supplied.
The expression evaluates to TRUE, FALSE, or UNKNOWN. The CAAP rule:
| Result | Effect on the rule |
|---|---|
| TRUE | The rule applies. The effective DACL is evaluated. |
| FALSE | The rule is skipped. |
| UNKNOWN | The rule is skipped. |
The UNKNOWN behaviour is opposite to the conditional-ACE deny rule. In a DACL, an UNKNOWN expression on a deny ACE causes the deny to apply (fail-closed on deny). For a CAAP applies-to, UNKNOWN causes the rule to be skipped (fail-open on the rule's existence). The reason: a rule that does not apply means the running grant is not further restricted; skipping is the conservative choice for CAAP, because the absence of a restriction is the safe default.
This split is worth remembering. The rule of thumb: UNKNOWN always errs in whichever direction does not narrow access. In DACLs, that means UNKNOWN denies are denials (because denying is the existing direction the ACE is going); in CAAP applies-to, that means UNKNOWN rules are skipped (because applying the rule would further narrow access).
Common applies-to expressions:
| Example expression | What it does |
|---|---|
| (absent) | Rule applies to every object that references the policy. |
@Resource.Classification == "TopSecret" | Rule applies only to objects whose Classification resource attribute is TopSecret. |
@Resource.Department == "Engineering" && @Resource.Sensitivity == "Internal" | Rule applies only when both attributes match. |
Exists(@Resource.RetentionUntil) && @Resource.RetentionUntil > @Local.Now | Rule applies only to objects whose retention period has not expired. |
The expression can reference any combination of the four namespaces. It produces a single boolean; whatever combination of attributes you need to inspect, the expression is the place to do it.
The effective DACL #
The effective_dacl is a regular DACL — an ACL of ACEs, with the same format any other DACL uses (see ACLs, ACEs, and access masks). It is evaluated via a recursive sub-AccessCheck at policy-evaluation time, producing a granted mask for the calling token.
The mask is then intersected with the running grant from the rest of the pipeline. The intersection is the narrowing: bits granted by the rule's effective DACL are preserved; bits not granted are dropped.
The rule's effective DACL can contain any ACE types a regular DACL can. Conditional ACEs work — referencing claims, attributes, and local context. Object ACEs work — scoping by property GUID. Generic rights work — mapped via the object type's GenericMapping table at evaluation time, exactly as they would be in a regular DACL.
What the effective DACL cannot do: include SYSTEM_SCOPED_POLICY_ID_ACE references to other policies. Even if such ACEs were present, the kernel's no-recursion rule strips them before evaluating the synthetic SD (see Evaluation). CAAP rules do not nest.
The maximum size of an effective DACL is 65,535 bytes — the same limit a regular DACL has.
The effective SACL #
The effective_sacl is optional. When present, it contributes its ACEs to the access check's SACL walk for the object being evaluated. This lets a policy contribute audit rules that fire alongside the object's own audit ACEs.
The audit ACEs in an effective SACL fire based on the final access decision, not on the policy's contribution to it. An audit ACE in an effective SACL that matches a successful access fires as a "successful access" event, regardless of whether the success came from the object's own DACL or from any specific policy.
This is the audit half of CAAP. A policy that wants to log every access to objects it covers — for regulatory or compliance reasons — does so by including an audit ACE in its effective SACL. The audit ACE applies wherever the rule applies, without the object's own administrator needing to add an audit ACE on every object.
The effective SACL can also contain SYSTEM_MANDATORY_LABEL, SYSTEM_PROCESS_TRUST_LABEL, and SYSTEM_RESOURCE_ATTRIBUTE ACEs — but they are not respected at policy evaluation. CAAP can only contribute to audit. It cannot apply a mandatory label or PIP label to an object through this channel. Those entries, if present, are ignored.
Staged DACL and SACL #
The staged_dacl and staged_sacl fields hold proposed replacements for the corresponding effective entries. They are evaluated in parallel during AccessCheck but do not affect the granted mask. Their purpose is to let administrators test policy changes against real traffic without committing.
If the staged DACL would have produced a different granted mask, or the staged SACL would have produced a different set of audit events, the access check sets a staging mismatch flag in its output. Tools watching for mismatches can use the flag to discover where a proposed policy change would have changed behaviour.
The full mechanics — when staging is used, what the mismatch flag actually contains, how rollout proceeds — are in Staged policies.
A complete rule, in shape #
Putting it all together, a CAAP rule for "TopSecret objects can only be read by users in the Cleared group" might look like:
| Field | Value |
|---|---|
applies_to | @Resource.Classification == "TopSecret" |
effective_dacl | An ACL containing: ACCESS_ALLOWED Cleared-Group GENERIC_READ, and nothing else. |
effective_sacl | An ACL containing: SYSTEM_AUDIT_ACE Everyone GENERIC_READ with `SUCCESSFUL_ACCESS_ACE_FLAG |
staged_dacl | (absent) |
staged_sacl | (absent) |
A user not in the Cleared group attempting to read a TopSecret object: the rule applies (attribute matches), the effective DACL grants GENERIC_READ only to the Cleared group, the user is not in the group so the rule grants nothing, the intersection drops GENERIC_READ from the running grant. Access denied. The audit ACE in the effective SACL records the attempt.
A user in the Cleared group attempting to read the same object: the rule applies, the DACL grants GENERIC_READ to the Cleared group, the user is in the group, the rule contributes GENERIC_READ to the intersection, the running grant survives, access succeeds. The audit ACE still records the access.
A user (anyone) attempting to read an object whose Classification is "Internal", not "TopSecret": the rule's applies-to evaluates FALSE, the rule is skipped, the running grant passes through unchanged. CAAP contributes nothing on this access.
Limits #
For reference, the size and count limits the kernel enforces at ingestion:
| Limit | Value |
|---|---|
| Wire-format spec size | 256 KB |
| Rules per policy | 256 |
| Bytes per applies-to expression | 64 KB |
| Bytes per effective or staged DACL/SACL | 65,535 (matches the SD format limit) |
| Version byte | Must be 0x01 |
A policy exceeding any of these is rejected by kacs_set_caap with -EINVAL. The limits are generous; a realistic policy will be far below them.
Where to go next #
For how a policy's rules are actually evaluated during AccessCheck — the flow, the no-recursion rule, and the composition — read Evaluation.
For the staged DACL and SACL fields and the rollout pattern they enable, read Staged policies.
For the byte-level layout these structures are encoded in, read CAAP format.
Evaluation
Peios / Peios Security Fundamentals / Central Access Policies
CAAP evaluation is step 12 of the access-check pipeline. By this point the DACL walk has run, owner implicit rights have been applied, restricted-token and confinement passes have done their work, and the running grant is whatever survived. CAAP can only narrow this grant further — it never adds.
The mechanism is the same shape every narrowing layer uses: re-walk the DACL against a different identity (or DACL), intersect, move on. The difference for CAAP is what gets walked: each referenced policy's rules are evaluated, and each applicable rule's effective DACL is intersected with the running grant.
This page covers the evaluation flow, the no-recursion rule that prevents CAAP from referencing other CAAPs, and the composition with the rest of the pipeline.
The evaluation flow #
flowchart LR
A["Running grant after step 11"] --> X["For each SYSTEM_SCOPED_POLICY_ID_ACE in SACL"]
X --> L["Look up policy in cache"]
L -->|found| R["For each rule in policy"]
L -->|not found| Y["Apply recovery policy"]
R --> T["Evaluate applies-to"]
T -->|TRUE| E["Evaluate effective DACL against token"]
T -->|FALSE / UNKNOWN| S["Skip rule"]
E --> I["Intersect with running grant"]
Y --> I
I --> N["Next rule or policy"]
N --> F["Final running grant after step 12"]
In code-shape order:
- Scan the object's SACL for
SYSTEM_SCOPED_POLICY_ID_ACEentries that are not inherit-only. - For each such ACE, take the SID and look it up in the kernel's policy cache.
- If the policy is not in the cache, apply the recovery policy — a hardcoded fallback that grants
GENERIC_ALLonly toBUILTIN\Administrators,SYSTEM, andOWNER_RIGHTS. The intersection happens just like any other CAAP rule's would. See Distribution and recovery. - If the policy is in the cache, iterate through its rules.
- For each rule, evaluate the
applies_toexpression against the caller's token, the object's resource attributes, and the local claims supplied by the caller. If the expression evaluates to TRUE, the rule applies; if FALSE or UNKNOWN, the rule is skipped. - For each applicable rule, evaluate the
effective_daclagainst the calling token. This is a recursive sub-AccessCheck — the rule's DACL is walked against the token the same way a regular DACL would be. - Intersect the rule's grant with the running grant. Bits not present in both are dropped from the running grant.
- Collect the rule's
effective_saclfor the eventual audit walk at step 14. - Also evaluate the staged DACL/SACL if present, in parallel. The staged evaluation does not affect the running grant — it only contributes to the staging mismatch flag if its result differs. See Staged policies.
- Move on to the next rule (or, when all rules are evaluated, the next policy).
- After every applicable rule from every applicable policy, the final running grant is what survives. This becomes the input to step 13.
The order does not affect the result. Intersection is commutative — two CAAP rules applied in either order produce the same final grant — so the kernel can evaluate rules in whatever order is convenient.
The recursive sub-AccessCheck #
When the kernel evaluates a rule's effective DACL, it constructs a synthetic SD to run the AccessCheck against:
- The synthetic SD's DACL is the rule's
effective_dacl. - The synthetic SD's owner is the object's actual owner. (Owner implicit rights still apply if the caller is the owner — a CAAP cannot suppress owner implicit grants.)
- The synthetic SD's primary group is the object's actual primary group.
- The synthetic SD's SACL is stripped of any
SYSTEM_SCOPED_POLICY_ID_ACEentries before evaluation. This is the no-recursion rule.
The recursive AccessCheck then runs against this synthetic SD with the same token and the same desired mask. It produces a granted mask. That mask is what gets intersected with the running grant.
The no-recursion rule #
The synthetic SD's SACL has its SYSTEM_SCOPED_POLICY_ID_ACE entries stripped before evaluation. This is what stops a CAAP from referencing another CAAP.
Without this rule, a CAAP rule's effective DACL could contain — or its synthetic SD's SACL could imply — references to additional policies, which would themselves be evaluated, which could reference further policies, and so on. The chain has no natural bound. The kernel addresses this by simply refusing to follow any CAAP reference inside a CAAP evaluation: the synthetic SD's SACL has its scoped-policy ACEs erased, so the recursive AccessCheck sees no CAAP references.
A reasonable question: what if a CAAP author wants their policy to compose with another? The answer is to put both SYSTEM_SCOPED_POLICY_ID_ACE entries in the object's SACL, not nest them. The two policies will be evaluated as siblings — each is intersected with the running grant — and the order does not matter because intersection is commutative. The effect is the same as nesting would have produced but without the recursion concern.
Composition with other narrowing layers #
CAAP is the last narrowing layer in the pipeline. By the time it runs at step 12:
- The DACL walk (step 8) has produced the initial grant from the object's own DACL.
- The restricted-token pass (step 10), if active, has intersected against the restricted-SID-only view.
- The confinement pass (step 11), if active, has intersected against the confinement identity.
CAAP then intersects against each applicable rule's effective DACL. The running grant survives all four (the DACL walk plus three intersections) if and only if every layer permitted the bit.
The composition is conjunctive: each layer must independently grant the right for it to remain. Adding CAAP to a token already restricted and confined further narrows; it cannot bring back access the earlier layers stripped.
Privilege grants are subject to CAAP intersection, like other bits. A CAAP rule whose effective DACL does not grant a right will strip that right from the running grant even if a privilege had granted it in step 4 or step 9. CAAP is privilege-blind in the same way confinement is.
See Narrowing layers for the full composition.
CAAP and audit #
The audit contribution of CAAP — the effective_sacl of each applicable rule — is collected during step 12 but not consumed until step 14, the SACL audit walk. The audit ACEs in CAAP effective SACLs are added to the object's own SACL ACEs, and the audit walk treats the combined set as a single SACL for the purposes of deciding which events to fire.
Audit ACEs in a CAAP can target the same identities and produce the same kinds of events as regular SACL audit ACEs. The CAAP contribution does not get its own event type or its own audit log entry — the events look like any other audit events, except that the matched ACE happens to have come from a policy rather than the object's own SACL.
This means the audit pipeline does not need to know about CAAP specifically. From the perspective of an audit consumer, the events are the same. The provenance — which ACE matched, whether that ACE was on the object or in a CAAP — is recorded in the event's trigger field for consumers that care.
The full audit model is in Auditing.
Errors during evaluation #
A handful of failure modes can occur during CAAP evaluation. The kernel handles each one specifically:
| Condition | Behaviour |
|---|---|
| Referenced policy SID not in cache | Apply the recovery policy. The running grant is intersected with what the recovery policy would grant. The recovery policy is administrator-friendly: it grants GENERIC_ALL to Administrators, SYSTEM, and OWNER_RIGHTS only. |
| Policy in cache but malformed | The same as not in cache — recovery policy applies. (kacs_set_caap should reject malformed policies at ingestion, but defence in depth.) |
| Applies-to expression returns UNKNOWN | Rule is skipped. (Different from a deny-ACE's UNKNOWN, which would apply — see Policies and rules for the asymmetry rationale.) |
| Effective DACL evaluation produces an error | Treated as "this rule denies everything except privilege-granted bits". The running grant is reduced to whatever privileges had pre-decided as granted. |
The pattern: errors fail-closed (i.e. they reduce the running grant, not extend it). A malformed policy or a corrupted effective DACL cannot accidentally grant access. The worst case is denial of access, with the recovery policy ensuring administrators retain a way back in.
Check-at-open #
CAAP evaluation runs only during AccessCheck calls. A handle that was opened ten minutes ago has its granted mask cached on the file descriptor; the access check that decided it ran at open time. A subsequent CAAP update does not retroactively affect that handle.
For FACS-managed objects (files, directories), the granted mask is cached on the open handle and used for subsequent operations against the handle. The mask is not recomputed. See The handle model.
For non-FACS objects that re-evaluate AccessCheck on each operation (some IPC endpoints, some token operations), the CAAP update would be visible immediately on the next operation.
The user-visible effect: changing a CAAP changes what new accesses see. It does not change what existing handles can do. Tools that need a CAAP update to be visible immediately need to coordinate session revocation or service restart — see Distribution and recovery.
Where to go next #
For the parallel evaluation of proposed policy changes and the staging mismatch flag, read Staged policies.
For how policies reach the kernel's cache and what happens when a referenced policy is missing, read Distribution and recovery.
For how the CAAP intersection composes with the restricted-token and confinement passes, read Narrowing layers.
Staged policies
Peios / Peios Security Fundamentals / Central Access Policies
Rolling out a change to a central access policy is operationally risky in a way that local DACL changes are not. A DACL change affects one object; if it is wrong, one object misbehaves. A CAAP change affects every object that references the policy — potentially thousands of files, registry keys, or service endpoints. An overly-permissive change is a security incident; an overly-restrictive change is a system-wide outage.
Staged policies are the testing primitive that makes CAAP rollout safe. A CAAP rule can carry, alongside its effective_dacl and effective_sacl, a proposed replacement called the staged_dacl and staged_sacl. During AccessCheck, the kernel evaluates both versions in parallel. The effective version is what affects the granted mask; the staged version does not. But the kernel records whether the staged version would have produced different behaviour, and reports that as a staging mismatch flag in the access check's output.
A staging mismatch is a signal: "if this staged change had been live, this access would have gone differently". Administrators can collect mismatches over a representative period of access traffic and make an informed decision about whether to commit the staged version.
This page covers how staging works mechanically, what the mismatch flag means, and the rollout pattern it enables.
How staging fits into evaluation #
When the kernel evaluates a CAAP rule at step 12 of the pipeline:
- The rule's applies-to expression is evaluated. If it returns FALSE or UNKNOWN, the rule is skipped entirely; no effective evaluation, no staged evaluation.
- If the applies-to returned TRUE, the
effective_daclis evaluated against the calling token. The result is intersected with the running grant. - If the rule has a
staged_dacl, the kernel also evaluates it against the same token. The result is not intersected with anything; the running grant uses only the effective result. - The kernel compares the staged result against the effective result. If they differ — that is, the staged DACL would have granted a different mask than the effective DACL did — the access check's staging mismatch flag is set.
- The same comparison happens for the
staged_saclversuseffective_sacl: if the staged audit ACEs would have produced different events (a different set of audits fired, or different bits triggered them), the staging mismatch flag is also set.
The mismatch flag is a boolean per access check. It does not say which rule or policy caused the mismatch; that level of detail would require richer reporting. What it does say is: somewhere in the CAAP layer of this access check, a staged change would have made a difference.
Tools watching the flag can correlate it with the call's parameters (which token, which object, which requested rights) to localise where staging would have changed behaviour. This is enough for an administrator to make a go/no-go decision on a policy rollout.
What the mismatch flag is and is not #
The flag is informational. It does not change behaviour — the access check returned the granted mask the effective policy produced, and the caller's operation proceeds accordingly. The flag is for observers (audit systems, deployment tools, monitoring dashboards).
The flag is boolean. It says "yes, somewhere a staged version differed from the effective" or "no, the staged versions all produced identical results". It does not enumerate which rules differed or which audit events changed; the accompanying caap-policy-diagnostic event records only the effective and staged total granted masks. Richer staging diagnostics are a job for the test pipeline (running representative AccessCheck calls and recording the differences); the kernel's report is the existence of a difference, not its detail.
The flag is per access check. Each access check call produces its own flag value in its own output. Aggregating mismatches across many calls is the consumer's job.
The DACL mismatch #
A DACL staging mismatch is set when the staged_dacl produces a different granted mask than the effective_dacl for the same call.
The comparison is bit-exact. If the effective DACL would grant FILE_READ_DATA | FILE_READ_ATTRIBUTES and the staged DACL would grant only FILE_READ_DATA, that is a mismatch. If both would grant the same bits, no mismatch. Bits not present in either grant are irrelevant.
The asymmetry is honest: a staged DACL more permissive than the effective would also trigger a mismatch. Staging is not about "this would deny more"; it is about "this would behave differently".
The SACL mismatch #
The SACL mismatch is more subtle because SACL evaluation produces audit events, not granted bits. The staged_sacl mismatch is set when the staged SACL would have produced a different set of audit events for the same call than the effective SACL did.
"Different set" means: the union of events that would have fired from the staged SACL is not equal to the union from the effective SACL. An event in one but not the other, or vice versa, is a mismatch. This includes audit ACE matches that would have triggered, and conditional audit ACEs whose conditional expressions differ.
The mismatch does not record which events differed. The kernel compares the two event sets internally and discards them; the caap-policy-diagnostic event (kind = staging-mismatch) it emits carries only the effective and staged total granted masks and an object_results_differ boolean. For a pure SACL mismatch those masks may even be equal — the flag is the only signal that the audit behaviour differed.
The rollout pattern #
The intended deployment flow for a CAAP change:
- Author the staged change. Take the existing policy. Add or modify the
staged_dacland/orstaged_saclto reflect the proposed change. Leave the effective versions unchanged. - Distribute the policy. authd pushes the policy (with staged versions populated) to the kernel via
kacs_set_caap. Other machines in the deployment get the same policy through their authd instances. - Run for a period. The system runs normally. Every access check produces a granted mask; tokens behave as if the effective version is in force. The mismatch flag is set whenever the staged version would have produced different behaviour.
- Collect mismatch reports. A monitoring tool records mismatches and their contexts (which call, which token, which object, which requested rights). Administrators review the report.
- Decide. If the mismatches reflect intended behaviour changes (the change tightens access in exactly the cases the administrator expected to tighten), the change is good. If unexpected accesses are flagged, the change has unintended side effects and needs revision.
- Commit. Replace the policy with one where
effective_daclandeffective_saclhold what the staged versions held. Push viakacs_set_caap. The change is now live; subsequent access checks use the new effective version. - Optionally, drop the staging fields. Once the change is committed, the policy can be re-pushed without staged fields, or with new staged fields representing the next planned change.
This flow lets administrators see the effect of a CAAP change on real traffic before committing. The mismatch flag turns "what will this change do?" from a question requiring synthetic test data into an observation of the actual system.
Staging applies to one rule, not whole policies #
Each rule in a policy can have its own staged DACL and SACL. The staging is per-rule, not per-policy. A policy with three rules can have one rule staging a change while the other two stay aligned (no staged version, or staged equal to effective). The mismatch is set whenever any active rule's staged version differs from its effective version.
Operationally, this is exactly what you want. A policy administrator typically wants to stage one change at a time, not stage the whole policy. The rule-level granularity lets each change be reasoned about independently.
What staging does not do #
A few clarifications:
- Staging does not affect access. The staged DACL never contributes to the granted mask. Whatever the effective version returns is what the caller gets.
- Staging does not affect audit events. The staged SACL does not fire events; only the effective SACL does. The staged SACL is evaluated only for the comparison.
- Staging is not versioning. There is no "previous staged" or "next staged". Each rule has at most one staged DACL and one staged SACL. Replacing them replaces them.
- Staging is not a rollback mechanism. If a committed change turns out to be wrong, the rollback is to re-author the policy with the old version and push it again. The staging fields are forward-looking, not backward-looking.
How staging changes for the audit consumer #
An audit consumer that wants to use staging meaningfully should look for the staging mismatch flag in audit events. The flag's presence is a signal to investigate; the access in question would have been decided differently under the proposed change.
Aggregating mismatches over time and bucketing them by (token, object class, requested rights) gives the administrator a picture: "the proposed change would have denied N accesses, of which M look intentional and (N-M) look unintentional". From there, deciding whether to commit is straightforward.
Without staging, the equivalent diagnostic is impossible without either a parallel test system or an A/B rollout — both expensive. Staging makes the comparison free per access check.
Where to go next #
For how a committed policy is pushed to the kernel — and the recovery behaviour when a referenced policy is missing — read Distribution and recovery.
For the caap-policy-diagnostic event that carries the staging mismatch, read Events and transport.
Distribution and recovery
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy that no one has loaded into the kernel does not affect access checks. The kernel maintains a policy cache — a map from policy SID to parsed policy — that AccessCheck consults at step 12. Distributing a policy means getting it into this cache. The mechanism is the kacs_set_caap syscall, called by authd.
This page covers how policies get into the kernel, what happens when a referenced policy is missing, and the boot-time sequence that makes the model usable.
kacs_set_caap #
The syscall is straightforward:
kacs_set_caap(policy_sid, sid_len, spec_or_null, spec_len)
| Parameter | Meaning |
|---|---|
policy_sid | The SID identifying the policy. A 4–68 byte binary SID. Establishes (or replaces) the cache entry. |
sid_len | The length of the SID in bytes. |
spec_or_null | The wire-format spec to install at this SID. NULL means "remove the policy at this SID from the cache". |
spec_len | The length of the spec in bytes. Zero when removing. |
The call requires SeTcbPrivilege. The only callers in a running system are authd and peinit; ordinary processes cannot push policies.
The kernel:
- Validates the SID and the spec. A malformed spec (wrong version byte, truncated fields, too many rules, invalid applies-to bytecode) is rejected with
-EINVAL. No partial installation; the call is atomic. - Parses the spec into the cache's internal representation.
- Replaces any existing entry at the policy's SID with the new one. If no entry existed, creates one.
- Returns success.
The policy is now live. The next access check that references this policy SID will see the new version. Existing handles (their access checks already done) are unaffected, per the check-at-open model.
To remove a policy, the caller passes NULL for the spec. The kernel deletes the cache entry. Subsequent references to this SID will not find the policy and the recovery policy will apply.
Who calls kacs_set_caap #
authd is the primary caller. authd's job is the bridge between policy storage (the registry on a standalone machine, the domain's directory on a domain-joined one) and the kernel. On startup:
- authd connects to its source of policy. Standalone: it reads from the registry. Domain-joined: it queries the domain's directory service.
- For each policy in the source, authd parses the policy into the wire format and calls
kacs_set_caapto install it. - After each policy change in the source (a registry write, a directory replication), authd re-pushes the affected policy via
kacs_set_caap.
peinit can also call kacs_set_caap for early-boot policies that need to be in place before authd starts. This is rare — most of the time, the kernel's CAAP cache is empty until authd takes over.
There is no path for an ordinary application to push a CAAP. The privilege requirement (SeTcbPrivilege) and the architectural restriction (only authd and peinit hold it) mean policy authority is concentrated in one place.
The policy cache and its lifetime #
The cache is an in-kernel data structure with the following properties:
- Empty at boot. The kernel does not persist the cache across reboots. Every boot starts with an empty cache.
- Populated by call. authd (or peinit) pushes policies one at a time via
kacs_set_caap. - Updated in place. A subsequent push at the same SID replaces the existing entry.
- Removed by null spec. A push with a NULL spec deletes the entry.
- Lives for the lifetime of the running kernel. A reboot starts over.
The cache is keyed by SID. There is no other index — no name, no category, no version. The SID is the canonical reference, and it must match between the object's SYSTEM_SCOPED_POLICY_ID_ACE reference and the cache entry.
What happens when a policy is missing #
A common case during early boot, or when a policy reference outlives the policy itself (the policy was removed but objects still reference it): the SACL of an object contains a SYSTEM_SCOPED_POLICY_ID_ACE whose SID does not appear in the policy cache. The kernel needs to do something.
The choice between failing open (treat the missing policy as "no restriction") and failing closed (treat the missing policy as "deny everything") is a security/usability trade-off. Failing open would let administrative misconfiguration silently disable security policies. Failing closed would render objects unreachable when a policy is briefly unavailable.
The kernel picks neither extreme. It applies a recovery policy — a hardcoded fallback that grants GENERIC_ALL only to:
BUILTIN\AdministratorsSYSTEMOWNER_RIGHTS(the owner of the object, but only if not suppressed by an OWNER RIGHTS ACE in the object's DACL)
The recovery policy is conservative in the sensible direction: ordinary users lose access to objects whose policy is missing, but administrators and SYSTEM retain enough authority to investigate, fix, and restore the policy. Owners can still reach their own objects (with the OWNER RIGHTS caveat).
The recovery policy fires automatically when the kernel cannot find a referenced policy. It applies the same way any other CAAP rule would — its grant is intersected with the running grant from the rest of the pipeline. The intersection ensures that the recovery policy cannot grant more than the DACL would have. In the typical case, a non-administrator user attempting to access an object covered by a missing policy gets nothing (DACL + recovery intersect to empty); an administrator gets whatever the DACL would have given them.
The recovery policy is not configurable. It is hardcoded into the kernel. The administrator-and-SYSTEM-only grant is the safe default; administrators who want a different fallback fix the policy distribution problem, they do not change what the recovery policy is.
The boot sequence #
The kernel's CAAP cache is empty at boot. Until authd starts and populates the cache, every access check that touches a CAAP-referencing object will get the recovery policy.
This is by design but it has a consequence: security-sensitive services that depend on CAAP must not start before authd is running. If a service that handles user requests starts before authd has loaded its policies, the service's access checks will use the recovery policy. For ordinary users accessing the service, that means denial of service. For administrators, that means working but not in the way the intended policies define.
The standard boot order:
- Kernel initialises. SYSTEM token created. Anonymous token created.
- peinit starts. peinit may push early-boot policies via
kacs_set_caapif any exist. - authd starts. authd connects to its policy source and pushes every policy via
kacs_set_caap. - authd signals that policy distribution is complete (typically by reaching a steady state).
- Other services start, knowing the CAAP cache is now populated.
The fourth and fifth steps require coordination — peinit, the service manager, and authd need to agree on when CAAP is ready. The exact mechanism is part of Boot and trust establishment.
If your service-start ordering does not respect this, the symptoms are: access denied for ordinary users, mysterious "policy not found" recoveries in the audit log, services that work for administrators but not for anyone else.
Updates and replication #
When a policy changes in the source (an administrator edits a registry value, or the directory replicates a new version), authd notices the change and re-pushes the affected policy via kacs_set_caap. The cache entry is updated atomically: subsequent access checks see the new version, ones already in progress see whichever version was current at the time they evaluated the relevant step.
There is no kernel notification mechanism for "this policy changed". Tools that want to react to a CAAP update need to subscribe to authd's events or watch the source directly. The kernel itself does not announce.
Replication across machines is authd's job, not the kernel's. The kernel knows nothing about other machines; it sees only the policies authd has pushed locally. On a domain-joined system, each machine's authd reads from the directory independently and pushes to its local kernel. The directory's replication latency becomes the propagation delay for CAAP changes across the deployment.
Cache eviction is administrative #
The kernel does not evict cached policies on its own. A policy stays in the cache until:
kacs_set_caapis called with the same SID and a NULL spec (explicit removal).- The kernel reboots (cache is wiped).
There is no LRU, no size-based eviction, no automatic cleanup. The cache grows or shrinks based only on explicit calls. This is intentional: a policy disappearing from the kernel cache because of memory pressure would be a security issue.
If memory is genuinely tight, the right behaviour is to keep the policies and reduce something else. The kernel's CAAP cache is not where the system's memory pressure should land.
What CAAP does not get from distribution #
A few things distribution does not handle, that are worth knowing:
- Object-side enforcement. Distributing a policy makes the policy reachable when an object's SACL references it. The SACL still has to be set up — adding a
SYSTEM_SCOPED_POLICY_ID_ACEto an object's SACL is a separate administrative action on each object (or a class of objects via a directory-management tool). - Audit propagation. A policy's audit events fire as part of the access check; they go to KMES like any other audit event. The distribution mechanism does not bundle a separate audit pipeline.
- Per-user policies. A policy that should apply only to specific users is one whose effective DACL or applies-to expression filters by user identity. Distribution does not target specific users; every policy in the cache applies to whoever accesses an object that references it.
These are policy-author concerns, not distribution concerns. The distribution layer is simple: push policies, identified by SID, into a cache the kernel consults. Everything else is the policy author's responsibility.
Where to go next #
For what the kernel does with a cached policy on each access check, read Evaluation.
For the boot-time coordination that decides when the CAAP cache is ready, read Boot and trust establishment.
File access
Peios / Peios Security Fundamentals / File access
FACS — the File Access Control Shim — is the kernel layer that applies KACS access control to files. Where the kernel's access pipeline runs against any protected object, FACS is the specific bridge between the file system (inode, dentry, page cache) and the access check. Every read, write, open, and metadata operation on a file goes through FACS at some level; FACS decides whether the operation proceeds and, when relevant, what flags or rights apply.
The model FACS uses is the handle model: AccessCheck runs once at open time, the granted mask is cached on the file descriptor that comes back, and every subsequent operation through that fd reads from the cache rather than re-running the access check. The implications of this single design choice ripple through every page in this topic.
This page covers the model at a conceptual level. Later pages cover the handle model in detail, the syscalls for opening files (native and legacy), the SD management operations, and the special cases that the model has to accommodate.
The handle model in one sentence #
Access is decided at the moment a file descriptor is opened; from then on, the descriptor carries an immutable "granted access mask" that gates every operation.
That sentence is the whole model. Once a file is open, the kernel knows what the caller is allowed to do with this file via this handle. Reads, writes, metadata queries, mmaps — each operation has a required access mask (what it needs to do its work) and succeeds only if the required mask is a subset of the cached granted mask.
The mask does not change. Adjustments to the file's DACL, to the calling token, or to anything else after the open do not affect the cached mask. The handle is a snapshot of what the access check said at open time.
The full mechanics — what gets cached, what operations consult the cache, what is and is not subject to the model — are in The handle model.
Why the handle model #
Two main reasons:
Performance. Re-running AccessCheck on every read or write would be expensive. The DACL walk plus narrowing layers plus possibly CAAP evaluation is non-trivial work. Caching the result on the handle reduces operations to a single bitmask comparison — orders of magnitude cheaper.
Consistency. A long-running operation should not change behaviour halfway through because the DACL was modified. A file being read for backup should not start failing midway because an administrator tightened the DACL; either the backup tool has the right at the start (in which case it should complete) or it doesn't (in which case it shouldn't have been able to open in the first place).
The trade-off is that an open handle survives a policy change. If you tighten a file's DACL, processes that already have the file open keep their existing access. New opens get the new policy; old handles are unchanged.
This is the check-at-open principle — covered for CAAP in Central access policies and applied uniformly here. The kernel does not retroactively revoke open handles.
Where FACS sits #
FACS lives between the file system and the rest of KACS. The arrangement:
flowchart LR
A["Userspace syscall (open, read, write)"] --> B["FACS"]
B -->|at open| C["AccessCheck"]
B -->|cached mask on fd| D["File operation"]
C --> D
D -->|persisted| E["Filesystem (ext4, xfs, etc.)"]
At open, FACS calls AccessCheck and caches the result on the fd. At every subsequent operation, FACS reads the cached mask and decides. The filesystem itself doesn't see KACS; it sees only operations that FACS has already vetted.
This means:
- The DACL and the filesystem are decoupled. FACS is what enforces KACS on files; the filesystem stores the SD as metadata (an xattr, typically) but does not interpret it.
- A filesystem can be FACS-managed or not. Mount policy determines whether FACS applies to a given mount; see Mount policies.
- Different filesystems can store SDs differently. ext4, XFS, NTFS — each has its own xattr or native mechanism for persisting the SD. FACS handles the abstraction; the access check works the same way regardless.
Two open syscalls #
The kernel exposes two ways to open a file:
| Syscall | Mode | Common use |
|---|---|---|
kacs_open | KACS-native — caller specifies an explicit desired access mask | New code; programmatic file access by services |
openat / open | Legacy — caller specifies POSIX flags (O_RDONLY, O_WRONLY, etc.) that map to access masks | Existing Linux applications |
kacs_open is the native interface. The caller specifies exactly which rights it wants and either gets all of them or fails. openat and friends are the POSIX-compatibility interface — they use the same FACS machinery internally but with a different mapping from input flags to access mask and with split "core" and "compat" semantics for handling partial grants.
Both paths converge in FACS. The cached mask on the resulting fd is the same shape regardless of which syscall produced the open.
The two interfaces are covered in detail in Opening files.
What FACS reads from where #
A summary of the data sources that feed into a FACS access check at open:
| Source | What it provides |
|---|---|
| Calling thread's effective token | Identity for AccessCheck — user SID, groups, integrity, privileges, etc. |
| File's inode | The owner SID, primary group, DACL, SACL (read via xattr or filesystem-native channel) |
| Mount policy | Whether FACS applies at all, how missing SDs are handled, the mount-level SD template |
| Process PSB | PIP fields for the dominance check during AccessCheck |
Caller's kacs_open parameters | The desired access mask, the create disposition, optionally a creator-supplied SD |
These come together at open. AccessCheck runs over them and produces the granted mask that goes on the fd.
What FACS does not do #
A few clarifications:
- FACS does not store SDs. The filesystem stores SDs; FACS reads them. Different filesystems use different mechanisms (xattrs on ext4 / XFS / Btrfs, native security streams on NTFS, mount-level synthesis on FAT/exFAT, in-memory on tmpfs).
- FACS does not validate SDs at boot. The first access to a file is when its SD is first read and validated; the kernel does not pre-scan filesystems looking for problems.
- FACS does not propagate DACL changes. Modifying a file's DACL affects future opens, not existing handles. This is the check-at-open rule.
- FACS does not gate operations that bypass the file system. A process that has a file mapped into memory via mmap can read or write that memory without going through FACS for each access — the access check happened at mmap time (or at open, then at mmap), and after that the kernel has no efficient way to intercept memory accesses. Mitigations like LSV apply at the mmap call; runtime memory access is governed by the page-table permissions the kernel set when the mapping was created.
Where to start #
If you want the handle model in detail — what is cached, what operations check the cache, when the cache can be stale, how fd transfer works — read The handle model.
If you want the open syscalls — kacs_open for KACS-native, openat for legacy compatibility, the differences in semantics and the rules for each — read Opening files.
If you want to read or modify a file's SD — kacs_get_sd, kacs_set_sd, the security_information bitmask, the rules for setting owner/DACL/SACL/label — read Managing file security.
If you want the edge cases — O_PATH, the exec dual gate, append-only files, sticky bit, POSIX ACLs that no longer work, NFS dual authority — read Special cases.
The handle model
Peios / Peios Security Fundamentals / File access
The handle model is the rule that an open file descriptor carries a fixed snapshot of access permissions, taken at the moment of open. Every operation through the fd consults this snapshot — not the file's current SD, not the calling token's current state, not anything else. The snapshot is the granted access mask, an immutable 32-bit value attached to the fd.
This page covers what the snapshot contains, which operations consult it, why immutability is the right choice, and the implications for handle transfer between processes.
What is on the fd #
When open succeeds, the kernel attaches several things to the resulting fd. The KACS-relevant pieces:
| Field | Set at | Mutable after open? |
|---|---|---|
| Granted access mask | AccessCheck at open | No |
| Continuous audit mask | AccessCheck at open (if alarm ACEs matched) | No |
| FACS-managed flag | Open path | No |
| (Filesystem state: position, mode, etc.) | open | Mutable per operation |
The granted access mask is the central object. It is whatever AccessCheck decided the caller's effective token was entitled to on this file at this moment. The bits set are the rights the fd holder has; the bits not set are rights they do not have.
The continuous audit mask is the union of all alarm ACEs that matched at open time. Every subsequent operation through the fd will check against this mask and emit a continuous-audit event if the operation's required mask overlaps it. See The SACL and Audit ACEs.
The FACS-managed flag distinguishes fds that go through the handle model from those that don't. O_PATH fds, for example, are not FACS-managed (covered in Special cases).
What operations consult the cache #
Almost every operation on a FACS-managed fd checks the cached mask. The rule: the operation has a required access mask (what it needs the caller to have been granted at open) and succeeds only if required & ~granted == 0 — every required bit is in the granted mask.
| Operation | Required access |
|---|---|
read | FILE_READ_DATA |
pread, readv, process_vm_readv (when reading from the fd as target) | Same |
write | FILE_WRITE_DATA |
pwrite, writev | Same |
| Append-only write (RWF_APPEND, O_APPEND) | FILE_APPEND_DATA (without requiring FILE_WRITE_DATA) |
ftruncate | FILE_WRITE_DATA |
fchmod | FILE_WRITE_ATTRIBUTES |
fchown | WRITE_OWNER (updates only the inert Linux uid/gid; see Special cases) |
mmap(PROT_READ) | FILE_READ_DATA |
mmap(PROT_WRITE) | FILE_WRITE_DATA |
mmap(PROT_EXEC) | FILE_EXECUTE |
fstat | FILE_READ_ATTRIBUTES |
fgetxattr | FILE_READ_EA (for ordinary xattrs; the security namespace is unconditionally denied) |
fsetxattr | FILE_WRITE_EA (same exception) |
fdatasync, fsync | None — these are control operations, not data operations |
flock, fcntl(F_SETLK) | None |
Each operation knows what it requires; the kernel compares against the cached mask and decides.
A subtle case: the operation's required mask comes from the operation's semantics, not from POSIX flags. A read() on a fd opened O_RDONLY needs FILE_READ_DATA because read needs FILE_READ_DATA, not because the fd was opened with O_RDONLY. The two coincide because open(O_RDONLY) requested and got FILE_READ_DATA, but the per-operation check is on what the operation needs, not on what the open requested.
Immutability and why #
The cached granted mask cannot change after open. The kernel does not provide a syscall to refresh it. Modifying the file's DACL does not propagate to existing fds. Adjusting the calling token's privileges does not retroactively re-grant rights on previously-opened fds.
The reasoning is two-fold:
Atomic policy. A long-running operation should not change behaviour partway through. A backup tool that opens a file at time T should be able to complete the read at time T+1 even if the DACL was modified between. Either the right to read was granted at open (in which case the read should succeed) or it wasn't (in which case the open should have failed).
Performance. Caching is the whole point. If the kernel re-evaluated AccessCheck on every read, the handle model would not give any of the performance benefit. The cache is what makes the model work.
The implication: a DACL update is observable only to new opens. Existing handles continue with the rights they had. To force a policy change to take effect, the operator must either close existing handles (or kill the processes holding them) or wait for the handles to be released naturally.
fd transfer preserves the mask #
A file descriptor can be transferred between processes — via dup, fork, SCM_RIGHTS, exec, pidfd_getfd. In every case, the transfer preserves the granted access mask exactly.
A fd passed via SCM_RIGHTS to another process gives that process the same access the originating process had through the fd. The recipient's token is not re-checked at transfer; the granted mask is what counts.
This is intentional. Capabilities are passable. A process that opened a file with broad rights can hand the fd to a helper process; the helper inherits the rights even if the helper's own token would not have granted them.
The model is: opening a file produces a capability (the fd + the cached mask), and capabilities are transferable. The transfer is the way the model expects rights to be delegated.
The corollary: a process should be careful about which fds it shares. Passing a fd to a less-trusted helper grants that helper the rights the fd carries. There is no narrowing-at-transfer mechanism.
A more restrictive variant — handing off only some of the access rights — requires opening a fresh fd with the narrower set. The original fd holder, who presumably has the broader set, can open the file again with a narrower mask and pass that fd.
fork and exec #
fork copies the file table; all fds are inherited by the child along with their cached masks. The child has the same rights on the same files as the parent at the moment of fork.
exec (with no special flags) preserves the file table; fds carry over to the new program with their cached masks intact.
exec with FD_CLOEXEC set on a fd causes that fd to be closed at exec; the child program does not inherit it. This is the standard "close on exec" mechanism, used widely. A handle that should not survive exec gets FD_CLOEXEC either at open (via O_CLOEXEC) or later (via fcntl(F_SETFD, FD_CLOEXEC)).
Within the same process, threads share the file table; a fd opened in one thread is usable from any thread in the same process.
The cached mask follows the fd everywhere the fd goes.
When the cache can be wrong #
The cached mask is a snapshot at open time. There are a small number of cases where the cache may be wrong relative to the world at large:
- The DACL was changed after open. The cache reflects the DACL at open; the on-disk DACL may now grant different rights. New opens would get the new rights; this fd still has the old. Not a bug — the model is check-at-open.
- The calling token's state changed. The token's privileges may have been adjusted, the integrity level may have changed (via NEW_PROCESS_MIN at exec, say). The cached mask reflects the token at open. Operations through this fd use the cached value, not the current token state.
- The file was deleted and replaced. An
unlinkof the path the fd was opened against does not affect the fd; the inode remains alive as long as the fd is open. The cache still reflects what was true of the now-unlinked inode. A new file created at the same path is a different inode; the cache is unaffected. - Mount policy changed. Changing the mount policy on a superblock does not retroactively affect the cached masks of fds open against files on that superblock. The cache is the snapshot; mount policy is the input to future opens.
In each case, the model's answer is the same: the cache is the truth for this fd's purposes. If you need a fresh view, close and reopen.
Operations not subject to the cache #
A handful of operations bypass the cache or use a different check:
execveat(AT_EMPTY_PATH)is the one exception in v0.20. Exec re-evaluates AccessCheck rather than using the cache. The reasoning is that exec changes the calling process's identity and PSB in ways that the cached open-time decision did not account for; running a fresh check makes the exec decision honest. This is the only v0.20 use-time access check.- O_PATH fds are not FACS-managed and have no cached mask. Operations against them either work unconditionally (
fstat) or use a fresh AccessCheck (operations that need real access). - Mapping a file's data into kernel space directly (e.g. via
splicebetween two fds) involves both fds' cached masks; neither is bypassed.
The exception list is short. For the overwhelming majority of operations, the cached mask is the gate.
Implications for processes #
Knowing the handle model has practical implications for code that handles files:
Open with exactly the rights you need. A fd opened with broader rights than needed is a broader capability than needed. If something might pass the fd elsewhere, the something gets the broader rights too. Minimum-rights opens are good hygiene.
Pass fds carefully. A fd shared via SCM_RIGHTS is a capability transfer. Audit who you pass to and what they could do with it.
Use FD_CLOEXEC for fds you don't want exec to inherit. This is also a defence-in-depth measure against accidental capability leakage to child processes.
Refreshing access requires reopening. A program that wants to see DACL changes needs to close and reopen, not poll for changes.
Where to go next #
For the two syscalls that produce these fds and stamp the granted mask, read Opening files.
For reading and writing the SD behind a handle, read Managing file security.
For the edges of the model — O_PATH, the exec dual gate, append-only handles — read Special cases.
Opening files
Peios / Peios Security Fundamentals / File access
There are two ways to open a file in Peios. kacs_open is the native interface — the caller specifies an explicit desired access mask, and the kernel either grants all of it or fails the open. openat (and the older open) is the legacy POSIX interface — POSIX flags map to access masks, and the kernel uses split "core" and "compat" semantics so that rights requested but not granted can sometimes be silently dropped without failing the open.
This page covers both syscalls, the create-disposition options, the MAXIMUM_ALLOWED mode, and the rules around the caller supplying an SD for newly-created files.
kacs_open — KACS-native #
kacs_open is the canonical Peios open syscall:
fd = kacs_open(dirfd, path, &how, sizeof(how), &status)
Where how is a kacs_open_how struct:
| Field | Meaning |
|---|---|
desired_access | The 32-bit mask of rights the caller wants. May include MAXIMUM_ALLOWED. |
create_disposition | One of SUPERSEDE / OPEN / CREATE / OPEN_IF / OVERWRITE / OVERWRITE_IF. |
create_options | Modifier flags — DIRECTORY, DELETE_ON_CLOSE. |
flags | Additional flags — AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
sd_ptr, sd_len | Optional creator-supplied SD for new files. |
The kernel:
- Resolves the path.
- Decides whether the file exists / should exist based on
create_disposition. - Runs AccessCheck with the calling token, the file's SD (or computes one for a newly-created file), and the
desired_accessmask. - Strict-mode semantics: every bit in
desired_accessmust be granted. If any requested right is not granted, the open fails with-EACCES. There is no "partial open" — either every requested bit is in the granted mask or the operation fails entirely. - On success, returns the fd with the granted mask cached. If
statuswas non-null, writes one of OPENED / CREATED / OVERWRITTEN / SUPERSEDED to it indicating what happened.
The strict-mode semantics are the right behaviour for native code. A caller that asks for read-and-write either gets both or fails; ambiguity is removed. The caller knows exactly what rights it has on the resulting fd because the mask is exactly what was requested.
The MAXIMUM_ALLOWED flag (covered below) is the exception — when set, the kernel relaxes strict mode and returns whatever rights the caller could have gotten.
kacs_open_how — fields in detail #
desired_access #
The 32-bit access mask. Standard format: object-specific bits 0–15, standard bits 16–20, special bits 24–25, generic bits 28–31. The kernel expands generic bits (GENERIC_READ, GENERIC_WRITE, etc.) using the file GenericMapping at evaluation time.
Special rights:
MAXIMUM_ALLOWED(0x02000000) — request the maximum grantable mask. See below.ACCESS_SYSTEM_SECURITY(0x01000000) — the right to read or modify the SACL. Gated by SeSecurityPrivilege.
A desired_access of zero is rejected — the kernel does not grant a fd with no rights.
create_disposition #
Defines what to do depending on whether the file exists (numeric constant values: Other constants):
| Value | If file exists | If file does not exist |
|---|---|---|
SUPERSEDE | Delete and recreate. | Create. |
OPEN | Open. | Fail with ENOENT. |
CREATE | Fail with EEXIST. | Create. |
OPEN_IF | Open. | Create. |
OVERWRITE | Truncate to zero and open. | Fail with ENOENT. |
OVERWRITE_IF | Truncate to zero and open. | Create. |
OPEN_IF is the most common ("get me access to this file, creating if necessary"). SUPERSEDE removes the inode and creates a new one — useful for atomic-replace patterns.
create_options #
Modifier flags:
| Flag | Meaning |
|---|---|
DIRECTORY | The target must be (or will be created as) a directory. If the disposition would create and this flag is set, a directory is created. If the disposition is open-only and the target is not a directory, fails with ENOTDIR. |
DELETE_ON_CLOSE | The file should be deleted when the last fd referencing it is closed. Useful for temporary files. |
flags #
Path-resolution flags:
| Flag | Meaning |
|---|---|
AT_EMPTY_PATH | The path is empty; operate on the directory referenced by dirfd. |
AT_SYMLINK_NOFOLLOW | Do not follow a terminal symlink. If the path resolves to a symlink, fails with ELOOP. |
sd_ptr / sd_len #
Optionally, the caller can supply a security descriptor for a newly-created file. The SD must be in self-relative format and within the standard size limit (65,535 bytes).
The rules for the creator-supplied SD:
- Permitted on a disposition that creates (CREATE, OPEN_IF when the file does not exist, OVERWRITE_IF when it does not exist, SUPERSEDE when it does not exist).
- Rejected on open-existing branches (OPEN, OPEN_IF when the file exists, OVERWRITE when the file exists). Supplying an SD on an existing-file path is an error (-EINVAL).
- If not supplied for a creation, the kernel synthesises an SD from the parent's inheritable ACEs and the creator's token defaults (see Inheritance).
- The caller must be entitled to set the owner SID — the same rule as
kacs_set_sd(own SID or a SE_GROUP_OWNER group, or SeRestorePrivilege to override).
status #
Optional output indicating what happened:
| Value | Meaning |
|---|---|
OPENED (1) | An existing file was opened. |
CREATED (2) | A new file was created. |
OVERWRITTEN (3) | An existing file was truncated and opened. |
SUPERSEDED (4) | An existing file was deleted and a new file was created. |
This is useful for OPEN_IF / OVERWRITE_IF dispositions where the caller wants to know what happened. For non-conditional dispositions, the answer is predictable from the disposition itself.
MAXIMUM_ALLOWED — relax strict mode #
When MAXIMUM_ALLOWED (0x02000000) is set in desired_access, the kernel changes mode:
- The
MAXIMUM_ALLOWEDflag is stripped from the desired mask. - AccessCheck runs in maximum-allowed mode (see DACL evaluation).
- The full DACL is walked, accumulating every right the caller could have been granted.
- The result is returned as the cached granted mask. It is not compared to the desired mask for strict-mode purposes.
Using MAXIMUM_ALLOWED, the open succeeds with whatever rights the caller could have gotten, including possibly fewer than what they "asked for". The other bits in desired_access are treated as a hint about what the caller would want — the kernel still evaluates them — but the call does not fail if the caller's actual rights are narrower.
The flag exists for tools that want to do "open with whatever access I can get" rather than "open with exactly these rights". A backup tool, for example, might want to open every file it encounters with whatever access is available rather than refusing the file because it cannot get write.
MAXIMUM_ALLOWED must be combined with at least one concrete data or execute bit. Calling with MAXIMUM_ALLOWED alone is rejected with -EINVAL — the kernel needs to know that some operation is intended, even if the specific rights are flexible.
openat (legacy) — POSIX-compatibility #
The legacy openat (and the older open) takes POSIX flags and maps them to access masks. The mapping is:
| POSIX flag | Maps to |
|---|---|
O_RDONLY | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE (the read category from GENERIC_READ) |
O_WRONLY | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE |
O_RDWR | O_RDONLY mapping ∪ O_WRONLY mapping |
O_APPEND | Adds FILE_APPEND_DATA |
O_TRUNC | Requires FILE_WRITE_DATA |
O_CREAT | Sets the disposition to OPEN_IF (with the FILE_ADD_FILE right on the parent) |
O_EXCL | With O_CREAT, switches the disposition to CREATE (fail if exists) |
O_PATH | Special — see Special cases. The fd is not FACS-managed. |
O_NOFOLLOW | Sets AT_SYMLINK_NOFOLLOW. |
So openat(O_RDONLY) maps to "open with FILE_READ_DATA + the rest of the read category". The kernel then runs AccessCheck for that mask.
Core vs compat rights #
The wrinkle: legacy openat uses a core vs compat split. The mapped rights are partitioned into:
- Core rights — the rights that the operation fundamentally needs. If any core right is denied, the open fails.
- Compat rights — rights that are conventionally granted with this POSIX flag but are not strictly necessary. If any compat right is denied, it is silently dropped from the cached mask — the open still succeeds, just with a narrower grant.
For O_RDONLY, the core right is FILE_READ_DATA (you cannot read without it). The compat rights are FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE — these are convenient to have, but a read-only open is still meaningful without them.
The result is that legacy open(O_RDONLY) succeeds when FILE_READ_DATA is granted, even if the other read-category rights are not. The fd ends up with the granted subset cached.
This is the difference from kacs_open. kacs_open(FILE_READ_DATA | FILE_READ_ATTRIBUTES) requires both and fails if either is denied; open(O_RDONLY) requires FILE_READ_DATA and silently drops FILE_READ_ATTRIBUTES if it is denied.
The reasoning: POSIX applications expect open to succeed with reasonable defaults. Failing because a niche right was not granted would break compatibility. The split lets the application succeed with the rights it really needs and treat the others as bonuses.
Why kacs_open exists #
If legacy openat works fine for POSIX applications, why have kacs_open?
Two reasons:
Precision. New code that knows what rights it needs should be able to request them explicitly. kacs_open lets a service say "I need exactly these rights" and either get them or be told they are not granted. There is no silent narrowing.
Direct SD provision. kacs_open lets the caller pass an explicit SD for a newly-created file. Legacy openat does not — POSIX open(O_CREAT) uses umask-based defaults plus the parent's inheritable ACEs, with no path for the caller to specify a custom SD.
For most application code, legacy openat is the right tool. For services that care about exact-rights or that need explicit-SD-at-creation, kacs_open is the answer.
Errors #
Both syscalls can fail with:
| Error | Cause |
|---|---|
-EACCES | Strict-mode access check failed (kacs_open), or core rights denied (legacy). Or a related path component is unreachable. |
-EEXIST | The disposition was CREATE and the file already exists. |
-ENOENT | The disposition was OPEN or OVERWRITE and the file does not exist. |
-ENOTDIR | KACS_CREATE_OPT_DIRECTORY was set but the target is not a directory; or a path component is not a directory. |
-ELOOP | AT_SYMLINK_NOFOLLOW was set and the path resolves to a symlink. |
-EINVAL | Various — MAXIMUM_ALLOWED with no concrete bits, an invalid create_disposition, an SD on an open-existing branch, etc. |
-EBADF | The dirfd is invalid. |
The error code tells you what went wrong. The kernel does not give cryptic codes; each maps to a specific named failure mode.
See also #
- The handle model — what the cached mask these opens produce actually does.
- Managing file security — reading and writing the SD behind a handle.
- DACL evaluation — the maximum-allowed walk MAXIMUM_ALLOWED relies on.
Managing file security
Peios / Peios Security Fundamentals / File access
Reading and writing a file's security descriptor is done through two dedicated syscalls — kacs_get_sd and kacs_set_sd — not through the xattr layer. Direct xattr access to the SD storage (the security.peios.sd or system.ntfs_security xattr) is unconditionally denied; the only way through is the syscall pair.
This page covers the syscalls, the security_information bitmask that decides which SD parts are accessed, the access rules, and the special LABEL_SECURITY_INFORMATION flag for setting the integrity label specifically.
Why the xattr layer is denied #
You might expect that the SD, stored in an xattr, would be readable and writable through the standard getxattr / setxattr syscalls. It is not. The kernel refuses every xattr operation on the SD xattr regardless of who is asking and what they hold.
The reasoning:
- Atomic semantics. Reading or writing the SD via xattr would expose the raw bytes; tools could read a partial SD (during a write by someone else), or write an SD whose internal structure is inconsistent with the file's other state.
- Access rule unification. The SD has its own access rules —
READ_CONTROLto read,WRITE_DAC/WRITE_OWNER/ACCESS_SYSTEM_SECURITYto write different parts. Routing throughkacs_get_sd/kacs_set_sdputs these rules in one place; routing through xattr would require duplicating them at the xattr layer. - Format flexibility. Different filesystems store the SD differently. The syscall abstraction lets the kernel translate; the xattr layer would force a specific format.
So the syscall pair is the only path. Reading or writing the SD goes through them; xattr operations on the SD xattr are denied.
kacs_get_sd #
The read syscall:
size = kacs_get_sd(dirfd, path, security_info, buf, buf_len, flags)
Returns the SD bytes for the requested components.
| Parameter | Meaning |
|---|---|
dirfd, path | The file to read. Standard dirfd-relative resolution. |
security_info | A bitmask saying which components to return. See below. |
buf, buf_len | Output buffer. |
flags | AT_EMPTY_PATH (use dirfd as fd-relative), AT_SYMLINK_NOFOLLOW (don't follow terminal symlink). |
The kernel:
- Resolves the path.
- Runs AccessCheck — the caller needs the access rights corresponding to the requested components (covered below).
- Reads the file's SD from its filesystem-native storage.
- Constructs a subset SD containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
- Returns the subset SD in
buf, with the total length as the return value.
Probe mode #
Calling with buf_len = 0 (or buf = NULL) is a probe — the kernel computes the size the SD would take and returns it without writing to the buffer. The probe returns the same size value that a non-probe call would; the caller can use this size to allocate exactly the right buffer.
The probe call always returns the size on success. It does not return -ERANGE — the probe is itself a question about size, and answering it is the kernel's job. -ERANGE would be the wrong signal for a deliberate probe.
A non-probe call with buf_len < required returns -ERANGE with the required size written somewhere accessible (typically the same length parameter, or via a separate output). The caller can then reallocate and retry.
Access rules #
Different components need different rights:
Requested component (via security_info flag) | Required right |
|---|---|
OWNER_SECURITY_INFORMATION | READ_CONTROL |
GROUP_SECURITY_INFORMATION | READ_CONTROL |
DACL_SECURITY_INFORMATION | READ_CONTROL |
SACL_SECURITY_INFORMATION | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | READ_CONTROL (the integrity label is in the SACL but its read is gated by READ_CONTROL, not ACCESS_SYSTEM_SECURITY) |
(The numeric flag values are catalogued in Other constants.)
READ_CONTROL is the standard "read SD" right and is implicitly granted to the owner. ACCESS_SYSTEM_SECURITY is gated by SeSecurityPrivilege — the SACL is read-restricted to administrators with the privilege.
A caller asking for components they do not have rights for gets -EACCES. A caller asking for a mix can succeed for the components they can access — but the kernel does this as an all-or-nothing operation: if any requested component fails its access check, the whole call fails.
For combining requested components: just OR the flags. kacs_get_sd(..., OWNER | DACL, ...) returns owner and DACL but not SACL or group.
SACL and LABEL are mutually exclusive #
SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined in one call. Setting both flags returns -EINVAL. The reasoning: LABEL_SECURITY_INFORMATION is a focused query for just the integrity label (which lives in the SACL); it has different access requirements than reading the full SACL. The kernel keeps the two paths separate.
kacs_set_sd #
The write syscall:
result = kacs_set_sd(dirfd, path, security_info, sd_buf, sd_len, flags)
| Parameter | Meaning |
|---|---|
dirfd, path | Target file. |
security_info | Which components to update. |
sd_buf, sd_len | The new SD bytes (self-relative format). |
flags | AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
The kernel:
- Resolves the path.
- Parses the SD blob. Rejects malformed SDs (size limit, bad ACL structure, etc.) with
-EINVAL. - Runs AccessCheck for the required rights per the components being updated.
- Validates additional rules — owner SID is the caller's own or a SE_GROUP_OWNER group (unless SeRestorePrivilege), MANDATORY-flagged resource attributes are not removed (unless SeTcbPrivilege), integrity label is not raised above caller's own (unless SeRelabelPrivilege).
- Writes the SD to the file's native storage.
- Returns 0 on success.
The write is atomic: either all requested components are updated or none are.
Access rules for writes #
| Component | Required right |
|---|---|
| Owner | WRITE_OWNER (plus the owner SID validation) |
| Group | WRITE_OWNER |
| DACL | WRITE_DAC |
| SACL | ACCESS_SYSTEM_SECURITY |
| LABEL (integrity label only) | WRITE_OWNER (plus integrity constraint) |
WRITE_DAC is the standard "modify DACL" right, implicitly granted to the owner. WRITE_OWNER is needed to change the owner field (and the validation rules apply per Ownership). ACCESS_SYSTEM_SECURITY is the SACL gate.
The integrity label #
LABEL_SECURITY_INFORMATION (0x10) is the focused write path for setting just the integrity label. The label lives in the SACL as a SYSTEM_MANDATORY_LABEL_ACE, but setting it via this flag is treated as a separate operation from setting the full SACL — with different access requirements:
- The right needed is
WRITE_OWNER, notACCESS_SYSTEM_SECURITY. - The caller cannot raise the integrity label above the calling token's own integrity level (without
SeRelabelPrivilege). - Lowering the integrity label to at or below the caller's own integrity level is allowed.
LABEL_SECURITY_INFORMATION and SACL_SECURITY_INFORMATION cannot be combined in one call — same rule as for reading.
The use case: a process that wants to lower its files' integrity labels without holding SeSecurityPrivilege. The label is in the SACL conceptually, but setting it gets the WRITE_OWNER gate rather than the SACL gate, because adjusting the label down is a less sensitive operation than rewriting the audit policy.
SD parsing and validation #
The provided SD blob must be:
- In self-relative format (
SE_SELF_RELATIVEflag set in control bits). - Within the 65,535-byte size limit.
- Internally consistent — the offsets in the header point to valid locations, the ACLs parse cleanly, the SIDs are well-formed.
Any failure of validation returns -EINVAL. The original SD on the file is unchanged.
Setting only some components #
The security_information flags tell the kernel which components of the provided SD to apply. A blob containing owner + DACL with only DACL_SECURITY_INFORMATION set updates only the DACL; the file's existing owner is preserved.
The blob structure must still be valid — components not being applied are typically absent (offset 0, PRESENT bit clear) in the blob, but the blob's header still needs to be a valid SD header.
This is the pattern for updating one part of an SD without touching the others. Read the SD (probe + fetch), update the relevant component, write back with only that component's flag set.
Ownership transfer rules #
Setting the owner via kacs_set_sd triggers the rules described in Ownership:
- The new owner must be the caller's own user_sid, or a SID in the caller's groups with
SE_GROUP_OWNERset, or the caller must holdSeRestorePrivilege. - The caller must have
WRITE_OWNERon the object, or holdSeTakeOwnershipPrivilege.
A failure here returns -EACCES.
The same rules apply whether you set ownership via OWNER_SECURITY_INFORMATION alone or in combination with other components.
What about file mode (the POSIX rwx bits)? #
The traditional POSIX file mode — chmod-style read/write/execute bits — does not exist in the same way under FACS. The Linux mode bits are stored alongside the SD as ordinary inode metadata, but they are not consulted by FACS for access control (except the execute bit as an exec prerequisite). The DACL is what decides access; the mode is informational only.
The mode-changing syscalls still work, gated on SD rights:
fchmod()succeeds only if the fd's granted mask includesWRITE_DAC(legacy opens request it as a compat right); otherwise-EACCES(-EBADFon O_PATH fds).chmod()runs a fresh access check requiringWRITE_DACon the file's SD.- In both cases the mode bits are updated but the SD is untouched. To change what actually decides access, call
kacs_set_sdwith a new DACL.
The mode is inert metadata; the SD is the truth.
Errors #
Common errors from both syscalls:
| Error | Cause |
|---|---|
-EACCES | Access check failed for the requested components. |
-EINVAL | Malformed SD blob, invalid security_information combination, size limit exceeded, or other validation failure. |
-EPERM | Owner SID validation failed without SeRestorePrivilege, or integrity label too high without SeRelabelPrivilege, or attempted MANDATORY attribute removal without SeTcbPrivilege. |
-ERANGE | (kacs_get_sd only) Buffer too small; required size returned. |
-ENOENT | The target path does not exist. |
-ELOOP | AT_SYMLINK_NOFOLLOW and the path is a symlink. |
Most failures are diagnostic and clear from the error code.
See also #
- Ownership — the owner-transfer rules kacs_set_sd enforces.
- The sd command — the shell wrapper for these syscalls.
- Opening files — supplying an SD at file creation instead.
Special cases
Peios / Peios Security Fundamentals / File access
The handle model handles most of file access cleanly. But files have edges — operations that bypass the model, semantics that don't fit, filesystems whose behaviour FACS cannot fully control. This page covers those edges.
Each of the special cases below is something that someone debugging FACS behaviour will eventually hit. Knowing the rules ahead of time keeps the gotchas from being surprises.
O_PATH — fds without a cached mask #
O_PATH is a Linux flag for opening a file in a "path-only" mode. The fd that comes back can be used to refer to the file (for openat(dirfd, ..., 0, fd), for fstatat, for path manipulation) but cannot be used for actual data operations.
Under FACS, an O_PATH open does not run a full AccessCheck. The fd is not FACS-managed; it has no cached granted mask. Operations that would normally consult the cache either work unconditionally (because they don't need any access) or use a fresh live AccessCheck (because they do):
| Operation on O_PATH fd | Behaviour |
|---|---|
fstat, fstatat | Works without access check. The fd is a path reference; getting stat info is allowed. |
openat using the fd as dirfd | Normal — runs AccessCheck for the new open. The dirfd's O_PATH status doesn't affect the new fd. |
kacs_get_sd with AT_EMPTY_PATH | Runs a live AccessCheck (the fd has no cached mask to consult). |
kacs_set_sd with AT_EMPTY_PATH | Same — live AccessCheck. |
read, write, mmap | Denied with EBADF. The fd cannot be used for data operations. |
fchmod, fchown, fgetxattr, fsetxattr, ioctl | Denied with EBADF. |
O_PATH is useful for path manipulation — keeping a reference to a directory while traversing the tree, or referring to a file by fd rather than by path. For these uses, the lack of a cached mask is irrelevant.
The catch: a process that wants to do anything with an O_PATH fd beyond path navigation needs to reopen it. The reopen is a fresh access check that decides what the resulting fd can actually do.
The exec dual gate #
Exec is the only operation in v0.20 that performs a use-time access check rather than relying on the cached mask. The reasoning: exec changes the process's identity (via the new binary's PIP) and effectively replaces it; the open-time decision is no longer the right one to use.
Specifically, execveat(AT_EMPTY_PATH) on an open fd:
- Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
- Requires both the
+xmode bit on the file andFILE_EXECUTEon the caller's access to be present.
The two checks answer two different questions:
+xon the file means this file is intended to be run as a program. It is a property of the file itself, set by whoever owns it — the file's author saying "this is an executable, not data". A file without+xis data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.FILE_EXECUTEon the access means this caller is allowed to execute this program. It is an access decision against the DACL — the question of whether this principal, on this token, is authorised to run this binary.
Both have to be true for exec to proceed. A data file (no +x) is not runnable by anyone; an executable file the caller is not authorised for is runnable in principle but not by this caller.
The dual gate is the v0.20 exception to the handle model. The reason for live evaluation here is that exec's consequences are large enough — the binary that runs is what the kernel will then trust at the verified PIP level — that running on a stale cache is the wrong default.
mmap(PROT_EXEC) is different from exec. It uses the cached mask (FILE_EXECUTE from the open), and additionally runs the LSV mitigation if enabled. mmap is not the same as exec; the dual-gate-with-live-check applies only to exec specifically.
Append-only handles #
A handle opened with FILE_APPEND_DATA granted but not FILE_WRITE_DATA is an append-only handle. The kernel enforces this strictly:
write()at the current offset is allowed only if the current offset is at end-of-file. Otherwise denied.pwrite()at any offset other than end-of-file is denied (RWF_APPEND-style).- Mapping the file shared writable (
mmap(MAP_SHARED, PROT_WRITE)) is denied. ftruncateto extend the file is denied (cannot rewind via truncate).fallocatemodes that would mutate (FALLOC_FL_PUNCH_HOLE, FALLOC_FL_COLLAPSE_RANGE, FALLOC_FL_ZERO_RANGE) are denied.
Operations that legitimately only append — POSIX write with O_APPEND, RWF_APPEND writes — work normally. The kernel makes sure the data lands at end-of-file.
The use case: log files that should be append-only. A logger holds a handle with FILE_APPEND_DATA but not FILE_WRITE_DATA; it can write log lines but cannot rewrite earlier ones. Even if the logger is compromised, the existing log entries are safe from modification through this handle.
Append-only is the FACS expression of the "secure log" pattern. It exists at the right-mask level, not as a separate file attribute.
sticky bit — no effect under FACS #
The traditional Unix sticky bit on a directory restricts file deletion within: only the owner of a file (or the owner of the directory) may unlink files in a sticky-bit directory. This is what makes /tmp work.
Under FACS, the sticky bit has no effect. Deletion is gated by FILE_DELETE_CHILD on the parent directory's SD. The sticky bit's semantics are not encoded.
The reason: FACS's access model is comprehensive enough that the sticky bit is unnecessary. A /tmp directory's DACL grants the directory's FILE_DELETE_CHILD to the appropriate principals (typically just the owner or the file owner, mediated through OWNER RIGHTS); this is the same effect the sticky bit had, expressed in the DACL.
For /tmp specifically, the Peios-default DACL provides equivalent semantics: each user can create files in /tmp (FILE_ADD_FILE granted to Authenticated Users), each file's owner can delete their own (FILE_DELETE_CHILD inherited per-file from OWNER RIGHTS ACEs), but a user cannot delete another user's files.
A directory whose mode includes the sticky bit (visible to ls -l as a t) is informational; the mode is derived from the SD by FACS. The bit can be set but has no enforcement consequence.
POSIX ACLs — replaced by KACS #
The Linux POSIX ACLs (set via setfacl and stored as system.posix_acl_access and system.posix_acl_default xattrs) are not honoured by FACS. They are replaced by KACS DACLs.
Specifically:
- Writes to
system.posix_acl_accessandsystem.posix_acl_defaultxattrs are unconditionally denied. - Existing POSIX ACL xattrs on files (carried over from a pre-Peios system, say) are ignored. FACS uses the KACS DACL exclusively.
The kernel will not silently translate POSIX ACLs to KACS DACLs. Migrating from a Linux system with POSIX ACLs requires re-writing the SDs in KACS form. Tools that can do this conversion exist in the migration tooling; the kernel does not do it on the fly.
fchown — gated on WRITE_OWNER #
The legacy fchown syscall changes a file's owner UID/GID. Under FACS, the family is permitted but gated on SD rights:
fchown()succeeds only if the fd's granted mask includesWRITE_OWNER; otherwise-EACCES.chown()andlchown()run a fresh access check requiringWRITE_OWNERon the file's SD.
The Linux uid/gid change does not alter the SD's owner SID. Changing the real owner goes through kacs_set_sd with OWNER_SECURITY_INFORMATION — the KACS semantics for ownership (covered in Managing file security) replace the legacy mode-and-owner duo.
The kernel surfaces the file's owner SID's projected UID as the result of stat-style queries, so ls -l shows a UID. But the UID is derived from the owner SID; setting it via fchown is not the way.
NFS — dual authority #
NFS client mounts are a special case. The underlying filesystem is on a remote server; the server enforces its own access control independent of what the client thinks. FACS on the client side cannot reach into the server's authorisation; it can only express its local view.
The pattern Peios uses: NFS client mounts are configured with the synthesize_ephemeral mount policy. The client synthesises a local SD for each file accessed (per the mount template), runs AccessCheck locally, and either authorises the operation or denies it. If authorised, the operation is forwarded to the server, which may then deny it for its own reasons.
The consequence: a locally-authorised open can still produce I/O errors when the server refuses. A caller may successfully open(O_RDONLY) against an NFS file (FACS's local synthesis decided to grant) but the read() returns -EACCES from the server.
This is "dual authority": the client and the server both have a say, and both have to permit. The client's denial is final on the client side; the server's denial is final on the server side. There is no single source of truth for what is allowed.
Other implications:
- Expect I/O errors from server-side denial. A successful open does not guarantee a successful read. Handle
EACCES(orEIO) from the operation, not just from the open. - The synthesised SD is local. Changes to the file's actual SD on the server are not visible to FACS's local synthesis. Tools that want to inspect the real SD need to query the server.
NFS server mounts (where Peios is the server) work the other direction: FACS enforces the SD on the local files; the protocol exposes it. The remote client's view of what is allowed is whatever the protocol negotiates, which may be limited by NFS-protocol-level restrictions but is ultimately decided by FACS.
/proc and /sys #
/proc and /sys are kernel-managed pseudo-filesystems. They are not FACS-managed in the standard sense — their mount policy is unmanaged, which means the FACS handle model does not apply.
What does happen:
- File operations under
/proc/<pid>/*route through the process's PSB / process SD checks (the two-check rule from PIP). Access is decided per-operation against the target process. /sys/kernel/security/kacs/*files have explicit SDs on them (set up by the kernel) and are accessed via the kernel's own check logic.- General
/procfiles (/proc/uptime,/proc/cpuinfo) are world-readable by convention. /syswrites are restricted toBUILTIN\AdministratorsandSYSTEMby hardcoded rule.
The result: operations on /proc and /sys work, but they don't go through the FACS handle model. The cached mask on the fd from opening one of these files is meaningless; the per-operation check is what gates access.
This is why cat /proc/self/status produces output without any apparent FACS involvement — the access is granted by the kernel's /proc-specific rules, and the kernel just makes data available.
The mount-policy classes are covered in Mount policies. unmanaged is the special class for kernel-managed filesystems; it cannot be set via the public ABI.
Whiteouts in renameat2 #
renameat2(RENAME_WHITEOUT) — the Linux-specific flag for creating a "whiteout" entry as part of a rename (used by overlay filesystems) — is supported on FACS-managed filesystems. The rename and the whiteout it leaves behind happen as one atomic operation, exactly as on stock Linux.
A whiteout is a chrdev(0,0) sentinel that overlay filesystems drop at the old name to mask a file in a lower layer. FACS authorises it the same way it authorises any new node: you need FILE_ADD_FILE on the source directory (where the whiteout lands), in addition to the usual rename rights. If you lack that right the whole rename is denied before anything is created, and the whiteout — like every freshly created node — inherits a security descriptor from its parent directory and is recorded in the audit trail.
This matters if you run overlayfs or union-style filesystems on FACS-managed storage: they work without the partial-support caveats earlier previews carried.
Summary table #
The special cases at a glance:
| Case | What is different |
|---|---|
| O_PATH | No cached mask; data operations denied; live check for SD ops |
| exec via execveat | Live AccessCheck plus Linux +x mode bit |
| Append-only handles | Write-at-end only; mmap-shared-writable and truncate denied |
| Sticky bit | No effect — the DACL is the gate |
| POSIX ACLs | Replaced by KACS; xattr writes denied |
| fchown / fchmod | Gated on WRITE_OWNER / WRITE_DAC; the mode bits are inert — the SD is the truth |
| NFS client | Dual authority; local FACS plus remote enforcement |
/proc and /sys | Unmanaged mount policy; per-operation check by kernel-specific rules |
| renameat2 RENAME_WHITEOUT | Supported; atomic, needs FILE_ADD_FILE on the source directory, whiteout inherits an SD |
Each is a deliberate decision. Some reflect Linux compatibility (POSIX ACLs replaced); some reflect security policy (exec dual gate, append-only enforcement); some reflect the model's limits (NFS dual authority). Knowing them ahead of time keeps them from being surprises.
Where to go next #
For the model these cases are edges of, read The handle model.
For how a mount's policy decides whether FACS applies at all, read Mount policies.
For the SD read/write syscalls referenced throughout, read Managing file security.
Confinement
Peios / Peios Security Fundamentals / Confinement
Confinement is the sandbox model in Peios. A confined application is one whose token carries a confinement identity — a single SID identifying which package or sandbox the application belongs to — and an enumerated list of capability SIDs naming what the sandbox is allowed to reach. The kernel enforces this confinement whether or not the code knows about it: the application cannot opt out, cannot disable it, cannot exercise a privilege to escape it.
Confinement is the answer to a specific question: how do you run code you do not fully trust on a system whose other resources you do trust? The answer is to give the code a token with a confinement identity and capabilities that name only what it should reach. The kernel then performs an absolute intersection: even if the code somehow obtains broader access through normal means, the confinement layer strips it back to what the capabilities permit.
Who confinement is for #
Confinement is administratively applied. A sysadmin deploys a service or installs an application under a confinement policy — the service definition declares the confinement SID and capabilities; the kernel enforces them. The confined code does not write the policy and cannot read it back to change it. From the application's perspective, confinement is just "the kernel will not let me reach X".
This is the key difference from restricted tokens. Restricted tokens are self-imposed — a program calls FilterToken to narrow its own authority before launching a sensitive operation. Confinement is externally imposed — the policy was decided before the application started and cannot be modified by the application.
The two coexist. A confined service can also internally restrict its own tokens for specific worker threads. The kernel applies both layers independently.
The three fields on the token #
Confinement state lives in three fields on the token:
| Field | Meaning |
|---|---|
confinement_sid | The package or sandbox identity. If null, the token is not confined and the confinement pass is a no-op. If non-null, the token is confined and the kernel will enforce. |
confinement_capabilities | An array of capability SIDs declaring what the confined application is allowed to reach. Used during the confinement intersection — entries here are the SIDs that match ACEs on objects the confined application is permitted to touch. |
confinement_exempt | A boolean escape hatch. When true, the confinement pass is skipped entirely. Set very rarely, only for code that legitimately needs to step outside its own confinement (a confined application's privileged helper, in some narrow patterns). |
All three are set at token creation and cannot be changed at runtime. authd is responsible for putting the right values on a confined application's token; once the token is minted, the fields are immutable.
The kernel imposes no special rule on ALL_APPLICATION_PACKAGES (S-1-15-2-1) at token creation: its presence in confinement_capabilities is what selects normal confinement mode, and its absence selects strict mode. The kernel neither synthesises it nor rejects tokens that carry it — authd decides which capabilities a package's token receives. See Capabilities and modes.
The model in one paragraph #
A confined token's effective access is the intersection of:
- What the DACL plus privileges would grant the token's full identity.
- What the DACL would grant a "fresh" caller whose entire identity is the confinement SID plus the declared capability SIDs.
In other words: the confined application has whatever rights its real identity has, and its confinement identity has, on every object it touches. If either side is missing, the right is dropped. The confinement layer is what makes the second condition real — without it, the token's full identity would be all that the access check considered.
The full mechanics of the intersection — when it fires, what it intersects against, what bypasses and what does not — are in The confinement pass. The capability matching specifically (how the SIDs in confinement_capabilities are compared against ACEs in DACLs) is in Capabilities and modes.
Why privileges do not bypass confinement #
The thing that makes confinement different from every other narrowing layer is its treatment of privileges. Restricted tokens preserve privilege-granted bits through the intersection. Confinement does not. A confined token with SeBackupPrivilege enabled and BACKUP_INTENT set still has its read access narrowed by the confinement intersection — the privilege grant survives steps 4 and 9 of the pipeline, then gets stripped at step 11.
The reason is the audience. A program that restricts itself is trusted to use privileges responsibly — it knows what it is doing because it wrote the restriction. A confined application is not trusted in the same way; the confinement policy came from outside, and a privilege that bypassed it would be an escape hatch for the confined code.
Confinement says: this code may run as whoever, with whatever privileges authd granted, but it cannot reach beyond the capabilities the administrator declared. Privileges are not the lever for breaking out.
What confinement is not #
A few things worth clarifying:
- Confinement is not a privilege-removal mechanism. A confined token can still carry privileges. The privileges fire normally in their kernel-standalone uses (a confined application with
SeShutdowncan shut down the system if nothing else gates the call). What confinement narrows is AccessCheck-influencing privileges specifically — the ones that grant bits via AccessCheck. The non-AccessCheck privileges work as usual. - Confinement is not a process-level firewall. It is a token-level intersection that runs during AccessCheck. It does not block network calls, ptrace, signals, or any other kernel surface that does not go through AccessCheck. Other layers (PIP, the process SD, kernel-standalone privilege checks) handle those.
- Confinement is not container isolation. It runs alongside the application's normal access surface, not inside a separate namespace. A confined application sees the same filesystem, the same registry, the same processes as everyone else — it just cannot exercise rights to most of them. Container-style namespace isolation is a separate concern.
- Confinement is not opt-in for the code. The code does not call a "please confine me" syscall. The token arrives confined; the kernel enforces.
When to use confinement vs other layers #
A quick map of when each narrowing layer is the right tool:
| Want this | Use this |
|---|---|
| A long-running service to run with less authority than its account would imply, set by administrative policy | Confinement |
| A program to internally drop authority for a specific sensitive operation, set by code | Restricted token |
| Centrally-defined organisational policy applied across many objects | CAAP |
| Prevent untrusted binaries from interfering with trusted ones | PIP |
| Block writes from lower-integrity to higher-integrity | MIC |
Confinement is for applications and services that need to be sandboxed by administrative decision. The capability model — "this application can reach the network, but not the filesystem outside its own data" — is what confinement expresses well.
Where to start #
If you want the capability model — how confinement capabilities match ACEs, the well-known and derived capability SIDs, and the difference between normal and strict modes — read Capabilities and modes.
If you want the mechanics of the confinement intersection — when it fires in the access pipeline, what does and does not bypass it, the role of confinement_exempt, and the isolation-boundary reservation — read The confinement pass.
If you want to understand the canonical Peios pattern for managing service access at scale — putting capability SIDs into a token's normal groups rather than (or in addition to) confinement_capabilities — read Positive confinement. The pattern is what most non-trivial deployments use, and the name is more misleading than the concept itself.
Capabilities and modes
Peios / Peios Security Fundamentals / Confinement
A confined application's reach is expressed as a list of capability SIDs on its token. Each capability is a named permission to use a kind of resource: network access, removable storage, the certificate store. The kernel matches the capability list against ACEs in the DACLs of objects the application touches; an ACE granting access to a capability the confined application has declared lets the access through, while objects whose DACLs do not mention any declared capability are unreachable.
This page covers the capability SID format, the well-known capabilities, how custom capabilities are derived from names, and the two confinement modes (normal and strict).
What a capability SID looks like #
All capability SIDs sit under the S-1-15-3-* namespace. There are two flavours:
- Well-known capabilities have small numeric sub-authorities (
S-1-15-3-1,S-1-15-3-2, etc.) assigned to specific named permissions. - Derived capabilities have eight sub-authorities computed from the SHA-256 hash of the capability name. The same name always produces the same SID. The hash is split into eight little-endian 32-bit values and appended to
S-1-15-3-.
The format itself does not distinguish the two. They are both S-1-15-3-...-shape SIDs; the access check matches them as plain SIDs.
The well-known capabilities #
| SID | Capability |
|---|---|
S-1-15-3-1 | internetClient — outbound internet access. |
S-1-15-3-2 | internetClientServer — inbound and outbound internet. |
S-1-15-3-3 | privateNetworkClientServer — LAN / private network access. |
S-1-15-3-8 | enterpriseAuthentication — domain credential access. |
S-1-15-3-9 | sharedUserCertificates — certificate store access. |
S-1-15-3-10 | removableStorage — removable media access. |
The SIDs at positions 4 through 7 are reserved and not used in v0.20. The well-known capability set is deliberately small — every additional well-known capability has to be defined globally and means the same thing on every Peios system. Capabilities specific to one application or one environment use the derived form.
The way these SIDs become meaningful is the same as any other SID: they appear in ACEs on objects whose DACLs grant access to them. The system's network stack, for example, has SDs on its endpoints that grant access to internetClient; a confined application carrying internetClient as a capability can reach those endpoints; one without it cannot. The capability SID is the link between the policy on the resource and the declaration on the token.
Derived capabilities #
For capabilities specific to an application or a deployment, the SID is derived from a string:
S-1-15-3-{h0}-{h1}-{h2}-{h3}-{h4}-{h5}-{h6}-{h7}
The eight 32-bit values come from SHA-256(name), split into eight 32-bit little-endian integers in order. The same name always produces the same SID. Two different names (even names that differ in a single character) produce different SIDs with overwhelming probability — SHA-256's collision resistance does the work.
A derived capability is meaningful when:
- The application's manifest declares the capability by name, so authd can compute the SID and put it on the token.
- The resources the capability protects have DACLs containing ACEs that grant rights to the same SID.
Both ends use the same derivation, so they meet at the same SID. The string name is human-readable convention; the kernel only ever sees the derived SID.
Derived capabilities are how an application-vendor or system integrator extends the model without coordinating with the OS. An application that needs access to a vendor-specific resource declares a vendor-specific capability name; the vendor's installer arranges for the resource's DACL to grant that capability; the two sides agree implicitly through the SHA-256 derivation.
Confinement modes — normal vs strict #
The well-known SID ALL_APPLICATION_PACKAGES (S-1-15-2-1) and the related ALL_RESTRICTED_APPLICATION_PACKAGES (S-1-15-2-2) define the two confinement modes. These are not capabilities themselves — they are matchers used in ACEs to grant access to all confined applications, or to confined applications in strict mode.
| Confinement mode | ALL_APPLICATION_PACKAGES matches the token? | ALL_RESTRICTED_APPLICATION_PACKAGES matches? |
|---|---|---|
| Normal | Yes | Yes |
| Strict | No | Yes |
The difference: an ACE granting rights to ALL_APPLICATION_PACKAGES lets every confined application reach the object in normal mode, but not strict-mode applications. An ACE granting rights to ALL_RESTRICTED_APPLICATION_PACKAGES lets confined applications in both modes through.
The mode is a property of the token at creation. It is encoded by whether ALL_APPLICATION_PACKAGES appears as one of the token's confinement_capabilities:
- If
ALL_APPLICATION_PACKAGES(S-1-15-2-1) is present in the token's confinement capabilities → normal mode. - If it is absent → strict mode.
There is no separate mode flag and no rejection rule — the mode is purely the presence or absence of the SID, and the kernel never synthesises or strips it.
The practical effect of strict mode: a strict-mode application can reach only objects whose DACLs explicitly grant access to its capabilities or to ALL_RESTRICTED_APPLICATION_PACKAGES. The much larger set of objects that grant to ALL_APPLICATION_PACKAGES are off-limits.
This is what "strict" means. Most operating-system resources whose policy grants broad access to confined applications use ALL_APPLICATION_PACKAGES; a strict application is choosing to opt out of those broad grants and rely only on its specific named capabilities.
When to choose strict over normal: when the application's threat model says it should not be able to reach resources whose authors granted broad access without specifically intending to include it. The trade-off is operational — strict applications often need their own specific capability grants on every resource they need, which is more work.
Capability matching during the confinement pass #
When the confinement pass (pipeline step 11) re-walks the DACL against the confinement identity, the matching rule is:
- The token's
confinement_sidmatches an ACE whose SID is the same. - Each entry in
confinement_capabilitiesmatches an ACE whose SID is the same. ALL_APPLICATION_PACKAGESmatches an ACE on that SID only if the token's capabilities include it (i.e. only in normal mode).ALL_RESTRICTED_APPLICATION_PACKAGESmatches an ACE on that SID always, in both modes.- Group attributes on the token's capabilities are ignored. The capability list is presence-based — what matters is whether a SID appears, not what its enabled/disabled state is.
The matching is bare SID equality. The capability identity does not carry a privilege bitmask, group membership semantics, or anything else. It is just a SID that names a kind of resource.
The full mechanics of the pass — what gets intersected, what bypasses — are in The confinement pass.
Capability declaration vs grant #
A small but important distinction: declaring a capability is not the same as having access to the resources it names.
A token's confinement_capabilities is the list of capabilities the confined application is allowed to use. Whether it actually gets access to a specific object still depends on the object's DACL — the capability SID has to appear in an allow ACE.
If a token declares internetClient but no network resource has an ACE granting internetClient rights, the capability declaration achieves nothing. Conversely, if a token does not declare internetClient but some objects have ACEs granting access to that SID, the confined application still cannot reach them — the capability has to be on the token and the ACE has to grant access to it.
The model is conjunctive: both the token-side declaration and the resource-side grant have to agree. Capabilities are a vocabulary; the DACLs are the actual permissions. Without both halves the access does not happen.
This page describes the declaration half — the appearance of capability SIDs in confinement_capabilities, where they are consumed by the confinement pass. Capability SIDs can also appear in a token's groups list, where they are consumed by the ordinary DACL walk and act as positive grants rather than confinement constraints. That convention — positive confinement — is how most non-trivial Peios deployments express service access. The capability vocabulary is the same; the placement on the token decides which mechanism uses it.
Capabilities and other narrowing layers #
Capabilities live in the confinement pass. They do not appear in:
- The normal DACL walk (step 8). The DACL walk uses the token's
user_sidandgroups, not itsconfinement_capabilities. Capabilities are invisible to step 8. - The restricted-token pass (step 10). Restricted tokens narrow against
restricted_sids, not against capabilities. A restricted-and-confined token gets both intersections. - CAAP evaluation (step 12). Central access policies do not match capabilities specifically; they match the token's normal SIDs.
The capability SID is only used in step 11. If you are writing an ACE that grants access to a capability, that ACE will only be relevant when a confined token is the caller. For a non-confined token, the ACE is just an entry with a SID nothing in the token matches.
Practical pattern: granting access to a capability #
A typical SD on a resource intended to be reachable by confined applications might look like:
- An ACE granting
BUILTIN\AdministratorsGENERIC_ALL. (Administrative full control.) - An ACE granting
SYSTEMGENERIC_ALL. (System full control.) - An ACE granting
Authenticated UsersGENERIC_READ. (Standard authenticated read.) - An ACE granting
internetClientFILE_READ_DATA | FILE_WRITE_DATA. (Confined applications with the internetClient capability can use it.)
A non-confined token reaches the resource through its normal identity (Authenticated Users, administrative groups, etc.). A confined token additionally needs the capability ACE to match a SID in its confinement_capabilities for the confinement pass to leave the access intact. The two sides — normal identity for the DACL walk, capability for the confinement pass — both have to grant.
The presence of capability ACEs on system resources is what makes confined applications usable in practice. Without them, every confined application would be locked out of everything that did not specifically know about it.
Where to go next #
For the mechanics of how these capabilities are matched at access-check time — what fires, what gets intersected, and what is preserved — read The confinement pass.
For the other use of the same capability SIDs — placed in groups as positive grants — read Positive confinement.
For the wider catalog of system-defined SIDs these capabilities sit alongside, read Well-known principals.
The confinement pass
Peios / Peios Security Fundamentals / Confinement
The confinement pass is the access-check step that enforces confinement policy. It fires at step 11 of the pipeline, after the DACL walk and the restricted-token pass have produced their results. The kernel runs the DACL one more time against the confinement identity — confinement_sid plus confinement_capabilities — and intersects the result with what the rest of the pipeline has so far granted.
The intersection is absolute. Any bit not present in both the running grant and the confinement-only grant is dropped. This is what makes confinement different from the restricted-token pass: there is no "privileges are restored after the intersection" step. What confinement removes stays removed.
This page covers the mechanics of the pass — when it fires, what the secondary walk does, what bypasses it, and the role of confinement_exempt and isolation_boundary.
When the pass fires #
The confinement pass fires when:
- The token's
confinement_sidis non-null, and - The token's
confinement_exemptflag is false.
If either is false, the pass is a no-op and the running grant passes through unchanged.
The pass is per access check. There is no caching of "this token always loses these bits" — the kernel runs the secondary walk every time, because the DACL on the object being accessed determines what the confinement identity would have been granted, and that varies per object.
What the secondary walk does #
flowchart LR
A["Running grant after step 10 (DACL + privileges + restricted intersection)"] --> X["Confinement intersect"]
B["DACL"] --> Y["Walk against confinement identity"]
Y --> X
X --> R["Running grant after step 11"]
The kernel runs a second DACL walk against the same DACL the normal walk ran against. But the matching identity is different:
| Element | Used in secondary walk |
|---|---|
| User SID | Confinement SID, in place of the token's user_sid. |
| Groups | Confinement capabilities (confinement_capabilities list), in place of the token's groups. Group attributes (enabled/disabled) are ignored — presence-based matching only. |
| Restricted SIDs | Ignored. The restricted-token pass already ran in step 10. |
| Owner SID | The object's owner SID, but owner implicit rights are not applied. A confined caller who happens to own the object does not get `READ_CONTROL |
| Mandatory label | The MIC pre-decisions from step 5 are not re-evaluated. They were applied before the DACL walk; they remain applied. |
In strict mode (the token does not carry ALL_APPLICATION_PACKAGES), an ACE granting rights to ALL_APPLICATION_PACKAGES does not match. In normal mode it does. ALL_RESTRICTED_APPLICATION_PACKAGES matches in both modes.
The walk produces a granted_confinement mask. The pipeline then computes:
granted = granted & granted_confinement
Any bit not in both sides drops out. No restoration step follows.
What confinement does not preserve #
The intersection drops bits regardless of where they came from. Specifically:
- Privilege-granted bits are dropped. Bits granted by
SeBackupPrivilege,SeRestorePrivilege,SeSecurityPrivilege, orSeTakeOwnershipPrivilegein steps 4 or 9 are subject to the intersection. If the confinement DACL would not have granted them, they are lost. This is the major distinction from the restricted-token pass. - Owner implicit rights are dropped if the confinement identity is not the owner. Step 8 granted the owner
READ_CONTROL | WRITE_DAC. The confinement secondary walk does not apply owner implicit rights to a non-matching identity — the confinement SID is what is being walked, and it is not the owner. So the owner's implicit rights are lost.
The owner-implicit-rights case is the surprising one. A confined application running as a user who owns an object cannot read or modify the SD of that object unless the DACL specifically grants the confinement identity those rights. The fact that the user is the owner does not help — the confined identity is what counts at step 11.
What confinement does preserve #
A handful of decisions are not re-evaluated in step 11:
- MIC decisions from step 5. Whatever MIC pre-decided as denied at step 5 stays decided. Confinement does not undo MIC; it adds on top.
- PIP decisions from step 5. Same.
- The token's identity for non-AccessCheck purposes. Confinement does not change
user_sidorgroupsfor any kernel API other than AccessCheck. A confined process still appears as its original user ingetpwuid-style queries, in/proc/<pid>/status, and in any other identity-display surface.
The intersection is purely an AccessCheck mechanism. It does not change who the process is; it changes what AccessCheck lets the process do.
confinement_exempt #
The confinement_exempt flag on a token is the escape hatch. When true, step 11 is skipped entirely: the running grant from step 10 passes through unchanged. The token is still confined in the sense that confinement_sid is set — it just is not enforced.
The flag is set very rarely. The intended use case: a privileged helper that runs alongside a confined application and needs to step outside the confinement for specific operations. Both halves share the user identity; the helper has confinement_exempt set so it can reach resources the confined main application cannot.
confinement_exempt is set at token creation by authd and cannot be changed at runtime. Like every other token field, the flag is immutable once minted.
The flag does not affect the rest of the pipeline. Steps 0 through 10 run normally on a confinement_exempt token. The flag short-circuits only step 11. PIP, MIC, the DACL walk, restricted-token narrowing, and CAAP all still apply.
isolation_boundary #
The token has a fourth confinement-related field: isolation_boundary. It is reserved in v0.20 — the kernel reads it but does not enforce it. The semantic the field is reserved for: an additional layer on top of confinement where objects outside the boundary are made invisible rather than just denied. A confined application with isolation_boundary set would see only objects whose policy granted to its boundary; everything else would appear not to exist (rather than appearing and being denied).
The distinction matters in two cases:
- Enumeration. A confined application listing the contents of a directory should see only objects in its boundary, not "denied" entries for objects outside.
- Existence checks. A confined application calling
staton a path outside its boundary should get "no such file" rather than "permission denied".
The full mechanics — what "outside the boundary" means, how it interacts with the FACS handle model, how object enumeration is filtered — are reserved for a future version. In v0.20, isolation_boundary is a no-op; the field is on the token for forward compatibility.
For now, treat it as unused. Tokens that need invisibility-instead-of-denial semantics will need an updated kernel to enforce them.
Composition with other narrowing layers #
A confined token can also be restricted, and can also be accessing a CAAP-bound object. All three narrowing layers (restricted at step 10, confinement at step 11, CAAP at step 12) fire in order. Each is a strict intersection. The final granted mask is the conjunction of:
- Whatever the DACL walk + privileges produced (steps 4–9).
- Whatever the restricted-token walk would have produced (step 10, if
restricted_sidsis non-empty). - Whatever the confinement walk would have produced (step 11, this page).
- Whatever every applicable CAAP rule's effective DACL would have produced (step 12).
For each step that is active, the running grant is narrowed by the intersection. For each that is not, the grant passes through.
The order matters in one subtle way: privileges are restored after the restricted-token pass but not after the confinement pass. A bit that survived step 10 because privileges restored it can still be dropped at step 11 if confinement does not grant it. The privilege rescue is partial.
See Narrowing layers for the composition rules across all three intersections.
A worked example #
A service is deployed under confinement. Its token has:
user_sid = jellyfin_user_SIDgroups = [jellyfin_user_SID, BUILTIN\Users, Authenticated Users, Everyone]confinement_sid = S-1-15-2-<jellyfin-package-hash>(the package identity — a hash-derived SID, not a well-known one)confinement_capabilities = [S-1-15-3-1 (internetClient), S-1-15-3-10 (removableStorage), S-1-15-2-1 (ALL_APPLICATION_PACKAGES — normal mode)]privileges = [SeChangeNotifyPrivilege, SeCreateSymbolicLinkPrivilege](default-grant set; nothing else)
The service tries to open /var/state/services/jellyfin/library.db for reading. The file's SD:
- Owner:
jellyfin_user_SID - DACL:
- ACE 1:
ACCESS_ALLOWED Authenticated Users GENERIC_READ - ACE 2:
ACCESS_ALLOWED ALL_APPLICATION_PACKAGES GENERIC_READ
- ACE 1:
The access check:
- Steps 0–4: no impersonation issue, SD valid, generic mapping expands GENERIC_READ to FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL, no privileges applicable.
- Step 5: no MIC label (default Medium / NO_WRITE_UP — does not block read), no PIP label. Nothing pre-decided.
- Step 6: virtual group injection.
OWNER RIGHTSis added (the token owns the file). But the object's DACL has no OWNER RIGHTS ACE, so the implicitREAD_CONTROL | WRITE_DACwill be granted at step 8. - Step 8: owner implicit grants READ_CONTROL | WRITE_DAC. The DACL walk grants FILE_READ_DATA et al. via ACE 1 (Authenticated Users match). The grant is comprehensive.
- Step 10: restricted-token pass skipped (no restricted_sids).
- Step 11: confinement pass. The secondary walk runs against the confinement identity.
- The confinement SID matches no ACE (the SD has no ACE on the package SID).
- The capability
internetClientmatches no ACE. - The capability
removableStoragematches no ACE. - The capability
ALL_APPLICATION_PACKAGESmatches ACE 2, granting GENERIC_READ-expanded bits. - Owner implicit rights are not applied (the confinement identity is not the user_sid).
- Result:
granted_confinement = {FILE_READ_DATA, FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE}. - The intersection with the running grant: the running grant had owner-implicit READ_CONTROL | WRITE_DAC plus the user-granted read bits. The intersection keeps the read bits and READ_CONTROL but drops WRITE_DAC (which the confinement identity would not have been granted).
- Step 12: no CAAP.
- Result: the service can read the file but cannot modify its DACL even though it owns the file. The owner implicit grant is gone because confinement does not preserve it.
This is the expected behaviour. The service runs as the user who owns its library file but does not get owner-style authority on the file because the confinement layer specifically removed it.
Where to go next #
For the convention that turns the same capability SIDs into positive grants — the canonical pattern for service access at scale — read Positive confinement.
For how the confinement intersection composes with the restricted-token and CAAP intersections, read Narrowing layers.
Positive confinement
Peios / Peios Security Fundamentals / Confinement
Positive confinement is a convention, not a kernel feature. It is the practice of taking capability SIDs — the same S-1-15-3-* SIDs that name a confinement capability — and placing them in a token's normal groups list rather than its confinement_capabilities list. The kernel does not distinguish capability SIDs from any other SIDs in the groups list; they participate in the ordinary DACL walk like every other group. The result is that the capability acts as a positive grant — "this token has this capability, and any ACE that grants rights to this capability grants those rights to this token" — rather than as a confinement constraint.
The name is awkward. "Positive confinement" is not about confinement at all in the access-narrowing sense. It is named after the kind of SID involved (capabilities, which come from the confinement model), with "positive" marking the opposite effect — grant rather than restrict. Once you have the convention in mind it makes sense; on first encounter the name does not help. Reading it as "positive use of capability SIDs" is the cleanest paraphrase.
This page covers what the convention is, why it exists, and how it composes with standard confinement.
A capability SID is just a SID #
The capability SIDs documented in Capabilities and modes — S-1-15-3-1 (internetClient), S-1-15-3-10 (removableStorage), and the derived capabilities produced from SHA-256 of capability names — are normal Peios SIDs. They follow the SID format; they compare with byte equality; they appear in ACEs like any other SID; they can be present on a token in any of the SID-bearing fields.
The kernel does not have a "capability SID" type tag. The format does not distinguish them from other SIDs. What makes a capability SID a capability is the namespace convention (S-1-15-3-*) and the way administrators choose to use it, not any special handling.
So when you see a capability SID in a token, the question is not "is this a capability". The question is which field on the token is it sitting in. The answer is what determines how the access check uses it.
Placement determines effect #
A token has several SID-bearing fields. Two of them are commonly the home of capability SIDs:
| Field | What happens to a capability SID placed here |
|---|---|
confinement_capabilities | The capability is consumed by the confinement pass at pipeline step 11. The capability matches ACEs only inside the confinement intersection — its role is to narrow what the confined token can reach. |
groups | The capability is consumed by the normal DACL walk at pipeline step 8. The capability matches ACEs in the ordinary first-writer-wins evaluation — its role is to grant the token whatever rights the ACE specifies. |
Standard confinement uses the first column. Positive confinement uses the second. The same SID, the same DACL, but a very different result.
A token can carry the same capability SID in both fields, or in either, or in neither. The kernel does not check for consistency between the two — the fields are independent.
There are also other SID-bearing fields on the token (the restricted_sids list, for example). Capability SIDs are not commonly placed there today, but nothing in the format prevents it; if a future convention emerges, the kernel will treat the SID like any other in that field. The model is open in this respect: a capability SID is reachable by every mechanism that walks any SID list on the token.
The two effects, compared #
A worked-through contrast on the same DACL.
The DACL on a resource contains one ACE:
ACCESS_ALLOWED S-1-15-3-1 (internetClient) GENERIC_READ
Case 1: token has internetClient in confinement_capabilities.
The token's normal identity (user_sid, groups) does not match the ACE, so step 8's DACL walk grants nothing. The token reaches step 11 with an empty grant. The confinement intersection re-walks the DACL against the confinement identity — and the capability list — and finds the ACE matches. The intersection produces a grant of GENERIC_READ-mapped bits. But the running grant from step 8 was empty, so the intersection yields empty too.
Result: no access. The capability said "the confined application is permitted to use this resource if its own identity reaches it", and the token's own identity didn't.
Case 2: token has internetClient in groups.
The token's groups include the capability SID. The normal DACL walk at step 8 matches the ACE against the group SID and grants GENERIC_READ-mapped bits. The confinement pass — if even active on this token — re-walks the DACL against confinement_sid and confinement_capabilities; if the token is not confined, step 11 is a no-op.
Result: access granted. The capability acted as a normal group membership — "the token is a member of the internetClient-bearing group, the DACL grants access to that group, the token gets access".
Case 3: token has internetClient in both.
Step 8 grants because the group SID matches. Step 11 (if confined) re-walks against the confinement identity and finds the same match — the capability is in confinement_capabilities, the ACE grants to the capability, the intersection passes. The grant survives.
Result: access granted, with the confinement intersection satisfied. This is the pattern when a token is genuinely confined and needs to reach a specific capability-gated resource through the confinement layer.
The kernel did the same thing in all three cases — it walked the SIDs it had in the fields it had them in, against the same DACL. The different outcomes are entirely a function of placement.
Why this convention exists #
In a deployment with a handful of services and a few resources, the obvious model is to give each service a dedicated user account and write ACEs naming that user. jellyfin reads /var/state/services/jellyfin/, owned by jellyfin, with a DACL granting jellyfin full access. Add a new service, create a new user, write new ACEs.
At any kind of scale this breaks down. A dozen services each touching a few dozen shared resources is hundreds of ACE entries to maintain, and every new service requires editing the DACL of every resource it touches. Service accounts become a sprawl of nobody-style entries that exist only to appear in ACL lists.
Positive confinement is the alternative pattern. Instead of:
- A
jellyfinuser, mentioned in every media-file DACL. - A
transmissionuser, mentioned in every download-directory DACL. - A
nextclouduser, mentioned in every shared-storage DACL.
You define capabilities — semantic units of access — and grant ACEs to those:
- A
media-library-readcapability. Every media file's DACL grantsGENERIC_READto this capability. - A
download-writecapability. Every download directory's DACL grantsGENERIC_WRITEto this capability. - A
shared-storagecapability. Every shared-storage object's DACL grants to this capability.
Then each service is given the capabilities it needs as entries in its token's groups. Adding a new media-handling service does not require modifying every media file's DACL — the DACL already grants media-library-read; the new service just needs the capability on its token.
The administrative model becomes: services consume capabilities; resources grant capabilities; the directory maintains which services have which capabilities. ACLs on shared resources are stable. Adding a service is a token-policy change, not a DACL-rewriting exercise.
This is what positive confinement is for. Most non-trivial Peios deployments use it. Dedicated service accounts continue to exist for cases where the per-service identity is genuinely meaningful (a service whose data should be exclusively its own), but for shared resources reached by multiple services, capability-style positive grants are the canonical pattern.
Positive confinement does not bypass confinement #
This is the most important rule about composition and it is worth saying directly: positive confinement does not bypass confinement. If a token is confined, putting a capability SID in the token's groups does not exempt the resulting access from the confinement pass at step 11. The DACL walk at step 8 may grant rights through the capability appearing in groups, but the confinement intersection still runs, and if the confinement identity (confinement_sid plus confinement_capabilities) does not match the same ACE, the grant is dropped.
The kernel does not look at the SID and reason about its "intent". It runs each pipeline step with the inputs that step uses. Step 8 uses the token's groups. Step 11 uses the token's confinement_capabilities. A capability SID present in one but not the other satisfies one step but not the other, and an intersection that fails at any active step removes the rights.
Practically: on a confined token, positive confinement and standard confinement are complementary, not alternatives. To reach an object via a capability, both halves need to be in place:
- The capability SID in
groups, so the normal DACL walk at step 8 grants access. - The capability SID also in
confinement_capabilities, so the confinement intersection at step 11 preserves the grant.
Either half on its own grants the confined token nothing. Putting the capability only in groups produces a step-8 grant that step 11 strips. Putting it only in confinement_capabilities keeps step 11 happy but step 8 grants nothing for the intersection to preserve.
The pattern, then, on a confined service's token: the same capability SID appears in both places. The DACL grants to the capability; the token bears it twice; both checks pass.
For tokens that are not confined — which is many of them, especially in standalone or smaller deployments — only the groups half matters. The confinement pass is a no-op for an unconfined token; the normal DACL walk is the only check that runs against the capability. This is the "positive confinement without confinement" case: the capability SIDs are used as ordinary groups for access management, and the confinement layer never enters the picture.
What this is not #
A few clarifications, because the name encourages misreading:
- It is not a bypass of confinement. Placing a capability SID in a confined token's
groupsdoes not exempt the resulting access from step 11. The confinement pass still fires; the grant from the DACL walk is still subject to the intersection. See "Positive confinement does not bypass confinement" above. - It is not a separate access-check pass. There is no "positive confinement evaluator" in the kernel. The DACL walk at step 8 finds the capability SID like it finds any other group SID. No new mechanism, no new code path, no special handling.
- It is not the same as confinement. A token with capability SIDs only in groups (no
confinement_sid) is not confined. The confinement pass is a no-op. The token has the broad access its identity grants; the capabilities sit alongside as additional group memberships. - It is not opt-in for the resource. A resource whose DACL grants access to a capability does not need to know whether the caller is using the capability positively or as a confinement entry. The DACL says "this SID gets these rights"; whoever has the SID in a relevant field gets the access.
- It is not a kernel feature you can disable. Because there is no feature flag — only a convention about where you put capability SIDs on tokens — there is nothing to turn off. Sites that prefer dedicated service accounts simply do not use capability SIDs in groups.
A worked deployment #
A Peios machine runs three services that all read the user's music library: jellyfin, mpd, and a future streaming-bridge. The administrative pattern is positive confinement.
The DACL on /data/media/music/ (and every file under it):
ACCESS_ALLOWED SYSTEM GENERIC_ALLACCESS_ALLOWED BUILTIN\Administrators GENERIC_ALLACCESS_ALLOWED music-library-read GENERIC_READ(the capability SID, derived from "music-library-read" via SHA-256)
The directory policy in authd configures each service's token to include the music-library-read capability SID in its groups:
jellyfin's token:groups = [jellyfin_user_SID, music-library-read, network-server, ...]mpd's token:groups = [mpd_user_SID, music-library-read, audio-output, ...]streaming-bridge's token:groups = [streaming-bridge_user_SID, music-library-read, network-server, ...]
Each service is a different user identity. Each service has the capability through its groups. Each service can read the music library.
The administrator adds a fourth music-handling service. The new service's token policy includes music-library-read in groups. The DACL on /data/media/music/ is not touched. The service starts reading the library immediately.
If the deployment additionally confines the services for sandboxing, the same capability SID also appears in each service's confinement_capabilities. The DACL ACE for music-library-read is unchanged. The token now has the SID in two places, and both passes find it. Access still works.
This is the canonical scale pattern. Capabilities are the vocabulary of access; tokens consume capabilities; resources grant capabilities; administrative policy decides who gets what. No DACL rewrites when new services arrive; no proliferation of per-service users in every shared-resource ACL.
Where to go next #
For the intersection that still applies when a confined token carries capabilities in groups, read The confinement pass.
For the ordinary DACL walk that consumes capability SIDs placed in groups, read DACL evaluation.
Process integrity protection
Peios / Peios Security Fundamentals / Process Integrity Protection
Process integrity protection — PIP — is the access-control layer that gates one process's ability to operate on another. Where the DACL and tokens decide "is this principal allowed?", PIP decides "is the calling binary trusted enough?". A PIP-protected process — peinit, authd, the kernel's own helpers — cannot be debugged, signalled, suspended, or read from by code running at lower trust. Even root cannot.
The protection is rooted in the binary's signature. At exec, the kernel verifies the new program against its public-key catalog and assigns the resulting process a two-dimensional trust label — pip_type and pip_trust — that records what kind of trust the binary established and at what level. From then on, every cross-process operation against this process compares the caller's label to this one.
This is not the same axis as identity. Two processes running as the same user can have different PIP labels because they are running different binaries. A user-mode process and a TCB daemon running as SYSTEM are at different PIP levels; the user-mode process cannot interfere with the daemon even though both run as the same principal. The barrier is not who, it is what.
Where PIP lives #
Each process has a Process Security Block — the PSB — attached by the kernel. The PSB carries:
| Field | Meaning |
|---|---|
pip_type | The PIP type. One of None, Protected, Isolated. |
pip_trust | The PIP trust level. A numeric tier within the type. |
security_descriptor | The process SD — see The process security descriptor. |
| Mitigation flags | The process's enabled security mitigations, including NO_CHILD (whether the process may fork) — see Process mitigations. |
The PSB is not the token. The token says who the process is acting as; the PSB says what kind of process it is. The two have different lifecycles, different fields, and are read by different parts of the access pipeline. A thread impersonating a user changes its effective token but not its process's PSB. The PSB is fixed once the binary execs.
The 2D trust model #
PIP's trust is encoded as two numbers, deliberately not collapsed into a single ordering:
| Axis | Range | What it means |
|---|---|---|
pip_type | 0 (None), 512 (Protected), 1024 (Isolated) | The kind of trust. None = unprotected; Protected = the standard PIP-protected process; Isolated = reserved for future use. |
pip_trust | numeric (0, 1024, 1536, 2048, 4096, 8192 in v0.20) | The tier of trust within a type. Higher numbers are more trusted within the same type. |
The 2D model exists because trust is not a single line. A signed application from a third-party developer is trusted for what it is — its publisher attested to it — but is not at the same trust as a Peios TCB binary that has been signed by the OS itself. They have different types of trust, and within each type, there can be tiers.
In v0.20 the catalog is small — the full list of S-1-19-T-L label SIDs is in Well-known SIDs. The shape of the ladder, by example:
| SID | type | trust | What it represents |
|---|---|---|---|
S-1-19-0-0 | None (0) | 0 | Unprotected. Default for unsigned processes. |
S-1-19-512-2048 | Protected (512) | 2048 | Peios-distributed applications. |
S-1-19-512-8192 | Protected (512) | 8192 | Peios TCB (peinit, authd, loregd, lpsd, eventd). |
The Isolated type (1024) exists for a future where some processes need to be protected from even the rest of the TCB. In v0.20 it is documented for forward compatibility; no binary will receive this type.
Most processes on a running system have pip_type = None. PIP protection is opt-in via signing — most user-mode applications run unsigned and unprotected. The PIP-protected processes are the kernel's specifically-signed daemons.
How PIP gets assigned #
The kernel sets pip_type and pip_trust at exec time and never changes them afterwards:
- The kernel reads the binary about to be exec'd.
- It looks for a signature — an ELF
.peios.sigsection or asecurity.peios.sigxattr. - It verifies the signature against its compiled-in public key catalog.
- On a valid signature, the PIP fields are set to whatever level the catalog says the signing key represents.
- On no signature, invalid signature, or any verification failure,
pip_typeis set to None (0) andpip_trustto 0. The exec still succeeds — bad signatures do not block execution, they only fail to grant PIP protection.
Once exec completes, the fields are immutable for the lifetime of that process. fork() inherits the parent's PIP fields. exec() recomputes them from the new binary.
The signing and verification mechanics — what the signature format is, how the catalog works, why bad signatures do not block exec — are in Binary signing.
What PIP gates #
PIP fires on every cross-process operation that requires the caller to inspect or affect another process:
- Signals (kill, signal delivery).
- ptrace and its kin (memory read/write, attach, single-step).
- pidfd_open, pidfd_getfd, pidfd_send_signal.
- Reading or writing
/proc/<pid>/*for processes other than oneself. - Opening another process's tokens (
kacs_open_process_token,kacs_open_thread_token). - Setting process attributes (priority, affinity, rlimits).
- Profiling another process (
perf_event_open).
In every case, the kernel runs the two-check rule: the calling process must pass both an SD check (the target's process SD must grant the requested right to the caller's token) and a PIP dominance check (the caller's PIP label must dominate the target's). Both must succeed. Neither alone is enough.
The full mechanism is in The two-check rule. What matters for the overview: PIP does not replace identity-based access control, it sits alongside it. A PIP-protected process can still be administered by an SD that grants the rights; the SD just is not the whole story.
What PIP does not do #
A few clarifications that come up:
- PIP is not encryption. A PIP-protected process's memory is in plaintext. The kernel does not encrypt it or seal it; it just refuses ordinary access channels to it. A compromised kernel or a hardware-DMA attack bypasses PIP entirely.
- PIP is not capability isolation. Two PIP-protected processes at the same level can interfere with each other freely. The level is the boundary, not the identity.
- PIP is not a sandbox. A PIP-protected process is not contained — it is protected from containment-bypassing code. PIP protects high-trust processes from low-trust ones, not the other way around.
- PIP is not user-configurable. There is no API to upgrade a process's PIP level at runtime. The signature catalog is in the kernel; only re-execing a different binary changes the level.
- PIP does not require the binary to be running as a particular user. A user-mode app run by an administrator and signed at TCB level would still be a TCB-level process from PIP's perspective. The signature is what matters, not who started it.
The cleanest mental model: PIP is "what kind of program is this, and who is allowed to mess with it?". The token is "who is this program acting as, and what can they reach?". Each answers a different question. Both run.
Where to start #
If you want the process SD specifically — the SD that lives on the PSB and gates operations on the process — read The process security descriptor.
If you want the dominance comparison and the two-check rule — the rule that every cross-process operation runs both an SD check and a PIP dominance check — read The two-check rule.
If you want the edge cases — how PIP interacts with impersonation, how it differs from MIC, why peinit ends up being the lifecycle manager for PIP-protected processes, and what the model does not cover — read PIP in practice.
The process security descriptor
Peios / Peios Security Fundamentals / Process Integrity Protection
A process is an object the kernel protects, like any other. It carries a security descriptor — the process SD — that defines who can do what to this process. Where the token's SD controls who can do what to the token (read, duplicate, impersonate), the process SD controls who can act on the running process itself: signal it, debug it, inspect its memory, change its priority, open its tokens.
The process SD sits on the PSB, not the token. The token's SD is a separate field, governing token operations. The two are distinct objects with distinct policies. A change to one does not affect the other.
This page covers what the process SD contains, the access rights it uses, how it is created, and how it is modified. The dominance pairing — how the process SD plus PIP forms the two-check rule — is in The two-check rule.
What the process SD contains #
The process SD has the same shape as any other SD — owner, primary group, DACL, optional SACL. The fields mean the same things they mean elsewhere (see Security descriptors). What differs is the access rights the DACL grants.
The DACL on a process SD uses the process access rights — a set of 16-bit object-specific rights tailored to what one might do to a process. The full catalog with bit values is in Access mask bits; in summary:
| Right | What it gates |
|---|---|
PROCESS_TERMINATE | Send terminating signals (SIGKILL, SIGTERM, SIGABRT, etc.). |
PROCESS_SIGNAL | Send non-terminating informational signals (SIGCHLD, SIGURG, SIGWINCH, etc.). |
PROCESS_VM_READ | Read the process's memory. Required by ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads. |
PROCESS_VM_WRITE | Write the process's memory. Required by ptrace(PTRACE_POKE*, PTRACE_ATTACH), process_vm_writev, /proc/<pid>/mem writes. |
PROCESS_DUP_HANDLE | Duplicate file descriptors out of the process via pidfd_getfd. |
PROCESS_SET_INFORMATION | Change process attributes — scheduling priority, CPU affinity, rlimits, certain /proc/<pid>/* writes. |
PROCESS_QUERY_INFORMATION | Read detailed process information — token, full /proc/<pid>/* reads (cmdline, status, io, limits, sched, mounts). |
PROCESS_SUSPEND_RESUME | Send stop/continue signals (SIGSTOP, SIGCONT). |
PROCESS_QUERY_LIMITED | Read limited process information (PID, image name, basic state). Required by pidfd_open. |
Plus the standard rights — DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE — and the special rights ACCESS_SYSTEM_SECURITY and MAXIMUM_ALLOWED. These mean the same things on a process as on any other object.
PROCESS_TERMINATE and PROCESS_SIGNAL are split because terminating signals are operationally different from informational ones. A monitoring tool might be allowed to send SIGCHLD-style signals without being allowed to kill the process. This is the kind of decomposition the process rights catalog makes possible.
Generic mapping #
The standard generic rights (GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE, GENERIC_ALL) map to combinations of process-specific rights via the process GenericMapping table, cataloged in Access mask bits.
A DACL ACE that grants GENERIC_READ to some principal effectively grants them the right to read the process's memory, query its detailed information, and read the SD itself. A DACL granting GENERIC_EXECUTE lets them terminate or suspend the process. These are the abstractions tools use when they say "grant read access to this process" without enumerating every specific right.
How the process SD is created #
A new process's SD is set up at process creation. The path:
- At fork, the child inherits a copy of the parent's process SD. The child's process is a different object from the parent's, but the SD is structurally the same.
- At exec, if the new binary's signing changes the PIP fields significantly (in particular, if the new PIP type is non-zero where the old was zero, or if a
KACS_IOC_INSTALLof a different user-identity token happens between fork and exec), the kernel may regenerate the process SD from a default template based on the new token's identity. The intent is that a process running a TCB binary should have a TCB-flavoured process SD, not whatever its parent had. - The default template, when used, grants the process's user identity
PROCESS_QUERY_LIMITEDandPROCESS_QUERY_INFORMATION, grantsBUILTIN\AdministratorsandSYSTEMthe right to inspect and signal, and denies everything else to ordinary callers.
For typical user processes, the SD that ends up on the PSB is the inherited copy of the parent's. For TCB processes — the daemons signed at TCB trust level — the SD is regenerated to reflect the role of the process.
How the process SD is modified #
The process SD on a running process can be updated via kacs_set_sd on the PSB, just like setting an SD on a file. The caller needs WRITE_DAC on the target process (for the DACL) and the appropriate rights for owner, group, and SACL changes. The standard SD modification rules from Security descriptors apply.
A common use case: a service whose SD needs to be tightened after startup. The service launches with a default SD that lets the supervisor inspect and signal it; once the service is in steady state, it tightens its own SD to remove some of those rights, restricting which external callers can affect it.
Another use case: a process voluntarily allowing another to debug it. Adding an ACE granting PROCESS_VM_READ | PROCESS_VM_WRITE to a specific helper SID lets that helper attach a debugger without administrator intervention.
The process SD does not affect PIP. Changing the SD does not change the PIP fields. The two are independent — the SD-on-PSB gates "who is allowed" while PIP gates "what trust level is allowed". A debugger added to the SD still needs to PIP-dominate the target to actually debug it.
What the process SD does not do #
A few clarifications:
- The process SD does not gate the process's own access to objects. It controls operations targeting the process. What the process itself can do is governed by its token, not by its own SD. A process can be
PROCESS_TERMINATE-denyed by its own SD (other processes can't kill it) while still being free to do whatever its token allows. - The process SD does not replace PIP. A non-PIP-protected process can have a restrictive SD; an inspector granted
PROCESS_VM_READby the SD can still read its memory if PIP permits. A PIP-protected process needs both the SD grant AND PIP dominance — see The two-check rule. - The process SD does not control file access. Each open file has its own SD; the process's SD is unrelated to what files the process can open.
- The process SD does not control token operations. The token has its own SD; opening or duplicating tokens goes through that, not the process SD (although the token operations also check process-level rights as a wrapper —
kacs_open_process_tokenrequiresPROCESS_QUERY_INFORMATIONon the process AND the appropriate right on the token).
The cleanest mental model: the process SD answers "may I (the caller) signal, debug, or inspect that process?". Everything else about the process's behaviour, identity, and authority lives in other SDs or in the token.
Where to go next #
For how the process SD pairs with PIP dominance on every cross-process operation, read The two-check rule.
For the general SD model these fields come from — owner, DACL, SACL, and the modification rules — read Security descriptors.
The two-check rule
Peios / Peios Security Fundamentals / Process Integrity Protection
When one process wants to act on another — to signal it, debug it, read its memory, change its priority, or open its tokens — the kernel runs two checks. The SD check asks "is the caller allowed by the target's process SD?". The PIP dominance check asks "is the caller's PIP label at least the target's?". The operation proceeds only if both checks pass. Either one failing is enough to deny.
The two-check rule is the rule that gives PIP its bite. Without it, an administrator's token would let any administrator-run code reach into a TCB daemon and read its memory; with it, the calling binary's trust level becomes a separate gate that no identity, no group, no privilege can bypass.
This page covers the rule itself: what each check looks at, how the dominance comparison works, what privileges can and cannot bypass, and how specific kernel operations map onto the rule.
The two checks, side by side #
flowchart LR
A["Cross-process operation"] --> B["SD check: process SD vs caller's token"]
A --> C["PIP check: caller's PSB vs target's PSB"]
B -->|grants the required right| D["AND"]
B -->|denies| F["Operation denied"]
C -->|caller dominates target| D
C -->|caller does not dominate| F
D --> E["Operation allowed"]
Both checks run at the entry point of the operation. They are not sequenced — there is no "first the SD check, then PIP". The kernel reads both inputs and computes both results; the operation proceeds only on both passing.
| Check | Reads | Compares | Decides |
|---|---|---|---|
| SD check | Target's process SD; caller's effective token | Standard AccessCheck pipeline against the SD's DACL | Which process rights the caller has on the target. |
| PIP dominance | Caller's PSB (pip_type, pip_trust); target's PSB | All-or-nothing comparison: caller's type ≥ target's type AND caller's trust ≥ target's trust | Whether the caller is trusted enough to act on the target. |
Each check is independent. A caller can pass one and fail the other. The operation result is pass_SD AND pass_PIP.
The dominance comparison #
PIP dominance is two-axis, conjunctive. The caller's PSB carries pip_type and pip_trust. The target's PSB carries the same fields. The caller dominates the target if and only if:
caller.pip_type >= target.pip_type AND
caller.pip_trust >= target.pip_trust
Both inequalities must hold. Both holding is dominance. Either failing is non-dominance.
A few non-dominance cases worth pinning:
- Same type, lower trust. A Protected/1024 caller does not dominate a Protected/8192 target. Same trust kind, but the target is more trusted.
- Higher trust, lower type. A None/8192 caller (which would be weird in v0.20 but legal in principle) does not dominate a Protected/0 target. The trust level is higher numerically, but the type is lower; both have to dominate independently.
- Different types, neither higher. A Protected/4096 caller does not dominate an Isolated/0 target. Protected (512) is less than Isolated (1024), so the type comparison fails. Even though the trust number is higher, the type fails the AND.
The comparison is not ordered. There is no "single PIP score" that says "you are more trusted than that other process". A caller can be more trusted on one axis and less on another, and the dominance check declines to compare further — without dominance on both axes, the operation is denied.
This two-axis structure is what lets the model distinguish "Authenticode-signed third-party app" from "Antimalware tool" — both are Protected type, but one's purpose is different from the other's, and they need not be ordered. The trust tier reflects the role; the type reflects the kind of authority.
The non-dominance default #
A caller with pip_type = None (the unsigned, default state) dominates only targets that also have pip_type = None. They cannot reach any Protected or Isolated target. Most user-mode processes are in this state — they can interfere with each other freely, but not with the TCB daemons.
A caller with pip_type = Protected dominates other Protected processes at the same or lower trust tier, and dominates None processes entirely. But not Isolated.
A caller with pip_type = Isolated (none in v0.20) dominates everything — Protected and None at any trust level, plus other Isolated processes at the same or lower trust.
The most common protective barrier is the None-to-Protected boundary. User-mode applications (pip_type = None) cannot signal, debug, inspect, or modify the running TCB processes (pip_type = Protected). This is the practical security boundary PIP creates.
What SeDebugPrivilege bypasses #
SeDebugPrivilege is the closest thing to an override for the SD check, but it does not override PIP.
The privilege, when present and enabled on the caller's token:
- Bypasses the SD check for cross-process operations. A caller holding the privilege does not need the target's SD to grant them the required process rights; the SD check is treated as passing regardless of what the DACL says.
- Does not bypass the PIP dominance check. A caller holding the privilege still must dominate the target's PIP. If they do not, the operation fails.
This is the most important rule about SeDebugPrivilege. The privilege gives the holder the ability to debug arbitrary user processes, but it does not give them the ability to reach into the TCB. A debugger with SeDebugPrivilege can attach to any user-mode process they can find; it cannot attach to a TCB daemon. The privilege handles identity-based gating; PIP handles trust-based gating, and the privilege has no power over the trust axis.
Tools that need to debug TCB processes need a different mechanism — typically running as a TCB binary themselves (i.e., a debugger signed at TCB level). There is no privilege that opens this door. The signature is the only path.
What other privileges do not bypass #
A few other privileges are worth noting:
SeTcbPrivilegedoes not bypass PIP either. The privilege grants a lot — token creation, mount-policy administration, central-policy distribution — but it operates within the PIP boundary. A TCB caller (holdingSeTcbPrivilege) is typically also a TCB-trust-level process anyway, so the dominance is satisfied by virtue of the binary, not the privilege.SeBackupPrivilegeandSeRestorePrivilegedo not bypass PIP. A backup tool granted access to read another process's memory via the SD check still needs to dominate PIP.SeImpersonatePrivilegedoes not bypass PIP. Impersonation changes the effective token; it does not change the PSB. A high-trust client's impersonation token does not promote the impersonating server's PIP level.
The pattern: no privilege in the catalog bypasses PIP dominance. PIP is the trust ceiling that operates above the privilege model. It is the answer to "what if a malicious administrator gets a privileged token?" — the answer is "they still cannot reach the TCB, because the TCB is at a higher PIP level than any binary they could run".
The operations the rule applies to #
The two-check rule fires on every cross-process operation. The full list of kernel surfaces it covers:
| Operation | Process right needed | Notes |
|---|---|---|
kill, signal delivery | PROCESS_TERMINATE, PROCESS_SUSPEND_RESUME, or PROCESS_SIGNAL (by signal class) | PIP dominance also required. Classified by default disposition — see Signals in detail for the full rules, the self-exemption, and the kernel-originated bypass. |
kill -STOP, kill -CONT | PROCESS_SUSPEND_RESUME | PIP dominance required. |
ptrace(PTRACE_ATTACH), ptrace(PTRACE_POKE*) | PROCESS_VM_WRITE | PIP dominance required. SeDebug bypasses the SD; not PIP. |
ptrace(PTRACE_PEEK*) | PROCESS_VM_READ | Same. |
PTRACE_TRACEME | PROCESS_VM_WRITE on caller's SD; PIP dominance of the nominated tracer over the calling process | Reverse-flavoured of the others. |
pidfd_open | PROCESS_QUERY_LIMITED | PIP dominance required. |
pidfd_getfd | PROCESS_DUP_HANDLE | PIP dominance required. |
process_vm_readv, process_vm_writev | PROCESS_VM_READ, PROCESS_VM_WRITE | Route through the ptrace check. |
/proc/<pid>/mem read/write | PROCESS_VM_READ / PROCESS_VM_WRITE | Same. |
/proc/<pid>/ basic reads (stat, comm, wchan) | PROCESS_QUERY_LIMITED | PIP dominance required. |
/proc/<pid>/ detailed reads (cmdline, status, io, limits, sched, mounts) | PROCESS_QUERY_INFORMATION | PIP dominance required. |
/proc/<pid>/ writes (sched, oom_adj, coredump_filter) | PROCESS_SET_INFORMATION | PIP dominance required. |
sched_setaffinity (cross-process) | PROCESS_SET_INFORMATION | PIP dominance + SeIncreaseBasePriorityPrivilege. |
setpgid (cross-process) | PROCESS_SET_INFORMATION | PIP dominance required. |
getpgid, getsid (cross-process) | PROCESS_QUERY_LIMITED | PIP dominance required. |
perf_event_open (target-specific) | PROCESS_QUERY_INFORMATION | PIP dominance required + SeProfileSingleProcessPrivilege. SeDebug bypasses SD only. |
capget(pid) (cross-process) | PROCESS_QUERY_INFORMATION | PIP dominance required. |
kacs_open_process_token | PROCESS_QUERY_INFORMATION on the process AND the rights on the token | PIP dominance required. |
kacs_open_thread_token | Same | PIP dominance required. |
In every case, two checks: process SD grants the right, and caller's PIP dominates target's. Failing either denies.
Same-process operations #
The two-check rule applies only to cross-process operations. When a thread acts on its own process — opening its own token, reading its own memory, signalling itself — neither check fires. A process can always operate on itself.
This is what makes the model usable. PIP is a boundary between processes, not a constraint on what a process can do internally. A TCB daemon can read its own memory, signal itself, modify its own token state without going through the two-check rule.
What happens on failure #
When the two-check rule denies an operation, the failure mode depends on the operation:
- Signal delivery returns
-EPERM. - ptrace returns
-EPERM(or-ESRCHin some no-trust-revealing paths). - pidfd_open,
pidfd_getfdreturn-EACCES. - /proc/
/ * reads return-EACCESor, in some cases, hide the directory entry entirely (the kernel does not reveal information about processes a caller cannot reach). - kacs_open_process_token returns
-EACCES.
The kernel deliberately does not always distinguish "you do not have the right" from "this process does not exist" — for some sensitive operations, returning the same error in both cases prevents a low-trust caller from learning about the presence of high-trust processes.
Signals in detail #
Signal delivery is the two-check operation with the most edge cases, so the rules deserve spelling out.
Which right a signal needs is decided by its default disposition. Signals whose default action terminates the target — SIGTERM and SIGKILL, but also SIGHUP, SIGINT, SIGUSR1/2, SIGPIPE, and every realtime signal — require PROCESS_TERMINATE. The job-control set (SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU, SIGCONT) requires PROCESS_SUSPEND_RESUME. Signals ignored by default (SIGCHLD, SIGURG, SIGWINCH) require PROCESS_SIGNAL. The kill(pid, 0) existence probe delivers nothing and requires PROCESS_QUERY_LIMITED — it is a query, and it is treated as one.
Self-signaling is exempt, structurally. A thread signaling its own process — raise(), abort(), pthread_kill() to a sibling thread — never runs either check. This does not rely on the default process SD granting the process access to itself: it holds even for restricted or confined tokens that would fail an access check against their own SD. A sandboxed process can always abort itself.
Kernel-originated signals bypass the checks entirely — there is no sending process to check. This covers hardware faults (SIGSEGV, SIGBUS), kernel notifications (SIGCHLD on child exit, SIGPIPE on broken pipe), and — the case worth pausing on — terminal-generated job control. When you press Ctrl-C, the tty driver signals the foreground process group; no permission check runs, and the interrupt reaches the foreground processes even if they are more privileged or more PIP-trusted than you. This is deliberate: authorization for keyboard signals is possession of the controlling terminal (gated by the terminal's file SD when it was opened), and a privileged process that attaches to your terminal has chosen to take input from you. Note the limit: this path only carries the terminal's own signals — nobody can use it to deliver an arbitrary kill().
Group kills are checked per target. kill(-pgid, ...), kill(0, ...), and kill(-1, ...) evaluate each candidate process independently, deliver to the subset the caller may signal, and succeed if at least one delivery happened. A mixed-privilege process group (an elevated member in your pipeline) gets partial delivery — the same behavior Linux exhibits for mixed-uid groups.
There is no same-session SIGCONT exception. POSIX carves out "any process may SIGCONT members of its own session"; Peios does not implement it — SIGCONT needs PROCESS_SUSPEND_RESUME like the rest of the job-control set. In practice the default process SD's user-SID entry covers resuming your own stopped jobs; the divergence only surfaces when resuming a same-session process whose identity no longer matches yours.
si_pid and si_uid are informational. The sender identity carried in siginfo_t is the sender's projected UID captured at send time, plus a PID that may have been recycled by the time you read it. Like SO_PEERCRED, these are for logging and display, never for authorization.
Why both checks exist #
It is reasonable to ask: why not collapse the two checks into one? Either the SD knows about the trust level, or PIP knows about the SD; pick one.
The answer is that the two checks model different things and have different administrators:
- The SD says "who, by identity, can act on this process". An object owner adjusts this DACL like any other. The rights are operationally meaningful (signal vs read vs debug) and are the natural unit for fine-grained access control.
- PIP says "what kind of binary can act on this process". The kernel sets this based on the binary's signature; no administrator can adjust it directly.
If you only had the SD, every binary that ran with the right token could reach any process. Privilege isolation would not exist; an administrator's debugger could attach to the TCB. Conversely, if you only had PIP, fine-grained per-process access control would be impossible; the TCB would be all-or-nothing.
The two together give you both: identity-based control with PIP as a hard ceiling above it. The combination is the security model.
Where to go next #
For the practical consequences of the rule — the impersonation asymmetry, peinit as lifecycle manager, and the v0.20 limitations — read PIP in practice.
For where PIP's checks sit in the wider AccessCheck pipeline, read Access decisions.
PIP in practice
Peios / Peios Security Fundamentals / Process Integrity Protection
The PIP model is small once you have the dominance rule and the two-check rule in hand. But the practical consequences of those two rules ripple outward in ways that are not obvious from the rules themselves. PIP's asymmetric relationship with impersonation; the operational pattern that ends up forcing peinit to be the lifecycle manager for every PIP-protected process; the contrast with MIC; the threat-model ceiling above which PIP simply does not protect; and the v0.20 limitations of the implementation.
This page covers each.
PIP and impersonation #
PIP reads the PSB, not the effective token. Impersonation changes the effective token; it does not change the PSB. The consequence: impersonating a high-trust identity does not raise a process's PIP level.
A worked example. A user-mode application — pip_type = None — accepts a connection from a TCB daemon. The TCB daemon connected at Delegation level. The application calls kacs_impersonate_peer and now has the TCB daemon's identity installed as its effective token. The token is at SYSTEM integrity, with TCB-flavoured group memberships, with many privileges enabled.
What can the application now do?
- Access checks that use the effective token (file access, registry access, regular DACL evaluation) succeed with the TCB daemon's authority. The application can open TCB-only files.
- Access checks that use the PSB (PIP dominance for cross-process operations) still see the application's None-level PSB. The application cannot signal or debug TCB daemons — its PIP did not change.
The asymmetry is deliberate. PIP is a property of the binary running, not of who the binary is acting as. Allowing impersonation to raise PIP would let any service that ever accepts a TCB-level client effectively become TCB-level for the duration. The model is built on the idea that the binary is what is trusted, and a binary cannot lend its trust through impersonation.
The same asymmetry applies in reverse: if a TCB daemon impersonates a user-mode client (rare; usually it just acts as them at the file-access level), the TCB daemon's PSB is still TCB-level. The impersonation does not lower its PIP.
MIC vs PIP #
PIP and MIC look superficially similar — both compare a numeric level on the caller against a level on the object, both block when the caller is below. The two diverge in several ways that matter.
| MIC | PIP | |
|---|---|---|
| What it labels | Tokens (integrity_level) | Processes (pip_type, pip_trust) on the PSB |
| What it reads at access time | Effective token (impersonation-visible) | PSB (impersonation-invisible) |
| Axes | 1 (integrity level) | 2 (type and trust) |
| Default | Medium with NO_WRITE_UP for unlabelled objects | None — objects are PIP-unrestricted unless they opt in via a label ACE |
| Bypassed by privileges? | Privilege-granted bits survive | No — privilege-granted bits are revoked for non-dominant callers |
| Default policy | Block writes from below | Only the rights in the trust-label ACE's mask are permitted; everything else is denied |
The two layers complement each other. MIC is the integrity axis for token-driven access (where impersonation matters); PIP is the trust axis for process-driven access (where the binary matters). An access can be blocked by either, or allowed by both.
The most consequential difference is the privilege handling. MIC respects privilege grants; PIP does not. A backup tool with SeBackup granted to it can read across MIC boundaries (read a higher-integrity file) because privileges preserve bits through MIC. The same backup tool cannot read across PIP boundaries — SeBackup does not grant the read on a PIP-protected object if the caller does not dominate. The privilege grant is stripped at the PIP check.
This is the security model's load-bearing assertion: privileges are not enough. A privileged but untrusted binary cannot bypass PIP-protected objects.
peinit as the lifecycle manager #
A PIP-protected process can only be signalled by a process that PIP-dominates it. For the TCB daemons — Protected/8192 — that means the signalling process needs at least Protected/8192. Which means it needs to be a TCB-signed binary itself.
In practice, the only process that fits this description and exists for the express purpose of managing other processes is peinit. peinit is signed at TCB level, runs at PIP Protected/8192, and is the system's init-equivalent.
The consequence: every PIP-protected process's lifecycle — start, restart, shutdown — flows through peinit, because no other binary can send the relevant signals. An ordinary administrator cannot use systemctl stop authd (or its Peios equivalent) directly; they ask peinit to do it. Even SIGTERM from a shell would be denied if the shell is not PIP-dominating.
This is the lifecycle manager pattern. It is not enforced by a specific kernel mechanism (peinit is not "marked" as the lifecycle manager); it falls out naturally from the PIP rule. peinit happens to be the only thing that can talk to TCB processes.
Practical implications:
- Tooling that interacts with the TCB sends commands to peinit, not directly to the TCB processes.
- Restart loops are implemented in peinit. If a TCB daemon crashes, peinit is the entity that decides whether and how to restart it — no external supervisor can.
- Service-management tools for non-TCB services can be more direct, because non-TCB processes are not PIP-protected and ordinary signal-sending works.
Privileges and PIP, in detail #
The relationship between privileges and PIP comes up enough to be worth pinning. The general rule: privileges do not bypass PIP. The specific cases:
SeDebugPrivilege: bypasses the SD check on cross-process operations. Does not bypass PIP. A privileged debugger can attach to any user-mode process; it cannot attach to TCB processes.SeBackupPrivilege/SeRestorePrivilege: grants read/write bits through AccessCheck. Bits granted by these privileges are subject to PIP — a non-dominant caller has them stripped during the PIP check for PIP-protected objects. (The grant happens at step 4 of AccessCheck; the PIP strip happens at step 5.)SeImpersonatePrivilege: lets a service impersonate clients. Does not affect PIP; the PSB stays the same. The impersonated token may carry MIC at a higher level (capped by the integrity ceiling), but PIP does not move.SeTakeOwnershipPrivilege: grantsWRITE_OWNERregardless of DACL. Subject to PIP — a non-dominant caller cannot take ownership of a PIP-protected object even with the privilege.SeTcbPrivilege: gates TCB operations (token creation, mount-policy administration). Held only by TCB processes anyway, which are PIP-dominant by virtue of their signing. The privilege does not extend PIP authority.
In short: every privilege the catalog defines operates within PIP, not above it. There is no privilege that bypasses PIP. The trust ceiling is absolute from the privilege model's perspective.
The threat-model ceiling #
PIP's protection is bounded by what the kernel can enforce. Specifically:
- A compromised kernel voids PIP. If an attacker has code running in the kernel (e.g., via a malicious or buggy kernel module), they can read PSBs directly, modify memory directly, and bypass every PIP check. The defence against this is
CONFIG_MODULE_SIG_FORCE=y— only signed kernel modules are accepted — but that defence is itself a precondition for PIP being meaningful. - Hardware DMA bypasses PIP. A device that can perform DMA can read or write any physical memory. The defence is IOMMU configuration, which is a kernel concern, not a PIP concern.
/dev/memaccess bypasses PIP if writable. Even reading/dev/memgives access to physical memory. The defence isCONFIG_STRICT_DEVMEM=y, which restricts/dev/memto I/O regions only.- PIP does not provide cryptographic memory isolation. Two PIP-protected processes share the same physical memory and the same kernel. PIP is logical, not cryptographic; a kernel compromise sees through it.
These are not bugs in PIP — they are the kernel's responsibility, sitting above the PIP layer. PIP is the right defence for "a buggy user-mode application that an attacker is running"; it is not the right defence for "an attacker who has kernel code execution".
For a deployment where the threat model includes kernel-code attackers, PIP is insufficient on its own and must be paired with hypervisor-style isolation. That is a Peios-v2 concern, not a PIP concern.
What v0.20 does not do #
A handful of PIP limitations specific to v0.20 are worth knowing:
- No per-binary revocation. A signed binary later found malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel's signing key catalog. There is no hash-based revocation list.
- The Isolated PIP type is reserved.
pip_type = 1024is documented and present in the model but no v0.20 signing key targets it. No binary will have this type until a future version defines its use. - Scripts are not signed. A script's PIP comes from its interpreter, not the script itself. A signed TCB interpreter running an untrusted script runs the untrusted code at TCB level. This is why interpreters are not signed at TCB unless the script content is itself trusted.
- Only one signing key in v0.20 (the TCB key). Lower-trust tiers (App, Authenticode) are reserved in the catalog but no key exists for them in v0.20. All signed binaries on a v0.20 system are TCB binaries. App-level and Authenticode-level signing is future work.
- PIP visibility in
/proc. PIDs and directory names remain visible in/procgetdents()listings regardless of PIP — the kernel reveals that processes exist, even when their data cannot be read. Only file access within/proc/<pid>/is gated. A future version may filter directory listings as well. - Coredumps are disabled for PIP-protected processes. A PIP-protected process's coredump is a potential secret leak; the kernel sets dumpability to false at exec and refuses to re-enable it via
prctl(PR_SET_DUMPABLE, 1)whilepip_type != None. The defence is mandatory.
These are limitations of the implementation, not the model. The model accommodates revocation, additional types, signed scripts, and richer visibility filtering; they just aren't there yet.
When PIP is the right tool #
A few patterns where PIP is the answer:
- Protecting kernel-adjacent daemons. authd, loregd, eventd, and other TCB components must not be reachable by ordinary user code, even by administrators. PIP is the layer that enforces this. Their signing at TCB level produces processes that no non-TCB caller can interfere with.
- Anti-malware tools that need to be hard to disable. A security daemon signed at the AntiMalware tier (
pip_type = Protected,pip_trust = 1536in the catalog) is protected against attempted disablement by malware running at lower trust. - Cryptographically signed third-party services that need protection from local administrators. A licensed-software service whose vendor has signed it at the App or Authenticode tier can be configured to refuse modification by anyone other than at the same or higher tier.
And patterns where PIP is not the answer:
- Sandboxing untrusted code. PIP protects high-trust processes from low-trust ones, not the other way around. To restrict what an untrusted process can do, use confinement or restricted tokens.
- Authorising users for specific operations. PIP is about binaries; identity-based authorisation is the DACL's job.
- Network access control. PIP does not gate network operations; nftables, the network stack, and the SDs on network endpoints do.
The cleanest mental check: ask "is the policy I want to express about what kind of program may do this?". If yes, PIP. If it is about who may do this, the DACL is the answer.
Where to go next #
For how a binary comes to carry a PIP level at all — signatures, verification at exec, and pinning — read Binary signing.
For the rule that gives PIP its bite on every cross-process operation, read The two-check rule.
Binary signing
Peios / Peios Security Fundamentals / Binary Signing
A signed binary carries a cryptographic signature that lets the kernel decide what trust level a process should run at when it execs that binary. The signature is the gate to process integrity protection — every PIP-protected process is PIP-protected because the binary it is running was signed at the appropriate trust level. There is no other way to acquire PIP. No syscall, no privileged operation, no runtime escalation. The signature is it.
The signing model is built on a single principle: signing only adds trust; it never blocks execution. An unsigned binary still runs — it just runs as pip_type = None, unprotected. A binary with a malformed or invalid signature also runs as None. The signature is the kernel's way of recognising "this binary has been blessed at this level by someone we trust"; its absence or invalidity is "we did not recognise this binary, so we will treat it as untrusted". That is different from "we are not going to let it run". Permissiveness is the rule that distinguishes signing from anti-virus.
What signing is for #
Signing serves one purpose: it lets the kernel assign a PIP type and trust level at exec. The kernel reads the binary, finds (or doesn't find) a signature, verifies it against its compiled-in public-key catalog, and sets the new process's pip_type and pip_trust accordingly. From that moment on, PIP enforcement uses those fields.
This is what PIP-protected processes need. peinit, authd, loregd, lpsd, eventd — the TCB daemons — are signed with the TCB key during image build. When the kernel execs them, it sets pip_type = Protected and pip_trust = 8192. Once running, no non-TCB caller can interfere with them because no non-TCB caller dominates their PIP label.
Without signing, the model has nowhere to anchor. There would be no way for the kernel to know that one binary is the real authd and another is malware impersonating it. The signature is the attestation that links a binary on disk to a trust level the kernel will assign at exec.
Where signing fits in the OS #
Signing is a kernel concern, not a userspace one. The kernel:
- Reads the signature blob from the binary.
- Verifies it against the compiled-in public-key catalog.
- Sets the
pip_typeandpip_trustfields on the new process's PSB.
Userspace tools — typically the image builder, peiso — produce the signature at build time. They have the private key, compute the hash, generate the signature blob, attach it to the binary, and ship it. After that, the private key must not be present on any running Peios system. Verification is public-key only; the only credential a running system needs is the public key, which is compiled into the kernel image.
The split between userspace signing and kernel verification means a Peios system can never sign new binaries itself. Distribution of new signed binaries happens through the package system, with signatures produced offline by whoever holds the relevant private key.
The permissiveness rule #
The kernel's behaviour when a signature is absent or invalid is the design decision that distinguishes signing from anti-virus.
| Condition at exec | Result |
|---|---|
| No signature at all | Process runs at pip_type = None, pip_trust = 0. |
| Signature present, version byte unrecognised | Same. |
| Signature present, key not in catalog | Same. |
| Signature present, but bytes do not verify (hash mismatch) | Same. |
| Signature present and verifies against a catalog key | Process runs at the PIP level the catalog entry specifies. |
The first four rows all produce the same outcome: the exec succeeds, the process runs at None. The fifth row produces a PIP-protected process. In no case does the exec fail because of signing.
The rationale: signing is the layer that adds trust to specific binaries. It is not the layer that decides "only these binaries may run". A user-mode application not signed at any level should still run; it should just not have PIP protection. The mechanism that decides what may run at all is mount policy (FACS), file permissions (DACL), and capabilities (KACS). Signing is a separate axis that sits on top.
This is why signing does not have a "policy" you configure to "require all binaries to be signed". The kernel does not enforce that. The signing layer itself is permissive.
The corollary is that mmap(PROT_EXEC) is not permissive — that is one of the Process mitigations (LSV, Library Signature Verification), and it enforces signing for libraries loaded into a PIP-protected process. But that is the mitigation, not the signing layer. Signing decides PIP at exec; LSV decides what libraries may then be loaded.
What signing does not do #
A few clarifications:
- Signing does not authenticate the user. A binary signed at TCB level run by an ordinary user is still a TCB-level process from PIP's perspective. The user is not the signer; the signer is whoever owned the private key when the binary was built. Identity-vs-trust is the two-axis story.
- Signing does not prevent execution. A binary that fails verification still runs. The signing layer does not gate exec; it gates PIP.
- Signing is not revocation. A previously-trusted binary later found to be malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel's public-key catalog. There is no hash-based revocation list in v0.20.
- Signing does not verify provenance. The signature confirms that whoever held the corresponding private key produced this exact bytes-on-disk. It does not confirm where the binary came from, who built it, or whether it does what its name suggests.
- Signing does not handle scripts. A script is not a signed binary; the script's PIP comes from the interpreter's PIP. See Verification and pinning.
What the user sees #
For most users, signing is invisible. A binary built into the Peios image — peinit, authd, the rest — is signed by peiso during build, and the user never sees the process. When they exec these binaries (or peinit execs them on the user's behalf), the kernel verifies the signature and sets PIP, and the binary runs as expected.
For binaries distributed through the package system, the package's .peipkg is itself signed at the package layer (different from the binary-signing layer this topic covers), and the binary inside it may or may not carry a Peios-format signature for PIP purposes. In v0.20, only TCB binaries do. Future versions may add Authenticode-style App and third-party tiers.
For binaries the user produces — a compiled program, a self-built tool — there is no signature, exec runs them at pip_type = None, and they get no PIP protection. They are still bound by all the other access control layers; they just are not high-trust.
Where to start #
If you want the on-disk format — what the signature blob actually looks like, where it lives in an ELF binary, how non-ELF files carry it — read Signature format.
If you want the kernel's behaviour at exec — how verification proceeds, the stable-snapshot rule, what content pinning does after a binary is verified, and how scripts and interpreters interact — read Verification and pinning.
If you want the key management story — where the TCB key lives, how peiso uses the private key at build time, the constraints on key handling — read Keys and image build.
Signature format
Peios / Peios Security Fundamentals / Binary Signing
The signature blob is the same shape regardless of where it sits — 3310 bytes, structured as a version byte followed by an ML-DSA-65 signature. What changes is where the blob lives and what bytes it covers. ELF binaries hold the blob in a dedicated section; non-ELF executables hold it in an extended attribute or alongside the file. The hash being signed is computed differently for each.
This page covers the blob's structure, where it can live, and how the content hash is computed for each placement.
The signature blob #
Every Peios signature is exactly 3310 bytes:
| Bytes | Field | Meaning |
|---|---|---|
| 0 | Version | 0x01. The only version supported in v0.20; any other value is rejected. |
| 1–3309 | ML-DSA-65 signature | A 3309-byte raw ML-DSA-65 signature. The thing the kernel verifies. |
The version byte is what gives the kernel room to evolve the format. A signature with version 0x02 (for example) would be treated as unrecognised in v0.20 — the kernel does not panic, it just declines to verify the signature, and the process runs at pip_type = None. Future versions may define new layouts; current versions will see them as if they were absent.
The signature is computed over a 32-byte content hash (covered below) using pure ML-DSA — FIPS 204's ML-DSA.Sign — and not HashML-DSA. The pre-hash variant HashML-DSA is for cases where the signer hashes the message first; here the signer treats the 32-byte hash as the message and signs it directly. There is no double-hashing.
The FIPS 204 context string is always empty. Signers must not set one, and a signature produced with a non-empty context will not verify. This matters if you are building your own signing tool: most ML-DSA libraries accept an optional context parameter, and leaving it at its default (empty) is what Peios expects.
The 3309-byte signature is the raw ML-DSA-65 signature encoding — no ASN.1 wrapper, no envelope, no metadata. The kernel parses it as raw bytes and feeds them straight into the verification routine.
ML-DSA is a post-quantum signature scheme, so a signature is considerably larger than the elliptic-curve signatures you may be used to. That size is why the blob is 3310 bytes rather than the 65 an Ed25519 design would need, and it is the reason the parameter set is ML-DSA-65 rather than the larger ML-DSA-87 — see Keys and image build.
Where the blob lives #
A signature blob can live in three places, depending on the file:
| Placement | When used |
|---|---|
ELF section .peios.sig | ELF binaries. The signature lives inside the file. |
security.peios.sig xattr | Non-ELF binaries. Also a fallback for ELF binaries without a .peios.sig section. |
Detached .sig file | Used during image build for non-ELF executables. The image builder reads the detached file and stamps the xattr. |
The order matters. For ELF binaries the kernel looks for the .peios.sig section first; if found, the xattr is not consulted as a fallback. The ELF section is the canonical location.
For non-ELF binaries — scripts (which are themselves not signed; see Verification and pinning), data files used by tools, anything that does not begin with the ELF magic — only the xattr is consulted.
Detached .sig files are a transient form. They exist on the file system being assembled by peiso (the image builder) so that the builder can stamp the xattr from them. By the time the image boots, detached files are not used; the xattrs are what the kernel reads.
The ELF .peios.sig section #
For ELF binaries, the signature lives in a section of the binary's own ELF structure:
| Attribute | Value |
|---|---|
| Section name | .peios.sig |
| Section type | SHT_PROGBITS (typical ELF section type, holds raw data) |
| Section size | Exactly 3310 bytes |
The section is part of the ELF file. It travels with the binary through every operation a normal ELF travels through: filesystem copy, network transfer, package archive, image-build, anything. As long as the ELF is intact, the signature is part of it.
This is why the ELF section is preferred for ELF binaries. An xattr is filesystem metadata; it can be lost in a copy that does not preserve xattrs (a cp without --preserve=xattr, an archiver that does not understand the namespace, a network transfer that strips them). An ELF section is part of the file itself; nothing short of editing the ELF strips it.
The security.peios.sig xattr #
For non-ELF files, the same 3310-byte blob lives in an extended attribute:
| Attribute | Value |
|---|---|
| xattr name | security.peios.sig |
| xattr value | The 3310-byte blob, identical to what an ELF section would hold |
The security.* namespace requires privileged access to set — ordinary writes do not propagate to the xattr, which is the security boundary. The image builder sets the xattr at build time using its privileged access; the kernel reads it at exec.
Loss of the xattr (a copy without xattr preservation, a backup-restore through a tool that ignores security.*) results in the file being treated as unsigned. The signature blob can be re-applied if the corresponding .sig file is preserved, but the xattr layer is not the most robust place for it. ELF binaries get more durability by virtue of their structural signature; non-ELF binaries depend on xattr-preserving operations.
Detached .sig files #
During image build, peiso accepts detached signature files alongside the executables they cover. The convention is <binary>.sig — a 3310-byte file containing exactly the blob that should be stamped as the xattr.
The detached form has no role in a running system. It exists only as a packaging convention so that the build system can keep signatures next to their files without modifying the files themselves. peiso reads the .sig files, validates them, stamps the xattrs, and discards the detached forms.
The content hash #
The signature is over a 32-byte SHA-256 hash of the file's content. The bytes that go into the hash differ between ELF and non-ELF files because of how the signature is placed.
ELF content hash #
For ELF binaries, the hash is computed over the binary with the .peios.sig section's contents zeroed. The section header entry — the entry in the section header table that describes the .peios.sig section — is preserved. Only the section's 3310 bytes of payload are replaced with zeros for the hash computation.
This rule lets the section travel with the file without the signature signing itself. If the hash were computed over the binary including the signature, the signer would face a chicken-and-egg problem: sign first to know what to put in the section, but the section's contents change the hash. By zeroing the section contents during the hash, the section's position and size are part of the hash (via the section header) but its contents are not.
Verification follows the same rule: the kernel zeros the section contents (in its own working copy, not on disk), computes the hash, and verifies the signature against that hash. If anything else in the binary changed, the hash differs and verification fails.
Non-ELF content hash #
For non-ELF files, the hash is simpler: SHA-256 of the entire file, byte-for-byte, no exclusions.
The signature for a non-ELF file does not live in the file, so there is no need to exclude any region from the hash. The xattr holds the signature, the file's bytes are unmodified, and the hash covers all of them.
Why a section header found means no xattr fallback #
A subtle rule worth knowing: if an ELF binary has a .peios.sig section header entry (even an empty or malformed one), the kernel uses that path and does not fall back to the xattr.
This is to prevent confusion. A binary that appears to be ELF-signed but has a corrupt section gives the kernel a deterministic answer: unsigned. The xattr is not consulted as a second chance. The reasoning is to keep the verification rule simple — once the kernel has decided "this is an ELF binary with a signing section", that is the one and only place it looks. A binary that has both a (broken) section and a (working) xattr is also unsigned; the section's existence shadows the xattr.
The implication for someone signing binaries: choose one path per binary. ELF binaries should have a .peios.sig section or an xattr, not both. Mixed cases are valid but the section wins, and a broken section makes the binary unsigned regardless of what the xattr says.
For non-ELF binaries, the xattr is the only path — there is no section to find. These binaries are signed via xattr exclusively.
Limits and constants #
For completeness:
| Limit / constant | Value |
|---|---|
| Signature blob size | 3310 bytes (1 byte version + 3309 bytes ML-DSA-65 signature) |
| Version byte | 0x01 in v0.20; anything else is treated as unrecognised |
| Content hash | SHA-256 (32 bytes) |
| Signature algorithm | ML-DSA-65 pure, empty context (not HashML-DSA) |
| ELF section name | .peios.sig |
| ELF section type | SHT_PROGBITS |
| Xattr name | security.peios.sig |
| Detached file convention | <binary>.sig (build-time only) |
See also #
- Binary signing — what the signature accomplishes.
- Verification and pinning — how the kernel consumes this format at exec.
- Keys and image build — who produces these blobs and with what key.
Verification and pinning
Peios / Peios Security Fundamentals / Binary Signing
When a process execs, the kernel runs the signing layer between resolving the binary and starting the new program. The flow is: find the signature, verify it, set the new PSB's PIP fields based on the result, then proceed with the exec. If the binary verifies, the resulting process is PIP-protected at the level the catalog says; if not, it runs as pip_type = None.
This page covers the verification flow, the stable-snapshot rule that handles concurrent writes during verification, what happens to the binary's inode after a successful verification, how scripts and interpreters work, and the symlink resolution rule.
The verification flow #
flowchart LR
A["execve()"] --> B["Resolve binary"]
B --> C["Find signature"]
C -->|no signature| N["pip_type = None"]
C -->|signature present| V["Verify against key catalog"]
V -->|invalid| N
V -->|valid| P["Look up key's PIP level"]
P --> S["Set PSB pip_type/pip_trust"]
N --> X["Continue exec"]
S --> X
X --> R["Process starts running"]
In order:
- The kernel resolves the path. Standard exec resolution — symlinks followed, mount-policy honoured — produces the target file. The resolution rules for symlinks are covered below.
- The kernel looks for a signature. For ELF binaries, it scans the section header table for
.peios.sig. For non-ELF binaries, it reads thesecurity.peios.sigxattr. If neither is present, the binary is unsigned. - The kernel computes the content hash. SHA-256 over the binary, following the rules from Signature format — section contents zeroed for ELF, full file for non-ELF.
- The kernel verifies the signature. ML-DSA-65 verification of the 3309-byte signature against the 32-byte hash, using each of the public keys in the catalog in turn. If any key verifies the signature, the kernel records which key matched.
- The kernel looks up the key's PIP level in the catalog. The catalog entry says
pip_type = X, pip_trust = Y. These values are written to the new process's PSB. - Or, on any failure, the PSB's PIP fields are set to
pip_type = None, pip_trust = 0. The exec proceeds. - The exec continues. The new program starts running.
Note that step 6 also fires for any of: signature absent; signature present but version byte unrecognised; signature present but no key in the catalog verifies; signature present and verifies, but the catalog entry is somehow malformed. The kernel does not distinguish these cases in the result — they all produce pip_type = None.
The stable-snapshot rule #
A subtle case the kernel handles: what if the binary is being written to during verification?
The kernel reads the binary's content twice during exec — once to compute the hash, once (potentially) when mapping the binary into the new process's address space. If the file size or content changes between these reads, the hash computed in the first read might not match the bytes that end up executed in the second.
The kernel addresses this with a stable snapshot: the file's size is recorded at the start of verification, and the kernel only hashes bytes within that size. If the file size changes during verification, the kernel detects the change and treats the binary as unsigned (the hash was computed over a state that may no longer be valid). The resulting process runs at pip_type = None.
This is the "invalid or unstable = unsigned" case. The kernel does not retry; it does not block until the file stabilises; it simply gives up on verification for this exec. The same binary, exec'd a moment later when writes have stopped, will verify normally.
The rule applies only to the read side of the snapshot. Once the kernel has the hash and has verified the signature, the file is pinned (see below) and further writes are rejected — there is no window after verification where the binary's bytes can drift.
Scripts and interpreters #
A script — a text file starting with a shebang line — is not a signed binary. Scripts are interpreted by another program (the interpreter named in the shebang); they themselves carry no executable code that the kernel verifies. So how does signing interact with them?
The answer: the process's PIP comes from the interpreter, not the script. When you exec a script, the kernel actually execs the interpreter, passing the script as an argument. Verification runs on the interpreter binary. The resulting process's PIP is whatever the interpreter's signature says.
The consequence:
- A signed-at-TCB interpreter (say, a Python signed at TCB level) running an untrusted script runs that script as a TCB process. The script's content is irrelevant; PIP comes from the interpreter.
- An unsigned interpreter running a signed script runs as None — there is nothing to verify on the script.
- Two scripts running under the same interpreter run at the same PIP level, regardless of what the scripts do.
In v0.20 no interpreter is signed at the TCB level. The TCB binaries (peinit, authd, etc.) are compiled binaries, not interpreters. This is intentional: signing an interpreter at TCB level would trivially give every script running under it TCB authority, which is exactly the wrong tool. If a future Peios version signs interpreters, they will be signed at lower trust tiers (App, perhaps) where the script content is also trusted.
For administrators: do not assume scripts are protected by PIP. They run at the interpreter's level, which in v0.20 is None for every common interpreter. If you need a script to run as a trusted process, compile it into a binary (or run it under a future Peios-blessed interpreter, when one exists).
Symlink resolution #
When exec is given a path that is a symlink, the symlink is followed before the signing check runs. The kernel resolves the symlink chain to the final target file, then runs verification on that file. The signature on the target is what counts; signatures on intermediate symlinks are ignored.
Specifically:
- A symlink itself does not carry a signature. Symlinks are filesystem metadata, not executable content.
- A signature xattr on a symlink is not consulted by the kernel.
- The verification runs on the target's bytes, with the target's signature (xattr or ELF section).
This is the natural behaviour — exec follows symlinks anyway, and the signature is a property of the binary, not the path. But it has one implication worth noting: replacing a binary by changing a symlink's target is also changing what the kernel will verify. A symlink that currently points at a signed TCB binary, redirected to point at an unsigned attacker-controlled binary, will cause exec to run the latter and assign pip_type = None. The signing layer does not detect that the symlink target changed; it only verifies whatever the resolution lands on.
This is the right place for protection to come from elsewhere — typically the DACL on the symlink (denying writes by non-administrators) plus the DACL on the target directory. Signing is a content check; path integrity is the filesystem's job.
Content pinning #
Once a binary has been successfully verified and the resulting process has been assigned a non-zero pip_type, the kernel pins the binary's inode. From that moment on:
- Writes are rejected. Any attempt to
write()to the file (via any open fd) is denied. - Truncation is rejected.
truncate(),ftruncate()on the file are denied. - fallocate is rejected. Anything that would mutate the file's data or size, including hole-punching and zero-fill operations.
- xattr mutation of
security.peios.sigis rejected. The signature itself cannot be modified once verified. Other xattrs may be touchable (depending on FACS policy), but the signature xattr is part of the pinning.
The pin is on the inode, not the path. A file with the same name created at the same location with a new inode is not pinned; it is a separate file with a separate signature.
This is the "update by inode replacement" pattern. To update a verified binary:
- Place the new (signed) binary at a different path.
- Rename it to overwrite the old path. The rename creates a new inode at the target name; the old inode (still pinned) is no longer reachable by name.
- Once no process is holding the old inode open, it gets reclaimed.
The new binary at the new inode is unverified until something execs it. The first exec triggers verification, and if successful, that new inode is pinned in turn.
This is also the deletion pattern for a verified binary: the file can be deleted via unlink() (the name goes away; the inode stays alive as long as anyone has it open), but the file cannot be modified in place. Atomic replacement is the only update path.
Why pin? #
Pinning exists because verification is a snapshot. The kernel verified the bytes at one moment; later modifications to those bytes would mean the running process's PIP no longer matches what is on disk. Pinning prevents that drift.
Consider the alternative: a verified TCB binary whose bytes are modifiable. An attacker who can write to that file could change its contents to inject malicious code. Future execs of the same path would still verify (the signature is over the old bytes; the section/xattr still says it is signed), but the actual bytes running would be the modified ones. The kernel would unwittingly grant TCB authority to malicious code.
Pinning closes the gap. After verification, the bytes are locked. The next exec re-reads the bytes and re-verifies — same bytes, same signature, same result. There is no window in which the on-disk file differs from what was verified.
The pin is in-kernel state, not a filesystem flag. A reboot loses all pins; the next exec of each binary verifies again from scratch. This is fine because the pins are operationally short-lived — they exist for the duration of a binary's use after first exec, which is typically the lifetime of the kernel.
A subtle case: pinning before exec completes #
A binary that is being verified has not yet finished exec'ing. The pin happens after verification completes; before that, the binary is just a file. If verification fails (returns "unsigned"), the file is not pinned — there is nothing to pin, since the resulting process has pip_type = None and no other process has gained a TCB-relevant interest in this inode.
But if verification succeeds and exec then fails for unrelated reasons (out of memory, mmap failure, etc.), the PIP values are not committed to the PSB. The pin, however, has been applied. A subsequent exec of the same file will re-verify and re-pin (or rather, find the pin already in place), but the original failed exec does not roll back the pin.
This is consistent with the pin being about the inode's bytes, not about any specific process. Once an inode has been verified, the kernel knows what those bytes are and will not let them change, independent of whether any specific exec succeeded in using them.
Where to go next #
For where the public keys come from and how signed binaries are produced, read Keys and image build.
For the exact blob layout and content-hash rules verification relies on, read Signature format.
For what the assigned PIP fields go on to enforce, read Process integrity protection.
Keys and image build
Peios / Peios Security Fundamentals / Binary Signing
A signed binary's trust level is whatever the key that signed it says it should be. The mapping from "this key" to "this PIP level" is the kernel's public-key catalog. The catalog is compiled into the kernel image; there is no way to add keys to a running system, and there is no way to remove them either short of replacing the kernel.
This page covers the catalog's structure, who holds the private keys, how peiso (the image builder) uses them, the constraints on key handling, and the v0.20 limitation of having only one key.
The catalog #
The kernel holds an in-memory table mapping each known public key to the PIP level its signature confers. Each entry is structured:
| Field | Size | Meaning |
|---|---|---|
| Public key | 1952 bytes | The raw ML-DSA-65 public key. |
pip_type | 4 bytes (little-endian u32) | The PIP type this key represents. |
pip_trust | 4 bytes (little-endian u32) | The PIP trust level this key represents. |
Each entry is 1960 bytes. The table is a contiguous array of entries terminated by an all-zero entry — the kernel walks until it sees a sentinel.
At exec, when the kernel verifies a signature, it tries each key in the table in turn until one verifies the signature or the table runs out. If a key verifies, its pip_type and pip_trust are written to the new process's PSB. If no key verifies, the binary is treated as unsigned.
The table is part of the kernel image. It is read-only at runtime; there is no syscall to add an entry, modify an entry, or remove one. Replacing the table requires replacing the kernel.
Why ML-DSA-65 #
FIPS 204 defines three parameter sets — ML-DSA-44, ML-DSA-65 and ML-DSA-87 — trading signature size against security margin. Peios uses ML-DSA-65, and the deciding constraint is where signatures live rather than cryptographic preference.
Non-ELF files carry their signature in the security.peios.sig extended attribute. Most ext4-family filesystems cap an xattr value at one filesystem block, typically 4096 bytes, unless the large-xattr (ea_inode) feature is enabled. ML-DSA-65's 3310-byte blob fits inside that with room to spare; ML-DSA-87's would be 4628 bytes and would not fit. ML-DSA-44 would fit too, but saves under a kilobyte per signature in exchange for a smaller security margin — a poor trade for an OS expected to have a long life.
So ML-DSA-65 is the largest parameter set that keeps signatures storable as ordinary extended attributes on ordinary filesystems.
What v0.20 actually contains #
In v0.20, the catalog has one entry: the TCB key. The mapping is:
| Key | pip_type | pip_trust | What it represents |
|---|---|---|---|
| TCB public key | 512 (Protected) | 8192 | Peios TCB binaries — peinit, authd, loregd, lpsd, eventd |
That is it. No App-level key, no Authenticode key, no AntiMalware key. The categorical PIP labels described in Process integrity protection — Protected/1024 for Authenticode, Protected/1536 for AntiMalware, Protected/2048 for App, Protected/4096 for Peios — are defined in the model but no key in v0.20 corresponds to them. No binary on a v0.20 system will have these PIP values because nothing exists to sign them at those levels.
The implication: every signed binary on a v0.20 Peios system runs at PIP Protected/8192. There are no other PIP-protected processes. PIP is binary: a process is either TCB-level or unprotected.
Future Peios versions will add keys for the other tiers. App-level signing will let Peios-distributed applications get their own PIP protection (mid-trust); Authenticode-level will let third-party signed binaries run at low-but-non-zero trust; AntiMalware-level will protect security tooling. The catalog grows; the model accommodates it without code changes.
The TCB private key #
The TCB private key is what peiso uses to sign TCB binaries at image build. It is the most security-sensitive artefact in the entire system: anyone with the TCB private key can sign a binary that the kernel will then trust at the highest level. There is no revocation, no per-binary blocklist, no way to invalidate a signed binary short of removing it.
The other constraints on the private key are just as absolute:
- The key is held by whoever builds the system image (in practice, the Peios project's release infrastructure for the official distribution; potentially a downstream packager for a fork).
- The key is not distributed. Users do not get a copy of the private key. They get the binaries the private key was used to sign.
The standard pattern: build infrastructure with the private key produces image artefacts. The artefacts include the public key compiled into the kernel image and the signed TCB binaries. The artefacts ship; the private key stays in the build infrastructure.
A system whose private key has been compromised loses the security guarantees of PIP. There is no way to retroactively recover from such a compromise; the only fix is to release a new image with a new public key and re-sign all TCB binaries with the new private key, then deploy. Existing systems running the old image continue to trust the old key.
peiso and the build flow #
peiso is the Peios image builder. Its job is to assemble a complete bootable Peios image — kernel, root filesystem, signed binaries, build artefacts. Signing is one of its tasks.
At build time, peiso:
- Compiles or assembles the kernel image. The public-key catalog is built into this image; peiso embeds the kernel-build's public keys (or asserts that they are already embedded).
- Collects the binaries that should be signed at TCB level. The full list is defined per release — typically peinit, authd, loregd, lpsd, eventd.
- For each binary, peiso:
- Computes the appropriate content hash (ELF section zeroed for ELF, full file for non-ELF — see Signature format).
- Signs the hash with the TCB private key, producing a 3309-byte ML-DSA-65 signature.
- Constructs the 3310-byte blob (version byte + signature).
- For ELF binaries: reserves the
.peios.sigsection (3310 zero bytes) before hashing, then writes the blob into the reserved section after signing. Because the section is zeroed during hashing per the ELF rule, the hash the kernel computes at verification time matches the one that was signed. - For non-ELF binaries: writes the blob as a detached
.sigfile next to the binary. At image-assembly time, peiso reads the detached file and stamps thesecurity.peios.sigxattr on the binary in the image's filesystem.
- The signed binaries are placed in the image. The detached
.sigfiles are discarded. - The image is finalised and emitted as an installable artefact.
At runtime — when the image boots — the kernel reads its own embedded public key, the TCB binaries' embedded sections (or xattrs) provide their signatures, and verification proceeds. No private key is involved at runtime.
Why one key in v0.20 #
A reasonable question: why does v0.20 ship with only the TCB key? Why not at least an App key for Peios-distributed applications?
The answer is that the App, Authenticode, and AntiMalware tiers require infrastructure that v0.20 does not have:
- App-level signing requires a Peios-managed signing service that signs released applications. The service exists conceptually; the operational pipeline does not.
- Authenticode-level signing requires a trust agreement with one or more third-party CAs or a Peios-managed equivalent. None of that is in place.
- AntiMalware requires a vetting process for security-tooling vendors. Same status.
The TCB key, by contrast, is just the project's own key, used to sign the project's own binaries. The infrastructure is the project's own release pipeline. It works on day one.
Future Peios versions will add the other tiers as the corresponding infrastructure comes online. The model accommodates them. The catalog grows. Until then, the only PIP-protected processes are TCB processes, and the model effectively has two levels: TCB and not-TCB.
Implications for ordinary deployments #
For someone running Peios:
- No signing tools are available on a running system. You cannot sign your own binaries to get PIP protection. The TCB private key is not available; no other key is defined.
- Binaries you write or compile yourself run as
pip_type = None. Including binaries built from source on the running Peios system. This is correct behaviour — there is no path for an untrusted-by-the-OS binary to acquire trust. - The only PIP-protected processes are TCB. peinit, authd, loregd, lpsd, eventd, and that is it. Everything else runs at None.
- Updates to TCB binaries come through the package system. A new release of authd, say, is in a
.peipkgsigned by the project's package-signing infrastructure (separate from the TCB binary signing — packages have their own signature for distribution integrity). The binary inside the package carries the TCB-key signature for PIP. Installing the package places the binary on disk; the kernel verifies it at next exec.
For administrators of a custom Peios fork (using their own kernel image with their own key catalog), the same model applies with whatever keys they have defined: their TCB-equivalent key signs their TCB binaries, those binaries run at TCB-equivalent PIP, and nothing else has PIP protection unless they add keys to their catalog.
Where to go next #
For the enforcement flow these keys feed — verification at exec and inode pinning — read Verification and pinning.
For the per-process hardening flags that build on signing — LSV gates executable mappings by signature trust — read Process mitigations.
Process mitigations
Peios / Peios Security Fundamentals / Process Mitigations
A mitigation is a per-process kernel-enforced hardening rule. Where the DACL, PIP, and the access check decide what objects a process may reach, mitigations decide what a process may do with itself — what regions of its own memory may be made executable, how its address space is laid out, what kinds of indirect control flow are permitted, what it can do with child processes.
Mitigations are stored on the process's PSB (Process Security Block) as a set of boolean flags. The PSB is the same per-process structure that holds the PIP fields and the process SD — covered in Process integrity protection. Each mitigation has its own flag; each flag controls one specific kernel-enforced behaviour.
The mitigation model is one-way: each flag can be turned on but never turned off. Once a process has enabled WXP (write-XOR-execute), it cannot disable WXP. The flag survives exec, so a child binary launched into a process that had enabled the mitigation inherits the constraint.
This page covers the model — what mitigations are, how they fit in alongside the other access-control layers, what the one-way and exec-preservation rules mean, and who sets them.
What mitigations protect against #
Mitigations exist for a different threat model than access control. The DACL protects an object from unauthorised callers; the access check decides whether the caller has the right to act. Mitigations protect a process from its own bugs and from injected code.
The motivating scenario for most mitigations is the same: a process has a memory-safety bug — a buffer overflow, a use-after-free, an integer overflow — that an attacker exploits to redirect execution. The exploit typically wants to do one of:
- Inject new executable code (shellcode) into the process and jump to it.
- Reuse existing code in unexpected ways (return-oriented programming, jump-oriented programming).
- Hijack indirect branches to land at attacker-chosen targets.
- Modify already-loaded code in place.
Each mitigation closes one or more of these doors. The mechanism is uniform: the kernel refuses the request that would enable the exploit, even though the syscall or memory operation looks legitimate. A process that has enabled WXP cannot mmap a writable-and-executable page; the kernel returns an error. A process with TLP enabled cannot mmap-as-executable a file outside the approved-paths cache; the kernel refuses.
The result is not "the bug is fixed" — the bug is still there, and the exploit may still be able to corrupt memory. What changes is what the exploit can do with the corrupted memory. A successful exploit on a process without mitigations gives the attacker arbitrary code execution; the same exploit on a process with mitigations gives the attacker a crashed process (the kernel refused the operation and the process aborts).
How mitigations differ from access control #
The two layers solve different problems:
| Access control | Mitigations | |
|---|---|---|
| What it gates | Operations against other objects (files, registry keys, processes) | Operations the process performs on its own memory and address space |
| Driven by | Identity and policy (token, SD, privileges) | Hardening posture chosen at process startup |
| Granularity | Per-object | Per-process |
| Adjustability | Identity can adjust within rules (AdjustPrivileges) | One-way; only ever tightened |
| Who sets it | authd (token), object owner / administrator (SD) | The launching process (typically peinit) or the process itself |
| Threat model | Untrusted callers reaching trusted objects | Code-execution exploits in the process's own memory |
A process can be subject to both layers simultaneously. A TCB daemon has restrictive access control (only TCB-level callers can interact with it) and a strict set of mitigations (WXP, LSV, TLP, CFI, PIE, SML). The two layers reinforce each other: access control keeps untrusted callers out, mitigations keep the process from being exploited even if untrusted input does reach it.
A process can also be subject to one without the other. An unprotected user-mode binary has no PIP, an open DACL, and no mitigations — it depends on the access control of objects it touches but has no internal hardening. The opposite — strict mitigations with permissive access — is less common in practice but legal.
The PSB storage and the one-way rule #
The mitigations live in a small bitfield on the PSB:
| Flag | Bit value |
|---|---|
| WXP | 0x001 |
| TLP | 0x002 |
| LSV | 0x004 |
| CFI (legacy alias for CFIF + CFIB) | 0x008 |
| UI_ACCESS | 0x010 (reserved) |
| NO_CHILD | 0x020 |
| CFIF | 0x040 |
| CFIB | 0x080 |
| PIE | 0x100 |
| SML | 0x200 |
| ALL | 0x3FF |
Each bit is independent. Setting a bit enables the mitigation; the bit can be set but never cleared. The kernel rejects any operation that would clear a previously-set bit.
The one-way rule is what makes mitigations trustworthy. A process that has WXP enabled cannot be tricked or coerced into disabling it. There is no syscall to clear a mitigation; there is no privilege that bypasses the rule. Once on, on for the lifetime of the process (and beyond — see below).
The same applies to NO_CHILD — bit 0x020 in the same bitfield. Once set, the process can never fork or clone again. There is no way to undo it.
Exec preservation #
When a process execs a new binary, almost everything about the process resets. The address space is wiped, the new binary is mapped, the entry point runs. But the PSB's mitigations survive exec. A process that had WXP enabled before exec still has WXP enabled after; the new binary inherits the constraint.
This is the rule that makes the one-way model genuinely one-way. Without exec preservation, an attacker who could control what binary the process execs could trivially "unset" the mitigations by exec'ing a binary in a fresh address space — but the kernel does not give the attacker that escape. The flags travel with the process, not with the binary.
The corollary: a binary that fundamentally cannot operate under a given mitigation (a JIT compiler under WXP, say) cannot be exec'd into a process that has that mitigation set. The exec will succeed (the kernel does not gate exec on mitigations), but the binary's first attempt to do the thing it needs (mmap PROT_EXEC of newly-written code) will fail with the appropriate error. The result is a process that runs the binary's startup code and then crashes when it tries to do its job.
Practical implication: deciding which mitigations to enable is a per-process decision made at startup, based on knowledge of what binary will run there. peinit knows that authd cannot tolerate WXP-incompatible operations because authd was compiled to be WXP-compatible. So peinit sets WXP on the authd process. A process launching arbitrary user binaries cannot make the same assumption; setting WXP on a user shell would break any JIT or self-modifying binary the user happened to run.
Who sets mitigations #
Three patterns for setting mitigations:
- peinit, before exec. When peinit launches a service, it forks, sets the desired mitigations on the child's PSB via
kacs_set_psb, then execs the service binary. The service comes up with the mitigations already in place. This is the standard pattern for system services. - The process itself, after startup. A process can set mitigations on its own PSB. The typical pattern is "after the early-startup work (which may need to relax some constraints), set the mitigations and continue with the constrained code". Self-application of mitigations is a hardening best practice for binaries that have a clear startup-then-steady-state split.
- A privileged supervisor. A process with
PROCESS_SET_INFORMATIONon the target and the appropriate PIP dominance can set mitigations on another process. Rare in practice — most mitigation-setting is either at exec time (peinit) or by the process itself.
In all three cases, the call is kacs_set_psb. The full mechanics — what fields can be set, what fails, who needs what privilege — are in Applying and lifecycle.
What the kernel actually does when a mitigation fires #
Each mitigation has its own enforcement points. The pattern is the same: a syscall the process is about to make is checked against the relevant mitigation; if the operation would violate the mitigation, the kernel returns an error (typically -EACCES or -EPERM) rather than performing the operation.
The process can then handle the error. In most cases, encountering a mitigation-blocked operation is unexpected — the process did not anticipate it could happen — and the result is a crash. In some cases, the process handles the error gracefully by falling back to a different code path. The kernel does not decide which; it just refuses the operation.
This is uniform across mitigations:
- WXP refuses
mprotectcalls that would transition pages W→X. - LSV refuses
mmap(PROT_EXEC)of unsigned or insufficiently-trusted files. - TLP refuses
mprotect(PROT_EXEC)of pages backing files outside approved paths. - CFIF refuses indirect branches that land outside ENDBR (or equivalent) target instructions.
- PIE refuses exec of non-PIE binaries (this one fires at exec, not at runtime).
Each is enforced by the kernel at the syscall layer. There is no userspace component. The process cannot bypass them by avoiding libc; the syscalls themselves carry the check.
Where to start #
If you want the catalog — what each individual mitigation does, when it fires, what kernel surfaces it covers — read Catalog.
If you want the operational mechanics — kacs_set_psb, the right syscall and privilege requirements, the lifecycle of a mitigation flag from initial set through fork and exec — read Applying and lifecycle.
Catalog
Peios / Peios Security Fundamentals / Process Mitigations
There are eight mitigations active in v0.20 plus a reserved slot. This page covers each one. Every mitigation works the same way structurally — a flag on the PSB; the kernel checks the flag at the syscall layer; offending operations are refused — but each gates a different kind of operation. Understanding the catalog is understanding which threats each closes off. The numeric KACS_MIT_* flag values are cataloged in Other constants.
LSV — Library Signature Verification #
LSV is the mitigation that decides which executable code may be loaded into the process from disk. With LSV enabled, mmap(PROT_EXEC) requires that the file backing the mapping carry a valid signature whose PIP trust level is at least the calling process's pip_trust. Unsigned files cannot be loaded executable; files signed at a lower trust level cannot either.
The kernel's behaviour:
- When
mmap(..., PROT_EXEC, ...)is called on a file fd, the kernel checks the file's signature. - If the file has no signature, or its signature is invalid, the call fails with
-EACCES. - If the file's signature is valid but its PIP trust level is below the calling process's, the call also fails with
-EACCES. - If the file's signature is valid and at sufficient trust, the call proceeds.
The result: a TCB process (pip_trust = 8192) can only load TCB-signed libraries. A future App-signed process (pip_trust = 2048) could load App-signed or higher libraries but not Authenticode-signed or unsigned ones. A None-trust process is not subject to LSV (LSV does not check against None; the process can load anything).
LSV is the mitigation that closes the "load arbitrary shared object" injection path. An attacker who has overwritten a function pointer in the process to point at dlopen("evil.so") finds that the call fails — the kernel refuses to map the unsigned library as executable.
LSV is appropriate for any PIP-protected process. peinit, authd, and the rest of the TCB all run with LSV enabled.
WXP — Write-XOR-Execute #
WXP refuses any operation that would transition a memory page from writable to executable. Pages can be writable, or executable, but not both at any moment, and not become executable having been writable.
Specifically:
mmap(..., PROT_WRITE | PROT_EXEC, ...)is refused if WXP is enabled.mprotect(addr, len, PROT_EXEC)on pages previously protected with PROT_WRITE is refused.mprotect(addr, len, PROT_WRITE)on pages previously protected with PROT_EXEC is refused.
The check fires per-page. The kernel tracks the protection history of each mapping; a page that has ever been writable cannot subsequently be made executable, and vice versa.
The effect: JITs (Just-In-Time compilers) cannot run with WXP enabled. A JIT's whole job is to allocate a region of writable memory, generate code into it, then flip the region executable — exactly what WXP refuses. Binaries that need this flexibility (managed-language runtimes, dynamic-recompilation engines) cannot be hardened with WXP.
Binaries that do not generate code at runtime are unaffected. A normal native binary loads its code from disk at exec (PROT_EXEC granted by exec, not by mprotect), uses stack and heap as PROT_READ | PROT_WRITE, and never needs to flip pages between write and execute. WXP is invisible to such binaries.
WXP is one of the cornerstones of process hardening. It closes the "inject shellcode and jump to it" pathway: an attacker who has corrupted memory cannot make their corrupted region executable. The exploit's payload, no matter how big, is just data.
TLP — Trusted Library Paths #
TLP gates which directories executable mappings may come from. The kernel maintains a per-system cache of approved directory prefixes (populated from the registry at boot); when TLP is enabled, a process can only mprotect(PROT_EXEC) (or mmap(PROT_EXEC)) a region backed by a file whose resolved absolute path begins with one of those prefixes.
The check:
- The kernel resolves the file's path to an absolute pathname.
- The absolute path is compared against each entry in the TLP cache.
- If the path begins with any entry's prefix, the operation proceeds.
- If not, the operation fails with
-EACCES.
The TLP cache holds typical system library paths: /usr/lib/, /lib/, perhaps a few service-specific paths. Anything in /tmp/, /home/, or in directories not explicitly in the cache is excluded.
The threat TLP closes is "load executable code from a writable directory". A code-injection attack that writes a shared object into /tmp/ and loads it via dlopen finds the load refused — /tmp/ is not in the TLP cache. The same attack that puts the file in /usr/lib/ is much harder; non-administrators cannot write to /usr/lib/ in a standard configuration.
TLP composes naturally with LSV. LSV says "the library must be signed at sufficient trust"; TLP says "the library must come from an approved location". Both are typically enabled together on hardened processes; either alone is a useful but partial defence.
TLP cache details:
- Maximum 64 entries.
- Each entry is a UTF-8 absolute directory prefix, beginning and ending with
/. - Maximum 4096 bytes per path.
- Populated by peinit at boot from the registry. The cache is machine-wide; every process sees the same prefixes.
CFIF and CFIB — Control Flow Integrity (Forward and Backward) #
CFIF (Forward) and CFIB (Backward) are the two halves of control-flow integrity. They are separate flags so a process can enable one without the other, though most hardened processes enable both. The combined effect is to refuse any indirect branch (forward or return) that lands at an unintended target.
The legacy flag CFI (0x008) is an alias that sets both CFIF and CFIB. Modern code should set CFIF (0x040) and CFIB (0x080) directly; the alias exists for compatibility.
CFIF — Forward CFI #
CFIF refuses indirect calls and jumps (function pointers, vtables) that land at an instruction other than a designated call target. On x86_64, the hardware mechanism is IBT (Indirect Branch Tracking) — every legitimate target of an indirect branch is marked with an ENDBR64 instruction; an indirect branch landing somewhere without ENDBR64 traps.
With CFIF enabled, the kernel ensures the process runs in IBT-enforcing mode. An indirect branch to a non-ENDBR64 target generates a control-protection fault, which the kernel turns into a fatal signal to the process.
The threat closed: ROP/JOP (Return-Oriented / Jump-Oriented Programming) attacks that chain together short "gadgets" found in legitimate code. The gadgets are short sequences ending in ret or indirect jump; without CFIF, an attacker can use them to perform arbitrary operations. With CFIF, the gadgets are no longer reachable because the indirect branch into them lands somewhere without ENDBR64.
CFIB — Backward CFI #
CFIB refuses ret instructions that do not return to the address pushed by the corresponding call. The hardware mechanism is the shadow stack — a separate stack maintained by the CPU that records return addresses. Every call pushes onto both the data stack and the shadow stack; every ret pops both and compares them. A mismatch traps.
With CFIB enabled, the kernel ensures the process runs with the shadow stack engaged. An attacker who overwrites a return address on the data stack cannot get the ret to honour their overwrite — the shadow stack still has the original address; the comparison fails; the process dies.
The threat closed: classic ROP. Overwriting return addresses is the foundation of return-oriented exploitation; CFIB makes the trick impossible (or, more precisely, makes it lead to immediate process termination instead of attacker-chosen execution).
Both CFIF and CFIB require hardware support (Intel CET, ARM BTI/PAC, etc.) plus binary support (the binary must have been compiled with the relevant flags). Enabling CFIF or CFIB on a process whose binary does not support it has no effect — the kernel can only enforce what the hardware can detect.
PIE — Position-Independent Executable #
PIE refuses exec of binaries that are not position-independent. The kernel checks the binary's ELF flags at exec; if PIE is enabled on the parent process's PSB and the new binary is not PIE-built, the exec fails with -EACCES.
PIE-built binaries are loaded at randomised addresses every time they exec — Address Space Layout Randomisation (ASLR) covers the executable's own segments, not just the heap and shared libraries. An attacker who would have needed to know the address of a specific instruction in the binary to construct a ROP chain finds the address randomised and unpredictable.
A non-PIE binary has fixed addresses for its code and globals. Every exec puts them in the same place. An attacker can compute exploitation gadget addresses once and reuse them across runs.
PIE is the easiest mitigation to enable: any modern compilation with -fPIE -pie produces a PIE binary. Distributions of Peios produce PIE binaries by default. The mitigation simply refuses to exec the rare exception.
The cost: a small (single-digit-percent) performance overhead because PIE binaries reference memory through GOT/PLT tables rather than direct addresses. The cost is dwarfed by the security benefit on any modern CPU.
SML — Speculation Mitigation Lock #
SML enables the most paranoid set of speculative-execution mitigations for the process — Spectre, Meltdown, MDS, and the rest of the side-channel family. With SML enabled, the kernel applies all relevant mitigations on every context switch into and out of this process: indirect-branch barriers, store buffers cleared, microcode flushes, anything the CPU supports for spectre-class defences.
The cost is significant — depending on the CPU and the workload, anywhere from a few percent to tens of percent of performance. Most workloads do not need it. The processes that do need it are the ones handling secrets that an attacker on the same machine could otherwise extract through speculative-execution side channels: cryptographic key holders, sealed-secret stores, the TCB processes that hold sensitive material.
SML is per-process. A SML-enabled process pays the cost; processes without SML in the same kernel use the default (lighter) set of mitigations.
The threat SML closes: an attacker who has unprivileged code running on the same machine (or in some configurations, even on a different machine sharing the same CPU) using speculative-execution timing side channels to extract data from a victim process. Without SML, the standard mitigations may be enough for most attacks; with SML, the process is hardened against even the most subtle.
NO_CHILD — Forbid fork and clone #
NO_CHILD refuses fork, clone, and any other path that would create a new process or new thread sharing the current address space. (CLONE_THREAD-style clones to add threads to the current process are not covered by NO_CHILD; the flag only refuses new processes.)
With NO_CHILD enabled:
fork()returns-EPERM.clone()returning a new process returns-EPERM.clone3()with similar flags returns-EPERM.
The threat closed: an exploit that has gained code execution in the process attempting to spawn a helper process to do its work. NO_CHILD makes that path impossible — the process is locked into being a single process; whatever the attacker does, they cannot fork out of it.
NO_CHILD is appropriate for processes that fundamentally do not need to fork. Many long-lived services (a single-process event-loop daemon, for example) never call fork in their normal operation. Setting NO_CHILD on such a service costs nothing operationally and closes a frequently-abused exploitation path.
Services that do fork during their operation (a classical Unix accept-fork-handle server) cannot use NO_CHILD. Either restructure the service to use threads or async I/O, or accept that NO_CHILD does not fit.
NO_CHILD does not prevent threads in the same process — pthread_create and its kin are CLONE_THREAD-style operations and remain available. The mitigation is specifically about new processes, not new threads.
UI_ACCESS — Reserved #
UI_ACCESS (0x010) is reserved in v0.20. The intended use is for processes that interact with the user interface in privileged ways; the mitigation, when defined, will restrict what UI surfaces the process may attach to.
In v0.20 the flag has no effect — setting it is allowed but does nothing. Future versions may define behaviour.
Combining mitigations #
Most hardened processes enable multiple mitigations. A typical TCB daemon's mitigation set in v0.20:
- WXP — no writable-executable pages
- LSV — only signed libraries
- TLP — only libraries from approved paths
- CFIF + CFIB — control-flow integrity
- PIE — ASLR-aware binaries
- NO_CHILD if applicable — no child processes
Plus possibly SML for secrets-handling processes.
The mitigations compose orthogonally — each one closes a different attack pathway. WXP closes shellcode injection; LSV closes signed-library-only-rule; TLP closes load-from-writable-directory; CFI closes ROP/JOP; PIE closes fixed-address attacks; NO_CHILD closes process-spawning escapes; SML closes speculative-execution side channels. A process with all of them is meaningfully harder to exploit than one with any subset.
The ALL flag (0x3FF) is shorthand for "enable every defined mitigation". A process that wants the strictest possible hardening can set ALL in one call.
What mitigations don't help with #
A few clarifications worth pinning:
- Mitigations do not protect against logic bugs. A process that has been tricked into doing something its code is allowed to do but should not (a misconfigured permission check, a misused API) is not protected by mitigations.
- Mitigations do not protect against bugs in the kernel. A kernel exploit operates above the mitigation layer; mitigations are kernel-enforced, and a compromised kernel can disable them.
- Mitigations do not protect against bugs in the language runtime that bypass them. A JIT that legitimately needs writable-executable pages is incompatible with WXP; running it with WXP either disables the JIT (which may fail in unexpected ways) or refuses to set WXP at all.
- Mitigations do not catch all exploits. They are layered defences. An exploit that fits within one mitigation's blind spot (a JIT spray attack against a non-WXP process, say) succeeds despite the other mitigations being on. Defence in depth is the goal — the more layers, the more an exploit must defeat.
See also #
- Process mitigations — the model these flags share.
- Applying and lifecycle — setting the flags and how they propagate.
- Binary signing — the signatures LSV verifies against.
Applying and lifecycle
Peios / Peios Security Fundamentals / Process Mitigations
Setting mitigations is a small kernel operation — call a syscall, pass a bitmask, the kernel sets the bits on the PSB. The complications are not in the call itself; they are in who can make the call, when in a process's life it can be made, and how the resulting state propagates through fork and exec.
This page covers the operational mechanics: the kacs_set_psb syscall, the privilege rules for setting mitigations on another process, and the lifecycle of a mitigation flag from initial set through process exit.
kacs_set_psb #
The kernel exposes one syscall for setting mitigations:
kacs_set_psb(target_pidfd, flags)
target_pidfd is a pidfd for the process whose PSB is to be modified. The caller's own process is acceptable; another process is also acceptable (subject to privilege rules below).
flags is a bitmask combining the values from the catalog:
| Flag | Bit |
|---|---|
| WXP | 0x001 |
| TLP | 0x002 |
| LSV | 0x004 |
| CFI (legacy alias) | 0x008 |
| UI_ACCESS | 0x010 |
| NO_CHILD | 0x020 |
| CFIF | 0x040 |
| CFIB | 0x080 |
| PIE | 0x100 |
| SML | 0x200 |
| ALL | 0x3FF |
The kernel ORs the flags into the target's existing mitigation bitfield. There is no "clear" — bits cannot be removed. Calling kacs_set_psb with a subset of currently-set bits leaves the previously-set bits intact; you can never use this call to disable a mitigation.
The kernel rejects:
- A pidfd pointing at a process the caller does not have authority to modify (see below).
- Unknown flag bits (anything outside the defined set is
-EINVAL).
The kernel does not reject:
- A call setting bits that are already set. The OR is idempotent.
- A call setting
UI_ACCESS(which is reserved). It sets the bit; the bit has no effect. - A call setting flags incompatible with the current binary's capabilities. Setting WXP on a process running a JIT is allowed; the JIT will fail the next time it tries to flip a page, but the
kacs_set_psbcall itself succeeds.
Self versus another process #
The privilege required depends on whose PSB is being modified:
Setting mitigations on your own process requires no privilege. Any process can call kacs_set_psb with its own pidfd (or, more commonly, with no pidfd to mean "self"). The call always succeeds for self-targeted invocations, regardless of identity, integrity, or anything else.
The reasoning: a process can only ever tighten its own constraints. There is no risk in letting a process restrict itself further. The model assumes that any code running in the process is, by definition, code the process has chosen to run; that code wanting to add a mitigation is fine.
Setting mitigations on another process requires:
PROCESS_SET_INFORMATIONon the target process — granted by the target's process SD.- PIP dominance over the target (per the two-check rule).
This is the standard cross-process operation pattern. The caller's PSB must dominate the target's, and the target's SD must grant the appropriate right to the caller. Both must be satisfied.
In practice this means the only common caller for cross-process kacs_set_psb is peinit (when launching a service that needs mitigations applied at exec). peinit has TCB-level PIP and is granted PROCESS_SET_INFORMATION on the services it launches; it sets the desired mitigations on the child's PSB after fork and before exec.
A self-applied mitigation does not require the caller to be the process itself in the strict sense — it just requires the pidfd to point at the caller's own process. A thread within a process can set mitigations on the process's PSB regardless of which thread does the call.
Where in the process lifecycle #
A mitigation can be set at any moment during a process's life. The kernel does not require it to happen at startup, before exec, or before any specific event. Practical patterns:
- At process creation, before exec. peinit forks, calls
kacs_set_psbon the child's pidfd, then execs the service binary. The mitigations are in place when the binary starts running. This is the standard. - At the entry point of the binary. The binary itself, immediately on startup, sets its desired mitigations on its own PSB. Suitable for binaries that are self-aware about their hardening posture.
- After early-stage initialisation. A process that has work to do during early startup that needs to relax some constraints (loading executable libraries from non-approved paths, for example) waits until after that work is done, then sets the mitigation.
A mitigation set late in a process's life closes off only future operations. Operations that have already happened — pages already mapped, libraries already loaded — are not retroactively checked. WXP set after the process has already mmap'd a writable-executable region does not unmap that region; it only refuses future such operations.
This is sometimes a useful pattern: a process needs WXP-incompatible behaviour during startup (say, runtime code generation for initialisation) and then transitions to a steady state where WXP is appropriate. The pattern is "do the WXP-incompatible work first, then kacs_set_psb(self, WXP)". From that point forward, WXP is enforced.
Fork: inheritance #
When a process forks, the child inherits the parent's mitigation flags exactly. Every bit set on the parent is set on the child. The child cannot un-set them at fork time; the one-way rule applies.
This is the natural extension of one-way: a process that has chosen to lock down its execution cannot give its children more authority than it has itself. If WXP is set, every child also has WXP. If NO_CHILD is set... well, the parent cannot fork in the first place, so the question does not arise.
Threads (CLONE_THREAD-style clones) share the parent's PSB rather than copying it. A new thread in the same process is bound by the same mitigations; setting a mitigation in one thread is visible to all threads immediately.
Exec: preservation, with one wrinkle #
When a process execs, all of:
- The process identity (token) — preserved.
- The PIP fields — re-computed from the new binary's signature.
- The process SD — typically preserved, may be re-defaulted if the user identity changed.
- The mitigation flags — preserved.
The new binary runs with whatever mitigations were on the PSB before exec. There is no way for exec to relax mitigations.
The one wrinkle: PIE.
PIE is the mitigation that fires at exec, not at runtime. With PIE set, the kernel checks the new binary's ELF flags during exec; if the binary is not PIE-built, the exec fails with -EACCES. The process attempting the exec sees its execve return with an error and continues running its current binary.
For other mitigations, the exec succeeds and the new binary inherits the mitigation. PIE is the one that can cause exec itself to fail.
This means setting PIE before launching a service is a way of saying "this service binary must be PIE, or it cannot run". If the operator updates the service to a binary that is not PIE, the next exec attempt will fail and the service will not start. This is sometimes desired (a hard constraint that the binary be PIE); sometimes inconvenient (an emergency rebuild that lost the PIE flag).
NO_CHILD and the lifecycle interaction #
NO_CHILD (bit 0x020) is also stored on the PSB and follows the same one-way rules as the other mitigations. Once set, the process cannot fork or clone-with-new-process.
The lifecycle interaction worth knowing: a process that wants to set up children and then lock itself down should do the forks first, then call kacs_set_psb(self, NO_CHILD). After that call, the process cannot create more processes.
If a process needs to be able to fork on demand (a server that handles each connection in a new process), NO_CHILD is not appropriate. The fork capability and NO_CHILD are mutually exclusive in steady state.
A process with NO_CHILD set can still call exec (replacing itself with a new binary in the same process) and create threads via CLONE_THREAD. The mitigation specifically blocks the spawning of new processes.
Querying mitigations #
A process can read its own mitigation flags via the PSB query. The interface — typically through kacs_open_self_token and a query on the PSB — returns the current flag bitfield.
For reading another process's flags, the same PROCESS_QUERY_INFORMATION + PIP dominance rules apply as for setting them. Typically only peinit or a debug tool would read another process's mitigation flags.
Note that the flags are independent of the PSB's other fields. Querying the mitigation flags does not reveal anything about the process's PIP level or its SD — those are separate queries. It does include NO_CHILD, which is bit 0x020 of the same bitfield.
What happens at process exit #
A process's PSB is destroyed when the process exits. The mitigation flags vanish with it. There is no persistence; the next time the same binary is exec'd in a fresh process, the mitigations have to be re-applied.
This is why launchers (peinit) apply mitigations on every launch. There is no cached "this binary always gets these mitigations" — every fresh process starts from the inherited PSB, which is whatever the launcher's PSB had plus whatever the launcher chose to add.
The corollary: a process whose launcher does not apply mitigations runs without them, regardless of the binary's intent. A binary that wants to be hardened should also call kacs_set_psb at its own entry point so the mitigations are guaranteed regardless of who launched it. Defence in depth: both the launcher and the binary should set the mitigations they need.
Errors #
kacs_set_psb can fail with:
| Error | Cause |
|---|---|
-EBADF | Invalid pidfd. |
-ESRCH | The target process has exited. |
-EACCES | The caller does not have PROCESS_SET_INFORMATION on the target. |
-EPERM | The caller does not PIP-dominate the target (when modifying another process). |
-EINVAL | Unknown flag bits in flags. |
In normal operation, the call succeeds. Failures are typically programming errors (wrong pidfd) or insufficient authority (a low-trust caller trying to modify a high-trust target).
Where to go next #
For what each flag actually enforces once set, read the Catalog.
For the SD-plus-dominance rules that gate setting mitigations on another process, read The two-check rule.
Auditing
Peios / Peios Security Fundamentals / Auditing
Auditing is the access check's record-keeping layer. Where the DACL and the rest of the pipeline decide whether access is granted, auditing decides what to remember about that decision. An audit event records an access attempt — who made it, what they wanted, what they got, what triggered the recording — and ships the event through KMES to whatever userspace consumer is listening.
Auditing runs after the access decision is made. By the time an audit event fires, the granted mask is already final; the audit walk is not part of the gate, only of the record. This means audit events are observational, not decisional: a fired audit event reports what already happened; it does not affect whether the access happens.
This page covers the model — what kinds of audit exist, where each one fires in the pipeline, and what they have in common.
What auditing is for #
Auditing answers questions of the form "did this happen?". It does not answer "is this allowed?" — that is the access check's job, and it has already been done by the time audit runs.
The questions audit handles well:
- "Who accessed this sensitive file in the last hour?"
- "Did this principal exercise SeDebugPrivilege today?"
- "How many failed write attempts were there against this object since Monday?"
- "Which sessions are currently writing to this directory?"
The questions audit does not handle well:
- "Should this access have been allowed?" — that is policy, not audit.
- "Will this access succeed?" — audit is post-decision, not predictive.
- "What rights does this user have on this object?" — audit observes events; computing rights requires the access check.
Auditing is most valuable for compliance and incident response. A regulated environment may need to log every access to certain classes of data; an incident-response team needs to reconstruct what happened in the moments before a compromise. Auditing produces the raw record those use cases consume.
The three audit categories #
Peios produces three categories of audit event from the access pipeline:
| Category | Triggered by | When it fires |
|---|---|---|
| Object-access audit | SYSTEM_AUDIT* ACEs in an object's SACL, or the calling token's audit_policy bitmask | At AccessCheck completion (one event per matching audit ACE per access) |
| Continuous audit | SYSTEM_ALARM* ACEs in an object's SACL | Per-operation on an open handle (every operation whose required mask overlaps the handle's continuous-audit mask) |
| Privilege-use audit | A privilege contributed to the granted mask, and the token's audit_policy requested it | At AccessCheck completion (one event per privilege that fired) |
Plus a fourth, tangentially related category produced by session lifecycle:
| Category | Triggered by | When it fires |
|---|---|---|
| Session destruction | A logon session loses its last token reference | When the session is destroyed |
Object-access and continuous audit are covered in Audit ACEs. Privilege-use audit and the token-forced subset of object-access audit are covered in Policy-forced auditing. Event schemas and the KMES transport are in Events and transport.
Where audit fits in the pipeline #
flowchart LR
A["AccessCheck pipeline steps 0-12"] --> B["Granted mask + privilege contributions"]
B --> C["Step 13: privilege-use audit emission"]
C --> D["Step 14: SACL audit walk"]
D --> E["Step 14b: token audit_policy forced events"]
E --> F["AccessCheck returns: granted mask, continuous_audit mask, staging_mismatch"]
F --> G["KMES delivers events to consumers"]
Step 13 fires privilege-use events for privileges that contributed bits. Step 14 walks the object's SACL (plus any CAAP effective SACLs collected during step 12) looking for audit and alarm ACEs that match the access. Step 14b checks the calling token's audit_policy bitmask for forced events that should fire regardless of any SACL ACE.
Each of these steps can produce zero or more events. A single access check might produce no events (no audit ACEs matched, no privileges used, no token-forced auditing); or it might produce many (multiple matching audit ACEs, multiple privileges, multiple forced events).
The events are sent through KMES — Peios's kernel-to-userspace event transport — to whichever consumer is subscribed. The kernel does not retain them, does not buffer them past the transport, does not retry them; KMES is fire-and-forget at the kernel-to-userspace boundary, and everything past that boundary is the transport pipeline's concern, not the audit layer's.
What an audit event records #
Every audit event includes:
- The subject — who was making the access. The token's user SID, group SIDs, integrity level, PIP type and trust, projected UID.
- The object context — a caller-supplied opaque blob identifying the object being accessed. Audit consumers use this to correlate events with the objects they cover.
- The access — what was requested, what was granted, whether the access succeeded.
- The trigger — which ACE matched (for SACL-driven events), which privilege fired (for privilege-use events), or which audit-policy bit (for token-forced events).
- The process context — pid, name, executable path. Useful for correlating events with running processes.
The exact field set varies by event type and is covered in Events and transport.
What audit does not do #
A few clarifications:
- Audit is not the access decision. An audit event firing does not mean access was granted; an event for a denied access is just as legitimate. Read the success flag in the event to know what happened.
- Audit is not retroactive. An audit ACE added to an SACL after some accesses have happened does not generate events for those past accesses. Auditing observes the present; the past is the past.
- Audit does not survive crash. An event in flight when the kernel crashes is lost. KMES is designed for the typical case where the kernel keeps running; for crash-resilient audit, a userspace consumer that persists events to disk is what provides durability.
- Audit does not authenticate. The recorded subject is whatever the calling token says they are. If the token has been issued correctly, the audit record reflects reality. The audit layer does not independently verify the principal's identity beyond what the token provides.
Where audit lives #
A handful of components are involved in producing and consuming audit:
- The kernel runs the audit walk during AccessCheck and produces the events.
- KMES transports the events from the kernel to userspace.
eventdis the userspace daemon that subscribes to KMES and writes events to wherever they should go (a local file, a remote log collector, a SIEM endpoint).- Audit consumers are tools that read what
eventdhas captured — query interfaces, dashboards, alerting systems.
This separation is what lets the kernel stay simple. The kernel's only job is generating events; everything else — buffering, persistence, querying, alerting — is userspace. A failure in the audit-storage layer does not affect the access check; the access decision happens regardless of whether the corresponding event was ever recorded.
Auditing is best-effort #
The audit layer aims to record every meaningful event, but it does not guarantee that every event is recorded. The reasons:
- An event in flight when the kernel crashes is lost.
- A consumer that has fallen behind may have its KMES backlog dropped (KMES's flow control is a per-consumer concern; the kernel does not block the access check on consumer back-pressure).
- A misconfigured SACL or
audit_policymay simply fail to generate the events that would have been useful for forensics.
Best-effort means audit is a signal, not a proof. A deployment that needs cryptographic non-repudiation of every access would need additional infrastructure on top of the audit layer. For the typical use cases — compliance, alerting, incident response — best-effort is enough.
The model deliberately puts performance ahead of guaranteed delivery. An access check that has to block until its audit event has been written to disk would be intolerably slow. The kernel chooses to record what it can and not slow down the actual work; the userspace audit pipeline is responsible for getting the events from the transport buffer to whatever destination they need to land at.
Where to start #
If you want to understand audit ACEs — how they work, the difference between standard (SYSTEM_AUDIT) and continuous (SYSTEM_ALARM) ACEs, audit polarity, and the conditional audit case — read Audit ACEs.
If you want privilege-use audit and the token-forced audit policy — events that fire independent of any SACL ACE — read Policy-forced auditing.
If you want the wire-level details — event schemas, what each field means, how KMES delivers events to consumers — read Events and transport.
Audit ACEs
Peios / Peios Security Fundamentals / Auditing
The primary mechanism for object-access auditing is audit ACEs in an object's SACL. An audit ACE says: when a specific principal (or principals matching a SID) attempts specific access rights, fire an event. The ACE can be conditioned on whether the attempt succeeded, whether it failed, or both. The event records the access; the access itself is decided by the DACL and the rest of the pipeline.
This page covers the two audit ACE families — SYSTEM_AUDIT* (one-shot at handle creation) and SYSTEM_ALARM* (continuous per-operation) — and the rules that govern when each fires.
SYSTEM_AUDIT — one event per access #
A SYSTEM_AUDIT ACE fires once when an access check completes. The ACE has the standard single-SID structure:
| Field | Meaning |
|---|---|
| AceType | SYSTEM_AUDIT (0x02), or one of the object/callback variants (0x07, 0x0D, 0x0F) |
| AceFlags | Includes SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and/or FAILED_ACCESS_ACE_FLAG (0x80) |
| Mask | The access mask the ACE is interested in |
| SID | The principal the ACE targets |
When an access check is in step 14 (the SACL audit walk), the kernel scans the SACL for audit ACEs. For each SYSTEM_AUDIT ACE, it asks:
- Does the caller's identity match the ACE's SID? If not, the ACE does not apply.
- Does the requested access overlap the ACE's mask? If not, the ACE does not apply.
- If the access succeeded and the ACE has
SUCCESSFUL_ACCESS_ACE_FLAGset, emit an event. - If the access failed and the ACE has
FAILED_ACCESS_ACE_FLAGset, emit an event. - Otherwise the ACE applies but does not fire.
One event per matching ACE. An object with three matching audit ACEs produces three events on a single access. A separate access later — a new handle, a new access check — produces a separate set of events.
The event is for the access check as a whole. It does not fire per-operation on the open handle; once the handle is open, subsequent operations do not re-fire the audit ACE. (For per-operation audit, see SYSTEM_ALARM below.)
Common patterns #
The SUCCESSFUL_ACCESS_ACE_FLAG and FAILED_ACCESS_ACE_FLAG flags can both be set in one ACE:
| Flag combination | Audit behaviour |
|---|---|
SUCCESSFUL_ACCESS_ACE_FLAG only | Audit only when the access succeeded. |
FAILED_ACCESS_ACE_FLAG only | Audit only when the access was denied. |
| Both set | Audit every access attempt, regardless of outcome. |
| Neither set | The ACE applies but never fires. |
The "both set" pattern gives a complete audit log for an object — every attempt, allowed or denied. The "failed only" pattern is useful for security monitoring: alert me whenever someone tries to do X but is refused. The "successful only" pattern is useful for compliance: record everyone who has actually touched the object.
A SACL with no audit ACEs produces no audit events (unless audit_policy on the token fires; see Policy-forced auditing). A SACL with audit ACEs but no relevant flags set is auditing nothing — the ACEs are essentially comments at that point. The flags are what makes them active.
SYSTEM_ALARM — continuous, per-operation #
A SYSTEM_ALARM ACE works differently. It does not fire at access-check time; instead, it configures a continuous audit mask on the open handle, and every subsequent operation through the handle whose required access overlaps that mask produces an event.
The ACE has the same structure as SYSTEM_AUDIT:
| Field | Meaning |
|---|---|
| AceType | SYSTEM_ALARM (0x03), or one of the variants (0x08, 0x0E, 0x10) |
| AceFlags | Same flag bits as audit |
| Mask | The mask to record on the handle |
| SID | The targeted principal |
During step 14 of access check, when a SYSTEM_ALARM ACE matches, the kernel:
- Computes a continuous-audit mask — the union of all matching alarm ACEs' masks.
- Returns the mask to the caller as part of the access check's output.
- The caller (typically FACS for files) stores the mask on the open handle. For registry keys the enforcement point is LCS, the kernel registry subsystem — never the registryd source, which stores data but makes no security decisions (and LCS v0.21 defines only open-time SACL audit for keys, not continuous audit).
Then, on each subsequent operation through the handle, the enforcement point compares the operation's required-access mask against the handle's continuous audit mask. If they overlap (any bit shared), the kernel emits a continuous-audit event for the operation.
Audit vs alarm: granularity #
The difference between SYSTEM_AUDIT and SYSTEM_ALARM is fundamentally about granularity:
| Aspect | SYSTEM_AUDIT | SYSTEM_ALARM |
|---|---|---|
| Event timing | Once, at access-check completion | Per operation on the open handle |
| Event count per handle | One (or one per matching ACE) | Many — one per matching operation |
| What is recorded | The access-check decision | The specific operation that was performed |
| Storage of state | None — fires and forgets | Continuous audit mask on the handle |
| Use case | "Who opened this file with these rights" | "Every read this principal performed on this object" |
For most auditing, SYSTEM_AUDIT is the right tool. The event volume is bounded by the number of distinct accesses, which is usually small.
For high-sensitivity objects where every operation matters — auditing a credential store's every read, recording every write to a security-relevant log file — SYSTEM_ALARM is the right tool. The event volume is bounded by operation count, which can be high, but the visibility into what is actually being done is also higher.
Both can coexist on the same SACL. A SACL with both SYSTEM_AUDIT and SYSTEM_ALARM produces an at-open event from the audit ACE and per-operation events from the alarm ACE.
Audit polarity for SID matching #
A subtle but important rule: audit ACEs match SIDs with deny polarity rather than allow polarity.
For an ACCESS_ALLOWED ACE in a DACL, a group on the token matches only if it has SE_GROUP_ENABLED set. Groups marked SE_GROUP_USE_FOR_DENY_ONLY are invisible to allow ACEs.
For an ACCESS_DENIED ACE, both regular groups and deny-only groups match. The broader view exists so denies cannot be sidestepped by demoting a group to deny-only.
Audit ACEs use the same broad view as deny ACEs. A token's deny-only groups match audit ACEs. Logon SIDs, disabled groups in some cases, every SID associated with the token contributes to audit matching.
The reasoning: the purpose of auditing is to capture the most complete picture of the identity making the access. A token that has been restricted (groups marked deny-only, say) is still that token, with that identity, and the audit log should reflect that. Limiting audit to the narrower allow-polarity view would let auditing miss accesses by tokens that had been deliberately downgraded.
Practical effect: if you write an audit ACE matching a specific group SID, it will fire whether the calling token currently has that group enabled, disabled, or marked deny-only. The audit ACE catches the broadest possible identity match.
Conditional audit ACEs #
The callback variants of audit and alarm ACEs (SYSTEM_AUDIT_CALLBACK, SYSTEM_AUDIT_CALLBACK_OBJECT, SYSTEM_ALARM_CALLBACK, SYSTEM_ALARM_CALLBACK_OBJECT) add a conditional expression. The ACE fires only when the expression evaluates appropriately for an audit.
The rule: an audit-callback ACE fires when its expression evaluates to TRUE or UNKNOWN. It does not fire on FALSE.
This is the opposite of the rule for ACCESS_ALLOWED_CALLBACK (where TRUE fires the allow, UNKNOWN does not) and matches the rule for ACCESS_DENIED_CALLBACK (TRUE or UNKNOWN fires the deny).
The reasoning: an audit ACE's job is to record events. Erring on the side of recording — including when the conditional cannot be definitively evaluated — produces a more complete audit log. A missed event is more dangerous than an extra one. UNKNOWN, therefore, fires the audit.
Practical pattern: an audit ACE conditioned on "the request came from outside the trusted network" — @Local.Source != "internal" — fires on accesses where the local-claims do not include a Source value (the expression evaluates UNKNOWN). The audit log captures the access even though the condition could not be evaluated; an investigator reviewing the log can decide whether the missing claim is meaningful.
Object-scoped audit ACEs #
The object variants (SYSTEM_AUDIT_OBJECT, etc.) carry one or two GUIDs that scope the ACE to specific properties of a directory-style object. The audit fires only when the access is to a property whose GUID matches.
For most objects (files, registry keys, processes), object ACEs are not used and the basic single-SID variants are what appears in SACLs. Object ACEs come up in directory-style objects where per-property audit is meaningful — auditing "who read the manager property of this user object" rather than just "who read this user object".
The mechanics of object ACEs are the same as elsewhere — the GUID-scoped match — and are covered in ACLs, ACEs, and access masks. The audit case is identical to the access-check case structurally; only the firing semantics differ.
Inherit-only audit ACEs #
An audit or alarm ACE with INHERIT_ONLY_ACE set does not fire on the object it sits on. It is for inheritance to children only — when a child is created, the audit ACE is copied to the child's SACL (subject to inheritance flags) and from then on fires on the child.
This is the same inheritance semantics that DACL ACEs use. An inherit-only audit ACE on a parent directory does not audit accesses to the parent; it just propagates to child files.
What audit ACEs do not produce #
A few clarifications:
- They do not skip pre-decided accesses. If MIC or PIP pre-decided write bits as denied at step 5, and the access check ends with the write denied, the audit walk still runs and audit ACEs fire normally. The pre-decision does not skip the audit walk.
- They do not produce duplicate events on the same handle for the same access. An access check produces one event per matching ACE. If a process accesses the same object twice (two separate opens, two access checks), each access check produces its own set of events.
- They do not record per-operation activity on an open handle. For per-operation events, alarm ACEs are the mechanism. Standard audit ACEs are "at the moment of opening, was this access allowed?".
- They do not affect access. The audit walk is observational. A matching audit ACE does not change what gets granted, even if the audit ACE has a different mask than the matching DACL ACE.
CAAP effective SACLs #
A SYSTEM_SCOPED_POLICY_ID_ACE in the object's SACL references a central access policy. That policy can have its own effective SACL containing audit ACEs (see Central access policies). The policy's audit ACEs are collected during CAAP evaluation at step 12 and merged into the audit walk at step 14.
From the audit consumer's perspective, an event triggered by a CAAP's effective SACL looks the same as one triggered by the object's own SACL. The event records what fired, and where the matched ACE came from is part of the event metadata, but the structure is uniform.
Practical implication: a policy administrator who wants to audit a class of objects can put the audit ACE in the policy's effective SACL once, and every object referencing the policy contributes audit events — better than putting a copy of the audit ACE on every object's SACL individually.
Where to go next #
For the events that fire without any SACL ACE — privilege-use audit and the token's audit_policy — read Policy-forced auditing.
For what the fired events contain and how they reach userspace, read Events and transport.
For where audit ACEs live and who may read or write them, read The SACL.
Policy-forced auditing
Peios / Peios Security Fundamentals / Auditing
Not every audit event comes from an audit ACE in an SACL. Two mechanisms produce events that fire regardless of whether an SACL ACE matched: privilege-use audit (fires when a privilege contributes bits to the granted mask) and the token's audit_policy bitmask (forces object-access or privilege-use events whenever the corresponding flag is set on the token).
Both mechanisms are forced by something other than an SACL ACE — by the privilege exercise itself, or by the token's per-principal audit policy. They run during the same audit walk as SACL ACEs but are independent of any specific ACE matching.
This page covers both mechanisms, when each fires, and how they compose with SACL-driven audit.
Privilege-use audit #
When AccessCheck completes, the pipeline knows which privileges contributed bits to the granted mask. A backup tool that used SeBackup to bypass the DACL has the privilege recorded against the bits it contributed; an administrator who used SeTakeOwnership to gain WRITE_OWNER has that privilege recorded.
If the calling token's audit_policy requests it, the kernel fires a privilege-use event for each privilege that contributed. The events fall into two flavours:
| Flavour | Triggered by | Fires when |
|---|---|---|
| Successful privilege use | audit_policy & PRIVILEGE_USE_SUCCESS (0x04) | A privilege contributed bits AND those bits survived to the final granted mask. |
| Failed privilege use | audit_policy & PRIVILEGE_USE_FAILURE (0x08) | A privilege contributed bits BUT those bits did NOT survive (stripped by a later layer like confinement or CAAP). |
The "failed" case is the interesting one. A privilege that fires but does not actually grant access tells the audit log that the caller attempted to exercise a privilege and was prevented by some narrowing layer. This is observable separately from a plain "access denied" — the privilege got far enough to fire and then was stripped.
A worked example. A token with SeBackupPrivilege enabled tries to read a confined object. The pipeline:
- Step 4: SeBackup grants read-category bits via the privilege.
- Step 8: DACL walk grants nothing additional.
- Step 11: confinement intersection strips the privilege-granted bits (confinement does not preserve privilege grants).
- Final granted mask: empty.
If audit_policy & PRIVILEGE_USE_SUCCESS is set, no event fires (no bits survived). If audit_policy & PRIVILEGE_USE_FAILURE is set, a failed-use event fires, recording "SeBackupPrivilege contributed FILE_READ_DATA but the bits were stripped".
Either configuration is useful. Tracking only success tells you when privileges actually grant access; tracking only failure tells you when privileges try to grant access but cannot. Tracking both gives you a complete picture of privilege exercise.
The event itself includes the privilege name (e.g. SeBackupPrivilege), the bits the privilege contributed pre-narrowing, the bits that survived to the final result, and a success boolean. The exact schema is in Events and transport.
Token audit_policy #
The token's audit_policy field is a small bitmask with four flags:
| Flag | Value | Effect |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x01 | Force an audit event on every successful access by this token, regardless of SACL ACE matches. |
OBJECT_ACCESS_FAILURE | 0x02 | Force an audit event on every failed access by this token. |
PRIVILEGE_USE_SUCCESS | 0x04 | Emit successful privilege-use events. |
PRIVILEGE_USE_FAILURE | 0x08 | Emit failed privilege-use events. |
PRIVILEGE_USE_* controls privilege-use audit (above). OBJECT_ACCESS_* controls a separate kind of force: object-access events that fire even when no SACL audit ACE matched.
How OBJECT_ACCESS_SUCCESS / FAILURE work #
When the calling token's audit_policy has OBJECT_ACCESS_SUCCESS set, step 14b of the pipeline fires an object-access event for every access by this token where the granted mask is non-empty — regardless of whether any SACL audit ACE would have matched.
When OBJECT_ACCESS_FAILURE is set, an event fires for every access where the granted mask did not satisfy the requested mask (i.e. some requested rights were denied).
These flags effectively say: "audit every access this principal makes". The audit log gets an event whether or not the object's owner thought to put an audit ACE in the SACL.
The use case: surveillance of specific principals. A security team responding to a suspected compromised account can set OBJECT_ACCESS_SUCCESS | OBJECT_ACCESS_FAILURE on that account's tokens (via authd reissuing the token) and capture every access the account makes. The objects' SACLs do not need to be modified; the audit comes from the token policy.
Another use case: high-sensitivity contractors or third parties who need temporary access. Their tokens are issued with OBJECT_ACCESS_* flags set so every access is logged independently of which specific objects they touch.
Setting audit_policy #
The audit_policy field is set at token creation and is immutable for the lifetime of the token. authd specifies the value when minting the token via kacs_create_token. There is no AdjustPrivileges-style call to modify audit_policy at runtime.
This is consistent with the rest of the immutable token state. A token's audit policy is decided when it is issued; changing it requires a new token.
For administrators wanting to toggle audit policy on a principal, the mechanism is: have authd reissue the principal's tokens with the new policy. Existing sessions continue to operate under the old policy; new sessions get the new policy. (For more immediate effect, revoke the existing sessions and force re-authentication.)
Composition with SACL audit #
The SACL audit walk (step 14) and the token audit policy (step 14b) run independently. Both can fire events for the same access.
A worked composition example. A SACL has one audit ACE on Everyone for FILE_READ_DATA with SUCCESSFUL_ACCESS_ACE_FLAG. The calling token has audit_policy = OBJECT_ACCESS_SUCCESS. An access for read succeeds.
- Step 14 (SACL walk): the audit ACE matches Everyone, the access succeeded, the success flag is set, fire one event with trigger = "sacl" and the matched ACE.
- Step 14b (token-forced):
OBJECT_ACCESS_SUCCESSis set, the access succeeded, fire one event with trigger = "policy".
Two events for one access. They are not duplicates — they have different triggers, and an audit consumer can distinguish them. The SACL-driven event records that this specific audit ACE matched; the policy-driven event records that the token's audit policy required logging.
In practice, audit consumers either treat both events as one entry (deduplicating by some criteria like access context) or keep them separate. The kernel does not merge them; both fire.
What policy-forced auditing does not do #
A few clarifications:
- Token audit_policy does not affect the access decision. The flags fire events; they do not gate access. A token with
OBJECT_ACCESS_FAILUREset is not somehow more likely to be denied; the access check runs identically. - Token audit_policy does not fire on every kernel surface. It fires on AccessCheck-mediated accesses. Operations that do not go through AccessCheck (a syscall the kernel handles directly without an SD lookup, an internal kernel operation) are not subject to it. Privilege-use audit fires only on privileges that fire during AccessCheck, not on privileges exercised in kernel-standalone paths.
- Privilege-use audit does not always fire. It fires only when the token's
audit_policyhas the corresponding bit set. A token withaudit_policy = 0produces no privilege-use events, even when privileges are exercised. The policy is opt-in per token.
The four bits in detail #
A summary table of what each bit controls:
| Bit | Controls | When event fires |
|---|---|---|
OBJECT_ACCESS_SUCCESS (0x01) | Successful object access | After step 14b, when granted mask is non-empty |
OBJECT_ACCESS_FAILURE (0x02) | Failed object access | After step 14b, when requested mask is not fully granted |
PRIVILEGE_USE_SUCCESS (0x04) | Privilege use that succeeded | After step 13, when a privilege's contributed bits survived |
PRIVILEGE_USE_FAILURE (0x08) | Privilege use that failed | After step 13, when a privilege's contributed bits were stripped |
The bits are independent. A token can have all four, none, or any combination. The most common configurations:
audit_policy | Use case |
|---|---|
0 | Default. Audit only from SACL ACEs. |
OBJECT_ACCESS_FAILURE | PRIVILEGE_USE_FAILURE | Capture every denial. Useful for security monitoring. |
OBJECT_ACCESS_SUCCESS | OBJECT_ACCESS_FAILURE | Surveillance — every access by this principal. |
| All four | Maximum visibility. Compliance audit, forensics. |
The choice is operational: how much audit volume can the deployment afford to handle, and what is the audit pipeline configured to do with the events. There is no security benefit to setting more bits than the consumer can actually process — events that are dropped because the consumer fell behind are not useful audit.
Putting it together #
The full set of auditing channels:
| Source | Fires when |
|---|---|
SYSTEM_AUDIT* ACE in SACL | The ACE matches the caller's identity and access |
SYSTEM_ALARM* ACE in SACL | Per-operation on an open handle when the operation overlaps the ACE's mask |
| CAAP effective SACL audit ACE | The CAAP rule applied and its audit ACE matched |
PRIVILEGE_USE_SUCCESS audit policy | A privilege contributed surviving bits to the granted mask |
PRIVILEGE_USE_FAILURE audit policy | A privilege contributed bits that were stripped by a later layer |
OBJECT_ACCESS_SUCCESS audit policy | The access succeeded (granted mask non-empty), no SACL ACE match required |
OBJECT_ACCESS_FAILURE audit policy | The access failed (some requested bits denied), no SACL ACE match required |
Plus the session-destruction event (covered in Logon sessions) which fires independently of any access check.
A single access can produce events from many of these channels at once. An audit consumer sees them, deduplicates or correlates as appropriate, and persists what is needed for downstream use.
Where to go next #
For the schemas of the events these mechanisms emit and how they reach consumers, read Events and transport.
For the privilege model behind privilege-use audit, read Privileges.
Events and transport
Peios / Peios Security Fundamentals / Auditing
The audit layer is the part of the access check that generates events. Once an event has been produced, its further life is the transport layer's concern — getting the event from the kernel to whatever userspace consumer is interested. Peios uses KMES (Kernel Message Event Stream) for this. The kernel writes events to KMES; userspace daemons subscribe and receive them; eventually they are written to wherever the deployment's audit pipeline wants them.
This page covers the event schemas — what each event type contains — and the transport mechanics at a conceptual level.
The event types #
Five event types come out of the access-check layer and its surroundings:
| Event type | When it fires | Fired by |
|---|---|---|
access-audit | An SACL audit ACE matched, or a token audit_policy OBJECT_ACCESS_* flag forced it | Step 14 and 14b of AccessCheck |
continuous-audit | An operation on an open handle matched the handle's continuous audit mask | The kernel enforcement point for the operation (FACS for file handles; other kernel subsystems such as LCS for their own objects) |
privilege-use | A privilege contributed bits AND the token's audit_policy PRIVILEGE_USE_* requested it | Step 13 of AccessCheck |
caap-policy-diagnostic | A CAAP SACL evaluation error, or a staged/effective result mismatch | Step 12 of AccessCheck |
logon-session-destroyed | A logon session lost its last token reference | Session destruction path (independent of access check) |
Each event is a self-describing record. Consumers do not need to know which check produced an event in order to parse it; the event itself carries enough metadata to identify itself and its context.
Event encoding #
All audit events are encoded as msgpack maps with UTF-8 string keys. msgpack is the binary serialisation format Peios uses across kernel/userspace boundaries; it produces compact records that are quick to parse and round-trip cleanly.
SIDs and ACEs appear as bin (binary blob) values in the msgpack — the same binary forms used in the kernel. UIDs, timestamps, and bitmasks appear as uint values. Strings appear as msgpack strings (UTF-8).
Consumers are expected to ignore unknown keys. Future kernel versions may add fields to event records without changing the existing ones. A consumer that processes only the fields it knows about and ignores the rest will continue to work across kernel upgrades.
The msgpack format and the key conventions are stable across the v0.20 line. The exact byte-level layout is documented in Wire formats reference; this page covers the logical structure.
The subject record #
A subject record appears in every event that involves a calling principal. It identifies the token under which the operation ran:
| Field | Type | Meaning |
|---|---|---|
user_sid | bin | The token's user SID. |
group_sids | array of bin | The token's group SIDs (with attributes, in the same SID_AND_ATTRIBUTES form the token holds). |
integrity_level | uint | The token's integrity level (one of the well-known integrity RIDs). |
pip_type | uint | The PIP type of the calling process. |
pip_trust | uint | The PIP trust level of the calling process. |
For events that fire from inside an impersonating thread, the subject reflects the effective token — the impersonation token — not the primary. This is what the access check ran against, and it is what should be recorded.
For continuous-audit events specifically, the subject is the effective token at the moment of the operation, not at the moment the handle was opened. A process whose effective token has changed since opening the handle gets the up-to-date subject in each continuous-audit event.
The process record #
A process record identifies the kernel-level context the event came from:
| Field | Type | Meaning |
|---|---|---|
pid | uint | Process ID. |
name | string | Process executable name. |
executable_path | string | Full path to the executable. |
These fields let a consumer correlate the event with a running (or recently-running) process. The pid is useful in the short term — the process may still exist when the consumer is processing the event. The name and path are useful for human-readable correlation and for long-term records where the pid may have been reused.
access-audit event schema #
The most common event type. Fired by step 14 (SACL audit walk) and step 14b (token audit_policy).
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record. |
object_context | bin or nil | Caller-supplied opaque object identifier. nil if not provided. |
requested_access | uint | The access mask the caller requested (after generic mapping). |
granted_access | uint | The mask of rights actually granted. |
success | bool | Whether the access succeeded (granted contains all of requested). |
trigger | map | Trigger record — see below. |
process | map | The process record. |
The trigger map identifies why this event fired:
| Field | Type | Meaning |
|---|---|---|
kind | string | Either "sacl" (a SACL audit ACE matched) or "policy" (token audit_policy forced it). |
ace | bin or nil | For "sacl" triggers, the matched ACE bytes. For "policy" triggers, nil. |
A consumer can use trigger.kind to filter: events from SACL ACEs versus events forced by token policy. The ace field for SACL triggers includes the bytes of the matched ACE so a consumer can reconstruct what specific rule produced the event.
continuous-audit event schema #
Fired per-operation on a handle whose continuous audit mask overlaps the operation's required mask.
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record (operation-time effective token, not handle-open token). |
object_context | bin or nil | Object identifier, if retained by the enforcement point. |
operation | string | The operation name. For FACS operations, prefixed with file. (e.g. file.read, file.write). |
requested_access | uint | The required mask for this operation. |
matched_access | uint | The subset of required_access that overlapped the handle's continuous audit mask. |
granted_access | uint | The mask cached on the handle at the time of the operation. |
success | bool | Whether the operation succeeded. |
process | map | The process record. |
The two access-mask fields require explanation:
requested_accessis what this operation asks for. Areadon a file requires read; anmmaprequires execute or write depending on mode.matched_accessis the subset ofrequested_accessthat intersected the handle's continuous audit mask. The event fires because of this overlap; the mask is recorded so consumers know which alarm ACEs were relevant.granted_accessis the cached access mask on the handle from the original open. Operations can only succeed if their required mask is a subset of the cached granted mask, so this field tells consumers what the handle was actually authorised for.
privilege-use event schema #
Fired at step 13 of AccessCheck for each privilege that contributed bits.
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record. |
object_context | bin or nil | Object identifier (the same one the access check was for). |
privilege | string | The canonical privilege name (e.g. SeBackupPrivilege). |
requested_access | uint | The bits the caller requested that this privilege might address. |
granted_access | uint | The bits the privilege actually contributed. |
surviving_access | uint | The subset of granted_access that survived to the final granted mask. |
success | bool | True if surviving_access is non-empty (the privilege contributed bits that made it through). |
process | map | The process record. |
The three access masks tell the full story: what the caller wanted, what the privilege tried to grant, what actually survived. A successful privilege-use event has a non-empty surviving_access; a failed event has surviving_access == 0 — the privilege tried to grant bits but they were stripped.
logon-session-destroyed event schema #
Fired when a logon session loses its last token reference and is destroyed.
| Field | Type | Meaning |
|---|---|---|
session_id | uint | The destroyed session's LUID. |
user_sid | bin | The user SID the session belonged to. |
logon_type | uint | The session's logon type. |
auth_package | string | The auth-package name (e.g. "Kerberos", "NTLM", "local"). |
created_at | uint | The session's creation timestamp. |
No subject record (the subject was this session). No process record (the session is identified by its ID, not by any specific process).
The event lets userspace consumers — notably authd — release session-scoped state (Kerberos tickets, cached directory data, per-session credentials).
KMES transport #
The kernel does not directly write audit events to disk, send them over a network, or hand them to a specific userspace process. The kernel writes events to KMES — Kernel Message Event Stream — which is a per-subscriber ring buffer with kernel-side production and userspace-side consumption.
Conceptually:
- The kernel produces an event (e.g. an audit fires during step 14).
- The kernel writes the event into each subscriber's KMES buffer.
- Userspace consumers read from their KMES buffers when they are ready.
- Once a buffer is drained, the kernel can continue producing into it.
The buffers are per-subscriber. A subscriber that falls behind has its own buffer fill up; eventually KMES applies flow control to that buffer (typically dropping the oldest events). The kernel's production of events to other subscribers is unaffected.
The audit subsystem typically has at least one subscriber: eventd, the userspace audit daemon. eventd subscribes to the audit event types, reads them from KMES, and writes them to wherever the deployment's audit pipeline goes (a local file, a remote syslog, a SIEM collector). Other subscribers — debugging tools, real-time monitors — can also exist.
The exact KMES protocol, buffer sizes, flow-control mechanics, and reliability guarantees are outside the scope of this auditing topic; they are defined in PSPK §2, the kernel-boundary protocol standard in which KMES is the core abstraction. For auditing purposes, what matters is:
- Events are not retained by the kernel beyond writing them to KMES.
- A subscriber that falls behind may lose events.
- Reliable persistence is the job of whoever drains the KMES buffer, not the kernel.
What consumers should look at #
For someone writing or operating an audit consumer:
- Filter by event type. Most consumers care about specific types (just
access-auditfor compliance, justprivilege-usefor privilege monitoring, etc.). Filtering early reduces processing volume. - Correlate by subject + object_context. A consumer that wants to track "everything user X did to object Y" should index events by these two fields.
- Use the trigger field to distinguish event sources. An
access-auditwith trigger "policy" was forced by token audit policy; one with trigger "sacl" came from a specific ACE. The distinction matters for some compliance scenarios. - Be tolerant of new fields. Future kernel versions may add fields to event records. Ignoring unknown keys is the rule; rejecting events with unfamiliar fields is a bug.
- Plan for event loss. A KMES subscriber that falls behind loses events. Consumers that need lossless audit must implement their own persistence layer above KMES and handle backpressure appropriately.
What the audit transport does not provide #
A few clarifications:
- No reliable delivery to the kernel's edge. Events written to KMES are best-effort; a crashing subscriber loses its buffer. The kernel does not block access checks on subscriber readiness.
- No replay. A consumer that joins late, or one that loses its connection, cannot ask for old events. The events are produced once; missing them means missing them.
- No event correlation across boots. Each boot starts fresh. Audit logs that span reboots are assembled by userspace persistence (eventd writing to disk, log aggregators collecting across systems).
- No authentication of events at the consumer. Events are produced by the kernel and received by KMES subscribers. There is no signature on events; the trust model is "the kernel said this, so it is true". A subscriber that needs cryptographic non-repudiation of events would need to add a signing layer in userspace.
The model is built for performance and simplicity over absolute guarantees. For deployments that need stronger properties, the userspace audit pipeline is where additional layers belong — not in the kernel-to-KMES boundary.
See also #
- Auditing — the model these events come from.
- Audit ACEs — the SACL mechanisms behind access-audit and continuous-audit events.
- Policy-forced auditing — the token policy behind privilege-use and forced object-access events.
- Wire formats reference — byte-level encoding of the SIDs and ACEs carried in events.
Inspecting security state
Peios / Peios Security Fundamentals / Inspecting security state
When something goes wrong with access control, the answer is almost always in the live kernel state — what's on the relevant token, what's on the object's SD, what session the calling thread belongs to. Static configuration (the directory, the registry) tells you what should be the case; the live kernel state tells you what is the case. Inspection is how you read it.
Peios exposes inspection through a handful of surfaces: pseudo-files under /proc for per-process tokens, securityfs entries under /sys/kernel/security/kacs/ for the calling thread and the active sessions, and the KACS_IOC_QUERY ioctl on any token fd for the full menu of structured queries. This page maps the surfaces and the access rules they share; later pages in this topic cover each in depth.
What you can inspect #
Three classes of state are inspectable:
| State | Lives in | Primary inspection surface |
|---|---|---|
| Tokens | Per-thread (primary or effective), reference-counted in the kernel | /proc/<pid>/token, /proc/<pid>/task/<tid>/token, /sys/kernel/security/kacs/self, KACS_IOC_QUERY on a token fd |
| Logon sessions | Per-authentication-event, referenced by every token via auth_id | /sys/kernel/security/kacs/sessions (text listing); TokenStatistics query on a token (to get the session ID) |
| Processes | Per-process, PSB plus process SD plus token references | The process's token fd (for token state); querying the PSB (for PIP, mitigations); reading the process SD via kacs_get_sd |
The fourth thing one might expect — inspecting security descriptors on arbitrary objects (files, registry keys) — is covered by kacs_get_sd, the read counterpart of kacs_set_sd. That surface is part of the file-access and registry-access topics, not this one. This topic covers inspection of identity-and-process state specifically.
What you cannot inspect #
A few things that are not inspectable through this topic's surfaces:
- Per-thread state for threads other than the inspection target. The token fd you get from
/proc/<pid>/tokenis the primary token (a process-wide property). For thread-specific impersonation state, you need the thread-specific path/proc/<pid>/task/<tid>/token. - Internal kernel state. Reference counts, internal locks, cache state — these are not exposed. The inspection surfaces expose user-meaningful state only.
- Historical state. A token that has been destroyed is gone; its fields cannot be recovered. The kernel does not retain a history of tokens. For historical analysis you need the audit log.
- Other principals' state without authority. Reading another process's token requires
PROCESS_QUERY_INFORMATIONon the process plus PIP dominance plus the token access rights. The surfaces enforce all three. There is no "global view" available to a non-privileged caller.
Who can inspect what #
The access rules for inspection mirror the access rules for the underlying state. The kernel does not have a separate "inspection" privilege; reading state requires the same rights that any other read of that state would require.
For your own state (your thread's effective token, your process's primary token, the sessions you are part of):
/proc/self/token,/proc/self/task/<self-tid>/token— always readable./sys/kernel/security/kacs/self— always readable.- Token fd from
kacs_open_self_token— always returnable to you.
For another process's state:
/proc/<pid>/tokenrequiresPROCESS_QUERY_INFORMATIONon the target process plus PIP dominance./proc/<pid>/task/<tid>/tokenrequires the same.- Token fd from
kacs_open_process_tokenrequires the same plus the appropriate token rights.
For session state:
/sys/kernel/security/kacs/sessionsrequiresBUILTIN\AdministratorsorSYSTEM(enforced by the SD on the securityfs file).- Individual session details accessible via tokens you can already read (your own, or others' subject to the above rules).
For process state beyond the token (process SD, PIP, mitigations):
- Read your own PSB via
kacs_open_self_tokenor querying through the process-token's interface — always. - Read another process's PSB requires
PROCESS_QUERY_INFORMATIONplus PIP dominance.
The pattern: self is free, others need standard cross-process authority. PIP dominance is the absolute ceiling that nothing — no privilege, no inspection surface — bypasses. The kernel does not expose state of higher-trust processes to lower-trust callers under any conditions.
Two ways to read a token #
The kernel exposes two complementary ways to read what a token holds:
- Get a token fd, then query via
KACS_IOC_QUERY. This is the structured API. The ioctl takes a query class (one of 24 numbered classes) and returns binary data structured according to that class. Suitable for programmatic inspection. - Read a pseudo-file under
/procor/sys/kernel/security/kacs/. Some of these expose token fds (open them with O_PATH semantics and pass to ioctls); others expose text formats suitable for direct reading.
Both routes converge on the same kernel state. The pseudo-file route is convenient for shell-level inspection; the ioctl route is the right tool for programmatic use. A monitoring tool will typically use the ioctl; a sysadmin debugging an issue at a terminal will typically cat a pseudo-file.
The pseudo-files under /proc/<pid>/token are not text — they are token fds. cat-ing them does not produce human-readable output. The way to use them from the shell is the token command, which knows how to interpret a token fd and render its contents as readable text.
The /sys/kernel/security/kacs/sessions file is a text format — designed to be cat-able. It is the exception; the other surfaces are binary.
Standard query patterns #
A handful of patterns come up repeatedly:
- "Who is this thread?" — Open
/proc/<pid>/task/<tid>/token, query classTokenUserto get the user SID. - "What groups are on this token?" — Open the token, query the groups class.
- "What privileges are enabled?" — Open the token, query the privileges class.
- "Which session does this process belong to?" — Open the process's primary token, query
TokenStatisticsto get theauth_id, then look up that ID in/sys/kernel/security/kacs/sessions. - "Who owns this session?" — Either query
TokenStatistics(returns the auth_id) and look up the session in the listing, or read the listing directly and find the session by its details. - "What PIP level is this process at?" — Read the PSB through the process's token-related surfaces.
The two-step lookup for sessions (token → auth_id → sessions listing) is the standard pattern. Tokens know which session they belong to via auth_id; the session listing has the human-readable details (logon type, auth package, user SID, creation time).
Where to start #
If you want to inspect a token — what fields it has, how to query each one, the two-call pattern for variable-length data — read Inspecting tokens.
If you want to inspect a session — the text listing format, how to find which tokens belong to a session, how to track session lifecycle — read Inspecting sessions.
If you want to inspect a process — the process SD, the PSB's PIP and mitigation fields, the rules for cross-process inspection — read Inspecting processes.
If you want to inspect the audit event stream rather than static state — the live flow of events as access checks fire — read The event stream. That page covers revstrm, a low-level diagnostic probe that taps the raw KMES stream directly. It is a debugging tool for the audit pipeline, not the everyday way to view events (that is eventd's job).
If you have a denial in front of you and want a systematic walk through the diagnosis, Debugging a denial is the right page.
Inspecting tokens
Peios / Peios Security Fundamentals / Inspecting security state
A token's fields are read through the KACS_IOC_QUERY ioctl on a token fd. The ioctl takes a query class — a small integer naming what to return — and returns the corresponding data structured per that class. There are 24 defined classes covering everything from "the user SID" to "the full set of user and device claims".
This page covers how to obtain a token fd, the query ioctl mechanics, the two-call pattern for queries with variable-length output, and an overview of the class catalog.
Obtaining a token fd #
Token fds come from a handful of syscalls and pseudo-files:
| Source | Returns | Access check |
|---|---|---|
kacs_open_self_token | The calling thread's effective token (or primary, with KACS_REAL_TOKEN flag) | None — always succeeds |
kacs_open_process_token(pidfd) | A target process's primary token | PROCESS_QUERY_INFORMATION + PIP dominance + token SD rights |
kacs_open_thread_token(tid) | A specific thread's effective token | Same as above |
kacs_open_peer_token(sock_fd) | The peer's captured identity on a connected Unix socket | None beyond the connection itself |
/proc/<pid>/token | The primary token of process <pid> | PROCESS_QUERY_INFORMATION + PIP dominance |
/proc/<pid>/task/<tid>/token | The effective token of thread <tid> in process <pid> | Same |
/sys/kernel/security/kacs/self | The calling thread's effective token | None — always readable |
The fds carry an access mask. The mask is what the kernel granted at open time and what the subsequent ioctl will check against. A fd opened with TOKEN_QUERY cannot be used to install or duplicate the token; the ioctl will see the request as exceeding the fd's mask and refuse.
The pseudo-files under /proc and /sys/kernel/security/kacs/ return read-only fds — they carry TOKEN_QUERY and nothing else. To get a fd with more access you need one of the syscalls.
KACS_IOC_QUERY #
The ioctl is straightforward in shape:
ioctl(token_fd, KACS_IOC_QUERY, &args)
Where args is a kacs_query_args struct:
| Field | Meaning |
|---|---|
token_class | The numeric class identifying what to return (1–24 in v0.20). |
buf_len | Input: the size of the output buffer in bytes. Output: the actual number of bytes the query needed. |
buf_ptr | Userspace pointer to the output buffer. |
The kernel:
- Validates the class against the catalog. Unknown classes return
-EINVAL. - Checks that the fd grants
TOKEN_QUERY. If not, returns-EACCES. - Computes the size the response needs.
- If
buf_ptris zero orbuf_lenis zero — this is a size query — writes the required size tobuf_lenand returns 0. - If
buf_ptris non-zero butbuf_lenis smaller than required, returns-ERANGEwith the required size still written tobuf_len. - Otherwise writes the response to the buffer and returns 0.
The "two-call pattern" — size query then fetch — is the standard way to handle variable-length output:
- Call once with
buf_ptr = NULL(orbuf_len = 0). The kernel writes the required size intobuf_lenand returns 0. - Allocate a buffer of the indicated size.
- Call again with
buf_ptrset to the buffer andbuf_lenset to its size. The kernel writes the response.
For classes with a fixed-size response, a single call with a buffer of the known size works in one go. The two-call pattern is needed only for classes whose response size depends on the token's contents (the groups class, the restricted-SIDs class, the default-DACL class, the claims classes).
The ioctl is idempotent — multiple queries for the same class produce the same result as long as the token has not been modified. Tokens carry a modified_id counter that increments on adjustment; if a query is part of a pipeline that depends on consistency across multiple queries, the modified_id can be queried first to detect mid-pipeline changes.
Query class catalog #
There are 24 defined query classes. Each returns a structured payload defined for that class. The most commonly used:
| Class | Returns |
|---|---|
TokenUser | The token's user_sid and its attributes. |
TokenGroups | The groups array — every group SID with its attributes. Variable length. |
TokenPrivileges | The four privilege bitmasks — present, enabled, enabled-by-default, used. |
TokenOwner | The default owner SID. |
TokenPrimaryGroup | The default primary group SID. |
TokenDefaultDacl | The token's default DACL. Variable length. |
TokenSource | The source name and source-LUID identifying who minted the token. |
TokenType | Primary or Impersonation. |
TokenImpersonationLevel | Anonymous / Identification / Impersonation / Delegation (Primary tokens return Anonymous). |
TokenStatistics | token_id, auth_id (the logon-session ID), modified_id, token type, and expiry. |
TokenRestrictedSids | The restricted_sids array. Variable length. |
TokenSessionId | The interactive session ID. |
TokenOrigin | The originating logon-session ID. |
TokenElevationType | Default / Full / Limited. |
TokenIntegrityLevel | The integrity SID. |
TokenMandatoryPolicy | The mandatory_policy flags (NO_WRITE_UP, NEW_PROCESS_MIN). |
TokenLogonType | How the token's logon session was created — interactive, network, batch, service, and so on. |
TokenLogonSid | The logon session's logon SID (S-1-5-5-X-Y). |
TokenAppContainerSid | The confinement SID. Empty if the token is not confined. |
TokenCapabilities | The confinement capability SIDs with their attributes. Variable length. |
The remaining classes are TokenDeviceGroups (the device group SIDs), TokenUserClaims and TokenDeviceClaims (the claim arrays evaluated by conditional ACEs), and TokenProjectedSupplementaryGids (the token's projected Linux supplementary GIDs). Note there is no query class for the partner of a linked token pair — that goes through a separate ioctl, KACS_IOC_GET_LINKED_TOKEN, with its own access rules.
Each class's exact byte-level payload format is in the Wire formats reference; this page covers what each class is for.
Patterns by use case #
A handful of patterns come up repeatedly:
"Who is this thread acting as?" Open the thread's effective token (/proc/<pid>/task/<tid>/token or kacs_open_self_token). Query TokenUser to get the principal SID. Optionally query TokenImpersonationLevel to see if this is an impersonation token, and what level.
"What rights does this token have on this object?" This is not a query — you call AccessCheck with the token, the object's SD, and the access mask you want to test. Querying the token alone does not tell you the answer; the rights depend on the SD too.
"Which session does this token belong to?" Query TokenStatistics to get auth_id. Look up that ID in /sys/kernel/security/kacs/sessions for the session's details.
"Is this token elevated?" Query TokenElevationType. If Full, this token is the elevated half of a linked pair. If Default, it is not part of a pair. If Limited, it is the non-elevated half — the elevated counterpart is reachable via KACS_IOC_GET_LINKED_TOKEN.
"What privileges can this token actually exercise?" Query TokenPrivileges and inspect both the present and enabled bitmasks. A privilege is exercisable if it is both present and enabled. A privilege that is present but disabled can be enabled via AdjustPrivileges; a privilege that is absent cannot.
"Has this token been adjusted since I last looked?" Query TokenStatistics. The modified_id field is a counter that increments on every adjustment. If it has changed since your last query, the token has been adjusted.
What query classes do not let you do #
A few clarifications:
- You cannot modify a token through a query class. Queries are read-only. Modification goes through AdjustPrivileges, AdjustGroups, AdjustDefault, or
kacs_set_sd. - You cannot enumerate every token on the system. There is no "list all tokens" call. You can walk
/proc/*/tokento find tokens belonging to currently-running processes, but tokens held only by file descriptors with no associated running process are not enumerable. - You cannot read tokens you do not have authority for. A token fd with only
TOKEN_QUERYlets you query, but the fd had to be opened with appropriate authority. The query ioctl does not bypass the access checks at open time. - You cannot query undefined classes. Class numbers outside the defined range (1–24) return
-EINVAL. There are no hidden or reserved slots — all 24 defined classes are valid.
Reading from the shell #
For a sysadmin debugging at a terminal, the token command is the utility that wraps this ioctl. It handles the two-call pattern, decodes the binary payloads, and renders the results as readable text — so the query classes above become token subcommands rather than raw ioctl calls.
For programmatic use, the ioctl is what you call directly. Language bindings (the C SDK, the Python wrapper) provide ergonomic wrappers but ultimately call the same ioctl.
The pseudo-file approach — /proc/<pid>/token, /sys/kernel/security/kacs/self — gives you the token fd; the actual query still goes through the ioctl. Pseudo-files are just a convenient way to acquire the fd from the shell.
See also #
- Inspecting security state — the topic overview and the shared access rules.
- The token command — the shell wrapper around this ioctl.
- Tokens — what the queried fields mean.
- Wire formats reference — byte-level payload formats for each query class.
Inspecting sessions
Peios / Peios Security Fundamentals / Inspecting security state
Logon sessions are the kernel's records of authentication events. Every token belongs to one session; every running thread is acting under a token; therefore every running thread is associated with a session. Inspecting the system's active sessions tells you who is currently signed in, in what mode, when, and which authentication mechanism brought them in.
The primary inspection surface is /sys/kernel/security/kacs/sessions — a text-format pseudo-file that lists every active session. This page covers the listing format, the standard pattern for finding a session from a token, and how to track session lifecycle through the audit stream.
The sessions pseudo-file #
/sys/kernel/security/kacs/sessions is a text file produced by the kernel on each read. Each line describes one active session:
session_id=<decimal-u64> user_sid=<lowercase-hex-sid> logon_type=<decimal-u32> auth_package=<lowercase-hex-utf8> created_at=<decimal-u64>
Fields are space-separated, in key=value form. The format is stable for the listed fields; consumers should ignore unknown additional fields, which future versions may append.
| Field | Type | Meaning |
|---|---|---|
session_id | decimal u64 | The session's LUID. Same as the auth_id recorded on every token belonging to this session. |
user_sid | lowercase hex | The SID of the principal who signed in. |
logon_type | decimal u32 | The logon type. See Logon types. |
auth_package | lowercase hex of UTF-8 | The auth-package name (e.g. "Kerberos", "NTLM", "local") encoded as lowercase hex of the UTF-8 bytes. |
created_at | decimal u64 | Creation timestamp (kernel-internal monotonic units). |
The user_sid and auth_package fields are hex-encoded for parser stability — the SID is a binary structure, and the auth-package name could in principle contain characters that complicate text parsing. Hex encoding is uniform.
Access rule #
The file's SD grants read to BUILTIN\Administrators and SYSTEM only. A non-administrative caller will get EACCES on open(). This is intentional: the listing reveals every active session on the machine, including their identities and timestamps, which is information you do not want a low-privileged process to read.
For a sysadmin running as root (which projects to a token in the administrative group), reading the file is straightforward. For service accounts that need session enumeration capability, the right approach is to grant the relevant SID access via the file's SD, not to weaken the default protection.
Bootstrap sessions #
Two sessions exist before authd is up:
| Session ID | Use |
|---|---|
| 0 | The SYSTEM session. Attached to init, inherited by every early-boot process. Stays present for the lifetime of the system. |
| 998 | The Anonymous session. Backs the singleton Anonymous token. |
Both appear in the listing. They are not bugs; their presence is the normal state of any running system.
Finding a session from a token #
The standard pattern: given a thread or process, find its session.
- Open the token. For a thread, use
/proc/<pid>/task/<tid>/token. For a process's primary, use/proc/<pid>/token. For yourself, usekacs_open_self_tokenor/sys/kernel/security/kacs/self. - Query
TokenStatisticsviaKACS_IOC_QUERY. The response includesauth_id(the session's LUID). - Look up
auth_idin/sys/kernel/security/kacs/sessionsto find the matchingsession_id. The line gives you the session's full details.
This is the standard "which session is this process in" query. The session ID is the key; the listing has the rest.
For programmatic enumeration ("for each running process, which session is it in"):
for each pid in /proc/*:
for each tid in /proc/<pid>/task/*:
open /proc/<pid>/task/<tid>/token
query TokenStatistics, get auth_id
cross-reference with the sessions listing
This produces a complete picture of which thread is in which session. The logonse command uses exactly this pattern — it walks the running processes to render which processes belong to which session.
Tracking session lifecycle #
Sessions are created and destroyed dynamically. The listing always reflects the current state; to track changes over time you need to either poll the listing or subscribe to session lifecycle events.
Session creation #
The kernel does not emit a kernel-level event when a session is created — there is no logon-session-created event in v0.20 audit. Tracking creations requires either:
- Periodic polling of
/sys/kernel/security/kacs/sessionsand comparing against the previous snapshot. - Hooking into authd, which is the only thing that creates sessions and could in principle emit a higher-level event. (This is an authd integration concern, not a kernel one.)
- Listening for the audit events that fire on successful authentications — these are emitted by authd and consumed via KMES.
For most monitoring purposes, the audit stream from authd is the right source. The kernel-level sessions listing tells you "what is right now"; the audit stream tells you "what happened recently".
Session destruction #
The kernel does emit a logon-session-destroyed event when a session loses its last token reference. The event includes the session ID, the user SID, the logon type, the auth-package name, and the creation timestamp — enough to reconstruct what the session was.
The event is documented in Events and transport. Tools that want to track session lifecycle subscribe to this event via KMES and write a record on each occurrence.
The pattern: at session creation (detected via authd or via polling), record the start; at the logon-session-destroyed event, record the end. The two together give you a complete session log.
Inspecting an individual session #
A session ID is just a u64, but the listing line gives you everything currently knowable about the session from the kernel's perspective. There is no separate "session detail" query that returns more than what the listing provides.
If you need detail beyond what the listing offers (the privileges granted at sign-in, the policy that applied, the auth-package's specific authentication flow), the source is authd's own state, accessible via authd's APIs. The kernel records the session existence and minimal metadata; authd records the rest.
This separation is the standard kernel/userspace split. The kernel knows the session exists, who it is for, when it was created, and what auth-package created it. authd knows what happened during authentication.
Counting tokens per session #
A session can have many tokens. The number of tokens belonging to a session is the count of:
- Primary tokens attached to processes whose
auth_idmatches. - Impersonation tokens currently installed on threads whose
auth_idmatches. - Token fds open against tokens with that
auth_id.
Counting these from the outside is awkward — there is no "tokens per session" query. The standard way is to walk /proc/*/token and /proc/*/task/*/token, query TokenStatistics on each, and count matches. This is what session-revocation tooling does (authd specifically).
The reason for the awkward enumeration: each token is a separate kernel object, and there is no per-session index. The kernel knows tokens reference sessions (via auth_id); it does not maintain a reverse index of which tokens reference which session. Walking the running processes is the way to find tokens that exist.
A token held only by a file descriptor with no associated running process — e.g., a token fd passed via SCM_RIGHTS to a recipient that hasn't installed it — is not enumerable by walking /proc. Such tokens still keep the session alive (refcount), but their existence is not visible to a session-enumeration tool. They will reveal themselves only when the holding process tries to use them.
Session expiry #
A session's created_at is set at creation; there is no expires_at in the listing. Sessions do not expire on a kernel timer. A session lives as long as its tokens have references; it ends when the last token reference drops.
The token's session continues to exist regardless of any token's expiration value.
If a deployment needs strict session timeouts, the enforcement is in userspace. authd can monitor created_at against a policy maximum and revoke sessions whose age exceeds the limit. Revocation is the userspace-coordinated process described in Session lifecycle: authd walks /proc/*/token, finds tokens with the target auth_id, kills the holding processes.
The lack of kernel-side timer enforcement is a deliberate simplification. Adding kernel timers for session expiry would push expiry policy into the kernel; keeping it in userspace lets administrators define their own rules.
Where to go next #
For the rest of a process's inspectable state — its PSB and its process SD — read Inspecting processes.
For what sessions are, how they are created, and how revocation actually works, read Session lifecycle.
Inspecting processes
Peios / Peios Security Fundamentals / Inspecting security state
A process's inspectable state spans three things: its token (its identity), its PSB (its PIP labels and mitigation flags), and its process SD (the policy on the process as an object). Each is read through its own surface but the access rules are similar — your own state is always readable; another process's state needs PROCESS_QUERY_INFORMATION plus PIP dominance.
This page covers the per-process inspection surfaces beyond the token. Tokens are covered in Inspecting tokens; this page is about the PSB and the process SD.
What the PSB holds #
The Process Security Block is the per-process kernel structure with:
| Field | Meaning |
|---|---|
pip_type | The process's PIP type (None / Protected / Isolated). |
pip_trust | The PIP trust level within the type. |
| Mitigation flags | The bitfield of enabled mitigations (WXP, LSV, TLP, CFIF, CFIB, PIE, SML, NO_CHILD, etc.). |
security_descriptor | The process SD, governing cross-process operations. |
These are the inspectable fields. Internal fields (refcounts, lock state) are not exposed.
Inspecting your own process #
For a thread inspecting its own process's PSB, the path is:
- Open the process's primary token via
kacs_open_self_token(with theKACS_REAL_TOKENflag if you need the primary specifically, not the impersonation). The returned fd lets you query token state. - Query through the process-related classes —
TokenSessionIdand related — which return PSB-adjacent state where available. - Read the process SD via
kacs_get_sdwith a self-targeted query (using the appropriate flags for "this process").
For some PSB fields, dedicated query routes exist:
- The PIP fields can be read by querying the calling process's PSB through a dedicated path. The typical surface is via the token's session/process classes, which carry the PIP fields as part of the per-token snapshot.
- The mitigation bitfield is readable from the process itself; the typical pattern is to query the PSB directly via the appropriate ioctl.
The exact API for reading the PSB is in the Kernel ABI reference; the conceptual point for this page is that all PSB fields are introspectable by the process itself, with no privilege required.
Inspecting another process's PSB #
To inspect another process's PSB, you need:
PROCESS_QUERY_INFORMATIONon the target's process SD.- PIP dominance over the target (the caller's PIP must dominate the target's, per the two-check rule).
Both requirements apply. A token-bearing principal granted PROCESS_QUERY_INFORMATION cannot inspect a higher-PIP process even with the SD grant — the PIP check is independent.
Once both checks pass, the same query mechanisms work: open the target's primary token (via kacs_open_process_token), query through KACS_IOC_QUERY, read the process SD via kacs_get_sd.
The PIP dominance requirement is the same one that gates every cross-process operation. A low-trust caller cannot see into a high-trust process even via inspection. A SeDebugPrivilege-holder can bypass the SD check (PROCESS_QUERY_INFORMATION becomes trivially granted) but does not bypass PIP — a privileged debugger still cannot inspect TCB processes.
In practice, only peinit and processes signed at the same PIP level as the target can inspect TCB processes. Ordinary administrators with SeDebugPrivilege are blocked at the PIP layer.
Reading the process SD #
A process's SD is read via kacs_get_sd with the appropriate process-targeted flags. The call:
kacs_get_sd(target_pidfd, security_information, buf, buf_len, flags)
Returns the SD components requested (per the security_information mask). Self-targeted queries are always allowed; cross-process queries require READ_CONTROL on the target's process SD plus PIP dominance.
READ_CONTROL is one of the standard rights every SD-bearing object exposes; it appears in the DACL like any other right. By default the owner of an object has it implicitly (see Ownership).
For inspecting the SACL specifically — to see audit ACEs, mandatory labels, PIP trust labels, scoped policy references — ACCESS_SYSTEM_SECURITY is the right needed, not READ_CONTROL. That right is gated by SeSecurityPrivilege. So:
- DACL: needs
READ_CONTROL(typically held by the owner). - SACL: needs
ACCESS_SYSTEM_SECURITY(typically held only by administrators withSeSecurityPrivilege). - Owner / primary group SIDs: need
READ_CONTROL.
A non-privileged caller can read a process's DACL (if granted) but not its SACL. For administrative inspection of the full SD including SACL, SeSecurityPrivilege is the lever.
Reading mitigation flags #
The mitigation bitfield on the PSB is read via a dedicated query path. For your own process the read is trivial. For another process the same PROCESS_QUERY_INFORMATION + PIP dominance rules apply.
The bitfield is the same one the kernel uses internally:
| Flag | Bit | Meaning |
|---|---|---|
| WXP | 0x001 | Write-XOR-Execute enabled |
| TLP | 0x002 | Trusted Library Paths enabled |
| LSV | 0x004 | Library Signature Verification enabled |
| CFI (legacy) | 0x008 | CFIF + CFIB combined alias |
| UI_ACCESS | 0x010 | Reserved |
| NO_CHILD | 0x020 | Forbid fork/clone-new-process |
| CFIF | 0x040 | Forward CFI |
| CFIB | 0x080 | Backward CFI |
| PIE | 0x100 | PIE-only exec |
| SML | 0x200 | Speculation mitigation lock |
A process's mitigation flags tell you what hardening it has enabled. Comparing this against the process's binary lets you reason about which exploitation paths are closed — a TCB-signed binary running with WXP, LSV, TLP, CFIF, CFIB, and PIE is comprehensively hardened; one with only PIE has minimal hardening.
The flags are one-way — once set, they cannot be cleared. So the snapshot you read now is also the snapshot for the rest of the process's life (except that new flags may be set). Re-reading produces the same or stricter result.
Cross-referencing process and token #
A common diagnostic pattern: given a process, know which session it is in, which user it acts as, what its PIP is, and what mitigations are active.
The sequence:
- Open the process's primary token via
/proc/<pid>/tokenorkacs_open_process_token. - Query
TokenUserfor the user SID. - Query
TokenStatisticsforauth_id. Cross-reference with/sys/kernel/security/kacs/sessionsfor session details. - Read the PSB for PIP and mitigations.
- Read the process SD for who can act on this process.
Each step requires the appropriate access, and each fails closed if the caller lacks authority over the target. For self-targeted queries everything succeeds.
For a debugger or monitoring tool, this is the standard "tell me everything about this process" workflow. The pieces are independent (each query is its own ioctl), but they combine to give a complete picture.
Live vs static state #
A process's state changes over time. The current state is what the inspection surfaces return; previous state is not retrievable.
The changing parts of a process's state:
- Threads come and go. A process's set of threads is dynamic. Re-running per-thread inspection picks up the current set.
- The thread's effective token may change (impersonation install/revert). Re-querying gets the current value.
- The primary token's adjustable fields (privileges enabled state, groups enabled state, default DACL) can change. The
modified_idcounter on the token tracks how many changes have happened. - The process SD can be modified by anyone with
WRITE_DACon the process. New ACEs appear; old ACEs disappear.
The immutable parts of a process's state (once set):
- PIP fields. Set at exec; never change for the lifetime of the process.
- Mitigation flags (including
NO_CHILD). One-way; can be tightened but never relaxed. - Token identity fields (user_sid, groups[].sid, restricted_sids, logon_sid). Set at token creation; the token can be replaced but never have its identity adjusted.
Knowing which fields are immutable helps with monitoring. A monitor that has already read the PIP fields once does not need to re-read them; they will not change. A monitor watching for privilege state changes needs to poll or subscribe to events — they can change at any time.
What inspection cannot tell you #
A few clarifications:
- It cannot tell you what access a process has. Inspection gives you the inputs to AccessCheck (the token, the object's SD); it does not compute the access. To know what a process can do to a specific object, call AccessCheck.
- It cannot give you a tamper-evident snapshot. The kernel may modify state between two reads; there is no "atomic snapshot" surface. Tools that need consistency should use the
modified_idcounter to detect changes. - It cannot reveal the contents of the target process's memory. Inspecting the PSB and the process SD shows you the kernel's metadata about the process. To read the process's memory you need
PROCESS_VM_READon the process SD plus PIP dominance plus a ptrace-like syscall. That is a different topic. - It cannot show you removed history. A token whose privileges were once enabled but have since been removed shows the current state, not the history. The
usedbit on a privilege is a sticky record of "this privilege has been exercised at some point", but specific timestamps are an audit-log concern, not an inspection concern.
The inspection surfaces are for the present moment. For historical questions, the audit log is the right source.
Where to go next #
For inspecting the live flow of audit events rather than current state, read The event stream.
For the identity half of a process's state — obtaining and querying token fds — read Inspecting tokens.
For the dominance rule that gates every cross-process inspection, read The two-check rule.
The event stream
Peios / Peios Security Fundamentals / Inspecting security state
The other pages in this topic inspect state — what a token holds, which session a thread belongs to, what a process is authorised for. This page inspects flow: the live stream of audit events the kernel produces as access checks fire. State tells you what is true right now; the event stream tells you what is happening, event by event, as it happens.
The tool for reading that stream raw is revstrm. It attaches directly to the KMES per-CPU ring buffers and prints every event it drains. It is deliberately oblivious to the rest of the audit pipeline: it knows nothing about eventd, applies no persistence, and makes no attempt at reliable delivery. It is a debugging probe for the transport layer itself.
What revstrm is (and is not) #
revstrm is not the normal way to look at audit events. The normal way is to query eventd, the userspace audit daemon that subscribes to KMES, persists events, and serves them to consumers. eventd is the durable, deployment-facing surface. revstrm sits underneath it, tapping the same kernel stream directly.
Two things make it worth having:
- It is a diagnostic. When events are not reaching eventd, or you suspect the kernel is not emitting what you expect,
revstrmlets you see the raw KMES stream with no daemon in the path. It is the "is the wire live?" probe for the audit pipeline. - It is the reference consumer.
revstrmexercises the full KMES consumption protocol of PSPK §2.4 — one drain thread per CPU ring, the futex notification wait, generation-change re-attach, and lapping/gap detection. It is the worked example that proves the consumption path before a production consumer like eventd relies on it.
Because it taps KMES directly rather than through eventd, revstrm is subject to the raw transport's limits: it is its own KMES subscriber, it can fall behind, and when it does its ring laps and it loses events — visibly (see Lapping and gaps). It is not a lossless audit sink and must never be relied on as one. For durable audit, that is eventd's job.
Access requirement #
Attaching to the KMES ring buffers requires SeSecurityPrivilege. Without it, the attach fails at the first ring with a clear hint rather than a silent empty stream:
revstrm: cannot attach to the KMES ring buffers (SeSecurityPrivilege required)
This is the same privilege class that gates the rest of the security-sensitive surfaces: reading the audit stream reveals every access decision on the machine, so it is not something a low-privileged process can do. An administrator (whose token holds SeSecurityPrivilege) can run it; an ordinary user cannot.
Synopsis #
revstrm [OPTION]...
With no options, revstrm follows the live stream: it attaches to every per-CPU ring and prints each event as it arrives, oldest surviving event first, until you interrupt it (Ctrl-C) or the output pipe closes. There is no target to name and no subscription to configure — it drains whatever the kernel is currently writing to KMES.
Options #
The option surface is small and entirely about what to show and how to show it — there is nothing to configure about the subscription itself.
| Option | Description |
|---|---|
-t, --type GLOB | Only show events whose event-type string matches GLOB. Repeatable; an event is shown if it matches any supplied pattern (OR). Uses shell-glob syntax (e.g. --type 'access-*'). |
-o, --origin CLASS | Only show events from origin CLASS, one of userspace, kmes, kacs, or lcs (case-insensitive; user/usr alias userspace). Repeatable; an event is shown if its origin matches any supplied class. |
-p, --pretty | Expand the msgpack payload across multiple indented lines instead of the compact one-line form. |
-s, --snapshot | Drain the events currently buffered across all rings and exit, instead of following the live stream. Single-threaded, in CPU order; there is nothing to wait for. |
--help | Print usage and exit. |
--version | Print version and exit. |
Long options may be abbreviated as long as the prefix is unambiguous (e.g. --sn for --snapshot).
The --type and --origin filters are applied by revstrm after draining, purely to reduce what is printed — they are display filters, not a kernel-side subscription. The kernel still writes every event into the ring, and revstrm still drains every event; filtered-out events are simply not printed. This matters for lapping: filtering does not reduce the drain load, so it does not make revstrm less likely to fall behind.
Output format #
Each event prints as a single header line (followed, under --pretty, by an indented payload block):
TIME cpuN #SEQUENCE ORIGIN event.type payload
| Field | Meaning |
|---|---|
TIME | UTC time-of-day with microsecond precision, HH:MM:SS.uuuuuu. The calendar date is dropped — a live tail cares about wall-clock time of day, not the day. |
cpuN | The per-CPU ring the event was drained from. Events are sharded per CPU all the way down; revstrm prints the CPU rather than merging into a single ordered stream. |
#SEQUENCE | The event's per-ring sequence number. Gaps in the sequence on a given CPU indicate lost events. |
ORIGIN | The origin class: one of USR, KMES, KACS, LCS (or cN for an unrecognised class). |
event.type | The event-type string (e.g. access-audit, logon-session-destroyed). |
payload | The msgpack payload, rendered as described below. |
Payload rendering #
By default the payload is rendered compactly on the header line, truncated if long. revstrm decodes the msgpack and applies a couple of field-name conventions to make raw events readable:
- Keys ending in
sidare rendered as canonical string SIDs (S-1-5-18); keys ending insidsrender as an array of them. - Keys ending in
accessare decoded into|-joined access-right names (e.g.FILE_READ_DATA|FILE_READ_ATTRIBUTES), with any unrecognised bits shown as a hex remainder so nothing is hidden.
A payload that will not decode as msgpack falls back to a hex preview rather than being dropped. With --pretty, the same payload is expanded into an aligned, indented block — one key per line, nested maps and arrays expanded beneath their key. Use --pretty when you are reading individual events closely; leave it off when tailing a busy stream.
The event schemas themselves — access-audit, continuous-audit, privilege-use, logon-session-destroyed — are documented in Events and transport. revstrm does not interpret them beyond the field-name conventions above; it is a stream printer, not an event analyser.
Example #
A short follow session might look like:
14:22:07.481923 cpu0 #10432 KACS access-audit {subject: {user_sid: S-1-5-21-…, integrity_level: 12288, …}, requested_access: FILE_READ_DATA|FILE_READ_ATTRIBUTES, granted_access: FILE_READ_DATA|FILE_READ_ATTRIBUTES, success: true, …}
14:22:07.492010 cpu3 #8871 KACS privilege-use {privilege: "SeBackupPrivilege", surviving_access: FILE_READ_DATA, success: true, …}
14:22:08.003114 cpu0 #10433 KACS logon-session-destroyed {session_id: 4051, user_sid: S-1-5-21-…, logon_type: 2, …}
Lapping and gaps #
revstrm is one KMES subscriber among possibly several, with its own per-CPU rings. If it cannot drain a ring fast enough — a burst of audit events, a slow terminal, a --pretty render on a busy stream — that ring laps: the kernel overwrites the oldest un-drained events with new ones. This is the flow-control behaviour of KMES, not a bug in revstrm.
When a ring laps, revstrm does not hide it. It prints a visible marker naming the CPU and the count lost:
--- cpu2: lost 37 event(s) (ring lapped) ---
A dropped event a debugger cannot see is the worst possible outcome, so lapping is always surfaced. If you see these markers, revstrm is not keeping up — narrowing the output with --type/--origin won't help (the drain still happens), but redirecting to a file, dropping --pretty, or reducing the event rate will. Sustained lapping is also a signal in its own right: the kernel is producing events faster than a single un-buffered consumer can drain them.
Gaps are also visible directly in the #SEQUENCE column: a jump in the per-CPU sequence number is a run of events that this subscriber never saw.
Exit behaviour #
- In follow mode (the default),
revstrmruns until interrupted or until stdout closes. A downstreamhead(or any reader) closing the pipe shutsrevstrmdown cleanly — it treats the broken pipe as "nothing left to print to" and exits, rather than being killed bySIGPIPE. If every per-CPU ring hits a fatal error, all drain threads exit and the process ends. - In snapshot mode (
--snapshot), it drains what is currently buffered across all rings and exits immediately.
When to use it #
Use revstrm when you are debugging the audit pipeline — "are events being emitted at all?", "is eventd's problem upstream or downstream of KMES?", "what exactly is the kernel putting on the wire?". Use eventd (or whatever consumes it in your deployment) for everything else: durable audit, historical queries, and any consumption that must not lose events.
See also #
- Inspecting security state — the state-inspection counterparts: tokens, sessions, and processes.
- Events and transport — the schemas of the events revstrm prints.
- Auditing — where the events come from.
Boot and trust establishment
Peios / Peios Security Fundamentals / Boot and trust establishment
Boot is the sequence by which Peios goes from "a kernel has been loaded and is starting" to "a fully-running system with real principals, real services, real policy". Most operating systems have a boot sequence, but Peios has a specific trust sequence — the kernel needs to establish how authority works on this running system from a starting point where no userspace exists yet, no authd is running, no directory has been consulted, and no tokens have been minted by anything but the kernel itself.
The chain of events is short and well-defined. The kernel sets up its own bootstrap tokens directly, attaches one to the first userspace process, and starts it. That first process is prelude, the PID 1 of an in-memory startup environment — the initramfs — whose job is to mount the system's real root filesystem and hand the machine over to it. peinit (signed at TCB, sitting at PID 1 of the real root) then takes over and begins launching services. Eventually authd starts and assumes responsibility for creating real identities for users and services. Each step relies on the previous; the chain has to be intact for the resulting system to be trustworthy.
This page covers the chain at a high level. Later pages cover each part in depth.
What boot establishes #
By the time boot is complete, the system has:
- Two kernel-direct bootstrap tokens (SYSTEM and Anonymous) that exist for the lifetime of the running kernel.
- A peinit process running as PID 1, signed at TCB level, holding the SYSTEM token's privileges plus knowledge of which services to launch.
- An authd process running (started by peinit), populated with the deployment's policy (privileges, claims, CAAP), ready to authenticate users and services.
- A set of service processes running under tokens authd minted, each with their own identity and authority.
- A populated CAAP cache in the kernel (pushed by authd).
- Mount policies applied (set by peinit at boot).
- The handle model in steady state — services have opened the files they need; their fds have cached masks.
Once all of this is in place, the system is "up". Users can sign in (authd handles the authentication; produces tokens; peinit launches their session processes); access checks resolve normally; audit events flow through KMES; everything that the rest of these docs describes is operating.
The interesting question is what happens during boot, when each of those pieces is being established. The chain is a sequence of "this couldn't work before, but now it can":
- Before the kernel finishes init, nothing works. The kernel is itself.
- After kernel init, the kernel has constructed the SYSTEM and Anonymous tokens. The first userspace process — prelude, the initramfs PID 1 — starts running on the SYSTEM token.
- After prelude has run the initramfs, the system's real root filesystem is mounted. prelude switches
/to it and execs the real init. - After peinit takes over at PID 1 of the real root, the kernel has a userspace component that can launch other userspace components. peinit can fork-and-exec services.
- After authd starts, the system has a way to mint real identities for users and services. Until now, every process was running on SYSTEM (inherited from init); from this point on, services can run as their own identities, and users can sign in.
- After authd has populated the CAAP cache, central access policies actually apply. Objects referencing CAAP get evaluated against the policy rather than the recovery policy.
- After mount policies have been applied, FACS knows what to do on each filesystem.
Each transition is the "next thing that has to happen for the system to be coherent". They roughly correspond to the pages in this topic.
The bootstrap problem #
The deeper question: how does any of this work in the first place? authd creates tokens, but authd is itself a process — what token does it run on? peinit launches services, but peinit is also a process — what privilege does it hold to be allowed to mint child tokens? The system has to have authority and identity to do anything, but authority and identity are exactly what boot is supposed to establish.
The kernel solves this with a few "primordial" operations that don't go through the normal pipeline:
- The SYSTEM token is constructed directly by the kernel, not via
kacs_create_token. It's there from the moment the kernel finishes initialising, before any userspace process exists. - The SYSTEM token has every privilege enabled, integrity System, every group SID that any token might ever need. It's authority-maximal.
- init runs on the SYSTEM token. Every child it forks inherits the SYSTEM token until something replaces it.
- peinit is signed at TCB level (verified by the kernel at exec — the verification happens through the same signing path every binary does). Once peinit is running on the SYSTEM token, it has authority to do anything; once peinit is also signed at TCB level, it has PIP authority too.
- authd is similarly signed at TCB and run by peinit with the SYSTEM token. Authd then uses its
SeCreateTokenPrivilege(from the SYSTEM token) to mint other tokens for other processes.
The chain works because each link is held by the previous. The kernel builds the SYSTEM token (link 1); prelude, the initramfs PID 1, runs on it (link 2); peinit takes over from prelude when the system switches to the real root (link 3); peinit launches authd (link 4); authd creates real tokens (link 5). Each link can do its job because the previous link gave it the authority.
The risk: anything that breaks a link breaks the chain. A peinit not signed at TCB level wouldn't get PIP protection at exec; a compromised authd would mint malicious tokens. The signing-and-PIP machinery is what makes the chain trustworthy: each binary in the chain is one whose signature the kernel verifies, and that signature anchors the kernel's trust in what the binary does next.
The "everything is SYSTEM" phase #
There is a brief window during boot where every userspace process is running on the SYSTEM token. From prelude starting in the initramfs, through the early phases of peinit, up until peinit starts launching real services as themselves, every process is SYSTEM.
This is a feature, not a bug. The SYSTEM token is authority-maximal. Whatever early-boot work needs to happen — reading configuration, populating mount policies, launching authd itself — can be done without worrying about whether the running process has the right privileges. It does, because everything is SYSTEM.
The window is short. peinit aims to launch authd as quickly as possible because most of the system is not useful until authd is running. From the moment authd is up, identities can be real, services can run as themselves, users can sign in. The SYSTEM-everywhere phase is what enables the bootstrap, then ends as soon as the real identity machinery can take over.
Services that must not start before authd is ready include anything that handles user requests. A file-server that started in the SYSTEM phase would have its access checks running as SYSTEM, not as the principal it should be acting for — which is operationally wrong even though it's authoritatively allowed. peinit waits for authd to be ready before launching such services.
Services that may start before authd include things that only need their own state — the registry daemon, observability daemons. These can run as SYSTEM for the rest of the boot if they need to, then ideally drop privileges or get reassigned a more specific token once authd is up.
The exact ordering — which services need to wait for what — is part of peinit's startup configuration, not a kernel concern.
Where to start #
If you want the kernel-direct bootstrap tokens — what SYSTEM and Anonymous contain, why they're constructed without going through kacs_create_token, how they relate to the rest of the token model — read Bootstrap tokens.
If you want the initramfs stage — prelude, the in-memory startup environment, the /boot/initramfs/ directory it is built from, and the handoff to the real root — read The initramfs stage.
If you want how the initramfs is composed — boot hooks, the capabilities they declare, how their order is resolved, and how to write one — read Boot hooks.
If you want peinit's role — what makes it the right thing to be PID 1, the service-launching pattern, the lifecycle-manager role that falls out of PIP — read peinit at PID 1.
If you want the authd transition — what authd does at startup, the CAAP cache population, the SYSTEM-everywhere-handoff — read authd handoff.
If you want the kernel-level invariants that the boot chain depends on — the LSM stack, the build config flags, what the kernel refuses to do — read Kernel invariants.
The boot artifacts themselves are built by two command-line tools, the two halves of Peios' Dynamic Boot system. mkirf compiles the /boot/initramfs/ source tree into the deterministic initramfs image; mkuki then wraps that image, together with a kernel and command line, into the single UEFI unified kernel image the firmware boots. Both can run once or stay resident, keeping the boot image current as their inputs change.
Bootstrap tokens
Peios / Peios Security Fundamentals / Boot and trust establishment
The kernel constructs two tokens directly during initialisation, before any userspace process exists. These are the SYSTEM token and the Anonymous token. Both exist as singletons in the kernel from the moment kernel init completes; both persist for the lifetime of the running kernel; both are created without going through kacs_create_token (there is no userspace caller to make that syscall).
The two tokens have opposite roles. SYSTEM is the authority-maximal token — every privilege, every well-known administrative group, integrity System — that gets attached to init and bootstraps every other userspace token. Anonymous is the identity-minimal token — no real principal, no privileges, integrity Untrusted — that backs the Anonymous-level of impersonation.
This page covers what each contains and why they're constructed this way.
SYSTEM — authority-maximal bootstrap #
The SYSTEM token is what init runs on. Specifically:
| Field | Value |
|---|---|
user_sid | S-1-5-18 (the SYSTEM well-known SID) |
groups | BUILTIN\Administrators (with SE_GROUP_OWNER), Everyone, Authenticated Users, Local, and the bootstrap logon SID S-1-5-5-0-0 |
integrity_level | S-1-16-16384 (System integrity — the highest) |
mandatory_policy | NO_WRITE_UP |
privileges | Every privilege defined in the catalog, all present, all enabled |
token_type | Primary |
impersonation_level | Anonymous (conventional for primary tokens) |
auth_id | 0 (the SYSTEM session) |
default_dacl | Grants SYSTEM and BUILTIN\Administrators GENERIC_ALL; denies everyone else by absence |
The SYSTEM token is the most powerful identity on a running Peios system. It has every privilege, full administrative-group membership, the highest integrity. The only thing it lacks is a specific real principal — it's a system-internal identity, not a user.
The SYSTEM token has session ID 0, which corresponds to the SYSTEM session (also kernel-constructed). The session is a peer of the token; both exist together from kernel init.
The default DACL on the SYSTEM token is restrictive in an important way: when SYSTEM creates a new object without specifying an explicit SD, the default DACL ends up on the object. Granting only SYSTEM and Administrators means objects SYSTEM creates are administrator-only by default. This is the right policy for things like service configuration files — they should not be readable by every user just because they were created during boot.
Anonymous — identity-minimal singleton #
The Anonymous token has the opposite shape. It's not used for running processes; it's used as the identity of Anonymous-level impersonation tokens (the lowest impersonation level — see Impersonation levels).
| Field | Value |
|---|---|
user_sid | S-1-5-7 (the Anonymous well-known SID) |
groups | Everyone only |
integrity_level | S-1-16-0 (Untrusted — the lowest) |
mandatory_policy | NO_WRITE_UP |
privileges | None |
token_type | Impersonation (it exists specifically for impersonation) |
impersonation_level | Anonymous |
auth_id | 998 (the Anonymous session) |
The Anonymous token has the bare minimum to be a token at all. No real principal (the user SID is the well-known "Anonymous" placeholder). Just Everyone for groups — explicitly not Authenticated Users. No privileges. Untrusted integrity.
When a client connects to a server and sets the impersonation level to Anonymous (or the gates downgrade the impersonation level to Anonymous), the server's effective token becomes a derivation of this Anonymous token — essentially a copy with the relevant session bindings. The kernel's "Anonymous identity" is centrally defined here; the rest of the system inherits from it.
Why kernel-direct construction #
The two tokens are constructed by direct kernel initialisation, not via kacs_create_token. This matters because kacs_create_token requires SeCreateTokenPrivilege — which requires a caller — which requires a token — which is exactly what's being established. The bootstrap problem.
The kernel breaks the cycle by constructing these two tokens with internal code that doesn't go through the usual API. The kernel can do this because it is the kernel; there's no privilege check on itself.
The implications:
- No record exists of who minted these tokens. The
sourcefield, normally set to whatever component calledkacs_create_token, is set by the kernel to indicate "kernel-internal". The tokens were not minted by authd, peinit, or any other component. - They cannot be re-created at runtime. Even though both tokens have the same shape every time they're constructed (the values are kernel-internal constants), no userspace component can ask the kernel to construct another SYSTEM token at the same level. SeCreateTokenPrivilege lets userspace create new tokens; it doesn't grant access to construct kernel-internal singletons.
- They are singletons. There is exactly one SYSTEM token and one Anonymous token at any moment in the kernel. Other tokens may reference them or be derived from them, but the canonical instances are unique.
The kernel-direct construction is the foundation: it gives the rest of the system a starting identity to build from. Once SYSTEM exists and is attached to init, everything that follows can use normal API calls — they have a token, they have privileges, they can mint other tokens.
What's attached to what #
At the end of kernel init:
- SYSTEM token. Constructed; ready.
- Anonymous token. Constructed; ready.
- SYSTEM session (ID 0). Constructed; the SYSTEM token is bound to it.
- Anonymous session (ID 998). Constructed; the Anonymous token is bound to it.
- init process. About to start; will have the SYSTEM token attached to it.
Init starts and takes the SYSTEM token as its primary token. The first userspace process the kernel runs is prelude, the PID 1 of the initramfs — the in-memory startup environment that mounts the real root (see The initramfs stage). The SYSTEM token survives every exec along the way: prelude runs on it, and when prelude hands the machine to the real root it execs peinit, which takes over with the SYSTEM token as its own.
From this point on, the only entity with the SYSTEM token is peinit (and anything peinit chooses to fork before assigning each child its own token). When peinit launches authd, it does so by forking and then either letting the child inherit SYSTEM (briefly, until authd starts assigning a different token to itself) or by replacing the token with one peinit prepared.
The Anonymous token is not attached to any process. It exists as a kernel-internal object; impersonation tokens at the Anonymous level are derivations of it but the canonical instance lives in the kernel.
Lifecycle #
Both bootstrap tokens are never destroyed during the kernel's lifetime. They are reference-counted like any other tokens, but their references never drop to zero because:
- The SYSTEM token has at least one attachment (initially init, then peinit, then whatever process inherits from peinit). Even after peinit assigns specific tokens to most child processes, peinit itself continues to run on SYSTEM.
- The Anonymous token is a kernel-internal singleton. The kernel holds a reference to it; the reference is not dropped until kernel shutdown.
On reboot, the kernel re-constructs them at init. They are not persistent — there is no on-disk SYSTEM-token record — they are re-built fresh every boot. The fields are deterministic, so the new instances are equivalent to the previous ones structurally; the actual token instances are different objects.
The session IDs (0 for SYSTEM, 998 for Anonymous) are also fixed across boots. The logon SID derived from the SYSTEM session is S-1-5-5-0-0.
Why these specific shapes #
The SYSTEM token has every privilege and full administrative-group membership because it needs to be able to do anything. Boot involves operations that require a wide range of privileges — SeLoadDriverPrivilege to load kernel modules, SeBackupPrivilege / SeRestorePrivilege for early-system manipulation, SeShutdownPrivilege for orderly shutdown. Restricting SYSTEM would force peinit to know in advance which privileges it would need, which is brittle.
SeBackupPrivilege and SeRestorePrivilege are special — they're on the SYSTEM token at boot, but peinit typically calls FilterToken to remove them when assigning tokens to services that don't need them. SYSTEM having them is the starting point; the actual services usually run without them.
The Anonymous token has the opposite design: nothing it shouldn't need. The Anonymous identity is what gets used when a caller has not authenticated, and granting it any group beyond Everyone would risk accidentally giving anonymous callers access to resources whose DACLs name those groups. Empty privileges, Untrusted integrity, no Authenticated Users membership.
The two tokens together establish the trust range. SYSTEM is "the most trust the system can confer"; Anonymous is "the least trust meaningful". Every other token sits somewhere between, with parameters set by authd from the directory.
What the bootstrap tokens are not #
A few clarifications:
- They are not user accounts. SYSTEM is the system's own identity; Anonymous is the placeholder for "no identity". Neither corresponds to a user that signed in.
- They are not interchangeable with administrator tokens. A real user in the
BUILTIN\Administratorsgroup has a different token from SYSTEM — same group membership conceptually, but with the user's actual user_sid, the user's session, etc. SYSTEM and Administrators are not the same identity. - They don't persist across reboots. Every boot constructs fresh instances. Anything keyed to the SYSTEM token's instance ID (which is rare; nothing should be) would lose its reference at reboot.
- They cannot be modified. Almost everything about them is immutable. AdjustPrivileges would technically be possible on SYSTEM (a token holder of SYSTEM could enable/disable privileges, or even remove them) but the kernel makes such adjustments a one-time event for the bootstrap token, and the field-level operations are constrained. In practice nothing modifies the SYSTEM token directly; peinit makes filtered copies for child tokens.
Where to go next #
For the first userspace process the SYSTEM token is handed to, read The initramfs stage.
For the moment real identities start replacing SYSTEM, read authd handoff.
For what a token is in general — types, fields, lifecycle — read Tokens.
The initramfs stage
Peios / Peios Security Fundamentals / Boot and trust establishment
The kernel cannot reach the real root filesystem on its own. By the time it has finished its own initialisation it can speak to a CPU and some memory, but the disk the system is installed on may sit behind a storage driver that is not yet loaded, a volume manager that has not been assembled, or an encrypted container that has not been unlocked. Mounting that filesystem is work, and it is work that has to be done in userspace — the kernel does not mount real roots by guesswork.
So there is a stage in between. The bootloader loads a second thing into memory alongside the kernel: a small, complete root filesystem called the initramfs. The kernel unpacks it into a RAM-backed filesystem, makes that filesystem /, and starts a PID 1 inside it. This in-memory system exists for one purpose — to get the real root mounted and then get out of the way. On Peios, the PID 1 of that in-memory system is prelude.
The initramfs stage sits between two of the other pages in this topic. Bootstrap tokens covers what the kernel constructs before any userspace exists; peinit at PID 1 covers the init system that runs on the real root. prelude is what runs in between — the first userspace process the system ever has, and the last thing that runs before the real system begins.
prelude in the trust chain #
prelude is the first userspace process on the machine. The kernel attaches the SYSTEM token to it — prelude is the "init" that Bootstrap tokens describes the SYSTEM token being handed to. From the moment prelude starts, the "everything is SYSTEM" phase of boot (covered in the overview) is underway: inside the initramfs there is exactly one identity, SYSTEM, and exactly one job, getting to the real root. There is no directory, no authd, no notion of separate principals — and no need for one. The initramfs is a single-purpose, single-identity environment.
The initramfs's integrity is the integrity of the initramfs image itself. The image is a sealed archive: once it is built, nothing edits it in place. It is read into RAM at boot, used once, and discarded at the handoff — there is nothing inside it to tamper with at runtime. Under Secure Boot (a later milestone), the firmware verifies the whole boot artifact — kernel and initramfs together — before the kernel is allowed to run at all, so the sealed image is also a verified one.
prelude — the initramfs PID 1 #
prelude is a small, dedicated init. It is not a general-purpose process manager and it is not peinit: peinit supervises services for the system's entire lifetime, whereas prelude runs one short, fixed sequence and then replaces itself with the real init. They are separate programs with separate jobs. The name reflects the relationship — prelude is the short piece that runs before peinit's main one.
What prelude itself does is deliberately minimal. It is the invariant skeleton of the boot — the part that is identical on every Peios machine. Preparing the kernel's virtual filesystems, running the hooks, checking that a root was mounted, switching to it: that is the whole of prelude. Everything that varies between machines — which storage driver is needed, whether there is disk encryption, what kind of filesystem the root is — is not in prelude at all. It is in boot hooks.
This division is the important design point. A machine that boots from a plain disk, a machine that boots from an encrypted volume, and a machine that boots over a network differ only in their hooks. prelude is the same binary on all three. It never has to change to support a new kind of deployment — a new deployment is a new hook, not a new prelude.
Where the initramfs comes from #
The initramfs is not a mysterious binary blob. It is compiled from an ordinary directory on the real root filesystem: /boot/initramfs/. Whatever is in that directory becomes the contents of the in-memory root. The directory is the source; the initramfs image is the build product.
The layout is straightforward:
/boot/initramfs/
init symlink to the prelude binary — the initramfs PID 1
usr/
sbin/prelude the prelude binary itself
bin/
dash the shell; sh -> dash provides /usr/bin/sh
peiosutils one multi-call binary backing mount, chroot, ls, … via symlinks
lib64 -> usr/lib/x86_64-linux-peios
the x86-64 ABI loader path; /bin, /sbin and /lib views
do not exist before the runtime topology is mounted
usr/libexec/prelude/hooks.d/
mount-root.sh mounts a live medium's squashfs (live-boot)
mount-root-disk.sh
mounts an installed root partition (disk-boot); both
contribute rootfs-ready, and `root=` on the cmdline
decides which one acts
mount-rootfs-stratafs-base.sh
mounts the real root's StrataFS views after rootfs-ready
...
lcl/libexec/prelude/hooks.d/
operator-placed hooks; a file here replaces a packaged
hook of the same name
It is a normal directory. You can list it, read it, and see exactly what the initramfs will contain — inspecting the boot environment is ls /boot/initramfs/, not unpacking an archive. This is intentional: the initramfs should feel like part of the filesystem an administrator already understands, not a separate, opaque build system.
Most of what lands in the directory is put there by packages. A boot feature — disk encryption, an exotic storage backend — is an ordinary peipkg; installing it drops its hook (and any helper binaries) into /boot/initramfs/, and removing the package takes them away. There is no separate "initramfs configuration" to edit and no central list of supported features: the directory is the configuration, and a package contributes to the boot simply by installing files into it. Boot hooks covers this in full.
Because hooks are shell scripts, the initramfs has to carry a shell. That shell is dash: it provides the sh capability prelude depends on and supplies /usr/bin/sh. Hooks use #!/usr/bin/sh, because they run before any root-level /bin StrataFS view exists. The ordinary utilities a hook invokes — mount, chroot, ls, and the rest — come from peiosutils, a single multi-call binary that backs each tool through a symlink (mount, chroot, and friends are all the one binary, invoked under different names). Driver- or filesystem-specific tools a particular hook needs — blkid, a mkfs helper — arrive with the feature package that ships that hook, not with the base initramfs. Module loading is the exception: modprobe comes from kmod and the modules themselves from kernel-modules-irf, both base packages rather than per-feature ones, because any hook that touches storage may need them.
Kernel modules in the initramfs #
The initramfs carries its own set of kernel modules, shipped by the kernel-modules-irf package. It is a separate root from the real one and shares nothing with it, so it needs its own copy of anything it uses — the same reason it needs its own mountpoints and its own loader link.
The set is deliberately a subset. The initramfs only has to reach the root filesystem, so it carries storage controllers and block devices, the input drivers a passphrase prompt needs, filesystem and crypto drivers, and network drivers for a root reached over the network. It does not carry graphics, sound, media or wireless: the real root can load those for itself once it is mounted, and everything in the initramfs is paid for on every boot — it is loaded whole into memory, and under a UKI it sits inside an image the firmware verifies in one piece.
The set is also generic: every machine gets the same modules, rather than a set tailored to the hardware it was built on. Two things make that the only workable choice. Composing a root resolves offline and runs no install-time side effects, so nothing in that path may inspect the hardware it is composing for. And because a UKI is signed as a single image, a per-machine initramfs would have to be assembled and signed on the target — which would mean a signing key on every installed machine. A generic initramfs is also what mainstream distributions default to; tailoring it is the opt-in, not the norm.
A module being available in the initramfs is not the same as it being loaded, and the kernel never loads a driver for hardware on its own: it enumerates the devices and emits a uevent for each one naming the module that can drive it, but binding a driver to a device is userspace's job. In the initramfs that job is done once, by the coldplug hook that the coldplug-irf package ships. It runs after the initramfs's own views are assembled and before any root-mount hook, reads the modalias of every device the kernel has enumerated under /sys/bus, and hands the whole set to modprobe in one call. A machine whose root sits behind a modular storage driver — NVMe, for instance — has that driver loaded by the time the root-mount hook goes looking for the disk. Aliases that match no module are the normal case and are ignored; a module that fails to load is reported and is not, on its own, a boot failure — whether the missing driver matters is for the root-mount hook to decide, because it is the one that knows which disk it needs.
The hook is a single pass and does not stay resident, which is enough for the initramfs: everything that matters to reaching the root is present before PID 1 runs. Devices that appear later, and the stable /dev/disk/by-* names, belong to the device manager on the real root.
What prelude does at boot #
When the kernel starts prelude, it runs one fixed sequence, in order:
- Prepare the environment. prelude mounts the kernel's virtual filesystems —
/proc,/sys,/dev— so that it and the hooks can see processes, devices, and kernel state, and arranges the mount environment so the later root switch is unobstructed. - Determine the target init. prelude reads the kernel command line for an
init=value — the program to hand off to on the real root. The shipped image names/bin/peinit2. If the command line does not name one, prelude searches/bin/peinit2,/sbin/init,/bin/init, and finally/bin/sh. These are target-root runtime paths: the base-topology hook creates their StrataFS views before prelude hands off. Prelude's own initramfs rescue shell remains/usr/bin/sh, because that environment has no/binview. - Run the hooks. prelude creates an empty
/mnt/rootfsdirectory — the mount point the real root will appear at — and runs the boot hooks in order. The hooks do the deployment-specific work: load drivers, unlock encryption, assemble volumes, and mount the real root onto/mnt/rootfs. The packagedmount-rootfs-stratafs-base.shhook (from thefsbasepackage) then mounts the conventional runtime views inside that root. prelude does none of this itself; it runs the hook list. The order was decided when the initramfs was built (see below, and Boot hooks). - Verify the real root. After the hooks have run, prelude checks that
/mnt/rootfsactually has a filesystem mounted on it. If no hook mounted a root, prelude fails the boot rather than handing off to nothing. This is the one outcome prelude insists on: some hook must have produced a mounted root. - Hand off. prelude carries the kernel virtual filesystems into the new root, frees the now-finished in-memory root to reclaim its space, switches
/to the real root, and executes the target init. From that exec onward, the initramfs is gone and the real system is running.
prelude runs this sequence exactly once. It does not loop, supervise, or stay resident — the moment the real init is exec'd, prelude has ceased to exist; the exec replaces it. Its entire lifetime is the few seconds of the initramfs stage.
When the initramfs stage fails #
prelude does not limp. If any step fails — a hook exits with an error, no hook mounts the root, the target init cannot be found — prelude stops and halts the machine. It does not fall through to a half-configured system, and it does not try to work around a failed hook.
This is the right behaviour for the stage. The initramfs's only job is to deliver a correctly-mounted real root to a real init. A failure there means the system cannot be brought up safely; continuing would only produce a system in an undefined state. Halting makes the failure unambiguous — the console shows which step failed — and leaves the operator with a clean situation to diagnose rather than a subtly-broken running system. The one way to intervene rather than halt is the debug knobs below.
Debugging the initramfs stage #
Two kernel command-line knobs (familiar from dracut) turn the fixed sequence into something an operator can step through. prelude reads them from /proc/cmdline, the same place it reads init= — as PID 1 it has no argv, so the command line is its only input. The interactive shell they open is dash, run as sh -i on the console.
-
rd.shell— drop to a shell on failure instead of halting. If any boot step fails whilerd.shellis set, prelude opens the shell so you can inspect the half-built initramfs: see what the hooks mounted, read the log, try a mount by hand. The failure still ends the boot — when you exit the shell, prelude halts as it would have anyway. Withoutrd.shell, a failure halts immediately. -
rd.break— stop before any hook runs. Barerd.breakpauses just before the first hook and opens the shell; exit the shell and the boot continues normally. This is the point where the kernel virtual filesystems are up but nothing deployment-specific has happened yet. -
rd.break=<hook>— stop just before a named hook. The value is matched against a hook's file name — or its full path — as it appears in the resolved boot sequence, sord.break=mount-root.shbreaks immediately before that hook and lets everything ordered before it run first. The value is comma-separated and the flag may be repeated —rd.break=modules.sh,mount-root.sh— to break before several hooks.
The rd.break shells are true breakpoints, not failures: prelude forks them, so it stays PID 1 and the boot resumes exactly where it paused when the shell exits. Setting any rd.break also implies rd.shell — so if the boot goes on to fail, you get the failure shell too.
Keeping the initramfs current #
Because the directory is the source and the initramfs image is a build product, the two have to be kept in step. Whenever /boot/initramfs/ changes — a feature package is installed or removed, the kernel is updated, an administrator edits a hook — the image has to be rebuilt from the directory.
The tool that does this is mkirf — see mkirf for the full command reference. It reads /boot/initramfs/, resolves the order the hooks must run in, checks the directory is internally consistent, and writes the compressed initramfs image. Three properties of it matter to an operator:
- It validates. mkirf will not produce an image from a directory that cannot boot. A hook set with an impossible ordering, a hook that depends on a capability nothing provides, a missing
init— each of these stops the build with a clear error. A misconfigured boot is caught when the image is built, on a running system where the message is easy to read, rather than as a mystery failure at the next boot. Boot hooks covers exactly what is checked. - It is deterministic. The same directory always compiles to the same image, byte for byte — identities, timestamps, and ordering are all normalised. This is what makes "did anything actually change?" a meaningful question, and it underpins later work such as signed boot artifacts.
- It keeps the directory pristine. mkirf reads the directory and writes the image elsewhere; it never writes back into
/boot/initramfs/. The resolved hook order is recorded inside the image, not in the source. The directory you inspect is always exactly what packages and you have put there.
mkirf can run once and exit, or run in a watch mode where it stays resident and recompiles automatically whenever the directory changes. Watch mode is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
When more than one kernel is installed, there is one initramfs per kernel: the drivers a kernel needs are specific to its version, so each kernel gets an image built against its own modules.
The handoff to the real root #
The final act of the initramfs stage is the handoff: prelude replaces the in-memory root with the real root and gives the machine to the real init.
Concretely, the real root has been mounted at /mnt/rootfs by a hook; prelude carries the kernel virtual filesystems across into it, switches / from the initramfs to /mnt/rootfs, and execs the target init. Switching the root is the operation usually called switch_root — after it, / is the real filesystem and the in-memory initramfs is gone, its RAM reclaimed.
The init prelude execs is the one identified back in step 2 — the init= value from the kernel command line, or the fallback search. On a complete Peios system that init is peinit, which takes over as PID 1 of the real root and brings up the rest of userspace. During earlier development, before peinit exists, the target is a simpler stand-in; the contract is the same either way — prelude delivers a mounted real root and a console, and execs whatever the real init is.
The handoff is a one-way door. Once / is the real root and the real init is running, the initramfs is not coming back; the in-memory stage exists only up to this moment. The SYSTEM token, though, survives the handoff — the real init inherits it across the exec, exactly as every program inherits its primary token across exec. That is the thread tying this page to Bootstrap tokens: the SYSTEM token the kernel attached to prelude is the same token peinit starts life holding.
What prelude does not do #
A few clarifications:
- prelude does not mount the real root itself. A hook does. prelude provides the
/mnt/rootfsmount point and verifies the result, but the mount is always a hook's job — that is what keeps prelude identical across deployments. See Boot hooks. - prelude does not decide hook order. The order is resolved when the initramfs is built, by mkirf, and recorded in the image. prelude reads the resolved list and runs it; it has no ordering logic of its own.
- prelude does not supervise anything. It runs the hook sequence once and execs the real init. It has no steady state — contrast peinit, which runs as the system's lifecycle manager for the whole of its uptime.
- prelude is not the real init. It is the initramfs init. peinit is the real-root init. They are separate binaries with separate jobs; prelude's last act is to exec the real init, not to become it.
- prelude does not persist anything. Nothing it does is written to disk. The initramfs is in RAM, used once, and discarded. Persistent state begins on the real root, with peinit.
Where to go next #
For the deployment-specific scripts prelude runs, read Boot hooks.
For the init system prelude hands the machine to, read peinit at PID 1.
For the tool that compiles /boot/initramfs/ into the image, read mkirf.
Boot hooks
Peios / Peios Security Fundamentals / Boot and trust establishment
A boot hook is a shell script that runs inside the initramfs, before the real root is mounted — during the initramfs stage. Hooks are how Peios keeps prelude deployment-agnostic: every part of early boot that depends on this particular machine is a hook, and prelude runs the hooks without knowing what any of them do.
The work hooks do is the work of getting to the real root: loading the storage driver that makes the system's disk visible; unlocking an encrypted container; assembling an LVM volume group or a RAID array; and, finally, mounting the real root filesystem onto /mnt/rootfs. None of that is the same on two arbitrary machines, so none of it is built into prelude — it is all hooks. prelude supplies the fixed skeleton of the boot; hooks supply everything that varies.
Where hooks live #
Hooks live in two directories, which mirror the layout's vendor/operator split everywhere else:
| Directory | For |
|---|---|
/usr/libexec/prelude/hooks.d/ | hooks shipped by packages |
/lcl/libexec/prelude/hooks.d/ | hooks an operator put there by hand |
Every regular file directly in either directory is a hook, and prelude runs all of them. Neither is searched recursively — a subdirectory is not a hook — and the file extension does not matter. A hook is recognised by being a file in one of those directories, and it is run according to its #! shebang line, the same way any script is run.
A file name identifies a hook. The same name in both directories is one hook with two candidate bodies, not two hooks: the operator's copy wins and the packaged one is skipped, so a shipped hook can be replaced without deleting a package's payload. The build says which file lost, because a hook silently replaced by another is exactly the kind of thing that should not be quiet.
An older hooks/ directory at the initramfs root is still read, and every hook found there produces a build warning naming where it should move. That is deliberate: a hook in a directory nobody scans is not an error — it simply is not there, and the boot fails much later with nothing mounted — so the move cannot be a flag day. The warning going quiet is what says the migration is done. Hooks are #!/usr/bin/sh scripts, and the initramfs's /usr/bin/sh is provided by dash. The full package-storage path is required because hooks run before a /bin runtime view exists.
How hooks get there #
There are two ways a hook reaches the directory, and they are identical as far as prelude is concerned:
- From a package. Most hooks arrive as part of a feature peipkg. Installing
peios-luks(disk encryption) drops a hook that unlocks encrypted volumes; installing a filesystem feature drops a hook that mounts that kind of root. The package's payload simply includes a file under/usr/libexec/prelude/hooks.d/, and removing the package removes the hook. This is the feature-as-a-package model applied to boot: there is no edition of Peios that "has LUKS" and another that does not — there is apeios-lukspackage, and a machine either has it installed or it does not. - By hand. An administrator can write a hook and place it in
/lcl/libexec/prelude/hooks.d/directly. A site with an unusual storage arrangement, or a one-off need, does not have to build a package — a script in the directory is a hook.
Either way, the next time the initramfs is built the new hook is picked up. (See The initramfs stage for the build.)
The ordering problem #
Hooks have an order, and the order matters. A hook that mounts the root cannot run before the hook that loads the disk's storage driver; a hook that unlocks an encrypted volume cannot run before that volume's driver is loaded. Run them in the wrong order and the boot fails.
The obvious approach — number the files, 10-modules, 20-crypto, 30-mount, and run them in numeric order — is the approach Peios deliberately does not take. Numeric prefixes work right up until two packages, written by people who never spoke to each other, both pick 20. Then every package author has to know the whole number line, and the "order" is a fiction held together by convention. This is the historical sysvinit problem, and it does not scale.
Instead, a hook declares what it needs and what it offers, in terms of named capabilities, and the order is computed from those declarations. The hook that mounts the root says "I require crypto-unlocked"; the hook that unlocks encryption says "I provide crypto-unlocked"; the order follows. No hook needs to know any other hook's name or position — only the capabilities. Packages that never met each other compose correctly because they agree on capability names, not on numbers.
The metadata block #
A hook declares its capabilities in a metadata block at the top of the script: a fenced comment block, every line of it a comment, so the block is invisible to the shell when the script actually runs.
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# ... the hook's actual work follows ...
The rules of the block are small and exact:
- It opens with a line that is exactly
# /// hookand closes with a line that is exactly# ///. - Every line in between is a comment line —
#followed by content, or a bare#. This is what keeps the block inert: the shell sees only comments. - The content of those lines, with the
#stripped, is up to four keys:provides— capabilities this hook satisfies on its own. Several hooks may provide the same capability, and any one of them suffices; they are alternatives.contributes— capabilities this hook is one part of. Every contributor must complete before the capability counts as satisfied.requires— capabilities that must already be satisfied before this hook runs. If nothing supplies one, the build fails.after— capabilities to be ordered after if anything supplies them. If nothing does, the hook simply runs; this is not an error.
- Each key's value is a list of capability names in double quotes —
provides = ["a", "b"]. An empty list is allowed, and a trailing comma is allowed.
Why there are four keys and not two #
The four are two ways to supply a capability crossed with two ways to consume one, and each pair exists to say something the other cannot.
contributes is how a hook runs before something. A hook is otherwise ordered only by what it consumes — so "run before the root is mounted" would require the root-mounting hooks to name your hook, which they cannot, because they were packaged before it existed. Declaring yourself part of a capability inverts the relationship: everything that consumes it now waits for you. Without this, the only way to be early is to win the file-name tie-break, which is the numeric-prefix problem wearing a disguise.
after is how a shared vocabulary survives a small image. A requires on a capability nothing supplies is a build error — deliberately, since it catches an initramfs that cannot possibly boot. But that makes any standard capability name dangerous: a hook mentioning network-up would break every image that has no networking. after expresses "if this happens at all, it happens before me", which is what most ordering against an optional milestone actually means.
provides and contributes differ in what "satisfied" means. With alternatives, one supplier doing the job is enough — which is exactly the live-boot/disk-boot pair below, where one mounts the root and the other stands aside. With contributors, the capability is not satisfied until every one of them has completed — three hooks each unlocking a layer of an encrypted stack, say. A capability must be one or the other; declaring it both ways is a build error, because there would be no answer to whether it is satisfied yet.
The format is a small, deliberate subset of TOML: enough to declare two lists of names, and no more. It is checked strictly when the initramfs is built — an unknown key, a list that is not well-formed, a block that is opened and never closed, two blocks in one file — each of these is a build error, not a quiet misread. A typo in a hook's metadata is caught at build time, with a message naming the hook and the line.
The metadata lives inside the hook script, not in a separate file, so a hook is a single self-contained thing: copy the script and its ordering travels with it.
The capability vocabulary #
Capabilities are just names, and a hook can in principle name its own. But the milestones every initramfs passes through are a small fixed vocabulary, so that hooks from unrelated packages line up against the same reference points:
| Capability | Meaning |
|---|---|
initramfs-ready | The initramfs itself is usable — its filesystem topology assembled, its console set up. |
rootfs-ready | /mnt/rootfs is mounted and ready for read/write manipulation. |
rootfs-strata-ready | The full StrataFS topology exists inside /mnt/rootfs. |
The vocabulary is deliberately short. Names for things Peios does not yet do — device settling, volume assembly — are not reserved in advance: a capability that nothing supplies and nothing consumes is a name with no meaning behind it, and inventing one early only fixes a shape before we know it. Driver loading, which Peios does do, has no capability of its own for the same reason: the coldplug hook simply contributes to rootfs-ready, which is what it means for the root-mount hooks, and nothing has yet needed to order against it more precisely than that.
Once every hook has finished, prelude chroots into /mnt/rootfs. There is no "last point at which a hook can run" capability, because that is already every hook's guarantee.
initramfs-ready is the one capability with a rule #
A hook that must run before all the others cannot say so by ordinary means — it would need every other hook to name it, including hooks written by people who have never heard of it, whose forgetting would not fail the build but would quietly run their hook in an initramfs with no /bin.
So initramfs-ready carries one extra rule:
Every hook that does not supply
initramfs-readyis implicitly ordered after it.
The exemption is derived, not declared — you are exempt exactly when you are part of the capability. There is no cycle to construct and nothing for a hook author to remember. A hook assembling the initramfs's own topology simply declares contributes = ["initramfs-ready"] and lands before everything.
Two things fall out of the existing rules rather than needing special cases. An initramfs with no contributors leaves the capability unsupplied, and an after on an unsupplied capability is vacuous — so a minimal initramfs just runs, with nothing to configure. And a hook carrying no metadata block does not gain the edge, because it already runs after every declaring hook; adding it would have pulled the escape hatch into the DAG.
mkirf materialises these edges into the sequence as ordinary after entries, rather than leaving prelude to know the rule. That keeps one implementation instead of two that have to agree, and makes the sequence file explain itself — in a rescue shell you can read why a hook ran where it did, instead of needing a rule that appears nowhere in the image.
More than one hook may supply the same capability #
Nothing limits a capability to a single supplier, and rootfs-ready is where that matters: getting a root filesystem ready is several jobs. A hook that unlocks an encrypted container, a hook that assembles a volume group, and a hook that performs the mount each contributes = ["rootfs-ready"], and the capability is not achieved until all of them have finished. None of them needs to know the others exist.
A capability does not have to be a hard, machine-specific thing. What matters for ordering is the declaration, not how much work the hook does to honour it. An encryption hook on a machine with no encrypted volumes has nothing to unlock and declines (exit 69) — which, for a capability it contributes to, completes its part: "nothing needed doing here" is a way of being done. The capability is still achieved, and everything waiting on it proceeds.
How the order is resolved #
When the initramfs is built, mkirf reads every hook's metadata and computes the running order:
- A hook that consumes a capability (by
requiresor byafter) runs after every hook that supplies it (byprovidesor bycontributes). That single rule is the whole of the ordering. - Hooks with no ordering relationship between them run in a stable, predictable order — by file name — so the resolved sequence is the same every time the initramfs is built.
Note that ordering does not distinguish alternatives from contributors, nor hard requirements from soft ones: all four keys produce the same "supplier first" edge. What they change is validity — whether an unsupplied capability is an error — and what a capability being "satisfied" will mean to prelude at boot.
Three situations are build errors. They stop the build, and no initramfs is produced:
- A cycle — hook A consumes something B supplies, and B consumes something A supplies. There is no order that satisfies both; the build reports the hooks caught in the cycle.
- An unsatisfied requirement — a hook
requiresa capability that nothing installed supplies. Rather than build an initramfs that is guaranteed to fail at boot, mkirf reports the missing capability. Anafteron an unsupplied capability is explicitly fine. - A capability that is both provided and contributed to — one hook calls it an alternative, another calls itself a part of it. The two answers to "is it satisfied?" contradict each other, so mkirf names both sides and stops rather than picking one.
This is the payoff of declared capabilities: an impossible or incomplete hook set is a build error on a running system, with a readable message, never a boot that hangs with no explanation.
The resolved order is recorded inside the initramfs image, at /system/prelude/hooks.seq.<n>, and prelude reads it at boot. An operator does not write or edit those files — they are generated. The hook directories are the source of truth; the order is derived from what they contain.
The <n> is the format version, and mkirf writes every version it can express faithfully, prelude reading the newest it understands. That is not ceremony: mkirf ships in peiosutils and prelude in its own package, so an image can pair a newer writer with an older reader, and separate files are what let one image satisfy both. A sequence newer than prelude understands is a warning when a usable one sits beside it, and a boot failure when it is the only one present.
A missing sequence is always an error, never "no hooks to run" — mkirf writes one for every image, including an empty one for an image with no hooks at all. Were an absent file read as "nothing to do", a manifest that failed to generate would become a boot that silently ran no hooks, mounted no root, and reported the failure a long way from its cause.
Hooks with no metadata #
A hook is allowed to carry no metadata block at all. It is still a valid hook — it simply has no declared capabilities, and therefore no ordering constraints. Such a hook runs after all the hooks that do have constraints, in file-name order.
The build emits a warning for a hook with no block — because a hook that merely forgot its metadata looks identical to one that genuinely has none, and the warning makes the difference visible. A hook that is deliberately unconstrained can carry an empty block — a # /// hook line immediately followed by # /// — to say so on purpose, which suppresses the warning.
How prelude runs a hook #
At boot, prelude runs the hooks one at a time, each to completion before the next begins, scheduling from their declarations — repeated passes over the sequence, running whatever is ready, retrying anything that deferred once something else has progressed. Each hook runs as a separate process with a minimal environment — PATH=/usr/bin and TERM=linux — so the initramfs's shell and utilities (dash, peiosutils, and any tools a feature package added) are found without depending on a not-yet-mounted root-level view.
A hook reports what happened through its exit code, and there are four things it can say:
| Exit | Meaning | What prelude does |
|---|---|---|
0 | Satisfied — I did my part. | Its provides are achieved; its contributes are one step closer to complete. |
69 | Declined — not applicable on this machine. | It is not counted toward the capabilities it declared. |
75 | Deferred — I cannot run yet, and I have changed nothing. | Re-queued and tried again once something else has made progress. |
| anything else | Failed. | The boot stops and the machine halts. |
The two middle codes are borrowed from sysexits.h (EX_UNAVAILABLE, EX_TEMPFAIL) rather than invented, for a practical reason: 1 and 2 are what any failing command returns and 126, 127 and 128+n are the shell's own, so a small dedicated range is the only place a deliberate signal cannot be mistaken for an accident. A hook killed by a signal is always a failure, whatever code it might have produced.
Declining is not the same as succeeding #
A hook that is not the right one for this machine declines (69) rather than exiting 0. The difference matters because provides means alternatives: a capability with several providers is achieved as soon as one of them is satisfied, and a provider that exits 0 is claiming to be that one.
This is what the live-boot/disk-boot pair does. Whichever hook root= does not select declines, and the other mounts the root. If every provider declines, the capability is never achieved and prelude stops with a message naming it — where previously a machine no hook would boot produced only the generic "nothing mounted the root" much later.
For contributes the sense is reversed: since every contributor must complete, a contributor that declines has completed — "nothing needed doing here" is a way of being done.
Deferring is a promise that nothing happened #
Deferred (75) means the hook's inputs are not ready yet. prelude runs hooks in repeated passes, retrying deferred ones once something else has progressed, and stops with a diagnosis if a whole pass completes nothing while work remains.
The contract that makes this safe is that a deferring hook has done nothing. Re-running it is free in a way that re-running a failed hook could never be — a failure may have left a half-assembled array, an opened device, or a burnt unlock attempt behind it. So:
- Defer before you act, never after. Check whether you can do the job; if not, exit
75immediately. Do not defer partway through. - Failure is still failure. If the work was attempted and went wrong, exit non-zero. Deferring a genuine error turns one clear failure into a stall reported as "nothing left to wait for".
This is what lets layered arrangements compose without absolute ordering: three hooks each contributing to an encrypted stack can be written without knowing which layer comes first, because the ones whose devices do not exist yet simply defer and are retried.
A few more consequences for anyone writing a hook:
- Check, and exit non-zero on failure. A hook that mounts the root must exit non-zero if the mount failed. A hook that fails silently turns into prelude's generic "nothing mounted the root" failure later — which is harder to diagnose than the hook reporting its own error at the point it happened.
- Hooks run as SYSTEM. Everything in the initramfs runs on the SYSTEM token (see Bootstrap tokens), so a hook has full authority. There is no identity model to work within inside the initramfs — that begins on the real root.
Writing a hook #
A complete, minimal hook — one that mounts an ext4 root from a known partition:
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# The device may not exist yet — another hook may still have to unlock or
# assemble it. Defer rather than fail: exit 75 promises we changed nothing,
# so prelude can retry us once something else has made progress.
[ ||
||
What this declares: it is one part of rootfs-ready, so anything waiting on a usable root filesystem is ordered after it. It does not name the hooks it depends on, because it does not know them.
A hook that unlocks the container it mounts, to pair with it:
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# Nothing encrypted on this machine — decline, which completes our part of
# the conjunction without claiming to have unlocked anything.
[ ||
||
Installed together, these two compose without either one naming the other, and without an ordering declaration between them at all. If the mount hook runs first it finds no device and defers; the unlock hook then runs; the mount hook is retried and succeeds. Add a volume-assembly hook later — also just contributes = ["rootfs-ready"] — and it slots in the same way, because each hook knows only whether it can act yet.
That is the model: hooks are composed by capability, ordering is declared only where it is genuinely known, and the rest is settled at boot by hooks that can say "not yet".
What hooks are not #
A few clarifications:
- Hooks do not run on the real root. They run inside the initramfs, before the handoff. Work that belongs to the running system — starting services, applying mount and access policy — is peinit's job, not a hook's.
- A boot hook is not a service. "Hook" here means specifically an initramfs hook. The initramfs stage ends when prelude execs the real init; everything after that — services, supervision, restart policy — is peinit's domain and works nothing like a hook.
- The generated order file is not edited by hand. It is the build's record of the resolved order. The
hooks/directory is the source; the order is computed from it, every time the image is built. - File names are not the ordering mechanism. A file name identifies a hook, and is the tie-break between hooks that have no capability relationship. Renaming a hook does not change where it runs relative to a hook it shares a capability with — the
provides/requiresdeclarations do that.
Where to go next #
For the stage that runs the hooks, read The initramfs stage.
For the build that validates hooks and resolves their order, read mkirf.
For what takes over once a hook has mounted the real root, read peinit at PID 1.
peinit at PID 1
Peios / Peios Security Fundamentals / Boot and trust establishment
peinit is the init system of a running Peios system — the process that takes over once the initramfs stage has mounted the real root. It runs at PID 1, signed at TCB level, holding the SYSTEM token. From there, peinit does the rest of the work needed to bring the system from a freshly-mounted real root to "fully-running with services and users".
The specific responsibilities are:
- Run the compiled-in Phase 1 bootstrap: probe that the root is writable, mount the virtual filesystems, restore the random seed, ensure the machine-id, set the clock from the RTC, and start registryd.
- Read the service catalog from the registry (
Machine\System\Services\), validate the dependency graph, and start services in dependency order — authd among them. - Mint SYSTEM tokens for platform services itself; request every other identity from authd.
- Continue running as the system's lifecycle manager — handling service crashes, system shutdown, eventual reboot.
This page covers each of these responsibilities, why peinit specifically is the right thing to be PID 1, and the patterns peinit uses for the work.
Why peinit at PID 1 #
PID 1 has special properties in Linux: it cannot be killed by ordinary signals, it inherits orphaned processes, it's the ancestor of everything else. Whichever process is PID 1 is operationally critical.
Peios chooses peinit specifically because:
- It's signed at TCB level. When peinit is exec'd — by prelude, at the switch to the real root — the signature verification at exec sets
pip_type = Protected,pip_trust = 8192. peinit is now PIP-protected at the highest level. No other process can signal it, debug it, or interfere with it. - It runs on the SYSTEM token. Inherited from init. peinit has every privilege; it can do anything that requires authority.
- It's purpose-built for the bootstrap. Other init-style processes do general-purpose process management; peinit specifically knows about Peios's identity model, its registry-defined service catalog, and the service-launching pattern Peios uses.
The combination of "PIP-protected" and "all privileges" and "purpose-built" makes peinit the right thing to be PID 1. A general-purpose init that wasn't signed at TCB level would be a weak link — every TCB-level process (authd, loregd, eventd) is unreachable by their lifecycle manager, which means lifecycle management could only be done by another TCB-level process. That role is peinit.
The TCB lifecycle-manager pattern #
A consequence of PIP: only a process that PIP-dominates the TCB daemons can signal them. Since the TCB daemons are at pip_type = Protected, pip_trust = 8192, only another process at the same level (or higher, but there is no higher in v0.20) can dominate.
peinit is the only such process at the right time. peinit is signed at TCB level, so it dominates. authd, loregd, eventd, lpsd are also at TCB level, so they dominate each other — but they don't manage each other's lifecycles by convention. peinit is the conventional lifecycle manager.
This is documented in PIP in practice. The TCB-lifecycle-management pattern is a consequence of PIP; peinit happens to be the binary that fills the role.
What peinit does at startup #
peinit's startup is two phases. The authoritative, step-by-step account lives in the peinit topic at Boot and boot modes; what follows is the trust-chain summary.
Phase 1 — compiled-in bootstrap #
Phase 1 runs before any configuration is available, in a fixed order compiled into the binary:
- Assert PID 1. peinit refuses to run as anything else.
- Probe that the real root is writable, and ensure the virtual filesystems (
/proc,/sys,/dev, …) are mounted. - Restore the random seed, ensure the machine-id, set the clock from the RTC.
- Start registryd and probe the registry schema. The registry is the source of all subsequent configuration, so its daemon comes up before anything else.
- Provision boot paths and infrastructure — the control socket, the job filesystem, loopback networking.
Phase 2 — the service graph #
With the registry available, peinit reads the service catalog from Machine\System\Services\, validates the dependency graph, and starts services in dependency order. authd is an ordinary Critical service in this graph — started after the infrastructure it depends on (eudev, lpsd), gated by the same readiness mechanics as any other service, not by a special wait-for-authd phase.
For each service, the pattern is fork-install-exec:
- peinit obtains the service's token — minted by peinit itself for SYSTEM/platform services, requested from authd for every other identity (see Identity and privileges).
- peinit forks; the child inherits a SYSTEM-derived token.
- peinit installs the service-specific token on the child via
KACS_IOC_INSTALL. - The child execs the service binary. The kernel verifies the signature and sets the PIP fields.
Steady state #
After the graph is started, peinit transitions to its steady-state role: the lifecycle manager. It watches for service crashes (a child process dies; peinit receives SIGCHLD), restarts them per policy, handles shutdown signals (an administrator calling shutdown), and otherwise runs as a long-lived daemon.
The system is now "up" — services are running, authd is creating tokens for users who sign in, FACS and KACS are enforcing access control, audit events are flowing through KMES.
The fork-install-exec pattern #
The pattern is the most important thing peinit does, and it's worth pinning. The order of operations is deliberate:
- fork. Creates the child process. The child inherits the parent's token, file descriptors, and PSB. At this point the child is essentially a clone of peinit.
- Install token. The child gets its service-specific token via
KACS_IOC_INSTALL. This is the moment its identity is set to be the right thing for the service. - exec. The new program runs. The kernel verifies the binary's signature and sets the PIP fields.
The reason for this order: the service's token needs to be in place before the service's startup code runs — the service should be able to assume from its first instruction that it's running as the right identity. exec comes last because that is the moment the new program takes over; by then the identity has been decided.
The pattern is what every service is launched through. Variations are minor — the specific token differs per service — but the structural sequence is the same. (When per-service mitigations land in peinit — see the note above — they will slot in between install and exec, since mitigation flags must be on the PSB before the new binary runs.)
What peinit does not do #
A few clarifications:
- peinit mints only SYSTEM tokens. Minting the SYSTEM token for platform services via
kacs_create_tokenis peinit's normal, specified path — no authd interaction is needed for those. Every other identity is requested from authd, the source of truth for who-gets-what privileges. - peinit is not the only TCB process. authd, loregd, eventd, lpsd are also at TCB level. They have their own jobs.
- peinit does not enforce policy. DACLs, conditional ACEs, mount policies — the kernel runs AccessCheck and makes every access decision; peinit just launches processes with the right identities.
- peinit does not handle user sessions directly. Users sign in via authd; authd produces tokens; peinit launches the session's first process. The user's session is managed by authd, not peinit.
- peinit's service management is peinit, not a separate daemon. Start order, dependencies, restart policy, the service state machine, and the control interface are peinit's own responsibility — there is no service-manager process living alongside it. That model is substantial enough to have its own topic; this page does not cover it. See peinit.
When peinit fails #
If peinit crashes or exits, the kernel typically panics — losing PID 1 is a system-fatal condition in Linux. Peios's peinit is built to be very stable for this reason; its work is narrow and bounded.
If peinit cannot complete a startup step (a service won't launch, authd won't start), the deployment-specific behaviour depends on the configured policy. Typical responses:
- For a critical failure (authd won't start), boot fails. The system enters a recovery state — typically a console login as SYSTEM, with limited services running, enough for an administrator to diagnose and fix.
- For a non-critical failure (a single service won't start), peinit logs the failure, leaves the service unstarted, and continues with the rest of boot.
The failure-mode behaviour is configurable but conservative by default. peinit doesn't try to "work around" failures by relaxing security; if the configured set of mitigations can't be applied, the service doesn't start rather than starting unhardened.
authd handoff
Peios / Peios Security Fundamentals / Boot and trust establishment
The single most consequential transition during boot is the moment authd comes online. Before authd, every process is running on the SYSTEM token (inherited from init through peinit); identity is uniform and authority-maximal. After authd, processes start running as their actual principals — services as their service identities, users as themselves after they sign in. authd is the bridge between "kernel-direct bootstrap identity" and "real identities derived from the directory".
This page covers what authd does at startup, how the handoff from SYSTEM-everywhere to real-identities-everywhere happens, and what authd is responsible for in steady state.
What authd is #
authd is the authentication daemon. Its core responsibilities:
- Authenticate principals. When a user signs in, authd verifies their credentials against the directory (locally for standalone systems, against the domain's directory for domain-joined ones).
- Mint tokens. Once a principal is authenticated, authd produces a token reflecting their identity, group memberships, privileges, integrity level, claims. The token is what the principal's processes run on.
- Manage logon sessions. authd creates a session per authentication event, attaches the minted tokens to it, tracks lifecycle via the
logon-session-destroyedevent. - Distribute CAAP. authd reads central access policies from its source (registry or the domain's directory) and pushes them into the kernel's policy cache via
kacs_set_caap. - Resolve identity-related queries. authd is the answer to "what privileges does this user have?", "what claims should be on this token?", "is this user a member of this group?". These queries go to authd, which consults the directory.
authd is signed at TCB level, runs at pip_type = Protected, pip_trust = 8192, and is one of the PIP-protected processes. It is launched by peinit early in boot and runs continuously until shutdown.
authd's startup sequence #
When peinit forks-and-execs authd, the new process goes through several initialisation steps:
1. Verify own state #
Just like peinit at startup, authd verifies it is running in the expected state — PIP-protected, holding the expected token, with the expected mitigations applied. Anything else indicates the boot is broken; authd refuses to proceed.
2. Connect to the directory #
authd opens its connection to the directory. The connection mechanism depends on deployment:
- Standalone systems use loregd (the registry daemon). authd communicates with loregd over a Unix socket and reads policy from registry keys.
- Domain-joined systems use Samba 4. authd talks to a Samba 4 instance (locally or on another machine in the domain) for domain identity and policy resolution.
The directory is the source of truth for everything authd needs to know — principals, groups, privileges, CAAP, claims. authd's connection to the directory is established early because everything else depends on it.
If the directory connection fails (loregd not running, AD unreachable), authd cannot mint real tokens. The system continues on bootstrap tokens (everything as SYSTEM) until the directory becomes available. authd retries with exponential backoff; in the meantime peinit may proceed with whatever services don't need authd.
3. Populate the CAAP cache #
For each central access policy in the directory, authd parses it and pushes it via kacs_set_caap. The kernel's policy cache fills up with the deployment's CAAP.
This step matters for the recovery policy: until this step completes, every CAAP-referencing object resolves to the recovery policy (administrators and SYSTEM only). After this step, the object's actual policy applies.
CAAP population happens before authd advertises itself as ready. This is what makes the boot ordering safe — services that depend on CAAP being live can wait for authd to signal readiness; by then, CAAP is in place.
4. Begin serving authentication requests #
authd opens its socket — typically a Unix domain socket — and starts accepting connections from clients. The clients include:
- peinit, asking for tokens to assign to new services.
- Login frontends (console, SSH, terminal services) authenticating users.
- Services that handle their own re-authentication (rare, but possible).
Each authentication request follows a similar pattern:
- Client connects to authd's socket and presents credentials.
- authd verifies the credentials against the directory.
- authd resolves the principal's full identity (groups, privileges, claims) from the directory.
- authd creates a logon session via
kacs_create_session. - authd calls
kacs_create_tokento mint the token, passing the wire-format specification with everything resolved. - authd returns the token (or token fd) to the client.
The client now has a token reflecting the authenticated principal. The client can install the token on a child process (if it's peinit launching a service) or on itself (if it's a login frontend completing the user's sign-in).
5. Signal readiness #
authd writes a readiness signal to peinit — typically by closing a pre-existing pipe fd, by writing a sentinel value to a known location, or by reaching a state peinit can probe for. peinit, which has been blocked waiting for this, proceeds to launch the rest of the services.
The handoff in one paragraph #
The transition: before authd is ready, every userspace process is running on the SYSTEM token (or a SYSTEM-derived FilterToken variant). After authd is ready, peinit's subsequent service-launch operations assign each new service a token authd produces — derived from the directory, with the appropriate identity for the service. The system goes from "everything SYSTEM" to "services as themselves" gradually, over the course of however long peinit takes to launch the rest of userspace.
There is no single moment where the system "becomes secure" — it's a gradient. Even before authd, the kernel's access control is fully operational; SYSTEM just happens to be everywhere. As real tokens replace SYSTEM in process after process, the system's authorisation surface narrows. By the time login frontends are accepting users, every service is at its right identity, and users sign in to fresh sessions of their own.
authd in steady state #
After startup, authd's day-to-day work is:
- Authenticate users when they sign in. Each login produces a session and a token (or a pair of tokens for UAC-style elevation).
- Mint tokens for new service starts. When peinit needs a token for a service it's about to launch, authd produces one.
- Track session lifecycle. authd subscribes to the kernel's
logon-session-destroyedevent and uses it to release session-scoped state (Kerberos tickets, cached directory data). - Distribute CAAP updates. When a policy in the directory changes, authd re-pushes it via
kacs_set_caap. The kernel's cache is kept in sync with the directory. - Handle session revocation. When a user must be forcibly logged out, authd walks
/proc/*/tokento find tokens with the offendingauth_id, identifies the holding processes, and signals them to exit. This is the userspace-coordinated revocation pattern documented in Session lifecycle.
authd is a long-running daemon. It is not periodically restarted. Its uptime equals the system's uptime (modulo any administrative restarts in response to configuration changes).
What authd does not do #
A few clarifications:
- authd does not make access decisions. AccessCheck is in the kernel. authd produces inputs to it (tokens) but does not decide whether any specific access is granted.
- authd does not enforce CAAP. AccessCheck enforces CAAP; authd just distributes the policies into the kernel cache.
- authd does not authenticate every operation. Authentication is a sign-in event. Operations done after sign-in run against the token, not against authd. authd does not see most of the per-operation traffic.
- authd does not store passwords. Credential verification goes against the directory; authd is an intermediary. The directory (loregd's registry, or the domain's directory) is where the credentials live.
- authd does not implement the directory. authd consumes a directory; it doesn't be one. For standalone systems, loregd is the directory; for domain-joined, the domain's directory via Samba 4. authd talks to whichever is appropriate.
The cleanest mental model: authd is the converter between "directory state" and "kernel-resolvable identity". Everything else — access decisions, audit, lifecycle — happens around authd, not through it.
When authd fails #
authd is signed at TCB level, runs hardened, and is operationally critical. If authd crashes:
- New authentication requests fail. Users cannot sign in, peinit cannot get new tokens for new services.
- Existing tokens continue to be valid. Processes running on tokens authd already minted are unaffected.
- The kernel's policy cache continues to hold whatever CAAP authd had pushed. Updates to CAAP in the directory will not be reflected until authd is restarted.
peinit detects authd's exit (it's a child of peinit) and typically restarts it per policy. The restart goes through the same fork-install-exec pattern as the initial launch; the new authd instance reads the directory fresh and re-establishes the CAAP cache.
Sessions that authd had been managing are still associated with the old authd's auth_id values; the new authd doesn't "inherit" them but doesn't need to — the existing sessions continue running on the tokens already minted; the kernel knows they exist.
A failure that exceeds the restart policy's retry budget is treated as a critical failure; peinit may transition the system to a recovery state where administrative intervention is required.
Boot ordering implications #
Knowing what authd does has implications for boot ordering:
- Services that need CAAP must start after authd has populated the cache. peinit enforces this by waiting for authd's readiness signal before launching such services.
- Services that need real tokens must start after authd is up. Similarly waited for.
- Services that are happy with SYSTEM can start before authd. This is mostly registry, observability, and other infrastructure services.
- Login frontends must start after authd is up. They can't authenticate users otherwise.
The exact graph of dependencies is part of peinit's configuration. peinit knows which services depend on what and starts them in the right order.
The handoff is one-way #
A subtle but important property: the handoff from kernel-direct tokens to authd-managed tokens is one-way. Once authd is running and has taken over identity creation, the system does not revert to "kernel-direct only" without a full restart.
If authd crashes and restarts, it picks up where it left off (re-reading the directory, re-populating CAAP). It doesn't reset the system to SYSTEM-everywhere; that initial transition happens only once per boot.
The reason: the running services have already been assigned real tokens. Going back to SYSTEM would require reassigning every service's token, which would mean restarting every service. That's a system-wide reboot, not a recovery.
So the trust establishment chain is a one-time event per boot. It happens early; it sets up the system's authority distribution; it stays in place until shutdown. Reboot is the only way to redo it.
Where to go next #
For the init system that launches authd and waits on its readiness, read peinit at PID 1.
For the SYSTEM-everywhere state the handoff replaces, read Bootstrap tokens.
For the recovery policy that applies until the CAAP cache is populated, read Distribution and recovery.
Kernel invariants
Peios / Peios Security Fundamentals / Boot and trust establishment
The boot chain — kernel-direct tokens, peinit at PID 1, the authd handoff — relies on a handful of kernel-level invariants to be safe. Things like "PKM is in the LSM stack and no incompatible LSM is", "the kernel rejects unsigned kernel modules", "physical memory is restricted to what is appropriate". If these invariants are violated, the security model the rest of these docs describes is no longer guaranteed.
This page covers the invariants, where each is enforced, and the consequences of failure. They are not user-facing settings in the sense of "things you might change" — they are kernel build-time and init-time configurations that the deployment requires. But knowing what they are helps with reasoning about the trust chain and diagnosing the rare case where something doesn't fit the expected model.
The LSM stack invariant #
The Linux Security Module framework lets the kernel host multiple security modules in a defined order. Peios's KACS is implemented as an LSM called PKM (Peios Kernel Module). For PKM to function correctly:
- PKM must be in the LSM stack.
- MAC (Mandatory Access Control) LSMs must not be in the stack — specifically SELinux, AppArmor, SMACK, and TOMOYO.
- The BPF LSM must not be active.
The reasoning: MAC LSMs and PKM both want to make authoritative access decisions, and the LSM framework's composition rules don't merge their decisions cleanly. A system with SELinux and PKM would have both modules trying to decide each access; the result would be that either both must agree (over-restrictive) or one supersedes the other (whichever the LSM order puts first), neither of which is the right semantic. Peios's model is that PKM is the sole authoritative module for the operations it covers.
What the kernel verifies #
The kernel verifies at PKM initialisation that no MAC LSM is active and that BPF LSM is not in use. If any are present, PKM refuses to activate. The kernel boots, but the access control is now broken — PKM is supposed to be enforcing, and it isn't. This is detected and reported as an init-time error.
Specifically, PKM refuses to activate if any of selinux, apparmor, smack, tomoyo, or bpf LSMs are present in the active stack.
The required stack #
The LSM stack Peios expects is, in order:
landlock, lockdown, yama, integrity, pkm
commoncapis implicit (Linux always includes it; not listed inCONFIG_LSMexplicitly).landlockis permitted — it's a process-local sandboxing LSM that processes opt into; it doesn't conflict with PKM.lockdownis permitted — it gates specific kernel-debugging interfaces; orthogonal to PKM.yamais permitted — it adds ptrace restrictions; complements PKM's ptrace gating.integrityis permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.pkmis the Peios module.
Distributors building a Peios kernel must include this stack. Building a kernel with SELinux enabled and active would produce a system where PKM cannot activate — boot would fail with a clear error.
Module signing #
CONFIG_MODULE_SIG_FORCE=y is required. This flag tells the kernel to refuse to load any kernel module that is not signed by a key the kernel recognises.
The reasoning: a kernel module runs with full kernel privileges. An attacker who can load arbitrary kernel modules bypasses every layer of PIP and KACS — they can read memory directly, modify any data structure, disable any check. The defence against this is to refuse to load unsigned modules.
CONFIG_MODULE_SIG_FORCE=y is the kernel-level switch that enforces it. With the flag set, unsigned modules are rejected at load time; a malicious or buggy administrator with SeLoadDriverPrivilege cannot load a malicious module unless they have the signing key, and it is not on a running system.
The module-signing key is deliberately a different key from the TCB key that signs binaries, and is not chained to it. Both are compiled into the image, so neither can rotate independently and neither can be an offline root — the TCB key signs binaries at build time. Chaining them would mean that compromising the TCB key also yielded the ability to load kernel modules. Keeping them independent contains binary signing and module loading separately. Both are ML-DSA-65.
The flag is verified at kernel build time. A kernel built without it doesn't enforce the rule and is not a valid Peios kernel for security purposes; the PIP threat model relies on this enforcement.
User-visible effect: kernel modules from the Peios distribution work (they are signed); modules built ad-hoc on a running system do not load. Third-party kernel modules (a vendor driver, say) must be signed by a key the kernel trusts, which in practice means going through the Peios signing infrastructure to add them.
The module-loading helper #
CONFIG_MODPROBE_PATH="/bin/modprobe" is required, and the binary it names must be TCB-signed.
When the kernel needs a module it does not have — mounting a filesystem whose driver is not built in, opening a socket for an unknown protocol family, resolving a crypto algorithm by name — it does not load it itself. It spawns a userspace helper to do it, and runs that helper at its own authority.
Two things follow. The path must be right: the upstream default is /sbin/modprobe, which does not exist on Peios, and left at that default every kernel-initiated load fails to find the helper while reporting nothing that names the cause.
And the path is a writable sysctl, which makes it an escalation vector — whoever can write /proc/sys/kernel/modprobe gets code of their choosing executed with the kernel behind it. The defence is not to protect the sysctl but to make redirecting it useless: KACS refuses any exec the kernel initiates on its own behalf unless the binary carries at least PeiosTcb trust. Pointing the sysctl elsewhere gains nothing without a TCB-signed binary to point it at.
This is the one place a signature gates execution rather than just labelling it. Everywhere else an unsigned binary runs and simply carries no integrity tier, because the process that asked for it bounds what it can do; a kernel-initiated exec has no such process behind it.
User-visible effect: a module the kernel asks for on your behalf loads only if the distribution's modprobe is in place and properly signed. A refusal is recorded as a kacs_exec event with reason umh-not-tcb, rather than appearing as an unexplained module-load failure.
Memory access restrictions #
CONFIG_STRICT_DEVMEM=y is required. This flag restricts /dev/mem and /dev/kmem access — physical-memory mapping pseudo-devices — to specific I/O regions, denying read or write access to actual system RAM.
The reasoning: a process that can read physical memory bypasses PIP. The DACL might say "this process cannot read the memory of authd"; the kernel's PIP check might agree; but if the process can mmap /dev/mem and read the corresponding physical pages, neither check matters. The defence is to restrict the device.
With CONFIG_STRICT_DEVMEM=y:
/dev/memis readable only for the kernel's defined I/O regions (memory-mapped I/O, the VGA framebuffer, etc.).- General RAM is not readable through
/dev/mem. /dev/kmemis unavailable.
This closes the physical-memory bypass for PIP. Combined with CONFIG_MODULE_SIG_FORCE, the two flags eliminate the kernel-level escapes from PIP that don't require an actual kernel compromise.
The flag is build-time; the kernel image either has it or doesn't. A kernel without it is not a valid Peios kernel for security purposes.
What the kernel will not have #
The Peios kernel is built with specific things excluded:
CONFIG_BPF_LSM=n— the BPF LSM is not built. BPF programs running as security modules would conflict with PKM and bypass its checks.- MAC LSMs disabled — SELinux, AppArmor, SMACK, TOMOYO are all disabled. Their config flags are off.
These exclusions are part of the kernel image. A distribution that includes these LSMs in its kernel image is not running Peios's intended security model.
What about user-space hardening #
A few things are explicit in the Peios environment that aren't strictly kernel invariants but are operational expectations:
peinitis signed at TCB level. If the binary peinit is exec'd from is not signed at TCB, the kernel's verification at exec setspip_type = None. peinit then runs unprotected, and any other process can interfere with it. This is operationally untenable; a properly-built Peios image has peinit signed.authd,loregd,eventd,lpsdare signed at TCB level. Same reasoning. These are the TCB daemons; they need PIP protection to function in their role.- No kernel modules are loaded other than signed ones. Per
CONFIG_MODULE_SIG_FORCE.
These are not enforced at boot in the sense of "the boot will fail" if violated — they are operational expectations. A Peios system where peinit is unsigned will boot, run, and "work" in a superficial sense; it just won't have the security properties Peios documents.
The invariant verification #
The kernel's init code does several checks at start:
- Verify PKM is in the LSM stack and no incompatible LSM is present. PKM refuses to activate if violated.
- Verify
CONFIG_MODULE_SIG_FORCEis set. (This is a build-time flag; at runtime the kernel just behaves consistently with the build.) - Verify
CONFIG_STRICT_DEVMEMis set. (Same; build-time.) - Construct the SYSTEM and Anonymous tokens. As covered in Bootstrap tokens.
- Attach the SYSTEM token to init. Init becomes the first process holding it.
- Start userspace. init execs the configured first program — prelude, the initramfs PID 1, which runs the initramfs and then execs the real init (peinit, in a normal Peios image). See The initramfs stage.
By the time userspace starts, the invariants are either satisfied (boot proceeds normally) or violated (boot fails or the security model is broken). The verification is internal; from the operator's perspective, the system either comes up cleanly or doesn't.
Failure modes #
If an invariant fails:
- MAC LSM detected at PKM init. PKM refuses to activate. The kernel boots but the access control is effectively absent. peinit's exec verification (looking for a TCB signature) returns "valid" but no PIP is set because PKM isn't running. The system is unsafe.
CONFIG_MODULE_SIG_FORCE=n. Modules can be loaded without signing. An attacker withSeLoadDriverPrivilegecan load arbitrary kernel modules and bypass everything. PIP is no longer meaningful.CONFIG_STRICT_DEVMEM=n./dev/memcan read physical memory. Any process withREAD_CONTROLon/dev/mem(or whose DACL grants it) can bypass PIP by reading memory directly.
Each failure mode is a security regression. The boot chain depends on the invariants holding. A deployment that needs to be sure its security model is operating correctly verifies these as part of the boot validation — typically via a post-boot tool that reads /proc/sys/kernel/lsm, checks build-time flags via the kernel config, etc.
For most Peios deployments built from the official kernel image, the invariants hold by construction. The verification is mostly relevant for custom kernels built from source — anyone modifying the kernel image needs to know which flags they cannot change.
Why these invariants #
Two themes underlie all of these:
The trust chain is rooted in kernel integrity. SYSTEM token, PIP, signed binaries — every layer of the model assumes the kernel is honest. If the kernel can be modified at runtime (unsigned modules), or its memory can be read or written from userspace (/dev/mem), the foundation crumbles.
PKM must be the sole authoritative LSM. Trying to compose PKM with MAC LSMs would give either over-restrictive or inconsistent semantics; the Peios access model is built on PKM's decisions, and that requires PKM to make the decisions alone.
The invariants are what make these themes operational: the kernel is built and initialised in ways that protect itself from runtime modification and that ensure PKM is in charge.
Where to go next #
For the tokens the verified kernel constructs at init, read Bootstrap tokens.
For the signing scheme module signing shares its keys with, read Binary signing.
For the protection model these invariants defend, read Process integrity protection.
mkirf
Peios / Peios Security Fundamentals / Boot and trust establishment
mkirf compiles an initramfs source tree into an initramfs image. The source tree is an ordinary directory — on a running Peios system that directory is /boot/initramfs/ — whose contents map 1:1 onto / inside the initramfs. The image is a single deterministic, gzip-compressed newc cpio archive that the bootloader loads into memory alongside the kernel.
mkirf [--watch] [--debounce SECS] [--exclude GLOB]... <src-dir> <out-file>
This page is the command reference. For what the initramfs stage is — prelude, the /boot/initramfs/ layout, and the handoff to the real root — see The initramfs stage; for how hooks declare their order, see Boot hooks. mkirf reads <src-dir> and writes <out-file>; it never writes back into the source tree, so the directory you inspect is always exactly what packages and you have put there.
What a build does #
Given a source tree, one build runs a fixed sequence:
-
Validate the layout.
<src-dir>must be a directory, must carry an executableinit(the initramfs PID 1 — a symlink is fine as long as it resolves within the tree), and must not already contain a hook sequence —hooks.seqorsystem/prelude/hooks.seq.<n>— whichmkirfgenerates. A missing or non-executableinit, or a stray sequence file, stops the build. -
Walk the tree. Every object beneath
<src-dir>becomes a cpio entry — regular files (with their executable bit preserved), directories, symlinks (target stored verbatim), character/block device nodes, and FIFOs. Sockets, and any other unsupported type, are rejected. Entries are sorted inLC_ALL=Cbyte order, which places every directory before its descendants — the order the kernel's unpacker requires. -
Resolve the hook order. Hooks are the regular files directly under
<src-dir>/usr/libexec/prelude/hooks.d/(packaged) or<src-dir>/lcl/libexec/prelude/hooks.d/(operator-placed), with the operator's copy winning when both hold the same file name. The legacy<src-dir>/hooks/is still read, and each hook found there is reported with the directory it should move to.mkirfparses each hook's# /// hookmetadata block, topologically sorts the resulting capability DAG, and bakes the execution order into generated manifests injected into the image at/system/prelude/hooks.seq.<n>. A missinghooks/directory simply means no hooks — the manifests are still written, empty, because prelude treats a missing sequence as a broken image rather than as "nothing to run".The version is in the file name, and
mkirfwrites every version it can express faithfully — currentlyhooks.seq.1(the flat resolved order) andhooks.seq.2(that order plus each hook's declarations).mkirfships inpeiosutilsand prelude ships in its own package, so an image can pair a newer writer with an older reader; naming the versions separately lets one image satisfy both, which a single file carrying an in-band version cannot. A version is dropped only when a future format carries something it can no longer project honestly.They live under
/system— the tier for derived content, alongsidesystem/retc— rather than/usr, which is the vendor tier no package ships these into, or/var/state, which is for mutable state rather than immutable build output. See Validation and errors below. -
Write atomically. The archive is written to a sibling temp file and
renamed into place, so a failed run never leaves a half-written image behind. On successmkirfprints the path, entry count, and byte size to stderr.
Early (pre-decompression) segments #
A reserved <src-dir>/++/ directory, if present, holds early initramfs segments — content the kernel consumes before it decompresses the main archive, namely CPU microcode and ACPI table overrides. Each immediate child of ++/ is one segment whose own contents map onto the cpio root (++/microcode/kernel/x86/microcode/GenuineIntel.bin becomes kernel/x86/microcode/GenuineIntel.bin); the segment directory name is a human label and never appears in the archive. Every ++/ entry must itself be a directory. The segments are merged into one uncompressed cpio prepended ahead of the gzip-compressed main archive. With no ++/ directory the output is exactly a single gzip member. mkirf stays format-agnostic here: it knows "early segments", never "microcode".
The deterministic-build guarantee #
The same source tree always compiles to the same image, byte for byte. This is what makes "did anything actually change?" a meaningful question and underpins later work such as signed boot artifacts. Determinism comes from normalising everything that would otherwise vary between builds:
- Ownership, inode numbers, and timestamps are all emitted as zero.
- Permission bits are normalised. The source tree is authoritative for file type and, for regular files, executability — nothing else. Read/write permission bits are not Peios's access mechanism, so they are flattened to a constant: directories
0755, symlinks0777, FIFOs and device nodes0644, regular files0755if executable and0644otherwise. - The gzip member carries mtime 0 and no embedded filename (the
gzip -nequivalent), at compression level 9. - The early region is byte-stable, with file payloads padded to a 16-byte boundary by widening the preceding entry's name field with NUL bytes.
Validation and errors #
mkirf will not produce an image from a source tree that cannot boot. A misconfiguration is caught when the image is built — on a running system where the message is easy to read — rather than as a mystery failure at the next boot. The checks:
- Layout.
<src-dir>is not a directory; noinit, orinitis not a regular file, or is not executable; a pre-existing hook sequence; a++entry that is not a directory; two++segments defining the same file. (Exit 1.) - Hooks. A malformed
# /// hookmetadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency cycle, arequiresthat nothing supplies, or a capability that is both provided and contributed to (a capability is either a set of alternatives or a set of contributors, never both). Anafternaming a capability nothing supplies is not an error — that is what distinguishes it fromrequires. (Exit 1.) A hook with no metadata block at all is not an error — it is the escape hatch: it is scheduled last, in name order, andmkirfprints a warning so a forgotten block is visible. - I/O. Any filesystem read, write, or compression failure. (Exit 1.)
- Usage. A semantic invocation error clap cannot express — currently,
--watchwith<out-file>inside<src-dir>(see below). (Exit 2.)
Watch mode #
With --watch, mkirf stays resident: it builds once up front (so the watch never begins from a stale archive), then watches <src-dir> recursively and rebuilds after every change, debounced. This is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
Watch mode is a foreground loop that runs until killed; supervising and restarting it is a service manager's job, not mkirf's. A rebuild that fails (say, a hook edited into a dependency cycle) is logged but does not stop the watch, so fixing the offending file recovers on the next change.
<out-file> must not live inside <src-dir>: each rebuild would write the image back into the watched tree and retrigger the watch endlessly. mkirf refuses this invocation up front (exit 2). The --debounce window (default 5 seconds) is the settle time — mkirf waits for the tree to be quiet for that long before rebuilding, so a burst of edits produces one rebuild rather than many.
Options #
| Option | Effect |
|---|---|
--watch | Stay resident and rebuild on every change to <src-dir>, after an initial build. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. A non-numeric value is a usage error. |
--exclude GLOB | Exclude paths (relative to <src-dir>) matching GLOB from the image. Repeatable. * and ? stay within a path segment; ** crosses separators. A matched directory is pruned with its whole subtree. A malformed glob is a usage error. |
The two positionals are both required:
| Positional | Meaning |
|---|---|
<src-dir> | The source tree; its contents map onto / in the initramfs. |
<out-file> | The output cpio.gz archive. |
Exit status #
| Code | Meaning |
|---|---|
0 | The image was built (or --help/--version was printed). |
1 | An operational failure — an invalid layout, an unresolvable hook set, or an I/O/compression error. |
2 | A usage error — a bad option, a missing positional, or --watch with <out-file> inside <src-dir>. |
See also #
- The initramfs stage — what
mkirf's output is for. - Boot hooks — the
# /// hookmetadata block and how order is resolved. - mkuki — wraps
mkirf's image, together with a kernel and command line, into a bootable UEFI unified kernel image.
mkuki
Peios / Peios Security Fundamentals / Boot and trust establishment
mkuki builds a UEFI unified kernel image (UKI): it takes a PE/COFF EFI stub and appends the kernel, the initramfs, and the kernel command line to it as named PE sections, producing a single EFI binary that UEFI firmware boots directly. Where mkirf produces the initramfs image, mkuki is the step that wraps that image — together with a kernel and a command line — into one bootable artifact. The two are the two halves of Peios' Dynamic Boot system.
mkuki --kernel PATH --initramfs PATH (--cmdline TEXT | --cmdline-file PATH) --out PATH
[--stub PATH] [--watch] [--debounce SECS]
mkuki --stub-info
The UKI section layout #
A UKI is an ordinary PE/COFF EFI executable — the stub — with three extra sections appended, which the stub reads at boot to find its payloads:
| Section | Content | Source |
|---|---|---|
.cmdline | The kernel command line, NUL-terminated. | --cmdline or --cmdline-file |
.linux | The kernel image. | --kernel |
.initrd | The initramfs cpio image. | --initramfs |
mkuki appends them in exactly that order. Each new section is marked as initialised, read-only data. The tool works at the PE level directly: it parses the stub's DOS/PE headers and section table, computes correctly-aligned virtual addresses and file offsets for the new sections, appends their data, updates the section count and the SizeOfImage/SizeOfHeaders fields, and — if the stub's header has no spare room for three more section entries — grows the header region and relocates the existing sections' file pointers to make space. A stub that is not a valid MZ/PE image, or whose optional header is malformed, is rejected.
The command line #
The command line is taken either literally from --cmdline or read from the file named by --cmdline-file (exactly one is required). Either way, mkuki trims trailing newlines and carriage returns and appends a single NUL terminator. A command line containing an embedded NUL byte is rejected.
The stub #
--stub names the EFI stub to append to. With no --stub, mkuki uses a stub bundled into the binary (the systemd EFI stub). mkuki --stub-info prints that bundled stub's provenance and exits, so you can see exactly what the default is without building anything.
Output and atomicity #
mkuki creates any missing parent directories of --out, writes the assembled image to a sibling temp file, and renames it into place, so an interrupted or failed build never leaves a half-written UKI behind. On success it prints the output path and byte size to stderr. Each of the three payloads must be non-empty; an empty .cmdline, .linux, or .initrd fails the build.
Watch mode #
With --watch, mkuki stays resident: it builds once up front, then rebuilds the UKI whenever an input changes — so the boot image tracks a new kernel, a freshly-repacked initramfs (mkirf's half of Dynamic Boot), or an edited command-line file with no manual step. Like mkirf's watch mode, it is a foreground loop that runs until killed; supervising it is a service manager's job, and a rebuild that fails (for example, a kernel caught mid-copy) is logged rather than fatal, so fixing the input recovers on the next change.
The watched inputs are --kernel, --initramfs, and — only if it is a --cmdline-file, since a literal --cmdline is static — the command-line file. mkuki watches each input's parent directory (non-recursively), not the file itself: this survives the atomic temp-and-rename writes that mkirf and mkuki both perform (which appear as a directory event a stale single-file watch would miss) and catches a versioned kernel being swapped in /boot. Because the watch is non-recursive, a write to an --out path nested deeper under a watched directory does not retrigger it — but an --out sitting directly in a watched input directory would, so mkuki refuses that invocation. The --debounce window (default 5 seconds) is the settle time before a rebuild.
Options #
| Option | Effect |
|---|---|
--kernel PATH | The kernel image; becomes the .linux section. Required (except with --stub-info). |
--initramfs PATH | The initramfs cpio; becomes the .initrd section. Required (except with --stub-info). |
--cmdline TEXT | The kernel command line, given literally; becomes the .cmdline section. Mutually exclusive with --cmdline-file. |
--cmdline-file PATH | Read the kernel command line from PATH instead. Mutually exclusive with --cmdline. |
--out PATH | The output UKI path. Required (except with --stub-info). |
--stub PATH | The PE/COFF EFI stub to append sections to. Defaults to the bundled systemd stub. |
--stub-info | Print the bundled stub's provenance and exit. |
--watch | Stay resident and rebuild the UKI whenever --kernel, --initramfs, or --cmdline-file changes. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. |
Exactly one of --cmdline or --cmdline-file must be given: supplying both, or neither, is a usage error.
Exit status #
| Code | Meaning |
|---|---|
0 | The UKI was built (or --stub-info, --help, or --version was printed). |
1 | A build or watch failure — a missing or unreadable input, an invalid stub, an empty payload, or an I/O error. |
2 | A usage error — a bad or missing option, or an invalid --cmdline/--cmdline-file combination. |
See also #
- mkirf — builds the
.initrdpayloadmkukiwraps. - The initramfs stage — what the bundled initramfs does once firmware boots the UKI.