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

Access Decisions

Single-page view · as markdown

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.

Peios Learn — documentation for the Peios project.

Built with Trail.