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

Peios Security Fundamentals

Understand the access-control model: identities, authentication, tokens, permissions, isolation and auditing. Practical administration and diagnosis are in Using Peios.

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.

LayerRole
PrincipalThe 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.
TokenThe runtime object that carries an identity into the kernel. Every thread has one.
Logon sessionThe 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:

  1. 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.
  2. authd, the authentication authority, creates a logon session: a kernel object that records the authentication event (who, how, when).
  3. 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:

PositionMeaning
SConstant marker — every SID string starts with S.
1Revision number. Always 1 in Peios.
5Identifier authority. A small integer naming the SID's overall namespace.
21-3623811015-3361044348-30300820Sub-authorities. A sequence of integers that locate this principal within the authority's namespace.
1001Relative 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\Administrators for S-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:

PatternMeaning
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-YA logon SID. Created per authentication event; lets ACEs target "the specific login session that did this".
S-1-16-NAn integrity level. Used as a label in a SACL, not in a DACL.
S-1-19-T-LA process integrity protection (PIP) trust label. Used in SACLs on objects that opt in to PIP.
S-1-5-80-h1-h2-h3-h4-h5A 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-...-h8A 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_level field).
  • 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.

SIDNameBehaviour
S-1-0-0NobodyMatches nothing. Used to write an ACE that can never apply, usually as a deliberate placeholder.
S-1-1-0EveryoneMatches every token, including Anonymous. The broadest possible grant.
S-1-2-0LocalMatches any token created by a local logon (not a network logon).
S-1-2-1Console LogonMatches a token created by a physical or console session.
S-1-3-0Creator OwnerPlaceholder. 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-1Creator GroupPlaceholder. Same as Creator Owner but for the primary group.
S-1-3-4Owner RightsSpecial. 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 #

SIDNameBehaviour
S-1-5-2NetworkMatches a token created by a network logon.
S-1-5-3BatchMatches a token created to run a scheduled job.
S-1-5-4InteractiveMatches a token created by an interactive logon — at the console, over SSH, or to a remote desktop.
S-1-5-6ServiceMatches a token created for a service.
S-1-5-7AnonymousThe user SID for tokens at the Anonymous impersonation level.
S-1-5-11Authenticated UsersMatches every successfully authenticated token. Excludes Anonymous.
S-1-5-14Remote InteractiveMatches a token created by a logon to a remote graphical desktop. Carried alongside Interactive, not instead of it.

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.

A remote desktop logon puts both S-1-5-4 and S-1-5-14 on the token. That pairing is what makes "only someone physically at the machine" expressible: the user counts as an interactive user in every ACL that was ever written, and can still be told apart by one that cares. Allow Interactive and deny Remote Interactive, and you have said exactly that — a rule you could not write if remote desktops had a type of their own and nothing else.

A remote desktop logon deliberately does not carry S-1-5-2 (Network), even though the session is reached over a wire. A deny entry beats any allow, so carrying Network would place every remote-desktop user inside every rule that denies access to network logons, with no way to carve them back out. Network says a credential crossed a wire; it does not say a screen did.

System actors #

SIDNameBehaviour
S-1-5-18Local System (SYSTEM)The kernel and TCB services run as SYSTEM. The most privileged identity on the local machine.
S-1-5-19Local ServiceBuilt-in service account with reduced privileges.
S-1-5-20Network ServiceBuilt-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:

SIDName
S-1-5-32-544BUILTIN\Administrators
S-1-5-32-545BUILTIN\Users
S-1-5-32-546BUILTIN\Guests
S-1-5-32-551BUILTIN\Backup Operators

Logon sessions and domains #

PatternMeaning
S-1-5-5-X-YA 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-RIDA principal in a specific domain. DA1-DA2-DA3 identifies the domain; RID identifies the principal within it.
S-1-5-21-DA1-DA2-DA3-500The Administrator account of that domain (RID 500 is reserved).
S-1-5-21-DA1-DA2-DA3-512Domain Admins.
S-1-5-21-DA1-DA2-DA3-513Domain Users.
S-1-5-21-DA1-DA2-DA3-515Domain 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.

PatternMeaning
S-1-5-80-h1-h2-h3-h4-h5A 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.

SIDLevel
S-1-16-0Untrusted
S-1-16-4096Low
S-1-16-8192Medium
S-1-16-12288High
S-1-16-16384System

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.

PatternMeaning
S-1-19-T-LPIP trust label. T is the PIP type (0 None, 512 Protected, 1024 Isolated). L is the trust level within that type.

Common combinations:

SIDTrust meaning
S-1-19-0-0No PIP protection. Default for unsigned processes.
S-1-19-512-1024Protected, Authenticode. Third-party signed binaries.
S-1-19-512-2048Protected, App. Peios-distributed applications.
S-1-19-512-4096Protected, Peios. Core Peios components.
S-1-19-512-8192Protected, 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 #

SIDName
S-1-15-2-1ALL_APPLICATION_PACKAGES — matches every confined application in normal confinement mode.
S-1-15-2-2ALL_RESTRICTED_APPLICATION_PACKAGES — matches confined applications in both normal and strict modes.

Well-known capabilities #

SIDCapability
S-1-15-3-1internetClient — outbound network.
S-1-15-3-2internetClientServer — inbound and outbound network.
S-1-15-3-3privateNetworkClientServer — LAN/private network.
S-1-15-3-8enterpriseAuthentication — domain credential access.
S-1-15-3-9sharedUserCertificates — certificate store access.
S-1-15-3-10removableStorage — removable media access.

Derived capability SIDs #

For application-defined capabilities beyond the well-known set, the SID is derived from the capability name:

PatternMeaning
S-1-15-3-h1-h2-h3-h4-h5-h6-h7-h8A 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.

SIDNameHow it is used
S-1-3-0Creator OwnerSubstituted at inheritance time.
S-1-3-1Creator GroupSubstituted at inheritance time.
S-1-3-4Owner RightsSuppresses owner implicit rights when present in a DACL.
S-1-5-10Principal SelfA 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\Administrators and SYSTEM for default protective DACLs.
  • Authenticated Users for "any logged-in user should be able to read this".
  • Everyone only when you actually mean "including unauthenticated network peers".
  • Creator Owner in inheritable ACEs that should follow the owner of a child object.
  • Owner Rights when 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-...-RID form) 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 Department or Clearance. 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:

TypeNotes
INT64A signed 64-bit integer.
UINT64An unsigned 64-bit integer.
STRINGA UTF-16LE string.
SIDA binary SID.
BOOLEANTRUE or FALSE.
OCTETA 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.

FlagEffect
DISABLEDThe claim is invisible to all conditional expressions. Effectively, the attribute does not exist.
USE_FOR_DENY_ONLYThe 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.
MANDATORYThe claim cannot be removed or modified without the SeTcbPrivilege. Used for claims that the system itself depends on.
CASE_SENSITIVEString 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:

  • @Resource values come from resource attributes in the object's SACL, not the caller's token. See Resource attributes.
  • @Local values 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.

GroupFieldsWhat they do
Identityuser_sid, groups, logon_sid, restricted_sidsWho the token represents.
Type and leveltoken_type, impersonation_level, integrity_level, mandatory_policyWhat kind of token this is and how it may be used.
Defaultsowner_sid_index, primary_group_index, default_daclWhat the token contributes when new objects are created without an explicit security descriptor.
Session and provenanceauth_id, source, token_id, created_at, expiration, originWhere the token came from and which authentication event it belongs to.
Privilegesprivilege bitmask (present / enabled / used / removed states)System-wide rights the token holds.
Confinementconfinement_sid, confinement_capabilities, confinement_exemptSandboxing constraints, when the token is for a confined application.
Claimsuser_claims, device_claims, device_groupsTyped attributes for conditional ACE evaluation.
Projectionprojected_uid, projected_gid, projected_supplementary_gidsLinux-compatibility identity numbers, derived from the rest of the token.
Audit policyaudit_policy bitmaskPer-token forced auditing flags.
Self SDsecurity_descriptorThe 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:

  1. Authentication succeeds, or a service is being launched.
  2. 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.
  3. It calls kacs_create_token with the resolved values.
  4. The kernel returns a token file descriptor with TOKEN_ALL_ACCESS.
  5. 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.

RightAction it gates
TOKEN_QUERYRead token information — fields, groups, privileges, etc.
TOKEN_DUPLICATECreate an independent copy via DuplicateToken or FilterToken.
TOKEN_IMPERSONATEInstall as a thread's impersonation token.
TOKEN_ASSIGN_PRIMARYInstall as a process's primary token.
TOKEN_ADJUST_PRIVILEGESEnable, disable, or permanently remove privileges.
TOKEN_ADJUST_GROUPSEnable or disable groups.
TOKEN_ADJUST_DEFAULTChange the default DACL, owner index, or primary group index.
TOKEN_ADJUST_INTERACTIVITY_SCOPEChange interactivity_scope (additionally requires SeTcbPrivilege). This does not change the token's LogonSession auth_id.

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 caseToken type
A login session's identityPrimary
A server thread acting on behalf of a connected clientImpersonation
A service running with the identity authd assigned to itPrimary
A thread querying its caller's identity for an audit log entryImpersonation (at the Identification level — see below)

impersonation_level — how far an identity may travel #

Every token carries an impersonation level, and it means the same thing on both kinds: nothing derived from this token may act above it. For an impersonation token that is the level the impersonator acts at. For a primary token it is the ceiling on everything the process can convey — what a peer captures when it connects, what gets attached to a message, what a duplicate can be. The level only ever goes down as identity travels; a fresh CreateToken is the only way back up. See Impersonation levels.

There are four levels, ordered from least to most permissive:

LevelWhat a server may do with this token
AnonymousNo identity. The token's user SID is the well-known Anonymous SID (S-1-5-7). The server learns nothing about the client.
IdentificationThe 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.
ImpersonationThe server may act as the client for all local operations. This is the default, and the level most server-to-client code paths assume.
DelegationSame 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:

ValueMeaning
DefaultThe token is not part of a linked pair. Most tokens.
FullThe elevated half of a linked pair.
LimitedThe 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.

ClassMeaningExamples
FixedSet 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.
AdjustableCan 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-wayCan 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.

FieldWhat it is
user_sidThe primary identity of the token. The principal's SID.
groupsAn array of SID_AND_ATTRIBUTES — every group the principal is a member of, each with its current enabled/disabled state and other flags.
logon_sidThe 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_sidsSecondary identity list for restricted tokens. Empty on unrestricted tokens.

Type and level #

FieldWhat it is
token_typePrimary or Impersonation.
impersonation_levelOne of the four levels listed above (only meaningful for Impersonation tokens).
integrity_levelNormally one of Untrusted / Low / Medium / High / System; technically any numeric S-1-16 level (see MIC).
mandatory_policyThe 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.

FieldWhat it is
owner_sid_indexIndex into [user_sid, groups[0..N-1]] selecting which SID becomes the default owner.
primary_group_indexSame idea for the default primary group.
default_daclThe DACL applied to new objects when no explicit SD is supplied.

Session and provenance #

FieldWhat it is
token_idA unique LUID identifying this token instance.
auth_idThe LUID of the logon session this token belongs to.
sourceAn 8-character name plus a LUID identifying the component that minted the token (e.g. authd).
created_atTimestamp of original minting. Copied unchanged by DuplicateToken and FilterToken.
expirationExpiry timestamp. Set by authd. Informational only in v0.20 — not enforced by AccessCheck.
originThe originating logon session for derived tokens (S4U, network logon).
modified_idA 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 #

FieldWhat it is
confinement_sidIf non-null, the token is for a confined application; its identity for access checks is intersected with this SID.
confinement_capabilitiesThe list of capability SIDs the confined application has declared.
confinement_exemptEscape hatch — if set, confinement is skipped entirely.
isolation_boundaryReserved for future use; not enforced in v0.20.

See Confinement for the sandbox model.

Claims #

FieldWhat it is
user_claimsTyped attributes about the user, populated from the directory at authentication time. Used by @User.* references in conditional ACEs.
device_claimsSame idea for the machine. Used by @Device.*.
device_groupsThe 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.

FieldWhat it is
projected_uidThe Linux UID the token's identity maps to (or 65534 if unmapped).
projected_gidSame for primary GID.
projected_supplementary_gidsSame for supplementary GIDs.

See Linux compatibility for what consumes these.

Audit policy #

FieldWhat it is
audit_policyA 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 #

FieldWhat it is
security_descriptorThe token is itself a protected object; this is its own SD. Governs who may query, duplicate, impersonate, install, or adjust the token.

Other #

FieldWhat it is
interactivity_scopeThe interactive-environment scope. Zero for services. Adjustable only with SeTcbPrivilege; distinct from the immutable LogonSession LUID in auth_id.
elevation_typeDefault / 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:

OperationEffectPrivilege required
kacs_create_tokenMint a token from scratch using a wire-format specification.SeCreateTokenPrivilege
DuplicateTokenMake 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
FilterTokenMake 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:

  1. Validates the inputs (well-formed SIDs, no duplicate luids, all index references in range, etc.).
  2. Allocates a new token object with refcount = 1.
  3. Assigns a fresh token_id LUID and modified_id = 0.
  4. Stamps the created_at timestamp.
  5. Injects the appropriate logon SID into the groups list (callers must not supply it).
  6. 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_INSTALL on a token fd makes the token the calling process's primary. Requires TOKEN_ASSIGN_PRIMARY on the token and SeAssignPrimaryTokenPrivilege on 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_IMPERSONATE on a token fd makes the token the calling thread's impersonation token. Requires TOKEN_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 an explicit integrity label lower than the token's (an unlabelled executable changes nothing), 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:

  • peios_token_impersonate_peer(fd) — read the peer's identity from a connected Unix socket (the KACS_SO_PEER_TOKEN socket option) and install it at the appropriate level. The most common path for services accepting client connections.
  • KACS_IOC_IMPERSONATE on 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 / ioctlWhat it changes
AdjustPrivilegesEnable, disable, or permanently remove privileges. A reset-all sentinel restores defaults.
AdjustGroupsEnable or disable group entries. Cannot target mandatory groups, deny-only groups, the logon SID, or the user SID.
AdjustDefaultModify the default DACL, owner index, or primary group index.
kacs_set_sd on the token fdModify 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_open with CREATE disposition, 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 kacs.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. Ending a session early is done in userspace: asked to, authd finds every process whose primary token belongs to the session and ends it, and the kernel destroys the session when the last reference goes. A token descriptor held by a process outside the session survives that, and keeps the session alive. This is documented under Session lifecycle.

Quick reference: which transitions change what #

TransitionIdentityPrivilegesGroupsIntegrityRefcount
ForkSameSameSameSame+1 (shared)
CLONE_THREADSameSameSameSameSame object
Exec (same integrity)SameSameSameSameSame object
Exec (NEW_PROCESS_MIN downgrade)SameSameSameLoweredNew token
Impersonation installDifferent (impersonation token)DifferentDifferentDifferent+1 on impersonation token
Impersonation revertBack to primaryBack to primaryBack to primaryBack to primary−1 on impersonation token
DuplicateTokenSameSameSameSameNew token with its own count
FilterTokenSameSubsetSubset (some deny-only, restricted_sids added)SameNew token
AdjustPrivilegesSameChanged (within rules)SameSameSame object
AdjustGroupsSameSameChanged (within rules)SameSame object
KACS_IOC_INSTALLSame 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:

  1. Normal pass. The DACL walk uses the token's user_sid, all enabled groups, and all the usual rules. Produces a granted_normal mask.
  2. Restricted pass. The DACL walk runs again, but the only SIDs considered for matching are those in restricted_sids. The user_sid does not match. None of the regular groups match. Only the entries in restricted_sids count. Produces a granted_restricted mask.
  3. 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 categoryWhere 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_ONLY in 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:

  1. Open its own primary token.
  2. Call KACS_IOC_RESTRICT to produce a restricted variant: remove every privilege except SeChangeNotifyPrivilege, mark unsafe groups deny-only, add the sandbox's allowed capability SIDs as the restricted SIDs, set the write-restricted flag.
  3. Fork.
  4. KACS_IOC_INSTALL the restricted token on the child.
  5. 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:

RestrictedConfinement
Who decidesThe program itself, in codeAdministrative policy, applied from outside
Typical callerA sandbox launcher, a hardened service, an anti-malware engineA service manager applying a service definition; a container runtime
Storage on the tokenrestricted_sids listconfinement_sid + confinement_capabilities
Where it fires in AccessCheckInside the DACL walk, identity-based intersectionAfter the DACL walk and privileges, absolute intersection
Bypassable with a privilege?Yes — privilege-granted bits surviveNo — confinement is absolute
Owner implicit rights still apply?YesNo
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_RESTRICT with 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_sids containing 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

Important

Not yet built in userspace. The kernel side of this page is complete and works as described — KACS_IOC_LINK_TOKENS, KACS_IOC_GET_LINKED_TOKEN, and the elevation_type field are all implemented. What does not happen yet is anything creating a pair. authd mints a single token carrying everything the principal is entitled to, and login runs the session on it; no interactive logon produces a Limited half, and there is no broker holding a Full one.

So on a current machine an administrator's shell holds their administrative privileges and High integrity for its whole life, and there is no elevation boundary. Read what follows as the model the system is built towards and the kernel already supports, not as a description of how your machine behaves today.

The blocker is not the token work. A Limited token marks BUILTIN\Administrators as USE_FOR_DENY_ONLY, and the security descriptor a stock image ships grants only LocalSystem and BUILTIN\Administrators — so a Limited shell could not list a directory. Per-subtree descriptors have to come first, otherwise the boundary would land on a machine nobody could use.

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:

ValueMeaning
DefaultToken is not part of a linked pair. The vast majority of tokens.
FullThe elevated half of a pair.
LimitedThe 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:

  • SeTcbPrivilege on the caller.
  • TOKEN_DUPLICATE on 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:

  1. Records the pair on the session.
  2. Sets the elevated token's elevation_type = Full.
  3. 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:

  1. authd authenticates the user.
  2. authd creates the user's session.
  3. authd mints the Full token with all the user's entitled privileges and groups.
  4. authd FilterTokens the Full token down to the Limited version — privileges removed, admin groups deny-only.
  5. authd calls KACS_IOC_LINK_TOKENS to link the two.
  6. authd installs the Limited token as the primary of the user's first process.
  7. 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 with TOKEN_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_TOKEN sees 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_TOKEN on either token return -ENOENT.
  • The individual tokens themselves continue to exist as long as their reference counts hold. Their elevation_type values 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.

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 signing somebody out. The second matters because logging out has to do more than just close a shell — it has to end every process of the sign-in, 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.

FieldWhat it is
session_idA unique LUID identifying the session. Same as the auth_id on every token belonging to the session.
logon_typeWhat kind of sign-in produced this session — Interactive, Network, Service, Batch, etc. See Logon types.
user_sidThe principal who signed in. The same SID appears in user_sid on every token of the session.
auth_packageA string identifying the authentication mechanism (e.g. "Kerberos", "NTLM", "local"). Informational; not used by AccessCheck.
created_atTimestamp of the sign-in.
logon_sidA 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 kacs.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 ending every process running on those tokens. Forced sign-out is implemented this way, in user space: asked by a person signing themselves out, or by an administrator signing somebody else out, authd ends every process whose primary token belongs to the session, and the kernel does the rest.

Bootstrap sessions #

Two sessions exist before authd is up, created by the kernel during boot:

SessionIDPurpose
SYSTEM session999The session of the SYSTEM token, attached to init and inherited by every process until authd assigns real tokens.
Anonymous session998The 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_logon_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 forced sign-out mechanics — including the kacs.session.destroyed event and what authd does to sign somebody out — 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 — by name, such as interactive or network — in the kacs.session.destroyed event that closes 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 seven types in v0.20.

The SSH assignments below follow the current PGSS SSH implementation contract, whose release qualification is tracked by PEI-67. They do not establish the behavior of older builds; the contract's coordinated-rebuild requirements still apply.

The seven types #

The numeric values are also catalogued in Other constants.

ValueNameWhat it means
2InteractiveThe user signed in at this machine's console or local desktop.
3NetworkThe user authenticated to access network resources without an interactive sign-in. Typical for SMB, RPC, federated services.
4BatchA scheduled job. The principal is logged in to run a task at a specific time, not by a user actively present.
5ServiceA service started under a specific principal. Used for the long-lived service-account model.
8NetworkCleartextA 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.
9NewCredentialsA 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.
10RemoteInteractiveThe user signed in to a remote desktop or over SSH, including commands without a PTY and SFTP. The session is reached remotely rather than at hardware this machine owns.

Values 1, 6, 7, and 11 onward are reserved and not used in v0.20.

When each type is used #

Interactive #

The type for local console and desktop sign-ins. SSH connections and remote desktop sessions use RemoteInteractive.

In audit logs, Interactive distinguishes a local interactive sign-in from a remote one. It is not a marker for all human activity: a person using SSH or a remote desktop has a RemoteInteractive session.

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.

RemoteInteractive #

A remote desktop or SSH session. Every SSH logon uses this type, including commands without a PTY and SFTP; it does not become Network or Batch when no terminal is requested.

GXWI requests this type when logging on through authd. The kernel and authd must both support value 10; an older authd refuses the request rather than substituting another type. After updating GXWI and authd, log off existing GXWI sessions and start new ones: reconnecting to an existing compositor retains that session's original token and logon type.

It is a separate type from Interactive rather than a flavour of it because the two differ in exactly one respect that an administrator may want to act on: whether the person is physically at the machine. Everything else about the session is the same, which is why a RemoteInteractive token carries the Interactive group SID as well as its own — see Well-known principals for what that pair lets you write.

Rebooting from a RemoteInteractive session needs SeRemoteShutdownPrivilege on top of SeShutdownPrivilege, as it does from a Network, NetworkCleartext or NewCredentials session. The session being remote is what counts, not whether it is graphical.

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/sessions and the session inspection APIs.
  • In every token's session — via the token's auth_id field, 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_logon_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 forced sign-out (ending a session before the person signs out themselves, which authd does on request).

Creation #

A session is created by kacs_create_logon_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:

FieldMeaning
logon_typeThe type — Interactive, Network, Service, Batch, etc. See Logon types.
auth_packageA string identifying the authentication mechanism (informational).
user_sidThe principal who signed in.

The kernel:

  1. Validates the inputs (well-formed SID, recognised logon type, sane string lengths).
  2. Allocates a session object with a fresh session_id LUID.
  3. Stamps created_at.
  4. Derives the logon SID S-1-5-5-X-Y from the session ID.
  5. Initialises the session's token reference count at zero.
  6. 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".

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. So the kernel only destroys a session on a refcount that transitions from positive to zero, not on one that has been zero since creation. After a successful first attachment, normal destruction rules apply.

The consequence is that a session no token ever attaches to is never reaped. Nothing times it out. If authd's mint fails after it created the session, authd rolls the session back itself with kacs_destroy_empty_logon_session, which refuses any session that has a live token. An empty session left by some other caller stays in the listing until the machine restarts, or until a caller holding SeTcbPrivilege destroys it — logonse destroy does exactly that.

The boot sessions #

Two sessions exist without ever passing through kacs_create_logon_session:

SessionIDCreated by
SYSTEM session999Direct kernel init
Anonymous session998Direct 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:

  1. Removes the session from the session table.
  2. Tears down any linked-pair association it was holding (see Elevation and linked tokens).
  3. Emits a kacs.session.destroyed event through KMES.

The event carries enough information for consumers to clean up downstream state:

FieldWhat it is
object.session.idThe destroyed session's ID.
object.session.user.sidThe principal.
object.session.logon-typeThe type, by name: interactive, network, batch, service, network-cleartext, new-credentials or remote-interactive.
object.session.auth-packageThe auth package string.
object.session.logon-timeThe session's creation time, in nanoseconds.

The consumers are audit pipelines, accounting tools and session-aware services. authd is not one of them: it keeps no per-session state — no tickets, no cached credentials, no handle on the tokens it mints — so there is nothing of its own for it to release when a session goes. An authority that did hold session-scoped state would subscribe here to learn when to drop it.

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:

  1. Every process running on a token of the session has exited.
  2. Every thread that was impersonating a token of the session has reverted or exited.
  3. Every fd held on a token of the session has been closed.
  4. 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, its child processes exit, and anything that was handed a descriptor for one of the session's tokens closes it. Once all of those happen, the kernel sees refcount zero, fires the event, frees the session. (authd holds nothing: it closes its descriptor for a token the moment it has handed the token over.)

Forced sign-out: 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_logon_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 sign-out — an administrator deciding that a person should not be signed in any more, or a person signing themselves out everywhere — is therefore a userspace operation, and authd performs it. A program that signs somebody out — a task manager, a command-line tool — asks with a SessionEnd request on /run/logon.sock (PGSS §2.22) and does none of the work itself. authd:

  1. Checks that the caller may. Anyone may end their own session, when they are a person signed in as themselves. Ending anyone else's needs the right granted by the SessionEndSecurity descriptor on Machine\Generic\Authn\Policy — by default SYSTEM and Administrators have it. Nobody may end SYSTEM's session (999), Anonymous's (998), or a service's: a service is stopped through the service manager.
  2. Finds every process whose primary token belongs to the session: it walks /proc, opens each process by pidfd, and reads auth_id from that process's primary token. A thread that is only impersonating a token of the session — a service answering the person's request — is not in the session, and is left alone.
  3. Sends each SIGTERM through its pidfd, so the signal reaches the process whose token was read even if its pid has since been reused. It waits up to five seconds for them to exit, then sends SIGKILL to what is left.
  4. Walks again, because a process may have forked during the grace period, and repeats — at most three rounds.
  5. Answers with how many processes it ended and how many still hold the session, and records the request, who made it and the result as an authd.session.ended event.

Once the last process exits, the session reaches refcount zero, the event fires, and the person is signed out.

What the audit trail shows #

A forced sign-out leaves two records, from two components:

  • authd.session.ended, written by authd when it has finished the rounds. It names who asked (subject.token.sid), the session (object.session.id), whose session it was (object.session.user.sid) and its logon type. outcome.success is false, with outcome.reason processes-remaining, when something still held the session after the last round. It is an essential event, so the emission policy cannot switch it off.
  • kacs.session.destroyed, written by the kernel when the last reference drops. It cannot say who asked: only authd knows that.

A refused request is not an authd event. Whether a caller may end another principal's session is an access check against the SessionEndSecurity descriptor, and KACS records that decision as kacs.audit.access.checked, with object.kind authd-session-end, when the descriptor's SACL asks it to. To audit refused sign-outs, give the descriptor a failure-audit ACE, such as S:(AU;FA;0x1;;;WD). Ending one's own session needs no grant, so it runs no check. Neither does a request for SYSTEM's, Anonymous's or a service's session, which is always refused.

A program that ends the session it is itself running in is ended with it. authd therefore answers such a request first, saying how many processes it found, and does the work afterwards.

What this cannot reach:

  • A token descriptor held outside the session. Token descriptors can be passed between processes; a process outside the session holding one keeps the session alive after every process in it has gone. authd walks processes, not descriptors, so it neither finds nor counts these.
  • A thread impersonating a token of the session, by design (step 2). The session lasts until that thread reverts.
  • A process that has exited but not been reaped. It holds its token until its parent reaps it, and counts as remaining until then.

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:

logon_session_id=1042 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.

How authentication works

Peios / Peios Security Fundamentals / Authentication

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 principal sources.

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.

Changing your own password #

The logon socket carries one other kind of conversation: changing your own credential. It opens with a different message, which names nobody — the account is the one your token says you are — and it ends without a token, because a change mints nothing. Your current credential is asked for first, even though you are signed in. passwd is the client for it.

Because every principal needs to reach the socket for this, reaching it is not permission to originate a logon. The authority decides that per caller, from the caller's token.

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.

What the audit trail records #

The caller is told only that authentication failed, but the audit trail may know more. Every sign-in leaves two events, one from each side of the conversation:

  • authd.logon.attempted, from the authority, once per LogonStart, however the logon ends. It names the originator: the principal of the program that asked, as source.token.sid. It also records the logon type asked for, the principal source asked, and the remote address when the originator gave an IP address. A success adds the principal signed in (subject.token.sid), the new session and the token. A failure adds outcome.reason: the denial the originator was sent, such as authentication-failed, logon-type-not-permitted or account-restricted.
  • lpsd.credential.verified, from the local principal source, when it checks a credential. It says what the wire must not. outcome.reason is no-such-account, wrong-credential, disabled or not-permitted, and object.account.sid names the account when one exists.

The name typed is never recorded, in either event. A name the authority could not match to an account is the caller's input, not an identity, and a record of it would turn the audit trail into a list of mistyped passwords and probed usernames. An account appears only by its SID.

authd.logon.attempted is essential, so the emission policy cannot switch it off. lpsd.credential.verified is standard and can be: a machine exposed to a network may be asked to check credentials far more often than it signs anybody in.

Where to start #

  • The login command — the terminal client, and how a live image signs in without being asked anything.
  • The passwd command — changing your own password.
  • Logon sessions — the kernel object a successful sign-in creates.
  • Authorities and principal sources — 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.

Authorities and principal sources

Peios / Peios Security Fundamentals / Authentication

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. Sources assert relative POSIX identifiers; authd applies each source's assigned range and refuses out-of-range values. That keeps source-provided numbers clear of uid 0 and other sources' ranges. See Numeric Scope for the rebasing and bounds rules.

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: the authority resolves effective POSIX identifiers and applies the machine's source-search order. See resolving names for what lookup answers mean, and Identity Lookup for the protocol boundary.

Where to start #

  • The local store — what lpsd keeps, 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 lps command — 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 / Authentication

lpsd keeps everything it knows in one file:

/var/state/lpsd/principals

It is read once at startup and held in memory. It changes when an authorised account or group change is made, including self-service account changes.

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, a Unix ID, and a description of what it is for, which anyone may read. 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 stored Unix ID is their RID, and a group's is its RID. These numbers are relative: 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.

The range is a boundary enforced by authd: an out-of-range relative ID is refused, so a source cannot number its principals as uid 0 or in another source's range. Numeric Scope specifies the rebasing and bounds checks.

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 authority communicates the range at registration for that purpose; lpsd must still assert relative identifiers.

At lookup time, getpwuid asks authd about the effective number; authd queries the relevant source in its relative namespace. See resolving names.

Note

A - where a uid should be means authd has assigned this machine no range at all, and every principal here will sign in as nobody. The UnixIDBase value on this source is missing or unusable.

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 alongside the account, 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. Its temporary inode is created with a protected SYSTEM-only descriptor before the first byte is written, flushed, then renamed over the old one. Stale temporary files are unlinked and recreated exclusively; they are never truncated or followed. At startup lpsd establishes a protected SYSTEM-only inheritable descriptor on its state directory before loading or provisioning the store. The final file has the same SYSTEM-only access policy, independent of parent inheritance.

A few hundred principals, rewritten when an authorised account or group change is made, 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.

Its format, and upgrading #

The file also carries a format number. lpsd reads the format it writes and the one before it: a store written before groups had descriptions loads with every description empty, and is written in the new format the next time anything changes. Nothing has to be done to upgrade it.

Going back is another matter. An older lpsd refuses a store in a format newer than it knows, and does not start, rather than guess at what it cannot read. So once a newer lpsd has changed the store, an older one cannot be put back in its place.

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, damaged or in a format this lpsd doesn't know, 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.

Resolving names

Peios / Peios Security Fundamentals / Authentication

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 #

The protocol assigns this role to the authority: sources assert relative POSIX identifiers and answer numeric queries in their own relative namespace, while applications ask the authority about effective, machine-wide identifiers. The Identity Lookup contract defines that boundary; Numeric Scope explains how the authority applies a source's range even though the source knows it.

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.

Note

A machine with one source has nothing to order, and can ignore this entirely.

Warnings about shadowed names #

If more than one source holds the same bare name, only the first owner in the search order answers the logon. After delivering a successful logon reply, authd asks later sources whether they also own that name. These are identity queries with no credentials or authentication assertion attached.

Each confirmed duplicate produces a warning in authd's service log, naming the entered name, its owning source and the later source. The warning does not change the token or select a different principal. Administrators should check whether an old local account is hiding a directory account, or whether a source order change has made a different SID win for a familiar name.

An absent, refusing or unavailable source makes the check inconclusive, not a failed logon. The success reply and normal logon audit record precede the check. All the later sources share one normal five-second lookup deadline; there is no extra query on a single-source machine or when the owning source is last. If the owning registration has disappeared or been replaced during authentication, authd skips the check rather than assuming its old position still holds. Absence of a warning therefore does not prove that a name is unique.

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:

AnswerMeans
FoundHere is the principal.
Not foundEvery source that could have answered was asked, and there is no such principal.
UnavailableA 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.

Tip

If names stop resolving and start reporting temporary failures, look for a principal source that is not running. authd logs which one it was waiting for.

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 the BUILTIN groups: Administrators, Users and Guests. A source holds the edges, so it can list them, and authd gathers a BUILTIN group's members from every source that may record 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, Remote 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 #

The authority holds no cache. Each lookup it receives 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. Sources already declare what one 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.

One thing does cache, and only within a single command. Turning a SID into a name for display — the owner column of ls -l, and the same rendering everywhere else in the userland — remembers what it has already asked for the lifetime of that one process, so listing a large directory owned by a handful of principals costs a handful of lookups rather than one per file.

It is scoped that way on purpose. Two separate commands share nothing, so an account renamed between them shows its new name on the very next one, and there is no stale answer to invalidate because there is nothing that outlives the command. This is what a Linux system already does for getpwuid, so ls behaves the way its users expect.

Names that are fixed by specification rather than held by any source — Everyone, Local System, BUILTIN\Administrators and the rest — are answered from a table built into the renderer and never asked about at all. There is nothing a source could add to them, and asking would mean a large listing querying the authority thousands of times about Everyone, and printing something different depending on whether the authority happened to be running.

Name service switch lookups — getpwnam and friends — are not cached anywhere. Each is its own connection and its own round trip.

Asking from a program #

A Linux program asks through getpwnam and friends, and needs nothing more. A Rust program that wants the answer itself — a SID's name, or every principal there is — can use the libauthd-client crate in the authd repository. The name service switch module, ls and the other userland tools, Principals Manager and the permissions editor all ask through it.

It keeps the three answers apart: not found comes back as no principal, and a failure says what it was. Its transient says whether asking again could help, which is true when authd didn't answer in time or a source that could have answered didn't. Each question has a connection of its own, unless the program holds one for a walk through a long listing, a page at a time.

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 peios_token_impersonate_peer(fd)"]
    C --> D["Server does the work"]
    D --> E["Server calls kacs_revert()"]
    E --> F["Server handles next request"]
  1. Client connects. Before connect, the client may have set KACS_SO_IMPERSONATION_LEVEL on the socket (peios_socket_set_impersonation_level) to bound how far its identity may travel (see Impersonation levels).
  2. Server accepts. The kernel captures the client's identity onto the socket at connect time.
  3. Server impersonates. A call to peios_token_impersonate_peer(fd) installs the captured identity onto the calling thread, at the effective level determined by the two-gate model.
  4. 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.
  5. 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:

  1. At accept, the server calls peios_token_open_peer(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.
  2. Most of the request is processed as the service's own identity. Decoding, dispatching, internal bookkeeping, response framing — none of these need the client.
  3. 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 calls kacs_revert immediately.
  4. The request continues at service identity. Further per-request impersonations follow the same install-do-revert pattern.
  5. 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 peios_token_impersonate_peer and peios_token_open_peer, and the explicit-fd variant — read Peer tokens and capture.

Impersonation levels

Peios / Peios Security Fundamentals / Impersonation

Every token carries an impersonation level — one of four values, ordered from least to most permissive. On an impersonation token it is the answer to "how much may a server do with this identity?". On a primary token it is the ceiling on everything derived from that process's 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 sets KACS_SO_IMPERSONATION_LEVEL on the socket (setsockopt under the SOL_KACS level, or peios_socket_set_impersonation_level) 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.

ValueNameWhat a server may do
0AnonymousNothing identity-related. The impersonation token has the Anonymous SID as its user; the server learns nothing about the actual client.
1IdentificationInspect 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.
2ImpersonationAct as the client for all local operations. This is the default and the level most server-to-client code paths assume.
3DelegationIdentical 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 sets its socket's level to Anonymous; 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:

  1. The client's request, set on the socket with the KACS_SO_IMPERSONATION_LEVEL socket option (peios_socket_set_impersonation_level) — usually before connect, though it can change at any time and bounds every capture made from then on. Default is Impersonation if the client says nothing.
  2. 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.
  3. The transport. Datagram and socketpair sockets capture nothing at connect, so identity on them travels per message (KACS_SO_PASS_TOKEN or a KACS_SCM_TOKEN cmsg); pipes carry no identity at all, and a server on a pipe must obtain a token some other way and use the explicit-fd ioctl. 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.

There is a fourth input that usually never shows, because it starts at the top: the client's own token. The level is a ratchet. A logon token from authd starts at Delegation, and every step identity takes — a capture at connect, a KACS_SO_PASS_TOKEN send, a KACS_IOC_DUPLICATE in either direction — produces something at or below its source. A process running on a primary that was made from an Impersonation-level token can set Delegation on every socket it opens and still conveys Impersonation. Nothing short of a fresh CreateToken (SeCreateTokenPrivilege) goes back up, which is what makes the client's original choice hold across any number of services and process launches.

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:

  1. The identity gate — is the server allowed to impersonate this particular user's identity?
  2. 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 resultIntegrity ceiling resultGranted level
Pass at full levelToken's integrity is at or below server'sThe level the client granted on the socket.
Pass at full levelToken's integrity exceeds server'sIdentification. 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'sIdentification. Token installs, server can inspect, but no AccessCheck will pass.
FailToken's integrity exceeds server'sIdentification. 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 peios_token_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:

  1. Call peios_token_impersonate_peer(fd) (or the explicit-fd variant).
  2. Open the thread's effective token.
  3. Read impersonation_level via KACS_IOC_QUERY.
  4. 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_revert always succeeds and does not consult the gates.
  • Reading a token. peios_token_open_peer or kacs_open_thread_token returns 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 peios_token_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 call (peios_token_impersonate_peer) and one cleanup call (kacs_revert).

The kernel surface behind it is a socket option. Every KACS socket option lives under one option level, SOL_KACS, and the peer token is getsockopt(fd, SOL_KACS, KACS_SO_PEER_TOKEN, &token_fd, &len) on the accepted connection: the kernel writes a new token fd into the option value. libpeios wraps that as peios_token_open_peer(fd), and peios_token_impersonate_peer(fd) is the same call followed by an install and a close.

This page covers the capture mechanism, the two ways of 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_SO_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:

  1. Reads the client's effective token (impersonation if set, primary otherwise).
  2. Reads the impersonation level the client requested on the socket with KACS_SO_IMPERSONATION_LEVEL (default Impersonation). The option can be changed at any time; it bounds every capture made from then on.
  3. Constructs a peer token derived from the client's token, at the requested level.
  4. 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 reads the peer token, the work of capturing identity is already done.

What the server reads is the connection's conveyed-identity register: the peer identity that goes with the data the server has consumed so far. The connect-time capture is its first value, and it stays that unless the client conveys a new identity with the data itself (next section). Whatever it holds, it is always an immutable snapshot — each read of KACS_SO_PEER_TOKEN returns a fresh fd, two reads may name different tokens, but no token ever changes under you.

Identity that travels with the data #

A single identity per connection is the right model for a client that connects, acts as itself, and disconnects. It is the wrong model for two things real systems do constantly:

  • Connection pooling. A service keeps a few long-lived connections to a downstream service and reuses them for many requests, on behalf of many different users. The identity at connect time is the pool's; the identity that matters is whoever is writing this request.
  • Proxies and routers. One process accepts from many clients and forwards their messages over its own connections. Its outbound connection carries one connect-time identity, but it is speaking for many.

Both are solved by letting identity travel in-band, attached to the bytes it describes, as a KACS_SCM_TOKEN ancillary message under the SOL_KACS level. It arrives ordered with the data — the kernel never coalesces bytes conveyed under different identities into one read — and as the server's reads pass each one, the register moves to it. There is nothing to listen for: the server reads a request, then asks KACS_SO_PEER_TOKEN who sent it.

There are two ways to send it, and the receiver cannot tell them apart, because the kernel guarantees the same thing for both: the sender could be this identity when it sent these bytes.

  • Automatically, by setting KACS_SO_PASS_TOKEN on the sending socket (peios_socket_set_pass_token). From then on every send carries the sender's effective identity, derived at the socket's impersonation level. This is the fix for pooling: a service that impersonates user A and writes through an off-the-shelf client library conveys A, because the capture happens at the sendmsg the library was already calling. Nothing in the library changes.
  • Explicitly, by attaching a token fd in a KACS_SCM_TOKEN cmsg on sendmsg. This is the fix for proxies: the router holds each client's token fd (read from its own inbound connection) and attaches it to the messages it forwards downstream. The kernel gates the attach as if the sender were impersonating that token — the fd needs TOKEN_IMPERSONATE, and if the two-gate model would cap the token below its own level the send fails with EPERM rather than quietly downgrading. Attaching is never more than impersonating, sending, and reverting; it just skips the install.

On the receiving side, a KACS_SCM_TOKEN cmsg (carrying a fresh TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE fd) is delivered with a read when the call supplied control-buffer space and the read's identity differs from what the register held. A server that never touches ancillary data loses nothing — the register is kept by the kernel regardless — which makes the simple loop the right default:

n = recvmsg(conn, &msg, 0);              /* just the data */
tok = peios_token_open_peer(conn);       /* who sent what I just read */

Parsing the cmsg is the path for pipelined or concurrent consumers that need each token bound to a specific message. SOCK_SEQPACKET keeps that association exact by construction (one send, one message, one identity), which is why it is the recommended socket type for Peios-native protocols; on SOCK_STREAM a read simply stops at each identity boundary.

Datagram sockets have no register (many senders share one socket), so on SOCK_DGRAM per-message identity is the only kind: each datagram carrying a token delivers it.

Two ways to use a peer token #

libpeios exposes two operations on a peer token:

  • peios_token_impersonate_peer(fd) — the combined operation. Reads the peer's identity from the socket and installs it on the calling thread, at the level the two-gate model permits, then closes the token fd. The most common path.
  • peios_token_open_peer(fd) — the inspect-and-store operation: getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN). Returns a token fd to the peer's identity without installing it. The fd carries TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE access — enough to read the token, to install it later via KACS_IOC_IMPERSONATE, and to duplicate it (for instance to a primary token for a process launched as the client), but not enough to adjust it. Duplicating never raises the level: the copy is capped at whatever the client granted.

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 peios_token_impersonate_peer and then kacs_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 peios_token_open_peer to 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 peios_token_open_peer mechanism 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 peios_token_open_peer, queries the token via KACS_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, the peer-token option, etc.) and the kernel installs it on the calling thread, running the two-gate model as usual.

peios_token_impersonate_peer is exactly peios_token_open_peer followed by KACS_IOC_IMPERSONATE and a close, fused for the common case where you do not need the token fd to outlive the impersonate-and-revert cycle. It is a library convenience: the kernel's only impersonation entry is the ioctl.

Transports that carry peer tokens #

Peer token capture works on Unix sockets that have a real connect step. Specifically:

TransportPeer token?
SOCK_STREAM Unix socketYes. Captured at connect; register follows conveyed identity.
SOCK_SEQPACKET Unix socketYes. Captured at connect; register follows conveyed identity.
SOCK_DGRAM Unix socketPer message only. No connect step, so nothing to capture and no register (KACS_SO_PEER_TOKEN fails with EOPNOTSUPP), but KACS_SO_PASS_TOKEN and KACS_SCM_TOKEN work on every datagram.
socketpair(2)Per message only, with a register. The two ends share an origin, so nothing is captured (ENODATA at first) — but the moment one end conveys an identity, the other's register holds it. The way a broker's private channels get identity.
Pipes (pipe(2), named FIFOs)No. Not sockets at all: ENOTSOCK.
TCP socketsNo. Peer is potentially remote; KACS does not capture network identities. EOPNOTSUPP.

A stream or seqpacket socket that is not yet connected fails with ENOTCONN. Servers using a transport that does not carry a peer token cannot use peios_token_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_RIGHTS message; 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.

Verifying the server #

Capture is symmetric. When the client's connect() completes, its own socket's register holds the identity of whoever called listen() on the name it reached, at Identification level unless the server raised its listener's level — enough to verify, not enough to impersonate. So a client checks the service it is about to trust with one call:

int server = peios_token_open_peer(sock);   /* who is actually listening */
/* query its user SID, compare with the principal the service is expected to run as */

This is what makes a socket path safe to connect to even when the path itself could have been bound by someone else: the name is a rendezvous, the token is the proof. A process that ends up holding a listener it did not create — one returned from peinit's descriptor store after a restart, or one handed over by a broker — calls peios_socket_restamp on it before accepting, so that clients see the process actually serving them rather than the one that originally called listen().

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 call peios_token_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):

  1. Accept the connection. Standard accept() on the Unix socket. The kernel has already captured the client's peer token onto this socket.
  2. Impersonate the peer. Call peios_token_impersonate_peer(fd). The kernel runs the two-gate model and installs the resulting token.
  3. (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.
  4. Do the work. Every access check on this thread is now against the impersonation token.
  5. Revert. Call kacs_revert(). The thread is back to its primary identity.
  6. 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):

  1. Accept the connection. As above.
  2. Capture the peer token fd. Call peios_token_open_peer(fd) and store the returned token fd in the request context. The fd carries TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE.
  3. (Optional) Inspect the captured token. Query the captured fd to verify the identity and level before doing any work.
  4. Do most of the request as the service. Decoding, dispatch, internal bookkeeping, response framing — all run as the service's own identity.
  5. For each access-requiring action, install the captured token on the current OS thread (KACS_IOC_IMPERSONATE on the stored fd), do the single action, call kacs_revert() immediately. Keep the impersonation window as tight as possible.
  6. 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_revert continues to impersonate the previous client. The next request handled on that thread uses the wrong identity. Make kacs_revert part 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:

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

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

What an SD does not contain #

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

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

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

Where SDs live #

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

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

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

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

The cost-of-modification asymmetry #

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

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

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

How an SD gets created #

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

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

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

The SD as an evaluation target #

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

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

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

Where to start #

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

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

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

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

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

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

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

ACLs, ACEs, and access masks

Peios / Peios Security Fundamentals / Security Descriptors

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

ACL structure #

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

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

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

ACE structure #

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

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

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

The ACE type catalog #

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

Access-control ACE family #

These ACEs participate in the DACL walk.

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

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

Audit and alarm ACE family #

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

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

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

System-policy ACE family #

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

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

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

ACE flags #

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

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

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

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

The full inheritance machinery is on Inheritance.

The access mask #

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

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

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

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

Generic mapping #

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

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

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

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

MAXIMUM_ALLOWED is a request, not a right #

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

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

Sizes and limits #

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

Where to go next #

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

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

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

DACL evaluation

Peios / Peios Security Fundamentals / Security Descriptors

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

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

The walk #

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

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

First-writer-wins, illustrated #

Suppose a DACL contains:

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

Alice asks for FILE_READ_DATA | FILE_WRITE_DATA. The walk:

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

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

Now suppose the DACL is in the other order:

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

The walk:

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

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

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

The canonical ACE order #

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

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

A DACL written in this order has the following properties:

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

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

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

NULL DACL vs empty DACL #

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

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

Warning

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

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

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

ACE flags that affect the walk #

A few flags change whether or how an ACE participates:

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

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

MAXIMUM_ALLOWED: the "what could I get" mode #

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

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

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

Two specific rules about MAXIMUM_ALLOWED:

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

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

What the DACL walk does not decide #

A handful of access decisions happen outside the DACL walk:

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

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

Practical tips #

A handful of patterns that come up repeatedly:

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

Where to go next #

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

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

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

Ownership and implicit rights

Peios / Peios Security Fundamentals / Security Descriptors

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

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

The implicit rights #

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

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

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

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

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

OWNER RIGHTS: suppressing the implicit grant #

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

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

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

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

Warning

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

Changing ownership #

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

Without SeRestorePrivilege, the new owner must be either:

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

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

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

sd owner ./report.txt @self

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

SeTakeOwnership and recovery limits #

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

It does not bypass:

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

Important

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

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

The primary group #

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

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

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

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

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

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

Recovery patterns #

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

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

Where to go next #

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

Inheritance

Peios / Peios Security Fundamentals / Security Descriptors

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

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

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

Eager vs lazy #

Two models are possible:

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

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

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

The inheritance flags #

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

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

Common combinations:

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

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

The merge algorithm #

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

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

The merge proceeds as follows:

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

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

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

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

Creator SIDs and generic rights can produce two ACEs #

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

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

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

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

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

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

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

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

Note

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

Sources of an inherited SD, ranked #

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

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

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

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

Protected ACLs #

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

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

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

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

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

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

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

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

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

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

Auto-inherit flags #

Two more SD control flags work alongside the protected flags:

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

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

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

What inheritance does not do #

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

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

Inheritance and creator-supplied SDs #

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

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

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

Where to go next #

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

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

Private state and data defaults #

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

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

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

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

Conditional ACEs

Peios / Peios Security Fundamentals / Security Descriptors

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

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

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

The model #

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

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

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

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

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

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

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

The four attribute namespaces #

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

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

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

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

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

The expression language #

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

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

@User.Department == "Engineering"

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

Member_of({SID-of-AdminGroup})

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

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

The operators fall into four families:

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

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

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

Value types and comparisons #

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

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

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

The "missing attribute" case #

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

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

Three-valued logic #

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

AND (&&) #

TRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

OR (||) #

TRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

NOT (!) #

InputOutput
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

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

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

A worked example #

Consider an SD on a sensitive file with this DACL:

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

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

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

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

Alice gets nothing. The conditional deny did its job.

Now user Bob, who is in the Cleared group:

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

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

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

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

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

When conditional ACEs are the right tool #

Conditional ACEs are the right tool when:

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

They are the wrong tool when:

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

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

Where to go next #

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

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

Resource attributes

Peios / Peios Security Fundamentals / Security Descriptors

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

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

What a resource attribute looks like #

Each resource attribute carries:

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

The flags are the same set used for claims:

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

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

Storage in the SACL #

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

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

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

Two structural notes:

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

How resource attributes are used #

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

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

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

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

Setting and modifying resource attributes #

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

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

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

Namespacing #

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

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

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

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

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

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

Resource attributes vs claims vs local context #

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

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

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

What resource attributes are not #

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

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

Where to go next #

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

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

The SACL

Peios / Peios Security Fundamentals / Security Descriptors

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

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

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

What the SACL holds #

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

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

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

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

Audit ACEs: one event per access attempt #

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

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

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

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

The full audit model is covered in Auditing.

Alarm ACEs: an event per operation #

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

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

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

SYSTEM_MANDATORY_LABEL: the integrity floor #

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

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

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

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

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

MIC is covered in detail in Access decisions.

SYSTEM_PROCESS_TRUST_LABEL: the PIP gate #

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

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

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

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

PIP is covered in Process integrity protection.

SYSTEM_SCOPED_POLICY_ID: central access policies #

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

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

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

SYSTEM_RESOURCE_ATTRIBUTE: typed properties on the object #

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

How the SACL is consumed #

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

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

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

Modifying the SACL #

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

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

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

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

The LABEL_SECURITY_INFORMATION shortcut #

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

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

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

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

What the SACL is not #

A few clarifications:

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

Where to go next #

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

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

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

SDDL

Peios / Peios Security Fundamentals / Security Descriptors

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

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

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

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

The shape of a descriptor string #

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

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

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

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

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

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

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

ACL flags #

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

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

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

An ACE #

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

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

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

Reading a real one:

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

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

ACE types #

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

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

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

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

ACE flags #

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

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

Access rights #

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

CodeRight
GAGeneric all
GRGeneric read
GWGeneric write
GXGeneric execute

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

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

File composites:

CodeMaskRight
FA0x001F01FFAll file access
FR0x00120089File read
FW0x00120116File write
FX0x001200A0File execute

Registry-key composites:

CodeMaskRight
KA0x000F003FAll key access
KR0x00020019Key read
KW0x00020006Key write
KX0x00020019Key execute

Directory-object rights, for object ACEs:

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

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

CodePolicy
NWNo write up
NRNo read up
NXNo execute up

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

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

(A;;FRFX0x6;;;WD)

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

SID codes #

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

Principals and logon types:

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

Inheritance placeholders:

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

Integrity levels:

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

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

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

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

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

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

The same two letters mean different things in different fields #

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

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

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

Not everything that parses is emitted #

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

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

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

Domain- and machine-relative aliases #

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

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

Reading a descriptor #

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

sd show --sddl /usr/bin/login

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

Where to go next #

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

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 with Se and end with Privilege; 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:

StateMeaning
AbsentThe privilege is not on this token. The token's bitmask has the relevant bit clear in both the "present" and "enabled" fields.
Present, disabledThe privilege is on the token but not currently in effect. The access check ignores it. The token may enable it via AdjustPrivileges.
Present, enabledThe privilege is on the token and is in effect. AccessCheck will use it where applicable.
UsedThe 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.

  • SeLoadDriverPrivilege gates kernel module loads. No DACL applies; either you have the privilege enabled or the load fails.
  • SeSystemtimePrivilege gates settimeofday.
  • SeCreateTokenPrivilege gates kacs_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:

  • SeSecurityPrivilege grants ACCESS_SYSTEM_SECURITY (SACL read/write) regardless of the DACL.
  • SeTakeOwnershipPrivilege grants WRITE_OWNER on any object.
  • SeBackupPrivilege grants read access to any object (with the backup intent flag set).
  • SeRestorePrivilege grants write access and ownership-change rights (with the restore intent flag).
  • SeRelabelPrivilege permits 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 membershipPrivileges
What it grantsWhatever ACEs name the groupA specific operation, named directly
Where it livesThe token's groups fieldThe token's privilege bitmask
How it is matchedACE SID matches token group SIDPrivilege code is consulted in specific kernel paths
Who manages itWhoever holds the accounts (a directory, or lpsd) and the per-object administrator (DACL entries)This machine's administrator, in local policy
GranularityGroup-wide; the same group across all objectsPer-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:

StateBitmask representationMeaning
Absentpresent bit clearNot on the token. AccessCheck cannot use it.
Present, disabledpresent set, enabled clearOn the token but not in effect. AccessCheck ignores it.
Present, enabledpresent set, enabled setIn effect. AccessCheck will use it where applicable.
Used (overlaid)used bit set, independent of the othersHas 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_token for 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:

ValueEffect
0Disable 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:

  1. Save previous state by calling AdjustPrivileges to enable the desired privilege.
  2. Do the work.
  3. 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_REMOVED is applied, the privilege is absent. A subsequent attempt to enable it fails (the privilege is no longer present).
  • Toggle the used bit. The kernel sets used automatically when a privilege is exercised. Userspace cannot clear it.
  • Set enabled without present. 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:

  • present is clear.
  • enabled is clear.
  • enabled_by_default is clear.
  • used remains 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:

FieldMutability
presentOnly ever clears (via SE_PRIVILEGE_REMOVED). Set only at token creation.
enabledToggled freely between 0 and the corresponding present bit.
enabled_by_defaultImmutable after token creation.
usedSet 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:

FlagEffect
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, and READ_CONTROL.
  • For registry keys: the read-category rights on the key (the specific values depend on the registry's GenericMapping).
  • For tokens: TOKEN_QUERY and READ_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_EA for files; corresponding rights on other object types).
  • DELETE.
  • WRITE_OWNER and WRITE_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:

  1. Token at startup has SeBackupPrivilege present and enabled. (Granted by authd's privilege policy to backup-role principals.)
  2. For each object to back up:
    • Call AccessCheck with BACKUP_INTENT set in privilege_intent. The kernel grants the read-category rights via SeBackup if the DACL would not.
    • Read the object.
  3. 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:

  1. Token at startup has SeRestorePrivilege present and enabled.
  2. For each object to restore:
    • Call AccessCheck with RESTORE_INTENT set. 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.
  3. 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 #

CategoryWhat the privileges doExamples
Kernel-standaloneGate specific kernel operations directly. Not consulted during the DACL walk.SeLoadDriverPrivilege, SeSystemtimePrivilege, SeCreateTokenPrivilege
AccessCheck-influencingCause 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-levelDefined for the directory and authd to interpret. The kernel records them on the token but does not enforce them itself.SeSyncAgentPrivilege, SeEnableDelegationPrivilege, SeMachineAccountPrivilege
ReservedPresent 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:

PrivilegeWhat it gates
SeCreateTokenPrivilegekacs_create_token. Token minting. Held only by authd and peinit.
SeAssignPrimaryTokenPrivilegeInstalling a token as another process's primary. Used by peinit.
SeImpersonatePrivilegeImpersonating 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, and a handful of other system operations. Mount-policy reads and changes also accept enabled SeManageVolumePrivilege; see the pinned-source qualification below. It also satisfies every check SeManageVolumePrivilege satisfies, since the TCB may do anything a volume manager may.
SeLoadDriverPrivilegeLoading and unloading kernel modules. Held only by peinit on its primary token; explicitly stripped via FilterToken from every other service.
SeManageVolumePrivilegeMounting, unmounting and reshaping the mount tree, including mount-policy reads and changes (policy=synth-* can author descriptors). The gate requires an enabled privilege and successful privilege-use marking; administrator membership alone is not enough. Granted to Administrators by policy. The most powerful privilege routinely granted outside the TCB — see the warning below.
SeShutdownPrivilegeLocal shutdown and reboot.
SeRemoteShutdownPrivilegeShutdown from a remote connection. Requires SeShutdown as well.
SeDebugPrivilegeInspecting 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.
SeSystemtimePrivilegeSetting the system clock.
SeIncreaseBasePriorityPrivilegeRaising another process's scheduling priority or setting its CPU affinity.
SeIncreaseQuotaPrivilegeOverriding resource limits for a process.
SeLockMemoryPrivilegeLocking pages in physical memory (mlock/mlockall).
SeAuditPrivilegeWriting entries to the audit log.
SeProfileSingleProcessPrivilegeCross-task profiling of a specific other process (perf_event_open). Own-task profiling needs no privilege.
SeSystemProfilePrivilegeSystem-wide profiling (perf_event_open with pid == -1): per-CPU, all-task, kernel-mode events.
SeChangeNotifyPrivilegeBypassing traverse checks during path resolution. Granted to every principal by default; rarely the answer to a question.
SeCreateSymbolicLinkPrivilegeCreating 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.

PrivilegeWhat it does
SeSecurityPrivilegeGrants ACCESS_SYSTEM_SECURITY on any object (SACL read/write). Also gates kernel-standalone audit-system operations.
SeTakeOwnershipPrivilegeGrants WRITE_OWNER on any object regardless of the DACL. Subject to MIC and PIP — does not bypass those.
SeBackupPrivilegeGrants read-category rights on any object when BACKUP_INTENT is set. Intent-gated.
SeRestorePrivilegeGrants 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.
SeRelabelPrivilegePermits 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:

  • SeSecurityPrivilege is consulted when AccessCheck sees ACCESS_SYSTEM_SECURITY in the requested mask.
  • SeBackupPrivilege and SeRestorePrivilege are consulted near the start of AccessCheck, but only if the corresponding intent flag is set in privilege_intent. See Intent-gated privileges.
  • SeTakeOwnershipPrivilege is consulted after the DACL walk: if the walk did not grant WRITE_OWNER and the mandatory policy did not block it, the privilege grants it.
  • SeRelabelPrivilege is consulted by kacs_set_sd when 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".

Important

Not currently grantable. SeTakeOwnershipPrivilege, SeRelabelPrivilege and SeSystemProfilePrivilege are enforced by the kernel exactly as described above and named in audit records, but they are missing from the published ABI headers that userspace is built against — so no policy can name them and authd cannot put them on a token. Everything on this page about how they behave is accurate; what is not yet true is that anyone can hold one. In practice that means a Peios machine currently has no way to grant take-ownership, so the usual escape hatch for a file whose DACL excludes you — take ownership, then rewrite the DACL — is unavailable.

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.

PrivilegeWhat it does
SeSyncAgentPrivilegeLets the holder read all directory objects regardless of per-object permissions. Used by directory replication agents.
SeEnableDelegationPrivilegeLets the holder mark a principal as trusted for delegation in the directory.
SeMachineAccountPrivilegeLets 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 #

In kernel source 8e0e22de3a59cad506bbbf8873de456e16ad272d, the volume-management gate accepts enabled SeManageVolumePrivilege or SeTcbPrivilege and must successfully mark the privilege as used. Both mount-policy writes and reads use it. This resolves the TCB-only descriptions for that source revision; it does not identify a historical release boundary or test the deployed kernel. The mount diagnostic can still name only TCB. Do not automatically grant TCB or globally remap CAP_SYS_ADMIN in response.

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:

PrivilegeWhy it is reserved
SeCreateGlobalPrivilegeNo per-session object namespaces in Peios.
SeCreatePagefilePrivilegeFolded into SeTcbPrivilege.
SeCreatePermanentPrivilegeNo Linux equivalent.
SeIncreaseWorkingSetPrivilegeLinux does not gate memory-residency hints.
SeTrustedCredManAccessPrivilegeReserved for future secrets infrastructure.
SeSystemEnvironmentPrivilegeReplaced by SDs on EFI variable files under FACS.
SeTimeZonePrivilegeLinux does not gate timezone changes.
SeUndockPrivilegeServer 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.

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:

InputSourceWhat it contributes
TokenThe calling thread's effective token.Identity (user SID, groups, restricted SIDs), token-level state (integrity level, mandatory policy, privileges, confinement).
Security descriptorThe object being accessed.Owner, primary group, DACL, SACL — everything the policy on the object says.
Desired maskThe caller.The 32-bit access mask of rights the caller wants. May include MAXIMUM_ALLOWED to ask "what could I have?".
privilege_intentThe caller.Flags for backup and restore intent — see Intent-gated privileges.
self_sidThe caller.The SID that PRINCIPAL_SELF (S-1-5-10) should resolve to during the DACL walk. Used by directory-style objects.
local_claimsThe caller.Per-call attributes in the @Local.* namespace, available to conditional ACE expressions.
Object type listThe caller (optional).A tree of property GUIDs for per-property evaluation. See "Object type list" below.
PIP type and trustThe PSB of the calling process.Used by the PIP step to compare against any trust label on the object.
Audit contextThe caller (optional).A msgpack map naming the object, such as {kind: "service", service: {name: "jellyfin"}}. The kernel rejects anything else with EINVAL, and copies it into the call's audit events as object.kind and object.<kind>.*, marked as the caller's claim.

Outputs:

OutputMeaning
Granted maskThe 32-bit access mask of rights the caller actually gets.
Audit emissionsAny audit events that fired (SACL audit ACEs, privilege-use, token-forced policy events).
Continuous audit maskFor file-like objects, the per-operation audit mask to cache on the open handle. See The SACL.
Staging mismatch flagSet 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.

StepNameWhat it does
0Impersonation level gateIf the effective token is Impersonation-type at the Identification level, deny everything immediately. The token may be inspected but not used for access decisions.
1Input validationThe 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.
2Generic mappingGeneric 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.
3Effective privilegesBackup and restore privileges are stripped from the privilege set unless the caller passed the corresponding intent flag. See Intent-gated privileges.
4Privilege grantsPre-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.
5Pre-SACL walkScan 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.
6Virtual group injectionOWNER 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.
7Tree initialisationIf the caller passed an object type list (per-property evaluation), the pipeline's decided/granted state is seeded per node. Skipped for ordinary calls.
8Normal DACL evaluationGrant 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.
9Post-DACL SeTakeOwnershipIf the walk did not grant WRITE_OWNER and the mandatory policy did not block it, SeTakeOwnershipPrivilege grants it now. See Ownership.
10Restricted token passIf 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.
11Confinement passIf 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.
12CAAP evaluationEach 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.
13Privilege-use audit emissionFor 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).
14SACL audit walkWalk 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.
14bToken audit policyThe token's audit_policy field can force audit events independently of any SACL ACE. Success and failure flags emit policy-forced events.
15Result computationThe 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:

SyscallWhat it does
kacs_access_checkThe common case. Returns a single granted mask for the whole object.
kacs_access_check_listThe 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.

The decision is made at open, and it stays made #

AccessCheck runs when a handle is created — at open, at connect, at attach — and stamps the granted mask on the handle. Later operations check that mask, not the descriptor. So changing a descriptor narrows what future opens get; it does not reach into handles that already exist, and it never reaches into memory. A mmap(MAP_SHARED) mapping consumes its rights from the descriptor it was made from, and once the pages are mapped, no descriptor edit, kacs_set_sd or token change can take them back: revocation does not extend to established mappings. If a file must become unreadable to a process that already mapped it, the only remedies are the ones that end the mapping — the process exits, or the file is replaced rather than edited in place.

The same holds for every handle-shaped object: a token handle, a process handle, a socket. Peios is deliberately NT-like here. The alternative — re-checking on every read — would make access control a per-page cost and would still be racy against memory that has already been copied.

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):

LevelSIDRIDTypical use
UntrustedS-1-16-00Tokens for highly-sandboxed processes. The lowest level.
LowS-1-16-40964096Tokens for confined applications, anti-malware quarantines.
MediumS-1-16-81928192The default for interactive sessions and most services.
HighS-1-16-1228812288Elevated administrative sessions. The Full token in a UAC-style linked pair.
SystemS-1-16-1638416384The 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-16 SID with one sub-authority — normally one of the five above, occasionally a non-standard level such as medium-plus or protected).
  • 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):

BitValueEffect on non-dominant callers
SYSTEM_MANDATORY_LABEL_NO_READ_UP0x01Read-category rights are denied.
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP0x02Write-category rights are denied.
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP0x04Execute-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:

  1. Extract the object's integrity label (or apply the Medium-with-NO_WRITE_UP default if there is no label ACE).
  2. Compare the token's integrity_level against the object's label.
  3. If the token's level is at least the object's level, MIC is satisfied. Nothing happens; the pipeline proceeds.
  4. 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 decided set but not granted.

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:

FlagValueEffect
NO_WRITE_UP0x01The MIC rule is active for this token's accesses.
NEW_PROCESS_MIN0x02At exec, if the executable carries an explicit integrity label lower than the token's, the token's integrity is lowered to match. An unlabelled executable leaves it unchanged.

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. A binary with no label at all is not "Medium" for this purpose: it inherits the launcher's level. The unlabelled-means-Medium rule above is about objects being accessed, and applying it to executables would demote every process on an image that labels nothing, the TCB included.

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 (SeSecurityPrivilege or SeRestorePrivilege).
  • 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's pip_type and pip_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_ACE are not PIP-protected; objects without a SYSTEM_MANDATORY_LABEL_ACE get 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 #

PrivilegeFires at stepGrantsIntent-gated?
SeSecurityPrivilege4ACCESS_SYSTEM_SECURITYNo
SeBackupPrivilege4Read-category rightsYes (BACKUP_INTENT)
SeRestorePrivilege4Write-category rights, WRITE_OWNER, WRITE_DAC, DELETE, ACCESS_SYSTEM_SECURITYYes (RESTORE_INTENT)
SeTakeOwnershipPrivilege9WRITE_OWNERNo
SeRelabelPrivilege(not AccessCheck — kacs_set_sd)Setting an integrity label above the caller's ownNo

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_EA for files; corresponding bits for other types).
  • DELETE.
  • WRITE_OWNER and WRITE_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_sd with LABEL_SECURITY_INFORMATION (or SACL_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_OWNER when the caller is operating on an object with a higher integrity label — specifically, the privilege "punches WRITE_OWNER through 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 in audit_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 (kacs.audit.privilege.used) for each fire records:

  • The name of the privilege (e.g. SeBackupPrivilege), as privilege.name.
  • The bits the privilege contributed pre-narrowing, as privilege.contributed.
  • The bits that survived to the final granted mask, as privilege.surviving.
  • The whole check's requested and granted masks, as access.requested and access.granted.
  • Whether the use was successful (bits survived) or failed (bits were stripped), as outcome.success.

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 #

StepLayerWhat it intersects against
10Restricted-token passA DACL walk using only the token's restricted_sids
11Confinement passA DACL walk using only the token's confinement SID set
12CAAP evaluationThe 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_capabilities list.
  • 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 through kacs_set_caap) 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, the current distribution limitation — 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.

LayerSet byIntersects againstPrivileges bypass?
Restricted tokenThe application (in code)The application's own restricted SID listYes
ConfinementThe system administrator (policy)The confinement SID + declared capabilitiesNo
CAAPThe directory administrator (policy)Centrally-defined access rulesNo

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.

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 kernel applies a policy's content at access-check time once it has been installed in the local policy cache. Central storage and automatic distribution are not implemented.

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. Policies are installed or replaced by kacs_set_caap calls. authd does not populate it at startup; automatic distribution is not implemented.

The cache is empty at boot and the current boot sequence leaves it empty. An access check referencing an absent policy uses the recovery policy (covered in Distribution and recovery). Waiting for authd readiness does not load policies.

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:

FieldMeaning
VersionA single byte. v0.20 uses 0x01. Any other value is rejected at ingestion.
Rule countA 32-bit little-endian count of the rules that follow. Bounded to 256 rules per policy.
RulesLength-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:

FieldMeaning
applies_toOptional. 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_daclA length-prefixed binary DACL. Required (a rule with no DACL would do nothing). Maximum 65,535 bytes.
effective_saclOptional. A length-prefixed binary SACL contributing audit and policy ACEs.
staged_daclOptional. A proposed replacement for effective_dacl, evaluated in parallel for testing.
staged_saclOptional. 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:

ResultEffect on the rule
TRUEThe rule applies. The effective DACL is evaluated.
FALSEThe rule is skipped.
UNKNOWNThe 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 expressionWhat 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.NowRule 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:

FieldValue
applies_to@Resource.Classification == "TopSecret"
effective_daclAn ACL containing: ACCESS_ALLOWED Cleared-Group GENERIC_READ, and nothing else.
effective_saclAn 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:

LimitValue
Wire-format spec size256 KB
Rules per policy256
Bytes per applies-to expression64 KB
Bytes per effective or staged DACL/SACL65,535 (matches the SD format limit)
Version byteMust 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:

  1. Scan the object's SACL for SYSTEM_SCOPED_POLICY_ID_ACE entries that are not inherit-only.
  2. For each such ACE, take the SID and look it up in the kernel's policy cache.
  3. If the policy is not in the cache, apply the recovery policy — a hardcoded fallback that grants GENERIC_ALL only to BUILTIN\Administrators, SYSTEM, and OWNER_RIGHTS. The intersection happens just like any other CAAP rule's would. See Distribution and recovery.
  4. If the policy is in the cache, iterate through its rules.
  5. For each rule, evaluate the applies_to expression 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.
  6. For each applicable rule, evaluate the effective_dacl against 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.
  7. Intersect the rule's grant with the running grant. Bits not present in both are dropped from the running grant.
  8. Collect the rule's effective_sacl for the eventual audit walk at step 14.
  9. 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.
  10. Move on to the next rule (or, when all rules are evaluated, the next policy).
  11. 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_ACE entries 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:

ConditionBehaviour
Referenced policy SID not in cacheApply 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 malformedThe 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 UNKNOWNRule 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 errorTreated 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:

  1. 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.
  2. If the applies-to returned TRUE, the effective_dacl is evaluated against the calling token. The result is intersected with the running grant.
  3. 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.
  4. 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.
  5. The same comparison happens for the staged_sacl versus effective_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 kacs.caap.staging.diverged event records only the requested mask and 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 kacs.caap.staging.diverged event it emits carries only access.requested, access.granted and access.granted-staged. It does not say whether per-node results of an object-type check differed either. For a pure SACL mismatch the two granted masks may even be equal — the event's existence is the only signal that the audit behaviour differed.

The rollout pattern #

The following is a conceptual rollout pattern. Kernel staging is available, but automatic distribution and the end-to-end administrative workflow are not implemented; see Distribution and recovery.

  1. Author the staged change. Take the existing policy. Add or modify the staged_dacl and/or staged_sacl to reflect the proposed change. Leave the effective versions unchanged.
  2. Install the policy in each kernel. The kernel interface is kacs_set_caap, including the staged fields. A distribution service would have to perform this step on each machine; authd does not currently do it.
  3. 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.
  4. Collect mismatch reports. A monitoring tool records mismatches and their contexts (which call, which token, which object, which requested rights). Administrators review the report.
  5. 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.
  6. Commit. Replace the policy with one where effective_dacl and effective_sacl hold what the staged versions held. Push via kacs_set_caap. The change is now live; subsequent access checks use the new effective version.
  7. 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 kacs.caap.staging.diverged event that carries the staging mismatch, read Events and transport.

Distribution and recovery

Peios / Peios Security Fundamentals / Central Access Policies

The kernel maintains a policy cache keyed by policy SID. Access checks consult it when an object references a central access policy. A missing entry invokes the recovery policy described below.

Important

Automatic policy distribution is not implemented. Neither authd nor peinit loads central access policies from the registry, populates this cache at startup, or replicates policies between machines. authd readiness does not mean that CAAP policies have been loaded.

The kernel cache, kacs_set_caap syscall and access-check enforcement exist. The integration tests exercise them by calling the syscall directly. This page describes that kernel mechanism and its recovery behavior, not an available deployment workflow.

kacs_set_caap #

The syscall is straightforward:

kacs_set_caap(policy_sid, sid_len, spec_or_null, spec_len)
ParameterMeaning
policy_sidThe SID identifying the policy. An 8–68 byte binary SID. Establishes (or replaces) the cache entry.
sid_lenThe length of the SID in bytes.
spec_or_nullThe wire-format spec to install at this SID. NULL means "remove the policy at this SID from the cache".
spec_lenThe length of the spec in bytes. Zero when removing.

The call requires enabled SeTcbPrivilege in the caller's effective token. The gate checks token authority, not an executable name. This requirement does not imply that authd or peinit currently calls it.

The kernel:

  1. 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.
  2. Parses the spec into the cache's internal representation.
  3. Replaces any existing entry at the policy's SID with the new one. If no entry existed, creates one.
  4. 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 #

There is currently no production distribution caller in authd or peinit. The test harness calls the syscall directly; there is no public C or Rust convenience wrapper, configured registry policy store, or cache-query syscall for management tools.

A future distribution service would need a policy source, update handling, boot readiness coordination and a recovery workflow. Those are outstanding implementation and design work, not services that an operator can enable today by changing a registry value.

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. An authorised caller installs policies one at a time via kacs_set_caap; the shipped daemons do not do this automatically.
  • 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\Administrators
  • SYSTEM
  • OWNER_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 starts empty. The current boot sequence does not populate it, and starting or restarting authd does not change that. An object referencing an absent policy continues to use the recovery policy.

A dependency on authd readiness cannot establish that a central policy is loaded. Services must not treat that dependency as a CAAP distribution barrier. Boot-time policy loading and its readiness contract remain unimplemented.

Updates and replication #

An explicit kacs_set_caap call can replace a local cache entry atomically. The kernel evaluates later accesses using the cache; existing handles keep their previously granted rights.

Automatic source watching, registry-to-cache updates and replication between machines are not implemented. There is no authd CAAP update stream for management tools to subscribe to. The kernel cache is local to one running kernel and does not provide persistence or replication itself.

Cache eviction is administrative #

The kernel does not evict cached policies on its own. A policy stays in the cache until:

  • kacs_set_caap is 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_ACE to 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 available kernel interface installs policies by SID into the cache. An operational distribution layer still needs to be designed and implemented.

Where to go next #

For what the kernel does with a cached policy on each access check, read Evaluation.

For the existing boot and identity sequence, read Boot and trust establishment; it does not establish CAAP readiness.

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. The rest of this topic explains the handle model, how to inspect and make bounded changes to file security, and the special cases that affect diagnosis. The syscall and SDK details live in the technical references linked below.

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:

SyscallModeCommon use
kacs_openKACS-native — caller specifies an explicit desired access maskNew code; programmatic file access by services
openat / openLegacy — caller specifies POSIX flags (O_RDONLY, O_WRONLY, etc.) that map to access masksExisting Linux applications

kacs_open is the native interface. In ordinary strict mode, the caller specifies 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.

For an operator, the important distinction is between a successful open and a successful later operation: that operation may need a right the handle did not receive. Diagnose the actual caller and operation with Debugging a denial, and use Managing file security for a deliberate policy change.

The programmer-facing kernel references are KACS-Native Open and Legacy Open Compatibility. Their exact masks and edge cases have documented discrepancies; this overview does not resolve them. Native creation can also accept a caller-supplied SD; see creation rules and Inheritance for the distinction from inherited policy.

What FACS reads from where #

A summary of the data sources that feed into a FACS access check at open:

SourceWhat it provides
Calling thread's effective tokenIdentity for AccessCheck — user SID, groups, integrity, privileges, etc.
File's inodeThe owner SID, primary group, DACL, SACL (read via xattr or filesystem-native channel)
Mount policyWhether FACS applies at all, how missing SDs are handled, the mount-level SD template
Process PSBPIP fields for the dominance check during AccessCheck
Caller's kacs_open parametersThe 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.

For programming, use the SDK opening reference, the native and legacy kernel references, and the generated KACS ABI. Read the open-interface discrepancy note alongside them.

To inspect a file's policy, make a bounded change with sd, and verify the original operation, 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:

FieldSet atMutable after open?
Granted access maskAccessCheck at openNo
Continuous audit maskAccessCheck at open (if alarm ACEs matched)No
FACS-managed flagOpen pathNo
(Filesystem state: position, mode, etc.)openMutable 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 kacs.audit.handle.used 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.

OperationRequired access
readFILE_READ_DATA
pread, readv, process_vm_readv (when reading from the fd as target)Same
writeFILE_WRITE_DATA
pwrite, writevSame
Append-only write (RWF_APPEND, O_APPEND)FILE_APPEND_DATA (without requiring FILE_WRITE_DATA)
ftruncateFILE_WRITE_DATA
fchmodFILE_WRITE_ATTRIBUTES
fchownWRITE_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
fstatFILE_READ_ATTRIBUTES
fgetxattrFILE_READ_EA (for ordinary xattrs; the security namespace is unconditionally denied)
fsetxattrFILE_WRITE_EA (same exception)
fdatasync, fsyncNone — 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 unlink of 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 splice between 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:

Warning

Don't expect tightening to revoke open handles. A security incident response that revokes a permission needs to also close fds. The kernel does not.

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 interfaces that establish the granted mask, see the programmer-facing native-open and legacy-open kernel references, with their unresolved documentation differences.

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.

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 fdBehaviour
fstat, fstatatWorks without access check. The fd is a path reference; getting stat info is allowed.
openat using the fd as dirfdNormal — runs AccessCheck for the new open. The dirfd's O_PATH status doesn't affect the new fd.
kacs_get_sd with AT_EMPTY_PATHRuns a live AccessCheck (the fd has no cached mask to consult).
kacs_set_sd with AT_EMPTY_PATHSame — live AccessCheck.
read, write, mmapDenied with EBADF. The fd cannot be used for data operations.
fchmod, fchown, fgetxattr, fsetxattr, ioctlDenied 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:

  1. Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
  2. Requires both the +x mode bit on the file and FILE_EXECUTE on the caller's access to be present.

The two checks answer two different questions:

  • +x on 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 +x is data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.
  • FILE_EXECUTE on 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.
  • ftruncate to extend the file is denied (cannot rewind via truncate).
  • fallocate modes 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_access and system.posix_acl_default xattrs 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 includes WRITE_OWNER; otherwise -EACCES.
  • chown() and lchown() run a fresh access check requiring WRITE_OWNER on 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.

Warning

Don't trust FACS results on NFS for security purposes. The local FACS decision is "the client will let this go forward"; the server may still refuse. If you need a security guarantee, the server's access control is what matters.

Other implications:

  • Expect I/O errors from server-side denial. A successful open does not guarantee a successful read. Handle EACCES (or EIO) 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 /proc files (/proc/uptime, /proc/cpuinfo) are world-readable by convention.
  • /sys writes are restricted to BUILTIN\Administrators and SYSTEM by 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:

CaseWhat is different
O_PATHNo cached mask; data operations denied; live check for SD ops
exec via execveatLive AccessCheck plus Linux +x mode bit
Append-only handlesWrite-at-end only; mmap-shared-writable and truncate denied
Sticky bitNo effect — the DACL is the gate
POSIX ACLsReplaced by KACS; xattr writes denied
fchown / fchmodGated on WRITE_OWNER / WRITE_DAC; the mode bits are inert — the SD is the truth
NFS clientDual authority; local FACS plus remote enforcement
/proc and /sysUnmanaged mount policy; per-operation check by kernel-specific rules
renameat2 RENAME_WHITEOUTSupported; 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:

FieldMeaning
confinement_sidThe 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_capabilitiesAn 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_exemptA 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:

  1. What the DACL plus privileges would grant the token's full identity.
  2. 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 SeShutdown can 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 thisUse this
A long-running service to run with less authority than its account would imply, set by administrative policyConfinement
A program to internally drop authority for a specific sensitive operation, set by codeRestricted token
Centrally-defined organisational policy applied across many objectsCAAP
Prevent untrusted binaries from interfering with trusted onesPIP
Block writes from lower-integrity to higher-integrityMIC

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 #

SIDCapability
S-1-15-3-1internetClient — outbound internet access.
S-1-15-3-2internetClientServer — inbound and outbound internet.
S-1-15-3-3privateNetworkClientServer — LAN / private network access.
S-1-15-3-8enterpriseAuthentication — domain credential access.
S-1-15-3-9sharedUserCertificates — certificate store access.
S-1-15-3-10removableStorage — 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:

  1. The application's manifest declares the capability by name, so authd can compute the SID and put it on the token.
  2. 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 modeALL_APPLICATION_PACKAGES matches the token?ALL_RESTRICTED_APPLICATION_PACKAGES matches?
NormalYesYes
StrictNoYes

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_sid matches an ACE whose SID is the same.
  • Each entry in confinement_capabilities matches an ACE whose SID is the same.
  • ALL_APPLICATION_PACKAGES matches an ACE on that SID only if the token's capabilities include it (i.e. only in normal mode).
  • ALL_RESTRICTED_APPLICATION_PACKAGES matches 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_sid and groups, not its confinement_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\Administrators GENERIC_ALL. (Administrative full control.)
  • An ACE granting SYSTEM GENERIC_ALL. (System full control.)
  • An ACE granting Authenticated Users GENERIC_READ. (Standard authenticated read.)
  • An ACE granting internetClient FILE_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_sid is non-null, and
  • The token's confinement_exempt flag 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:

ElementUsed in secondary walk
User SIDConfinement SID, in place of the token's user_sid.
GroupsConfinement capabilities (confinement_capabilities list), in place of the token's groups. Group attributes (enabled/disabled) are ignored — presence-based matching only.
Restricted SIDsIgnored. The restricted-token pass already ran in step 10.
Owner SIDThe 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 labelThe 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, or SeTakeOwnershipPrivilege in 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_sid or groups for any kernel API other than AccessCheck. A confined process still appears as its original user in getpwuid-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 stat on 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:

  1. Whatever the DACL walk + privileges produced (steps 4–9).
  2. Whatever the restricted-token walk would have produced (step 10, if restricted_sids is non-empty).
  3. Whatever the confinement walk would have produced (step 11, this page).
  4. 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_SID
  • groups = [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

The access check:

  1. 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.
  2. Step 5: no MIC label (default Medium / NO_WRITE_UP — does not block read), no PIP label. Nothing pre-decided.
  3. Step 6: virtual group injection. OWNER RIGHTS is added (the token owns the file). But the object's DACL has no OWNER RIGHTS ACE, so the implicit READ_CONTROL | WRITE_DAC will be granted at step 8.
  4. 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.
  5. Step 10: restricted-token pass skipped (no restricted_sids).
  6. 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 internetClient matches no ACE.
    • The capability removableStorage matches no ACE.
    • The capability ALL_APPLICATION_PACKAGES matches 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).
  7. Step 12: no CAAP.
  8. 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:

FieldWhat happens to a capability SID placed here
confinement_capabilitiesThe 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.
groupsThe 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 jellyfin user, mentioned in every media-file DACL.
  • A transmission user, mentioned in every download-directory DACL.
  • A nextcloud user, mentioned in every shared-storage DACL.

You define capabilities — semantic units of access — and grant ACEs to those:

  • A media-library-read capability. Every media file's DACL grants GENERIC_READ to this capability.
  • A download-write capability. Every download directory's DACL grants GENERIC_WRITE to this capability.
  • A shared-storage capability. 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 groups does 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_ALL
  • ACCESS_ALLOWED BUILTIN\Administrators GENERIC_ALL
  • ACCESS_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:

FieldMeaning
pip_typeThe PIP type. One of None, Protected, Isolated.
pip_trustThe PIP trust level. A numeric tier within the type.
security_descriptorThe process SD — see The process security descriptor.
Mitigation flagsThe 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:

AxisRangeWhat it means
pip_type0 (None), 512 (Protected), 1024 (Isolated)The kind of trust. None = unprotected; Protected = the standard PIP-protected process; Isolated = reserved for future use.
pip_trustnumeric (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:

SIDtypetrustWhat it represents
S-1-19-0-0None (0)0Unprotected. Default for unsigned processes.
S-1-19-512-2048Protected (512)2048Peios-distributed applications.
S-1-19-512-8192Protected (512)8192Peios 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:

  1. The kernel reads the binary about to be exec'd.
  2. It looks for a signature — an ELF .peios.sig section or a security.peios.sig xattr.
  3. It verifies the signature against its compiled-in public key catalog.
  4. On a valid signature, the PIP fields are set to whatever level the catalog says the signing key represents.
  5. On no signature, invalid signature, or any verification failure, pip_type is set to None (0) and pip_trust to 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:

RightWhat it gates
PROCESS_TERMINATESend terminating signals (SIGKILL, SIGTERM, SIGABRT, etc.).
PROCESS_SIGNALSend non-terminating informational signals (SIGCHLD, SIGURG, SIGWINCH, etc.).
PROCESS_VM_READRead the process's memory. Required by ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads.
PROCESS_VM_WRITEWrite the process's memory. Required by ptrace(PTRACE_POKE*, PTRACE_ATTACH), process_vm_writev, /proc/<pid>/mem writes.
PROCESS_DUP_HANDLEDuplicate file descriptors out of the process via pidfd_getfd.
PROCESS_SET_INFORMATIONChange process attributes — scheduling priority, CPU affinity, rlimits, certain /proc/<pid>/* writes.
PROCESS_QUERY_INFORMATIONRead detailed process information — token, full /proc/<pid>/* reads (cmdline, status, io, limits, sched, mounts).
PROCESS_SUSPEND_RESUMESend stop/continue signals (SIGSTOP, SIGCONT).
PROCESS_QUERY_LIMITEDRead 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:

  1. 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.
  2. 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_INSTALL of 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.
  3. The default template, when used, grants the process's user identity PROCESS_QUERY_LIMITED and PROCESS_QUERY_INFORMATION, grants BUILTIN\Administrators and SYSTEM the 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_READ by 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_token requires PROCESS_QUERY_INFORMATION on 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.

CheckReadsComparesDecides
SD checkTarget's process SD; caller's effective tokenStandard AccessCheck pipeline against the SD's DACLWhich process rights the caller has on the target.
PIP dominanceCaller's PSB (pip_type, pip_trust); target's PSBAll-or-nothing comparison: caller's type ≥ target's type AND caller's trust ≥ target's trustWhether 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:

  • SeTcbPrivilege does not bypass PIP either. The privilege grants a lot — token creation, mount-policy administration, central-policy distribution — but it operates within the PIP boundary. Mount-policy administration also accepts enabled SeManageVolumePrivilege at the verified kernel revision. A TCB caller (holding SeTcbPrivilege) is typically also a TCB-trust-level process anyway, so the dominance is satisfied by virtue of the binary, not the privilege.
  • SeBackupPrivilege and SeRestorePrivilege do not bypass PIP. A backup tool granted access to read another process's memory via the SD check still needs to dominate PIP.
  • SeImpersonatePrivilege does 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:

OperationProcess right neededNotes
kill, signal deliveryPROCESS_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 -CONTPROCESS_SUSPEND_RESUMEPIP dominance required.
ptrace(PTRACE_ATTACH), ptrace(PTRACE_POKE*)PROCESS_VM_WRITEPIP dominance required. SeDebug bypasses the SD; not PIP.
ptrace(PTRACE_PEEK*)PROCESS_VM_READSame.
PTRACE_TRACEMEPROCESS_VM_WRITE on caller's SD; PIP dominance of the nominated tracer over the calling processReverse-flavoured of the others.
pidfd_openPROCESS_QUERY_LIMITEDPIP dominance required.
pidfd_getfdPROCESS_DUP_HANDLEPIP dominance required.
process_vm_readv, process_vm_writevPROCESS_VM_READ, PROCESS_VM_WRITERoute through the ptrace check.
/proc/<pid>/mem read/writePROCESS_VM_READ / PROCESS_VM_WRITESame.
/proc/<pid>/ basic reads (stat, comm, wchan)PROCESS_QUERY_LIMITEDPIP dominance required.
/proc/<pid>/ detailed reads (cmdline, status, io, limits, sched, mounts)PROCESS_QUERY_INFORMATIONPIP dominance required.
/proc/<pid>/ writes (sched, oom_adj, coredump_filter)PROCESS_SET_INFORMATIONPIP dominance required.
sched_setaffinity (cross-process)PROCESS_SET_INFORMATIONPIP dominance + SeIncreaseBasePriorityPrivilege.
setpgid (cross-process)PROCESS_SET_INFORMATIONPIP dominance required.
getpgid, getsid (cross-process)PROCESS_QUERY_LIMITEDPIP dominance required.
perf_event_open (target-specific)PROCESS_QUERY_INFORMATIONPIP dominance required + SeProfileSingleProcessPrivilege. SeDebug bypasses SD only.
capget(pid) (cross-process)PROCESS_QUERY_INFORMATIONPIP dominance required.
kacs_open_process_tokenPROCESS_QUERY_INFORMATION on the process AND the rights on the tokenPIP dominance required.
kacs_open_thread_tokenSamePIP 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 -ESRCH in some no-trust-revealing paths).
  • pidfd_open, pidfd_getfd return -EACCES.
  • /proc//* reads return -EACCES or, 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 peios_token_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.

MICPIP
What it labelsTokens (integrity_level)Processes (pip_type, pip_trust) on the PSB
What it reads at access timeEffective token (impersonation-visible)PSB (impersonation-invisible)
Axes1 (integrity level)2 (type and trust)
DefaultMedium with NO_WRITE_UP for unlabelled objectsNone — objects are PIP-unrestricted unless they opt in via a label ACE
Bypassed by privileges?Privilege-granted bits surviveNo — privilege-granted bits are revoked for non-dominant callers
Default policyBlock writes from belowOnly 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: grants WRITE_OWNER regardless 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 also accepts enabled SeManageVolumePrivilege at the verified kernel revision. SeTcbPrivilege is 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/mem access bypasses PIP if writable. Even reading /dev/mem gives access to physical memory. The defence is CONFIG_STRICT_DEVMEM=y, which restricts /dev/mem to 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 = 1024 is 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 /proc getdents() 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) while pip_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 = 1536 in 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_type and pip_trust fields 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 execResult
No signature at allProcess runs at pip_type = None, pip_trust = 0.
Signature present, version byte unrecognisedSame.
Signature present, key not in catalogSame.
Signature present, but bytes do not verify (hash mismatch)Same.
Signature present and verifies against a catalog keyProcess 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:

BytesFieldMeaning
0Version0x01. The only version supported in v0.20; any other value is rejected.
1–3309ML-DSA-65 signatureA 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:

PlacementWhen used
ELF section .peios.sigELF binaries. The signature lives inside the file.
security.peios.sig xattrNon-ELF binaries. Also a fallback for ELF binaries without a .peios.sig section.
Detached <file>.peios.sig entryInside a package, for non-ELF files. The installer reads it and stamps the xattr; it never lands on disk itself.

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 signatures are a transport form. A package cannot carry extended attributes, so a non-ELF file's blob rides along as a payload entry named after the file with .peios.sig appended; peipkg (and peipkg-compose, when an image root is built) sets the xattr from it as the file is created and does not write the entry itself. By the time anything runs, detached entries are gone; 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:

AttributeValue
Section name.peios.sig
Section typeSHT_PROGBITS (typical ELF section type, holds raw data)
Section sizeExactly 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 its namespace (a plain cp, an archiver that does not understand the namespace, a network transfer that strips it). In native cp, security.peios.sig falls under the security preservation selection; xattr/xattrs selects only attributes outside security.*. 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:

AttributeValue
xattr namesecurity.peios.sig
xattr valueThe 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 <file>.peios.sig entries #

A package carries a non-ELF file's signature as a payload entry with the file's own path plus .peios.sig — for usr/lib/firmware/iwlwifi-so-a0-gf-a0-89.ucode.zst, the entry usr/lib/firmware/iwlwifi-so-a0-gf-a0-89.ucode.zst.peios.sig, holding exactly the 3310-byte blob. The suffix is the ELF section's name on purpose: pekit produces one thing, and only where it lands differs.

At pack time the entry is checked for shape — 3310 bytes, version byte 0x01, a target that exists in the same package and is not ELF (an ELF file's signature belongs in its section, and a second carrier would be ignored). At install, peipkg creates the target, sets security.peios.sig on it before the file becomes visible at its path, and skips the entry. The entry is not recorded as an installed file; removing the target removes the signature with it.

The detached form has no role in a running system and does not need one: a running system has no signing key, and a signature that travelled inside a package is already where the kernel looks.

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 / constantValue
Signature blob size3310 bytes (1 byte version + 3309 bytes ML-DSA-65 signature)
Version byte0x01 in v0.20; anything else is treated as unrecognised
Content hashSHA-256 (32 bytes)
Signature algorithmML-DSA-65 pure, empty context (not HashML-DSA)
ELF section name.peios.sig
ELF section typeSHT_PROGBITS
Xattr namesecurity.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:

  1. 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.
  2. The kernel looks for a signature. For ELF binaries, it scans the section header table for .peios.sig. For non-ELF binaries, it reads the security.peios.sig xattr. If neither is present, the binary is unsigned.
  3. 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.
  4. 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.
  5. 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.
  6. Or, on any failure, the PSB's PIP fields are set to pip_type = None, pip_trust = 0. The exec proceeds.
  7. 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.sig is 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:

  1. Place the new (signed) binary at a different path.
  2. 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.
  3. 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 pekit uses them and how peipkg carries the result onto disk, 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:

FieldSizeMeaning
Public key1952 bytesThe raw ML-DSA-65 public key.
pip_type4 bytes (little-endian u32)The PIP type this key represents.
pip_trust4 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.

To see which keys a running kernel trusts, open Security Policy on the desktop and choose Signing Keys, or read /sys/kernel/security/kacs/signing_keys, which lists each key by the SHA-256 of its public key with the PIP type and trust it confers:

$ cat /sys/kernel/security/kacs/signing_keys
key_sha256=3f9c…e01a pip_type=512 pip_trust=8192

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:

Keypip_typepip_trustWhat it represents
TCB public key512 (Protected)8192Peios 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 pekit uses to sign TCB binaries and firmware when their packages are built. 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.

Caution

The TCB private key must never be present on any running Peios system. It is for image build, and only image build. A running system has no need for the private key — the kernel verifies with the public key only.

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.

pekit, peipkg and the build flow #

Signing happens when a package is built, not when an image is assembled. pekit, the package builder, holds the TCB private key on the release infrastructure and signs as part of packing; peipkg and peipkg-compose (which peiso drives to lay down the image's root) carry the result onto the filesystem. No step in between needs the private key.

At package build, for each file a recipe's sign.pip table names, pekit:

  1. Computes the content hash — ELF section zeroed for an ELF file, the entire file for anything else (see Signature format).
  2. Signs the hash with the TCB private key and builds the 3310-byte blob (version byte + ML-DSA-65 signature).
  3. Places the blob. For an ELF file it reserves the .peios.sig section (3310 zero bytes) before hashing and writes the blob into it afterwards, so the hash the kernel computes matches the one signed. For a non-ELF file — a firmware blob, a data file — it writes the blob as a detached signature, <file>.peios.sig, next to the file. The file itself is untouched.
  4. Packs both into the .peipkg. The detached signature is an ordinary payload entry with a reserved suffix; the package format forbids extended attributes on entries, so this is how the xattr placement travels.

At install — whether peipkg install on a running system or peipkg-compose building an image root — for each detached signature the installer creates the target file, sets security.peios.sig on it to the blob's bytes, and does not write the detached entry to disk at all. The signature is checked at pack time for shape (right length, right version byte, a target that exists and is not ELF); the installer never needs a key.

When the image boots, the kernel's embedded key table verifies binaries from their sections and firmware and other non-ELF content from their xattrs. 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 .peipkg signed 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 replacement binary runs under the process's existing constraints. Children inherit committed flags at fork.

This page covers the model: what mitigations are, how they fit alongside access control, and why lifecycle boundaries matter. For an operational check, start with Applying and lifecycle: inspect the running process, compare its committed flags with the required policy, and keep a failed request separate from verified protection.

A flag in a catalog or launch configuration is not proof it is active. The Kernel TRM describes activation-backed, all-or-nothing application and current implementation limits. In particular, it documents CFIF as unavailable and CFIB activation as self-only. Check those limits before interpreting a requested hardening set.

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. An exploit may be stopped at a refused operation or cause a process fault instead of achieving code execution. That is a defence against particular exploitation paths, not a guarantee that every exploit is stopped or that every refusal crashes the process.

How mitigations differ from access control #

The two layers solve different problems:

Access controlMitigations
What it gatesOperations against other objects (files, registry keys, processes)Operations the process performs on its own memory and address space
Driven byIdentity and policy (token, SD, privileges)Hardening posture chosen at process startup
GranularityPer-objectPer-process
AdjustabilityIdentity can adjust within rules (AdjustPrivileges)One-way; only ever tightened
Who sets itauthd (token), object owner / administrator (SD)The launching process or the process itself
Threat modelUntrusted callers reaching trusted objectsCode-execution exploits in the process's own memory

A process can be subject to both layers simultaneously. A TCB daemon can have restrictive access control and a required hardening policy covering memory, libraries, control flow, address layout and speculation. Its actual mitigation set still depends on successful activation; neither its signing level nor the desired policy proves those flags are on. 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.

PIP, the process descriptor and mitigations are independent dimensions. A process without PIP can still have a restrictive descriptor and committed mitigations. For example, exec of an unsigned binary can remove PIP while preserving the process descriptor and mitigation flags. Inspect each relevant surface rather than inferring an open DACL or absent hardening from the lack of PIP.

The PSB storage and the one-way rule #

The mitigations live in a small bitfield on the PSB:

FlagBit value
WXP0x001
TLP0x002
LSV0x004
CFI (legacy alias for CFIF + CFIB)0x008
UI_ACCESS0x010 (reserved)
NO_CHILD0x020
CFIF0x040
CFIB0x080
PIE0x100
SML0x200
ALL0x3FF

The table identifies the request bits, not a set that every platform can enable. CFI expands to CFIF and CFIB and is not stored as a separate active flag; UI_ACCESS is reserved; ALL is the accepted-request mask. The ABI catalog is the numeric reference.

Before committing a new mitigation, the kernel must activate or verify its protection. If any requested protection fails, the request changes no bits. Previously committed flags cannot be cleared or weakened; requesting only a subset does not remove the rest.

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 cannot fork or clone a new process. Creating threads with CLONE_THREAD remains possible. There is no way to clear the restriction.

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.

Preservation is not a promise that the new binary will work. PIE explicitly rejects a non-PIE binary at exec. Other constraints may reject operations the new program needs, such as a JIT attempting to make writable code executable under WXP, or a library load failing LSV or TLP. Check the actual failing operation rather than assuming exec always succeeds and only later operations can fail.

Practical implication: choose the policy with knowledge of the program and its launch path. A service launcher can request the protections its service requires and check the result. A launcher of arbitrary user binaries cannot assume every program is compatible: applying WXP to a shell's process lineage can prevent JIT or self-modifying programs from working.

Who sets mitigations #

Three patterns for setting mitigations:

  • The launcher, before exec. The launcher's code running in the newly forked child can request compatible mitigations before exec and check the result. This is the between-fork-and-exec pattern described by the Kernel TRM. It is self-application in the child, distinct from the parent modifying another task.
  • The process itself, during startup. A program can request protections for its own PSB, preferably before untrusted input. Late application must validate existing state; it cannot exempt incompatible startup mappings simply because they were created before the request.
  • An authorised supervisor. A caller with PROCESS_SET_INFORMATION and PIP dominance can request changes to another process. Authority does not bypass activation limits: the Kernel TRM documents CFIB as self-only and CFIF as unavailable.

These are application patterns, not evidence of the service launcher's behavior. The source-checked peinit 0.0.12 launch path does not apply an additional per-service mitigation mask. Inherited or self-applied flags may still be present; inspect the running process rather than inferring protection from its launcher's identity.

In each case, success must be checked and committed state verified. Applying and lifecycle explains that evidence; the existing SDK reference holds the programming interface.

What the kernel actually does when a mitigation fires #

Each mitigation has its own enforcement points. Memory and process-operation checks can refuse a syscall with an error such as EACCES or EPERM. Architecture-backed protections operate through hardware and kernel support; a control-flow violation can fault rather than return a syscall error.

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.

The protections address different operations:

  • WXP refuses mprotect calls 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.
  • Forward CFI is intended to reject indirect branches outside designated targets such as ENDBR instructions. The Kernel TRM currently documents CFIF activation as unavailable; do not count it as active merely because it was requested.
  • PIE refuses exec of non-PIE binaries (this one fires at exec, not at runtime).

These protections depend on kernel enforcement and, where applicable, architecture support rather than cooperation from libc. Avoiding libc does not bypass kernel checks. A named protection still has to be supported and successfully committed before it can be relied on.

Where to start #

If you want the catalog — what each individual mitigation does, when it fires, what kernel surfaces it covers — read Catalog.

To inspect actual hardening, understand a refused request, or check what survives fork and exec, read Applying and lifecycle. Use its links to the command, SDK and Kernel TRM references for the corresponding interfaces.

Catalog

Peios / Peios Security Fundamentals / Process Mitigations

This catalog covers eight named protections plus the reserved UI_ACCESS slot. A defined flag is not a guarantee that it can be activated on a given process or platform. The Kernel TRM documents activation-backed, all-or-nothing requests and current implementation limits; Applying and lifecycle explains how to inspect the committed state.

The entries retain the threat model of each protection. Read the support and lifecycle limits alongside it before choosing a service policy. The numeric KACS_MIT_* values are cataloged in Other constants and the Kernel ABI.

LSV — Library Signature Verification #

LSV decides which file-backed executable code may be loaded into the process. It requires a valid signature. For a PIP-protected process, the library must also meet the process's PIP trust floor; for a process with no PIP protection, a valid signature is still required but the trust floor is not compared.

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 caller is PIP-protected and the file's valid signature is below its required trust, the call also fails with -EACCES.
  • If the file's signature is valid and meets the applicable trust requirement, it passes the LSV check. Other mitigation or access checks may still refuse the operation.

A TCB process (pip_trust = 8192) can therefore load only libraries meeting that trust. The conceptual tier model also describes an App tier (2048) that would need App-or-higher libraries rather than Authenticode or unsigned ones; the Kernel TRM currently documents only Protected/PeiosTcb as producible. Do not use a hypothetical tier as evidence of deployed support. A process with no PIP protection is still subject to LSV signature checks when the flag is on.

Activation also validates existing file-backed executable mappings. A process cannot first load unsigned code and then successfully commit LSV over it. Adding executable permission with mprotect runs the same verification; anonymous executable mappings are governed by WXP rather than LSV. See Library Signature Verification.

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 relevant to PIP-protected services such as peinit and authd, but the service name or signing tier is not proof that it is enabled. Check the required policy, library compatibility and committed state.

WXP — Write-XOR-Execute #

WXP rejects writable-and-executable mappings and transitions between writable and executable protection. These are the invariants documented by the Kernel TRM.

Specifically:

  • mmap(..., PROT_WRITE | PROT_EXEC, ...) is refused if WXP is enabled.
  • An mprotect transition from writable to executable is refused.
  • An mprotect transition from executable to writable is refused.

The Kernel TRM establishes transition checks and validation of observable existing state. It does not establish a lifetime history of every page's earlier protections; do not rely on that stronger model when assessing compatibility.

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.

A native binary that loads its executable code from disk and uses writable, non-executable stack and heap may not need these transitions. That is a compatibility consideration, not proof that every native binary is unaffected: review the program's actual mapping requirements.

WXP activation also checks existing mappings: the Kernel TRM specifies failure for an existing writable-executable mapping or another observable invariant violation. A late request is not an exemption for code generation done earlier.

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 the paths used for file-backed executable mappings. The kernel compares the resolved absolute backing path against a machine-wide cache of approved directory prefixes for mmap(PROT_EXEC) and when mprotect adds execute permission.

Current implementation limit: the Kernel TRM states that the cache has no production writer: no syscall, securityfs node or registry path fills it. Only a KUnit test helper populates it. The documented shipping cache is therefore empty, so it matches no path. Do not assume that peinit fills a usable library allowlist at boot or that editing the registry can do so.

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 an entry's prefix, it passes the TLP check. Other checks still apply.
  • If not, the operation fails with -EACCES.

Paths such as /usr/lib/, /lib/ or a service-specific directory illustrate possible prefixes, not installed cache entries. /tmp/, /home/ and every other path fail when none of the configured prefixes matches; with the documented empty production cache, even system-library paths fail.

The threat model is loading executable code from an untrusted location, such as an attacker writing a shared object into /tmp/ and loading it with dlopen. A useful path policy would allow only suitably protected directories; a trusted-looking pathname alone does not prove the directory cannot be modified. TLP checks the prefix, while filesystem access control determines who can write there.

TLP and LSV have complementary models: LSV requires an acceptable signature; TLP requires an approved location. That does not make their combined activation a safe default. TLP also validates existing file-backed executable mappings and fails if their paths cannot be resolved or accepted, including when the cache is empty. Anonymous mappings are outside TLP and LSV; WXP governs them.

TLP cache details:

  • Maximum 64 entries.
  • Each entry is an absolute directory-prefix byte string, beginning and ending with /, checked against kernel-resolved path bytes. The trailing slash prevents /usr/lib/ from matching /usr/libevil.
  • Maximum 4096 bytes per path.
  • The cache is machine-wide. Empty, relative, NUL-containing or non-slash-terminated prefixes are invalid; the Kernel TRM specifies atomic rejection of invalid updates. No supported production population route is documented.

CFIF and CFIB — Control Flow Integrity (Forward and Backward) #

CFIF (Forward) and CFIB (Backward) name separate control-flow protections: indirect-call/jump target checking and return-address checking. Do not assume both are available because both have flag values. The Kernel TRM currently documents CFIF as unavailable and CFIB activation as self-only.

The legacy request flag CFI (0x008) expands to CFIF (0x040) and CFIB (0x080); the alias is not stored in readback. Requesting the alias still requests the unavailable CFIF half, so it is not a shortcut around activation limits. Application code should name the specific supported protections its policy requires.

CFIF — Forward CFI #

The forward-CFI model rejects 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.

Such enforcement would require placing the process in IBT-enforcing mode, where an indirect branch to a non-ENDBR64 target raises a control-protection fault. The Kernel TRM says CFIF cannot currently be committed: activation against a live task returns ENODEV because no userspace IBT/BTI control surface is exposed. Treat a request as failed, not as silently active protection.

The threat model is code reuse through unintended indirect-call and jump targets, including JOP gadgets in legitimate code. Return-oriented programming uses return-address manipulation, covered by the separate backward-CFI model below. The presence of ENDBR instructions in a binary is not evidence that CFIF enforcement was activated.

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).

Hardware mechanisms such as Intel CET and architecture-specific BTI/PAC facilities illustrate why control-flow protection needs platform and program compatibility; they are not a cross-platform support guarantee. The Kernel TRM describes CFIB through shadow-stack enable-and-lock and rejects activation on a task other than the caller. A program must use a supported self-hardening route and check the result.

Unsupported architecture-backed activation fails closed. It is not a successful no-op on an incompatible platform or program. Do not infer protection from compiler flags alone; verify the committed mitigation state.

PIE — Position-Independent Executable #

PIE refuses exec of binaries that are not position-independent. The kernel checks the ELF image type at exec; if PIE is enabled on the 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 must be committed before the exec it is intended to constrain; setting it afterward does not prove that the current binary was checked at launch.

For build compatibility, PIE builds use compiler/linker options such as -fPIE -pie. Even when a distribution builds PIE binaries by default, verify the service's actual packaged binary; a local or emergency rebuild can lose the property.

Position-independent code can change addressing and indirection, including GOT/PLT use, with a performance cost that depends on the toolchain, architecture and workload. Do not use a fixed percentage as a compatibility or capacity guarantee.

SML — Speculation Mitigation Lock #

SML locks speculation mitigations on for the process. The threat family includes speculative-execution side channels such as Spectre, Meltdown and MDS; available defences and their cost depend on the platform. The Kernel TRM describes activation through the architecture's kernel interface, with failure if protection cannot be made effective. It also accepts a platform reporting speculation as unconditionally not affected, with nothing to enable. A committed SML flag is not a list of particular barriers, buffer clears or microcode operations applied on every context switch.

The performance cost can be significant and depends on the CPU and workload. Review the security requirement and capacity impact for secrets-handling processes such as cryptographic key holders, sealed-secret stores and TCB services; neither a fixed overhead estimate nor a blanket claim that a workload does not need SML establishes the right policy.

SML is a per-process commitment. The flag does not describe the complete system-wide speculation policy or the overhead borne by other processes.

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. SML prevents the process from disabling the documented protection; it does not establish that every side channel is eliminated.

NO_CHILD — Forbid fork and clone #

NO_CHILD refuses fork, clone without CLONE_THREAD, and other paths that create a new process. CLONE_THREAD operations that add threads to the current process are not covered. It constrains subsequent creation; it does not remove children that already exist.

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. The Kernel TRM describes its intended purpose as interaction with higher-integrity UI elements, for future desktop functionality.

Its presence in the accepted mask or in PSB readback is not evidence of an implemented UI protection. Do not include it as an effective defence in a hardening assessment.

Combining mitigations #

A hardening policy may address several independent needs:

  • WXP — no writable-executable pages
  • LSV — only signed libraries
  • TLP — only libraries from approved paths
  • CFIF and CFIB — forward and backward control-flow models, subject to the activation limits above
  • PIE — ASLR-aware binaries
  • NO_CHILD if applicable — no child processes

Plus possibly SML for secrets-handling processes.

These address different exploitation paths, but a policy must account for both compatibility and support. The current Kernel TRM's unavailable CFIF, self-only CFIB and empty production TLP cache prevent treating the list as a deployable default. Compare the service's required set with successful application and committed readback, not with an idealised list.

The ALL mask (0x3FF) identifies valid request bits. It is useful for input validation, not blanket hardening: it includes reserved and unavailable flags. Because requests are all-or-nothing, an unavailable requested protection prevents the whole request from committing. Do not substitute ALL for a reviewed set or silently omit a required protection to make a request succeed.

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

A requested mitigation mask is not evidence that the process is protected. Check the application's or launcher's result, then read the committed mitigation state of the process that is actually running. A service's intended policy, a flag's presence in the catalog, and a successful activation are different things.

For an operator investigating hardening:

  1. Identify the process and read it with logonse psb --pid PID, replacing PID with the target's process ID. With only --pid, this is a read-only inspection; see the logonse reference.
  2. Record the reported mitigations and process GUID alongside the service's required policy and any application error. The GUID distinguishes a replacement process from an earlier process with the same PID.
  3. Compare the committed flags with the required set and the activation limits. If a required flag is absent, or the report is refused or unavailable, do not treat the intended protection as verified.
  4. After an authorised launch or hardening change, check the result and read the same process again. Verify a new process separately after a restart; do not carry an old process's result forward.

This page explains the lifecycle and the limits of that evidence. Application code belongs in the SDK hardening guide and process.h reference; command syntax belongs in logonse. The Kernel TRM describes activation-backed behaviour and current implementation limits below. Use the documentation for the deployed release when checking support; this page does not establish when those implementation limits changed between releases.

Requests and committed protection #

kacs_set_psb is the kernel operation behind a mitigation request. It targets a process by pidfd and takes a mask of KACS_MIT_* flags. The SDK reference documents the callable wrapper, including -1 for the calling process; the kernel ABI holds the complete numeric flag table and syscall declaration.

A request is additive and one-way. It cannot clear an existing flag. Requesting an already-set flag or a subset of the committed set does not remove or weaken earlier protections. CFI is a legacy request alias for CFIF | CFIB; the alias itself is not retained in the committed bitfield. UI_ACCESS is reserved. ALL is the valid-bit mask, not a recommended policy or a promise that every defined flag can be activated.

The Kernel TRM specifies activation-backed, all-or-nothing application: before committing a new flag, the kernel activates its protection or verifies the required invariant. If any requested mitigation cannot be activated or verified, no bits from that request are changed. Bits committed by earlier successful requests remain set.

Unknown bits and an invalid or unauthorised target can be refused. Valid bits can also be refused because of existing mappings, platform support, or the activation route. A request made by the process itself is subject to these checks too.

Self versus another process #

Self-application needs neither PROCESS_SET_INFORMATION on another process nor PIP dominance over one. A thread can request hardening for its own process. This is permission to tighten its constraints, not an unconditional promise of success: activation and input validation still have to succeed.

Applying mitigations to another process requires both:

  • PROCESS_SET_INFORMATION on the target's process security descriptor.
  • PIP dominance over the target, under the two-check rule.

Satisfying both gates does not make every mitigation available remotely. The Kernel TRM documents CFIB activation as self-only: enabling it on a task other than the caller fails. It also documents CFIF activation against a live task as unconditionally returning ENODEV, because there is no userspace IBT/BTI control surface. A supervisor cannot overcome these activation limits merely by having authority over the target.

For a launcher using this pattern, distinguish the parent's code running in the freshly forked child from a parent targeting another process's pidfd. Both are possible application contexts, but only the former is self-application. Do not infer that a requested service policy was applied from the launcher's identity alone.

In the source-checked peinit 0.0.12 launch path, no additional per-service mitigation mask is applied. This does not imply that a service has no mitigations: inherited flags and application self-hardening are separate. Verify the running service's committed state, including after the process is replaced; a generic launcher pattern is not evidence that peinit applies it.

Where in the process lifecycle #

A process can request additional mitigations during its life, but when it requests them changes what must be validated and what events they can constrain:

  • After fork, before exec. The launcher's code running in the child can request hardening before loading the service binary. The launcher must handle a failed request rather than assume the child is hardened.
  • At the binary's entry point. The program can request compatible protections early, before processing untrusted input, and check the result.
  • After early-stage initialisation. This works only if the protection can be activated over the state already present. Finishing incompatible work first does not exempt its remaining mappings from validation.

The Kernel TRM specifically requires checks of existing state for runtime memory mitigations:

Newly requested protectionExisting state that prevents activation
WXPA writable-and-executable mapping, or another observable violation of the invariant.
TLPA file-backed executable mapping with a missing or unresolvable path, a path outside the approved cache, or another TLP denial.
LSVA file-backed executable mapping with missing, invalid, or insufficiently trusted signing material.

Anonymous executable mappings are governed by WXP; TLP and LSV apply to file-backed mappings. Architecture-backed CFIF, CFIB and SML must also be made effective for the target or the request fails closed. SML can instead be satisfied when the platform reports speculation as unconditionally not affected.

Do not treat late WXP application as leaving an existing writable-executable region exempt, or late TLP/LSV application as approving libraries already loaded from disallowed paths or without acceptable signatures. The documented response is refusal of the request, not automatic repair of the process. A JIT or startup code generator needs compatibility review; there is no general “generate code first, then set WXP” recipe.

Two restrictions are event-gated: PIE governs subsequent exec, and NO_CHILD governs subsequent process creation. Set them before the event they must constrain. Neither retrospectively changes the running binary or removes children that already exist.

Fork: inheritance #

At fork, the child inherits the parent's committed mitigation flags. It cannot remove them. It receives its own process GUID, so inspect the child as a distinct process instance.

If WXP is set, it is inherited. If NO_CHILD is set, the process cannot fork a child in the first place. Creating a thread with CLONE_THREAD shares the process's PSB rather than copying it; the same committed mitigation state applies to threads in that process. See PSB Lifecycle.

Exec: preserved flags and compatibility #

Exec replaces the binary and address space, but it does not provide an escape from committed mitigations:

  • Exec normally keeps the primary identity token, but NEW_PROCESS_MIN can replace it with a lower-integrity copy when the executable has an explicit lower integrity label. Thread impersonation is reverted at exec. See Token lifecycle.
  • PIP fields are recomputed from the new binary's signature.
  • The process security descriptor is preserved by exec; explicit descriptor or primary-token changes have their own rules.
  • Mitigation flags and the process GUID are preserved.

The important exec-specific restriction is PIE. If PIE is set and the new binary is not position-independent, exec is refused with EACCES. Updating a service to a non-PIE build can therefore prevent its next start. Treat an emergency rebuild that lost PIE support as a compatibility failure to investigate, not a reason to assume the old policy has disappeared.

Preservation does not guarantee that another binary will start or work correctly under the remaining mitigations. A JIT or self-modifying program may need operations WXP refuses; executable library mappings may fail TLP or LSV checks. Do not diagnose every launch failure as PIE merely because it is the explicit exec-gated flag. Review the failing operation and the catalog.

NO_CHILD and the lifecycle interaction #

Once NO_CHILD is committed, the process cannot fork or clone a new process. A service that needs startup workers must create them before it successfully commits this restriction. Those existing workers have their own inherited state; applying NO_CHILD later to the parent does not retroactively apply it to them.

A server that forks on demand cannot use NO_CHILD during that phase. The flag still permits exec, which replaces the binary in the same process, and CLONE_THREAD, which adds a thread rather than a process. See the NO_CHILD catalog entry.

Querying mitigations #

Use the read-only logonse psb --pid PID form above, or the documented text surface /proc/<pid>/psb. A PSB report contains pip_type, pip_trust, the committed mitigations bitfield, and process_guid. It does not contain the process security descriptor or the token; those are separate inspection surfaces.

A process can read its own PSB without an access check. Reading another process's PSB needs PROCESS_QUERY_LIMITED on its descriptor, not PIP dominance. The Kernel TRM also documents SeDebugPrivilege rescuing a descriptor denial; acquiring or enabling privileges is not a required diagnostic step. A refused report is incomplete evidence, not proof that the target has no mitigations.

Readback shows what is committed, not the mask a caller merely requested. The CFI alias is not retained; NO_CHILD is part of the same bitfield and UI_ACCESS remains reserved. PIE in that report constrains subsequent exec; it does not prove that the already-running binary was checked at a prior exec. Likewise NO_CHILD does not establish that the process has never created children.

For the format, access rules and software reader, see Reading the PSB and process.h. Record the process GUID when comparing reports across restarts or correlating events, since PIDs can be reused.

What happens at process exit #

The process's PSB and its flags disappear at exit. A new process starts with what it inherits from its parent, plus whatever its launch path or its own code successfully adds. The binary's filename does not carry a saved mitigation policy.

Launchers therefore need to handle the required policy on every launch, and a self-hardening binary needs to check its own requests regardless of who launched it. Neither arrangement guarantees success simply by making a request. If a required protection is unavailable, the launch or application must treat that as a hardening failure rather than continue under a false assumption. Recheck the replacement process instead of reusing the old process's report.

Errors #

Keep the application's exact error and target context. Distinguish input and authority failures from activation failures; a valid request by an authorised caller can still fail.

Error reported for a requestWhat to investigate
EBADFInvalid pidfd.
ESRCHThe target process has exited.
EACCESA denied PROCESS_SET_INFORMATION check when targeting another process; inspect the reported failure rather than assuming all denials have this cause.
EPERMMissing PIP dominance when targeting another process.
EINVALUnknown flag bits in the requested mask.
ENODEVThe Kernel TRM specifies this unconditionally for CFIF activation against a live task.

This is not an exhaustive activation-error catalog. Existing mapping incompatibilities, unavailable architecture support and CFIB's self-only restriction must also be considered. The SDK reference returns -1 with errno; raw kernel errors use the negative error form.

Under the documented all-or-nothing rule, a failed request commits none of that request's bits. Earlier successful commitments remain. Preserve the failure evidence, check the target's committed state, and review the required policy and supported launch path before trying a different request. Do not silently drop a required mitigation or use ALL as a recovery step.

Where to go next #

  • Catalog: what each flag protects and where support or compatibility limits matter.
  • logonse: existing command reference for PSB inspection and requests.
  • Hardening a process: application-side startup and failure handling.
  • PSB Fields: activation, current implementation limits and readback contract.
  • The two-check rule: authority required for changes to another process.

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:

CategoryTriggered byWhen it fires
Object-access auditSYSTEM_AUDIT* ACEs in an object's SACL, or the calling token's audit_policy bitmaskAt AccessCheck completion (one event per matching audit ACE per access)
Continuous auditSYSTEM_ALARM* ACEs in an object's SACLPer-operation on an open handle (every operation whose required mask overlaps the handle's continuous-audit mask)
Privilege-use auditA privilege contributed to the granted mask, and the token's audit_policy requested itAt AccessCheck completion (one event per privilege that fired)

Plus a fourth, tangentially related category produced by session lifecycle:

CategoryTriggered byWhen it fires
Session destructionA logon session loses its last token referenceWhen 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 and their attributes, integrity level, token and logon-session LUIDs, token type and impersonation level, projected UID, and the PIP type and trust in force.
  • The object — what kind of thing was accessed (object.kind) and, where the kernel can name it, which one. A daemon checking access to its own objects names them through the check's audit context, and those records are marked as carrying the daemon's claim.
  • 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 that the token's audit policy forced it.
  • The emitting process — 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 event's outcome.success 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.
  • eventd is 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 eventd has 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_policy may 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:

FieldMeaning
AceTypeSYSTEM_AUDIT (0x02), or one of the object/callback variants (0x07, 0x0D, 0x0F)
AceFlagsIncludes SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and/or FAILED_ACCESS_ACE_FLAG (0x80)
MaskThe access mask the ACE is interested in
SIDThe 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:

  1. Does the caller's identity match the ACE's SID? If not, the ACE does not apply.
  2. Does the requested access overlap the ACE's mask? If not, the ACE does not apply.
  3. If the access succeeded and the ACE has SUCCESSFUL_ACCESS_ACE_FLAG set, emit an event.
  4. If the access failed and the ACE has FAILED_ACCESS_ACE_FLAG set, emit an event.
  5. 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 combinationAudit behaviour
SUCCESSFUL_ACCESS_ACE_FLAG onlyAudit only when the access succeeded.
FAILED_ACCESS_ACE_FLAG onlyAudit only when the access was denied.
Both setAudit every access attempt, regardless of outcome.
Neither setThe 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:

FieldMeaning
AceTypeSYSTEM_ALARM (0x03), or one of the variants (0x08, 0x0E, 0x10)
AceFlagsSame flag bits as audit
MaskThe mask to record on the handle
SIDThe targeted principal

During step 14 of access check, when a SYSTEM_ALARM ACE matches, the kernel:

  1. Computes a continuous-audit mask — the union of all matching alarm ACEs' masks.
  2. Returns the mask to the caller as part of the access check's output.
  3. 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 kacs.audit.handle.used event for the operation.

Audit vs alarm: granularity #

The difference between SYSTEM_AUDIT and SYSTEM_ALARM is fundamentally about granularity:

AspectSYSTEM_AUDITSYSTEM_ALARM
Event timingOnce, at access-check completionPer operation on the open handle
Event count per handleOne (or one per matching ACE)Many — one per matching operation
What is recordedThe access-check decisionThe specific operation that was performed
Storage of stateNone — fires and forgetsContinuous 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 (kacs.audit.privilege.used) for each privilege that contributed. The events fall into two flavours:

FlavourTriggered byFires when
Successful privilege useaudit_policy & PRIVILEGE_USE_SUCCESS (0x04)A privilege contributed bits AND those bits survived to the final granted mask.
Failed privilege useaudit_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:

  1. Step 4: SeBackup grants read-category bits via the privilege.
  2. Step 8: DACL walk grants nothing additional.
  3. Step 11: confinement intersection strips the privilege-granted bits (confinement does not preserve privilege grants).
  4. 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 as privilege.name (e.g. SeBackupPrivilege), the bits the privilege contributed pre-narrowing as privilege.contributed, the bits that survived to the final result as privilege.surviving, and outcome.success, which is true when any bits survived. The exact schema is in Events and transport.

Token audit_policy #

The token's audit_policy field is a small bitmask with four flags:

FlagValueEffect
OBJECT_ACCESS_SUCCESS0x01Force an audit event on every successful access by this token, regardless of SACL ACE matches.
OBJECT_ACCESS_FAILURE0x02Force an audit event on every failed access by this token.
PRIVILEGE_USE_SUCCESS0x04Emit successful privilege-use events.
PRIVILEGE_USE_FAILURE0x08Emit 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 kacs.audit.access.checked event with trigger.kind sacl and the matched ACE as trigger.ace.
  • Step 14b (token-forced): OBJECT_ACCESS_SUCCESS is set, the access succeeded, fire one with trigger.kind policy and no trigger.ace.

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_FAILURE set 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_policy has the corresponding bit set. A token with audit_policy = 0 produces 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:

BitControlsWhen event fires
OBJECT_ACCESS_SUCCESS (0x01)Successful object accessAfter step 14b, when granted mask is non-empty
OBJECT_ACCESS_FAILURE (0x02)Failed object accessAfter step 14b, when requested mask is not fully granted
PRIVILEGE_USE_SUCCESS (0x04)Privilege use that succeededAfter step 13, when a privilege's contributed bits survived
PRIVILEGE_USE_FAILURE (0x08)Privilege use that failedAfter 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_policyUse case
0Default. Audit only from SACL ACEs.
OBJECT_ACCESS_FAILURE | PRIVILEGE_USE_FAILURECapture every denial. Useful for security monitoring.
OBJECT_ACCESS_SUCCESS | OBJECT_ACCESS_FAILURESurveillance — every access by this principal.
All fourMaximum 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:

SourceFires when
SYSTEM_AUDIT* ACE in SACLThe ACE matches the caller's identity and access
SYSTEM_ALARM* ACE in SACLPer-operation on an open handle when the operation overlaps the ACE's mask
CAAP effective SACL audit ACEThe CAAP rule applied and its audit ACE matched
PRIVILEGE_USE_SUCCESS audit policyA privilege contributed surviving bits to the granted mask
PRIVILEGE_USE_FAILURE audit policyA privilege contributed bits that were stripped by a later layer
OBJECT_ACCESS_SUCCESS audit policyThe access succeeded (granted mask non-empty), no SACL ACE match required
OBJECT_ACCESS_FAILURE audit policyThe 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 #

Six event types come out of the access-check layer and its surroundings:

Event typeWhen it firesFired by
kacs.audit.access.checkedAn SACL audit ACE matched, or a token audit_policy OBJECT_ACCESS_* flag forced itStep 14 and 14b of AccessCheck
kacs.audit.handle.usedAn operation on an open handle matched the handle's continuous audit maskThe kernel enforcement point for the operation (FACS, for file handles, is the only one today)
kacs.audit.privilege.usedA privilege contributed bits AND the token's audit_policy PRIVILEGE_USE_* requested itStep 13 of AccessCheck
kacs.caap.sacl.skippedA CAAP rule's SACL could not be parsed or evaluated, so its audit was skippedStep 14 of AccessCheck
kacs.caap.staging.divergedA staged central access policy decided differently from the effective oneStep 14 of AccessCheck
kacs.session.destroyedA logon session lost its last token referenceSession 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. evman <event-type> prints the full description of any of them, field by field.

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.

Fields are named by dotted paths, and each segment of a path is one level of nested map: access.requested is the key requested inside the map under access, and subject.token.sid is three maps deep. No key ever contains a dot. A value the kernel does not have is absent — never nil, an empty string or zero standing in for "none".

SIDs and ACEs appear as bin (binary blob) values in the msgpack — the same binary forms used in the kernel. UIDs, LUIDs, timestamps and bitmasks appear as uint values; timestamps are realtime nanoseconds. Errors are negative integers. 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 exact byte-level layout of the SIDs and ACEs is documented in Wire formats reference; this page covers the logical structure.

The subject #

The subject fields appear in every event that involves a calling principal. They identify the token under which the operation ran, and the PIP state in force:

FieldTypeMeaning
subject.token.sidbinThe token's user SID.
subject.token.groupsarray of binThe token's group SIDs.
subject.token.group-attributesarray of uintEach group's SE_GROUP_* attributes, parallel to groups: entry i describes group i.
subject.token.integrityuintThe token's integrity level (one of the well-known integrity RIDs).
subject.token.iduintThe token's own LUID.
subject.token.auth-iduintThe LUID of the logon session the token belongs to — the key that joins a record to kacs.session.destroyed.
subject.token.typestringprimary or impersonation.
subject.token.impersonationuintThe impersonation level, 0–3. A primary token reports 0, so read type first.
subject.token.uiduintThe Linux UID the token projects onto.
subject.pip.typeuintThe PIP type in force for the check.
subject.pip.trustuintThe PIP trust level in force for the check.

To see which groups the check actually matched on the allow side, keep the entries whose attributes have SE_GROUP_ENABLED set and SE_GROUP_USE_FOR_DENY_ONLY clear.

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 kacs.audit.handle.used 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 event.

The emitting process #

The same events identify the process the record was written from:

FieldTypeMeaning
emitter.process.piduintProcess ID.
emitter.process.namestringProcess executable name.
emitter.process.executablestringFull 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. The durable identifiers — the token and process GUIDs — are in the record's header, not the payload.

The object #

object.kind says what sort of thing was checked: file, process, token, socket, ipc, or a kind a userspace component named. Identity fields sit beneath it — a token check carries object.token.id and object.token.guid. Not every check can name its object yet: a kernel file check does not carry the file's path, and a process check does not carry the process's pid or GUID. Those fields are absent rather than guessed.

A daemon that checks access to its own objects through kacs_access_check names the object with an audit context, a msgpack map such as {kind: "service", service: {name: "jellyfin"}}. The kernel validates the map — the call fails with EINVAL if it is anything else — and copies it into the record as object.kind and object.service.name. A check made with no audit context has no object at all. Those values are the daemon's word rather than something the kernel observed, and so is everything else a kacs_access_check caller hands in: the security descriptor, and with it any matched trigger.ace, and any PIP type or trust it passes. So every record of a kacs_access_check or kacs_access_check_list call carries fields.attestation.userspace = true, with or without an audit context, and a record of a check the kernel makes itself never does.

kacs.audit.access.checked #

The most common event type. Fired by step 14 (SACL audit walk) and step 14b (token audit_policy). Besides the subject, the emitting process and the object:

FieldTypeMeaning
access.requesteduintThe access mask the caller requested (after generic mapping).
access.granteduintThe mask of rights actually granted.
outcome.successboolWhether the access succeeded (granted contains all of requested).
trigger.kindstringEither sacl (a SACL audit ACE matched) or policy (token audit_policy forced it).
trigger.acebinThe matched ACE bytes. Present only when trigger.kind is sacl.
fields.attestation.userspacebooltrue on every record of a kacs_access_check or kacs_access_check_list call, whose descriptor, audit context and PIP values are the caller's; absent on a check the kernel makes itself.

A consumer can use trigger.kind to filter: events from SACL ACEs versus events forced by token policy. The trigger.ace field includes the bytes of the matched ACE so a consumer can reconstruct what specific rule produced the event.

kacs.audit.handle.used #

Fired per-operation on a handle whose continuous audit mask overlaps the operation's required mask. Besides the subject (operation-time effective token, not handle-open token) and the emitting process:

FieldTypeMeaning
object.kindstringAlways file today.
object.file.pathstringThe file's absolute path.
operation.namestringThe operation name, prefixed with file. (e.g. file.permission, file.write, file.mmap).
access.requesteduintThe required mask for this operation.
access.matcheduintThe subset of access.requested that overlapped the handle's continuous audit mask.
access.granteduintThe mask cached on the handle at the time of the operation.
access.audit-maskuintThe handle's whole continuous audit mask.
outcome.successboolWhether the operation succeeded.
outcome.reasonstringWhy it failed — grant-deny, append-deny, signed-exec or unmanaged-sysfs. Present only on failure.

The access-mask fields require explanation:

  • access.requested is what this operation asks for. A read on a file requires read; an mmap requires execute or write depending on mode.
  • access.matched is the subset of access.requested that 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. access.audit-mask shows what else on the handle would fire.
  • access.granted is 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.

kacs.audit.privilege.used #

Fired at step 13 of AccessCheck for each privilege that contributed bits. Besides the subject, the emitting process and the object:

FieldTypeMeaning
privilege.namestringThe canonical privilege name (e.g. SeBackupPrivilege).
privilege.contributeduintThe bits the privilege supplied, intersected with what the caller asked for.
privilege.survivinguintThe subset of privilege.contributed that survived to the final granted mask.
access.requesteduintThe whole check's requested mask.
access.granteduintThe whole check's final granted mask.
outcome.successboolTrue if privilege.surviving is non-empty (the privilege contributed bits that made it through).
fields.attestation.userspaceboolAs for kacs.audit.access.checked.

The masks tell the full story: what the caller wanted, what the privilege tried to grant, what actually survived. A successful event has a non-empty privilege.surviving; a failed event has privilege.surviving == 0 — the privilege tried to grant bits but they were stripped.

kacs.caap.sacl.skipped and kacs.caap.staging.diverged #

The two central-access-policy diagnostics carry the subject, the emitting process and the object, and the check's access.requested, access.granted (the effective grant) and access.granted-staged (what the staged policy would have granted).

kacs.caap.sacl.skipped also names the rule whose SACL failed: caap.policy.sid, caap.rule.index, and caap.phase (effective-sacl or staged-sacl), with outcome.reason invalid-caap-sacl when the SACL would not parse and caap-sacl-evaluation-error when it parsed but could not be evaluated. The audit that rule asked for did not happen, and this record is the only sign of it.

kacs.caap.staging.diverged carries nothing else. A difference that lies in the audit records rather than the grant leaves the two masks equal; the record's existence is the signal.

kacs.session.destroyed #

Fired when a logon session loses its last token reference and is destroyed.

FieldTypeMeaning
object.session.iduintThe destroyed session's LUID.
object.session.user.sidbinThe user SID the session belonged to.
object.session.logon-typestringThe session's logon type: interactive, network, batch, service, network-cleartext, new-credentials or remote-interactive.
object.session.auth-packagestringThe auth-package name (e.g. "Kerberos", "NTLM", "local").
object.session.logon-timeuintWhen the session was created, in nanoseconds.

No subject and no emitting process: the session is identified by its ID, not by any specific principal or 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:

  1. The kernel produces an event (e.g. an audit fires during step 14).
  2. The kernel writes the event into each subscriber's KMES buffer.
  3. Userspace consumers read from their KMES buffers when they are ready.
  4. 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 kacs.audit.access.checked for compliance, just kacs.audit.privilege.used for privilege monitoring, etc.). The dotted names nest, so kacs.audit selects the whole audit trail. Filtering early reduces processing volume.
  • Correlate by subject + object. A consumer that wants to track "everything user X did to object Y" should index events by subject.token.sid and the object.* fields; subject.token.auth-id gathers everything from one sign-on.
  • Use the trigger field to distinguish event sources. A kacs.audit.access.checked with trigger.kind policy was forced by token audit policy; one with sacl came from a specific ACE. The distinction matters for some compliance scenarios.
  • Do not read asserted values as observed. A record with fields.attestation.userspace carries a userspace component's claim, about its object and the descriptor it checked against, which the kernel copied but did not verify.
  • 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-check and handle-use 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.

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 and claims), ready to authenticate users and services.
  • A set of service processes running under tokens authd minted, each with their own identity and authority.
  • An initially empty CAAP cache in the kernel. Automatic policy loading is not implemented; authd readiness does not populate it.
  • 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":

  1. Before the kernel finishes init, nothing works. The kernel is itself.
  2. 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.
  3. After prelude has run the initramfs, the system's real root filesystem is mounted. prelude switches / to it and execs the real init.
  4. 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.
  5. 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.
  6. The kernel can enforce central access policies explicitly installed through kacs_set_caap. This is not an automatic boot step: distribution is unimplemented, and references to absent policies use the recovery policy.
  7. 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 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 distribution limitation, the SYSTEM-everywhere-handoff — read authd handoff.

Optional implementation references #

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 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:

FieldValue
user_sidS-1-5-18 (the SYSTEM well-known SID)
groupsBUILTIN\Administrators (with SE_GROUP_OWNER), Everyone, Authenticated Users, Local, and the bootstrap logon SID S-1-5-5-0-999
integrity_levelS-1-16-16384 (System integrity — the highest)
mandatory_policyNO_WRITE_UP
privilegesEvery privilege defined in the catalog, all present, all enabled
token_typePrimary
impersonation_levelDelegation — the top of the ratchet, so nothing derived from SYSTEM is capped by it
auth_id0 (the SYSTEM session)
default_daclGrants 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).

FieldValue
user_sidS-1-5-7 (the Anonymous well-known SID)
groupsEveryone only
integrity_levelS-1-16-0 (Untrusted — the lowest)
mandatory_policyNO_WRITE_UP
privilegesNone
token_typeImpersonation (it exists specifically for impersonation)
impersonation_levelAnonymous
auth_id998 (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 source field, normally set to whatever component called kacs_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 999). 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 (999 for SYSTEM, 998 for Anonymous) are also fixed across boots. The logon SID derived from the SYSTEM session is S-1-5-5-0-999.

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\Administrators group 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), or
        mount-root-disk.sh
                         mounts an installed root partition (disk-boot). Their
                         packages conflict, so exactly one is present; the
                         selected hook contributes rootfs-ready.
        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. A hook must name an interpreter available when it runs; /usr/bin/sh is not a universal hook requirement. See the hook-authoring guidance for the image-specific distinction. 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 closed under its own dependencies: the build indexes it with depmod -e against the kernel's symbol table and fails if any module in it imports a symbol that neither the kernel nor another module in the set provides. That matters because a subset is exactly where an ordinary index goes quiet — depmod cannot record a dependency it cannot resolve, so a module whose provider was left out indexes cleanly and fails at boot with an unknown symbol. Every module the initramfs carries is therefore one it can load.

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 dev.peios.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 each newly observed set to modprobe. It rescans through a bounded settling window because loading one bus driver can enumerate another wave of devices — a modular USB controller revealing a USB storage device, for example — and there is no hotplug daemon in the initramfs to catch that second wave. A machine whose root sits behind a modular storage driver therefore 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 bounded one-shot and does not stay resident. It stops after the modalias set has remained unchanged for a short settling window, with an outer scan limit so broken or continuously changing hardware cannot hold the boot forever. 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:

  1. 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. It then seeds /dev with a security descriptor, which is a step with consequences well beyond the initramfs — see The descriptor prelude puts on /dev.
  2. 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 init= appears more than once the last one wins, as it does for any repeated kernel parameter, so you can override a bootloader entry by appending init=/bin/sh to it rather than rewriting the line; an init= with no value is not a value at all and is passed over, whatever its position. If the command line does not name one, prelude searches /bin/peinit2, /sbin/init, /bin/init, and finally /bin/sh. An init= does not replace that search, it goes in front of it: a named init that turns out not to be there is passed over, and prelude carries on down the list rather than failing. Only when every candidate has been tried does the boot stop. 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, so it does not rely on a /bin view.
  3. Run the hooks. prelude creates an empty /mnt/rootfs directory — 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 packaged mount-rootfs-stratafs-base.sh hook (from dev.peios.fsbase-stratafs-mount-hooks) 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).
  4. Verify the real root. After the hooks have run, prelude checks that /mnt/rootfs actually 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.
  5. 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.

The descriptor prelude puts on /dev #

Mounting /dev is the smaller half of step 1. The larger half is what prelude does immediately afterwards: it runs seed-sd -r /dev, stamping the devtmpfs root and every node already in it with a security descriptor.

That is necessary because of where devtmpfs starts. The kernel gives its root a SYSTEM-only descriptor when it initialises, which is plumbing rather than policy — it exists so that the kernel's own device thread can create nodes at all under DENY_MISSING, and SYSTEM-only is the right answer for that one job. But every node created since has inherited that single entry. On an unseeded /dev an administrator cannot list the directory, cannot open /dev/null, and cannot touch the disk an installer is about to format.

So prelude re-stamps the tree with the bootstrap descriptor: full control for SYSTEM and for Administrators, both inheritable, and an inherit-only entry for CREATOR OWNER. Nodes that appear later — a hot-plugged disk, partitions rescanned after a partition table is written — inherit that from the root rather than the kernel's.

The third entry is the one that is easy to overlook. Being inherit-only it grants nothing on the node it sits on; it applies to whatever is created beneath it, giving the creator full control of its own object. Without it a process that is not SYSTEM can create a file it cannot then read back, because the two entries above it are a non-empty ACL naming somebody else, and a token's own default is consulted only when inheritance yields nothing at all.

This descriptor is deliberately narrow, and it has to stay narrow. Whatever it grants, it grants on a raw disk nobody has met yet: a read entry for Everyone on /dev is every filesystem ACL on the machine bypassed by opening the block device. The permissive exceptions that a running system needs — /dev/null and its kin, writable by anyone — are per-node policy, and stamping them is peinit's job on the real root, not prelude's.

A failure here does not stop the boot. prelude reports it and carries on. A /dev that is still SYSTEM-only is a degraded system rather than an unbootable one — the recovery console still works there — and refusing to boot would turn a permissions defect into an outage. The console line is the only warning, so it is worth reading: it names how far the seeding got. It is tagged [ WARN ] rather than [FAILED], because the boot goes on to complete — the tag tells you whether the boot survived, and this one did — and it is not removed by peios.quiet=2.

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 while rd.shell is 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. Without rd.shell, a failure halts immediately.

    This holds all the way through the handoff. prelude settles the question the moment it reads the command line, at the end of step 1, and holds the answer for the rest of the boot — so a failure in step 5, after /proc has been carried into the new root and /proc/cmdline is no longer where it was, still opens the shell. That is the window the flag is most worth setting for: the kernel filesystems are moved, the old root may be part-emptied, and the namespace is mid-pivot. The only failures the flag cannot answer are the two that happen before the command line has been read at all — the mount-propagation call and the /proc mount itself — and prelude says so on the console rather than halting as though nothing had been asked for.

  • rd.break — stop before any hook runs. Bare rd.break pauses 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 — which includes reading the hook sequence: the breakpoint fires before prelude has looked at the manifest, so an image whose sequence is missing or unreadable still reaches the shell. That is worth knowing, because the sequence is a few lines of text at a known path, and a breakpoint shell resumes the boot when you exit it. Rewrite the file by hand and prelude reads what you wrote.

  • 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, so rd.break=mount-root.sh breaks 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, empties the in-memory root to reclaim its space, and then chdirs, chroots and execs the target init.

It is a chroot, and that is worth being exact about, because the operation this resembles — the one usually called switch_root — is not what happens. The kernel refuses to relocate a mount onto the initramfs rootfs, which rules out both MS_MOVE and pivot_root. So the initramfs rootfs does not go away: it remains the mount-namespace root, emptied and unreachable from anything running after the handoff, but present. What is reclaimed is the space its contents occupied, not the root itself.

That distinction matters to the init on the other side. peinit never assumes a clean single-root mount topology and never attempts pivot_root, for exactly this reason — see the initramfs contract in its manual, which describes the same handoff from the receiving end.

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 the real init is running there is no path back to the initramfs — its contents are gone and nothing running has a name for what remains; 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/rootfs mount 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.

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.

Note

This page is about peinit's place in the boot and trust chain — why it is PID 1, the identity it holds, and how it hands off to the rest of userspace. The full service-manager model — the service definition schema, the state machine, dependencies, supervision and restart policy, timers, jobs and operations, and the control interface — has its own topic. Start at peinit.

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:

  1. Assert PID 1. peinit refuses to run as anything else.
  2. Probe that the real root is writable, and ensure the virtual filesystems (/proc, /sys, /dev, …) are mounted.
  3. Restore the random seed, ensure the machine-id, set the clock from the RTC.
  4. Start registryd and probe the registry schema. The registry is the source of all subsequent configuration, so its daemon comes up before anything else.
  5. 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:

  1. 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).
  2. peinit forks; the child inherits a SYSTEM-derived token.
  3. peinit installs the service-specific token on the child via KACS_IOC_INSTALL.
  4. The child execs the service binary. The kernel verifies the signature and sets the PIP fields.

Note

Four jobs are sometimes attributed to peinit that it does not do. It does not populate the machine-wide TLP cache from the registry, and it does not apply per-service mitigation flags via kacs_set_psb between fork and exec. Mount-policy application (kacs_set_mount_policy) is likewise not a peinit responsibility, and neither is boot-time CAAP distribution, which is not implemented in either peinit or authd.

Service mitigation limits #

In the source-checked peinit 0.0.12, commit 0e20fef, the service-field catalog has no per-service mitigation-policy field. The child dispatch and child setup through exec install the prepared token, set up the working directory and file descriptors, and execute the service without an extra PSB mitigation request.

This reviewed launch path therefore does not apply an additional per-service mitigation mask. It does not establish that the resulting process has no mitigations: inherited flags and application self-hardening are separate. Inspect committed mitigations on the actual process and re-check replacements. This source finding does not verify the deployed release, kernel activation behavior or a running service's committed state.

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:

  1. 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.
  2. 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.
  3. 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.

What peinit does not do #

A few clarifications:

  • peinit mints only SYSTEM tokens. Minting the SYSTEM token for platform services via kacs_create_token is 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. Successful service launch is not evidence that peinit applied an additional mitigation policy: the reviewed launch path has no such setting. Inspect the running process's committed protection instead.

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 and attaches the minted tokens to it. The kernel destroys the session when its last token goes; authd keeps no per-session state, so it has nothing to clean up then. On request, it ends a session early by ending its processes.
  • CAAP distribution is not implemented. authd does not load, update or replicate the kernel's central access policies. See Distribution and recovery.
  • 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, 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 #

This step is not implemented. authd does not call kacs_set_caap or load central access policies from a registry or directory. The kernel cache starts empty; references to missing policies use the recovery policy. authd's readiness signal does not certify policy loading. See Distribution and recovery.

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:

  1. Client connects to authd's socket and presents credentials.
  2. authd verifies the credentials against the directory.
  3. authd resolves the principal's full identity (groups, privileges, claims) from the directory.
  4. authd creates a logon session via kacs_create_logon_session.
  5. authd calls kacs_create_token to mint the token, passing the wire-format specification with everything resolved.
  6. 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.
  • End sessions on request. When a person signs themselves out everywhere, or an administrator signs somebody out, a program asks authd over /run/logon.sock. authd checks the caller may, finds every process whose primary token belongs to the session, and sends each SIGTERM, then SIGKILL after a grace period, looking again for anything forked meanwhile. The kernel destroys the session when the last token goes. See Session lifecycle and PGSS §2.22.

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 or distribute CAAP. AccessCheck enforces policies installed in the kernel cache. Automatic distribution is not implemented.
  • 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.
  • Any CAAP entries explicitly installed in the kernel cache remain there. authd does not populate or update that cache.

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; restarting authd does not populate 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 cannot use authd readiness as proof that policies are loaded. Automatic distribution and a policy-readiness barrier are not implemented.
  • 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 resumes its authentication responsibilities. It does not populate 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.

Peios Learn — documentation for the Peios project.

Built with Trail.