File access
Single-page view · as markdown
File access
Peios / Peios Security Fundamentals / File access
FACS — the File Access Control Shim — is the kernel layer that applies KACS access control to files. Where the kernel's access pipeline runs against any protected object, FACS is the specific bridge between the file system (inode, dentry, page cache) and the access check. Every read, write, open, and metadata operation on a file goes through FACS at some level; FACS decides whether the operation proceeds and, when relevant, what flags or rights apply.
The model FACS uses is the handle model: AccessCheck runs once at open time, the granted mask is cached on the file descriptor that comes back, and every subsequent operation through that fd reads from the cache rather than re-running the access check. The implications of this single design choice ripple through every page in this topic.
This page covers the model at a conceptual level. Later pages cover the handle model in detail, the syscalls for opening files (native and legacy), the SD management operations, and the special cases that the model has to accommodate.
The handle model in one sentence #
Access is decided at the moment a file descriptor is opened; from then on, the descriptor carries an immutable "granted access mask" that gates every operation.
That sentence is the whole model. Once a file is open, the kernel knows what the caller is allowed to do with this file via this handle. Reads, writes, metadata queries, mmaps — each operation has a required access mask (what it needs to do its work) and succeeds only if the required mask is a subset of the cached granted mask.
The mask does not change. Adjustments to the file's DACL, to the calling token, or to anything else after the open do not affect the cached mask. The handle is a snapshot of what the access check said at open time.
The full mechanics — what gets cached, what operations consult the cache, what is and is not subject to the model — are in The handle model.
Why the handle model #
Two main reasons:
Performance. Re-running AccessCheck on every read or write would be expensive. The DACL walk plus narrowing layers plus possibly CAAP evaluation is non-trivial work. Caching the result on the handle reduces operations to a single bitmask comparison — orders of magnitude cheaper.
Consistency. A long-running operation should not change behaviour halfway through because the DACL was modified. A file being read for backup should not start failing midway because an administrator tightened the DACL; either the backup tool has the right at the start (in which case it should complete) or it doesn't (in which case it shouldn't have been able to open in the first place).
The trade-off is that an open handle survives a policy change. If you tighten a file's DACL, processes that already have the file open keep their existing access. New opens get the new policy; old handles are unchanged.
This is the check-at-open principle — covered for CAAP in Central access policies and applied uniformly here. The kernel does not retroactively revoke open handles.
Where FACS sits #
FACS lives between the file system and the rest of KACS. The arrangement:
flowchart LR
A["Userspace syscall (open, read, write)"] --> B["FACS"]
B -->|at open| C["AccessCheck"]
B -->|cached mask on fd| D["File operation"]
C --> D
D -->|persisted| E["Filesystem (ext4, xfs, etc.)"]
At open, FACS calls AccessCheck and caches the result on the fd. At every subsequent operation, FACS reads the cached mask and decides. The filesystem itself doesn't see KACS; it sees only operations that FACS has already vetted.
This means:
- The DACL and the filesystem are decoupled. FACS is what enforces KACS on files; the filesystem stores the SD as metadata (an xattr, typically) but does not interpret it.
- A filesystem can be FACS-managed or not. Mount policy determines whether FACS applies to a given mount; see Mount policies.
- Different filesystems can store SDs differently. ext4, XFS, NTFS — each has its own xattr or native mechanism for persisting the SD. FACS handles the abstraction; the access check works the same way regardless.
Two open syscalls #
The kernel exposes two ways to open a file:
| Syscall | Mode | Common use |
|---|---|---|
kacs_open | KACS-native — caller specifies an explicit desired access mask | New code; programmatic file access by services |
openat / open | Legacy — caller specifies POSIX flags (O_RDONLY, O_WRONLY, etc.) that map to access masks | Existing Linux applications |
kacs_open is the native interface. The caller specifies exactly which rights it wants and either gets all of them or fails. openat and friends are the POSIX-compatibility interface — they use the same FACS machinery internally but with a different mapping from input flags to access mask and with split "core" and "compat" semantics for handling partial grants.
Both paths converge in FACS. The cached mask on the resulting fd is the same shape regardless of which syscall produced the open.
The two interfaces are covered in detail in Opening files.
What FACS reads from where #
A summary of the data sources that feed into a FACS access check at open:
| Source | What it provides |
|---|---|
| Calling thread's effective token | Identity for AccessCheck — user SID, groups, integrity, privileges, etc. |
| File's inode | The owner SID, primary group, DACL, SACL (read via xattr or filesystem-native channel) |
| Mount policy | Whether FACS applies at all, how missing SDs are handled, the mount-level SD template |
| Process PSB | PIP fields for the dominance check during AccessCheck |
Caller's kacs_open parameters | The desired access mask, the create disposition, optionally a creator-supplied SD |
These come together at open. AccessCheck runs over them and produces the granted mask that goes on the fd.
What FACS does not do #
A few clarifications:
- FACS does not store SDs. The filesystem stores SDs; FACS reads them. Different filesystems use different mechanisms (xattrs on ext4 / XFS / Btrfs, native security streams on NTFS, mount-level synthesis on FAT/exFAT, in-memory on tmpfs).
- FACS does not validate SDs at boot. The first access to a file is when its SD is first read and validated; the kernel does not pre-scan filesystems looking for problems.
- FACS does not propagate DACL changes. Modifying a file's DACL affects future opens, not existing handles. This is the check-at-open rule.
- FACS does not gate operations that bypass the file system. A process that has a file mapped into memory via mmap can read or write that memory without going through FACS for each access — the access check happened at mmap time (or at open, then at mmap), and after that the kernel has no efficient way to intercept memory accesses. Mitigations like LSV apply at the mmap call; runtime memory access is governed by the page-table permissions the kernel set when the mapping was created.
Where to start #
If you want the handle model in detail — what is cached, what operations check the cache, when the cache can be stale, how fd transfer works — read The handle model.
If you want the open syscalls — kacs_open for KACS-native, openat for legacy compatibility, the differences in semantics and the rules for each — read Opening files.
If you want to read or modify a file's SD — kacs_get_sd, kacs_set_sd, the security_information bitmask, the rules for setting owner/DACL/SACL/label — read Managing file security.
If you want the edge cases — O_PATH, the exec dual gate, append-only files, sticky bit, POSIX ACLs that no longer work, NFS dual authority — read Special cases.
The handle model
Peios / Peios Security Fundamentals / File access
The handle model is the rule that an open file descriptor carries a fixed snapshot of access permissions, taken at the moment of open. Every operation through the fd consults this snapshot — not the file's current SD, not the calling token's current state, not anything else. The snapshot is the granted access mask, an immutable 32-bit value attached to the fd.
This page covers what the snapshot contains, which operations consult it, why immutability is the right choice, and the implications for handle transfer between processes.
What is on the fd #
When open succeeds, the kernel attaches several things to the resulting fd. The KACS-relevant pieces:
| Field | Set at | Mutable after open? |
|---|---|---|
| Granted access mask | AccessCheck at open | No |
| Continuous audit mask | AccessCheck at open (if alarm ACEs matched) | No |
| FACS-managed flag | Open path | No |
| (Filesystem state: position, mode, etc.) | open | Mutable per operation |
The granted access mask is the central object. It is whatever AccessCheck decided the caller's effective token was entitled to on this file at this moment. The bits set are the rights the fd holder has; the bits not set are rights they do not have.
The continuous audit mask is the union of all alarm ACEs that matched at open time. Every subsequent operation through the fd will check against this mask and emit a continuous-audit event if the operation's required mask overlaps it. See The SACL and Audit ACEs.
The FACS-managed flag distinguishes fds that go through the handle model from those that don't. O_PATH fds, for example, are not FACS-managed (covered in Special cases).
What operations consult the cache #
Almost every operation on a FACS-managed fd checks the cached mask. The rule: the operation has a required access mask (what it needs the caller to have been granted at open) and succeeds only if required & ~granted == 0 — every required bit is in the granted mask.
| Operation | Required access |
|---|---|
read | FILE_READ_DATA |
pread, readv, process_vm_readv (when reading from the fd as target) | Same |
write | FILE_WRITE_DATA |
pwrite, writev | Same |
| Append-only write (RWF_APPEND, O_APPEND) | FILE_APPEND_DATA (without requiring FILE_WRITE_DATA) |
ftruncate | FILE_WRITE_DATA |
fchmod | FILE_WRITE_ATTRIBUTES |
fchown | WRITE_OWNER (updates only the inert Linux uid/gid; see Special cases) |
mmap(PROT_READ) | FILE_READ_DATA |
mmap(PROT_WRITE) | FILE_WRITE_DATA |
mmap(PROT_EXEC) | FILE_EXECUTE |
fstat | FILE_READ_ATTRIBUTES |
fgetxattr | FILE_READ_EA (for ordinary xattrs; the security namespace is unconditionally denied) |
fsetxattr | FILE_WRITE_EA (same exception) |
fdatasync, fsync | None — these are control operations, not data operations |
flock, fcntl(F_SETLK) | None |
Each operation knows what it requires; the kernel compares against the cached mask and decides.
A subtle case: the operation's required mask comes from the operation's semantics, not from POSIX flags. A read() on a fd opened O_RDONLY needs FILE_READ_DATA because read needs FILE_READ_DATA, not because the fd was opened with O_RDONLY. The two coincide because open(O_RDONLY) requested and got FILE_READ_DATA, but the per-operation check is on what the operation needs, not on what the open requested.
Immutability and why #
The cached granted mask cannot change after open. The kernel does not provide a syscall to refresh it. Modifying the file's DACL does not propagate to existing fds. Adjusting the calling token's privileges does not retroactively re-grant rights on previously-opened fds.
The reasoning is two-fold:
Atomic policy. A long-running operation should not change behaviour partway through. A backup tool that opens a file at time T should be able to complete the read at time T+1 even if the DACL was modified between. Either the right to read was granted at open (in which case the read should succeed) or it wasn't (in which case the open should have failed).
Performance. Caching is the whole point. If the kernel re-evaluated AccessCheck on every read, the handle model would not give any of the performance benefit. The cache is what makes the model work.
The implication: a DACL update is observable only to new opens. Existing handles continue with the rights they had. To force a policy change to take effect, the operator must either close existing handles (or kill the processes holding them) or wait for the handles to be released naturally.
fd transfer preserves the mask #
A file descriptor can be transferred between processes — via dup, fork, SCM_RIGHTS, exec, pidfd_getfd. In every case, the transfer preserves the granted access mask exactly.
A fd passed via SCM_RIGHTS to another process gives that process the same access the originating process had through the fd. The recipient's token is not re-checked at transfer; the granted mask is what counts.
This is intentional. Capabilities are passable. A process that opened a file with broad rights can hand the fd to a helper process; the helper inherits the rights even if the helper's own token would not have granted them.
The model is: opening a file produces a capability (the fd + the cached mask), and capabilities are transferable. The transfer is the way the model expects rights to be delegated.
The corollary: a process should be careful about which fds it shares. Passing a fd to a less-trusted helper grants that helper the rights the fd carries. There is no narrowing-at-transfer mechanism.
A more restrictive variant — handing off only some of the access rights — requires opening a fresh fd with the narrower set. The original fd holder, who presumably has the broader set, can open the file again with a narrower mask and pass that fd.
fork and exec #
fork copies the file table; all fds are inherited by the child along with their cached masks. The child has the same rights on the same files as the parent at the moment of fork.
exec (with no special flags) preserves the file table; fds carry over to the new program with their cached masks intact.
exec with FD_CLOEXEC set on a fd causes that fd to be closed at exec; the child program does not inherit it. This is the standard "close on exec" mechanism, used widely. A handle that should not survive exec gets FD_CLOEXEC either at open (via O_CLOEXEC) or later (via fcntl(F_SETFD, FD_CLOEXEC)).
Within the same process, threads share the file table; a fd opened in one thread is usable from any thread in the same process.
The cached mask follows the fd everywhere the fd goes.
When the cache can be wrong #
The cached mask is a snapshot at open time. There are a small number of cases where the cache may be wrong relative to the world at large:
- The DACL was changed after open. The cache reflects the DACL at open; the on-disk DACL may now grant different rights. New opens would get the new rights; this fd still has the old. Not a bug — the model is check-at-open.
- The calling token's state changed. The token's privileges may have been adjusted, the integrity level may have changed (via NEW_PROCESS_MIN at exec, say). The cached mask reflects the token at open. Operations through this fd use the cached value, not the current token state.
- The file was deleted and replaced. An
unlinkof the path the fd was opened against does not affect the fd; the inode remains alive as long as the fd is open. The cache still reflects what was true of the now-unlinked inode. A new file created at the same path is a different inode; the cache is unaffected. - Mount policy changed. Changing the mount policy on a superblock does not retroactively affect the cached masks of fds open against files on that superblock. The cache is the snapshot; mount policy is the input to future opens.
In each case, the model's answer is the same: the cache is the truth for this fd's purposes. If you need a fresh view, close and reopen.
Operations not subject to the cache #
A handful of operations bypass the cache or use a different check:
execveat(AT_EMPTY_PATH)is the one exception in v0.20. Exec re-evaluates AccessCheck rather than using the cache. The reasoning is that exec changes the calling process's identity and PSB in ways that the cached open-time decision did not account for; running a fresh check makes the exec decision honest. This is the only v0.20 use-time access check.- O_PATH fds are not FACS-managed and have no cached mask. Operations against them either work unconditionally (
fstat) or use a fresh AccessCheck (operations that need real access). - Mapping a file's data into kernel space directly (e.g. via
splicebetween two fds) involves both fds' cached masks; neither is bypassed.
The exception list is short. For the overwhelming majority of operations, the cached mask is the gate.
Implications for processes #
Knowing the handle model has practical implications for code that handles files:
Open with exactly the rights you need. A fd opened with broader rights than needed is a broader capability than needed. If something might pass the fd elsewhere, the something gets the broader rights too. Minimum-rights opens are good hygiene.
Pass fds carefully. A fd shared via SCM_RIGHTS is a capability transfer. Audit who you pass to and what they could do with it.
Use FD_CLOEXEC for fds you don't want exec to inherit. This is also a defence-in-depth measure against accidental capability leakage to child processes.
Refreshing access requires reopening. A program that wants to see DACL changes needs to close and reopen, not poll for changes.
Where to go next #
For the two syscalls that produce these fds and stamp the granted mask, read Opening files.
For reading and writing the SD behind a handle, read Managing file security.
For the edges of the model — O_PATH, the exec dual gate, append-only handles — read Special cases.
Opening files
Peios / Peios Security Fundamentals / File access
There are two ways to open a file in Peios. kacs_open is the native interface — the caller specifies an explicit desired access mask, and the kernel either grants all of it or fails the open. openat (and the older open) is the legacy POSIX interface — POSIX flags map to access masks, and the kernel uses split "core" and "compat" semantics so that rights requested but not granted can sometimes be silently dropped without failing the open.
This page covers both syscalls, the create-disposition options, the MAXIMUM_ALLOWED mode, and the rules around the caller supplying an SD for newly-created files.
kacs_open — KACS-native #
kacs_open is the canonical Peios open syscall:
fd = kacs_open(dirfd, path, &how, sizeof(how), &status)
Where how is a kacs_open_how struct:
| Field | Meaning |
|---|---|
desired_access | The 32-bit mask of rights the caller wants. May include MAXIMUM_ALLOWED. |
create_disposition | One of SUPERSEDE / OPEN / CREATE / OPEN_IF / OVERWRITE / OVERWRITE_IF. |
create_options | Modifier flags — DIRECTORY, DELETE_ON_CLOSE. |
flags | Additional flags — AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
sd_ptr, sd_len | Optional creator-supplied SD for new files. |
The kernel:
- Resolves the path.
- Decides whether the file exists / should exist based on
create_disposition. - Runs AccessCheck with the calling token, the file's SD (or computes one for a newly-created file), and the
desired_accessmask. - Strict-mode semantics: every bit in
desired_accessmust be granted. If any requested right is not granted, the open fails with-EACCES. There is no "partial open" — either every requested bit is in the granted mask or the operation fails entirely. - On success, returns the fd with the granted mask cached. If
statuswas non-null, writes one of OPENED / CREATED / OVERWRITTEN / SUPERSEDED to it indicating what happened.
The strict-mode semantics are the right behaviour for native code. A caller that asks for read-and-write either gets both or fails; ambiguity is removed. The caller knows exactly what rights it has on the resulting fd because the mask is exactly what was requested.
The MAXIMUM_ALLOWED flag (covered below) is the exception — when set, the kernel relaxes strict mode and returns whatever rights the caller could have gotten.
kacs_open_how — fields in detail #
desired_access #
The 32-bit access mask. Standard format: object-specific bits 0–15, standard bits 16–20, special bits 24–25, generic bits 28–31. The kernel expands generic bits (GENERIC_READ, GENERIC_WRITE, etc.) using the file GenericMapping at evaluation time.
Special rights:
MAXIMUM_ALLOWED(0x02000000) — request the maximum grantable mask. See below.ACCESS_SYSTEM_SECURITY(0x01000000) — the right to read or modify the SACL. Gated by SeSecurityPrivilege.
A desired_access of zero is rejected — the kernel does not grant a fd with no rights.
create_disposition #
Defines what to do depending on whether the file exists (numeric constant values: Other constants):
| Value | If file exists | If file does not exist |
|---|---|---|
SUPERSEDE | Delete and recreate. | Create. |
OPEN | Open. | Fail with ENOENT. |
CREATE | Fail with EEXIST. | Create. |
OPEN_IF | Open. | Create. |
OVERWRITE | Truncate to zero and open. | Fail with ENOENT. |
OVERWRITE_IF | Truncate to zero and open. | Create. |
OPEN_IF is the most common ("get me access to this file, creating if necessary"). SUPERSEDE removes the inode and creates a new one — useful for atomic-replace patterns.
create_options #
Modifier flags:
| Flag | Meaning |
|---|---|
DIRECTORY | The target must be (or will be created as) a directory. If the disposition would create and this flag is set, a directory is created. If the disposition is open-only and the target is not a directory, fails with ENOTDIR. |
DELETE_ON_CLOSE | The file should be deleted when the last fd referencing it is closed. Useful for temporary files. |
flags #
Path-resolution flags:
| Flag | Meaning |
|---|---|
AT_EMPTY_PATH | The path is empty; operate on the directory referenced by dirfd. |
AT_SYMLINK_NOFOLLOW | Do not follow a terminal symlink. If the path resolves to a symlink, fails with ELOOP. |
sd_ptr / sd_len #
Optionally, the caller can supply a security descriptor for a newly-created file. The SD must be in self-relative format and within the standard size limit (65,535 bytes).
The rules for the creator-supplied SD:
- Permitted on a disposition that creates (CREATE, OPEN_IF when the file does not exist, OVERWRITE_IF when it does not exist, SUPERSEDE when it does not exist).
- Rejected on open-existing branches (OPEN, OPEN_IF when the file exists, OVERWRITE when the file exists). Supplying an SD on an existing-file path is an error (-EINVAL).
- If not supplied for a creation, the kernel synthesises an SD from the parent's inheritable ACEs and the creator's token defaults (see Inheritance).
- The caller must be entitled to set the owner SID — the same rule as
kacs_set_sd(own SID or a SE_GROUP_OWNER group, or SeRestorePrivilege to override).
status #
Optional output indicating what happened:
| Value | Meaning |
|---|---|
OPENED (1) | An existing file was opened. |
CREATED (2) | A new file was created. |
OVERWRITTEN (3) | An existing file was truncated and opened. |
SUPERSEDED (4) | An existing file was deleted and a new file was created. |
This is useful for OPEN_IF / OVERWRITE_IF dispositions where the caller wants to know what happened. For non-conditional dispositions, the answer is predictable from the disposition itself.
MAXIMUM_ALLOWED — relax strict mode #
When MAXIMUM_ALLOWED (0x02000000) is set in desired_access, the kernel changes mode:
- The
MAXIMUM_ALLOWEDflag is stripped from the desired mask. - AccessCheck runs in maximum-allowed mode (see DACL evaluation).
- The full DACL is walked, accumulating every right the caller could have been granted.
- The result is returned as the cached granted mask. It is not compared to the desired mask for strict-mode purposes.
Using MAXIMUM_ALLOWED, the open succeeds with whatever rights the caller could have gotten, including possibly fewer than what they "asked for". The other bits in desired_access are treated as a hint about what the caller would want — the kernel still evaluates them — but the call does not fail if the caller's actual rights are narrower.
The flag exists for tools that want to do "open with whatever access I can get" rather than "open with exactly these rights". A backup tool, for example, might want to open every file it encounters with whatever access is available rather than refusing the file because it cannot get write.
MAXIMUM_ALLOWED must be combined with at least one concrete data or execute bit. Calling with MAXIMUM_ALLOWED alone is rejected with -EINVAL — the kernel needs to know that some operation is intended, even if the specific rights are flexible.
openat (legacy) — POSIX-compatibility #
The legacy openat (and the older open) takes POSIX flags and maps them to access masks. The mapping is:
| POSIX flag | Maps to |
|---|---|
O_RDONLY | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE (the read category from GENERIC_READ) |
O_WRONLY | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE |
O_RDWR | O_RDONLY mapping ∪ O_WRONLY mapping |
O_APPEND | Adds FILE_APPEND_DATA |
O_TRUNC | Requires FILE_WRITE_DATA |
O_CREAT | Sets the disposition to OPEN_IF (with the FILE_ADD_FILE right on the parent) |
O_EXCL | With O_CREAT, switches the disposition to CREATE (fail if exists) |
O_PATH | Special — see Special cases. The fd is not FACS-managed. |
O_NOFOLLOW | Sets AT_SYMLINK_NOFOLLOW. |
So openat(O_RDONLY) maps to "open with FILE_READ_DATA + the rest of the read category". The kernel then runs AccessCheck for that mask.
Core vs compat rights #
The wrinkle: legacy openat uses a core vs compat split. The mapped rights are partitioned into:
- Core rights — the rights that the operation fundamentally needs. If any core right is denied, the open fails.
- Compat rights — rights that are conventionally granted with this POSIX flag but are not strictly necessary. If any compat right is denied, it is silently dropped from the cached mask — the open still succeeds, just with a narrower grant.
For O_RDONLY, the core right is FILE_READ_DATA (you cannot read without it). The compat rights are FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE — these are convenient to have, but a read-only open is still meaningful without them.
The result is that legacy open(O_RDONLY) succeeds when FILE_READ_DATA is granted, even if the other read-category rights are not. The fd ends up with the granted subset cached.
This is the difference from kacs_open. kacs_open(FILE_READ_DATA | FILE_READ_ATTRIBUTES) requires both and fails if either is denied; open(O_RDONLY) requires FILE_READ_DATA and silently drops FILE_READ_ATTRIBUTES if it is denied.
The reasoning: POSIX applications expect open to succeed with reasonable defaults. Failing because a niche right was not granted would break compatibility. The split lets the application succeed with the rights it really needs and treat the others as bonuses.
Why kacs_open exists #
If legacy openat works fine for POSIX applications, why have kacs_open?
Two reasons:
Precision. New code that knows what rights it needs should be able to request them explicitly. kacs_open lets a service say "I need exactly these rights" and either get them or be told they are not granted. There is no silent narrowing.
Direct SD provision. kacs_open lets the caller pass an explicit SD for a newly-created file. Legacy openat does not — POSIX open(O_CREAT) uses umask-based defaults plus the parent's inheritable ACEs, with no path for the caller to specify a custom SD.
For most application code, legacy openat is the right tool. For services that care about exact-rights or that need explicit-SD-at-creation, kacs_open is the answer.
Errors #
Both syscalls can fail with:
| Error | Cause |
|---|---|
-EACCES | Strict-mode access check failed (kacs_open), or core rights denied (legacy). Or a related path component is unreachable. |
-EEXIST | The disposition was CREATE and the file already exists. |
-ENOENT | The disposition was OPEN or OVERWRITE and the file does not exist. |
-ENOTDIR | KACS_CREATE_OPT_DIRECTORY was set but the target is not a directory; or a path component is not a directory. |
-ELOOP | AT_SYMLINK_NOFOLLOW was set and the path resolves to a symlink. |
-EINVAL | Various — MAXIMUM_ALLOWED with no concrete bits, an invalid create_disposition, an SD on an open-existing branch, etc. |
-EBADF | The dirfd is invalid. |
The error code tells you what went wrong. The kernel does not give cryptic codes; each maps to a specific named failure mode.
See also #
- The handle model — what the cached mask these opens produce actually does.
- Managing file security — reading and writing the SD behind a handle.
- DACL evaluation — the maximum-allowed walk MAXIMUM_ALLOWED relies on.
Managing file security
Peios / Peios Security Fundamentals / File access
Reading and writing a file's security descriptor is done through two dedicated syscalls — kacs_get_sd and kacs_set_sd — not through the xattr layer. Direct xattr access to the SD storage (the security.peios.sd or system.ntfs_security xattr) is unconditionally denied; the only way through is the syscall pair.
This page covers the syscalls, the security_information bitmask that decides which SD parts are accessed, the access rules, and the special LABEL_SECURITY_INFORMATION flag for setting the integrity label specifically.
Why the xattr layer is denied #
You might expect that the SD, stored in an xattr, would be readable and writable through the standard getxattr / setxattr syscalls. It is not. The kernel refuses every xattr operation on the SD xattr regardless of who is asking and what they hold.
The reasoning:
- Atomic semantics. Reading or writing the SD via xattr would expose the raw bytes; tools could read a partial SD (during a write by someone else), or write an SD whose internal structure is inconsistent with the file's other state.
- Access rule unification. The SD has its own access rules —
READ_CONTROLto read,WRITE_DAC/WRITE_OWNER/ACCESS_SYSTEM_SECURITYto write different parts. Routing throughkacs_get_sd/kacs_set_sdputs these rules in one place; routing through xattr would require duplicating them at the xattr layer. - Format flexibility. Different filesystems store the SD differently. The syscall abstraction lets the kernel translate; the xattr layer would force a specific format.
So the syscall pair is the only path. Reading or writing the SD goes through them; xattr operations on the SD xattr are denied.
kacs_get_sd #
The read syscall:
size = kacs_get_sd(dirfd, path, security_info, buf, buf_len, flags)
Returns the SD bytes for the requested components.
| Parameter | Meaning |
|---|---|
dirfd, path | The file to read. Standard dirfd-relative resolution. |
security_info | A bitmask saying which components to return. See below. |
buf, buf_len | Output buffer. |
flags | AT_EMPTY_PATH (use dirfd as fd-relative), AT_SYMLINK_NOFOLLOW (don't follow terminal symlink). |
The kernel:
- Resolves the path.
- Runs AccessCheck — the caller needs the access rights corresponding to the requested components (covered below).
- Reads the file's SD from its filesystem-native storage.
- Constructs a subset SD containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
- Returns the subset SD in
buf, with the total length as the return value.
Probe mode #
Calling with buf_len = 0 (or buf = NULL) is a probe — the kernel computes the size the SD would take and returns it without writing to the buffer. The probe returns the same size value that a non-probe call would; the caller can use this size to allocate exactly the right buffer.
The probe call always returns the size on success. It does not return -ERANGE — the probe is itself a question about size, and answering it is the kernel's job. -ERANGE would be the wrong signal for a deliberate probe.
A non-probe call with buf_len < required returns -ERANGE with the required size written somewhere accessible (typically the same length parameter, or via a separate output). The caller can then reallocate and retry.
Access rules #
Different components need different rights:
Requested component (via security_info flag) | Required right |
|---|---|
OWNER_SECURITY_INFORMATION | READ_CONTROL |
GROUP_SECURITY_INFORMATION | READ_CONTROL |
DACL_SECURITY_INFORMATION | READ_CONTROL |
SACL_SECURITY_INFORMATION | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | READ_CONTROL (the integrity label is in the SACL but its read is gated by READ_CONTROL, not ACCESS_SYSTEM_SECURITY) |
(The numeric flag values are catalogued in Other constants.)
READ_CONTROL is the standard "read SD" right and is implicitly granted to the owner. ACCESS_SYSTEM_SECURITY is gated by SeSecurityPrivilege — the SACL is read-restricted to administrators with the privilege.
A caller asking for components they do not have rights for gets -EACCES. A caller asking for a mix can succeed for the components they can access — but the kernel does this as an all-or-nothing operation: if any requested component fails its access check, the whole call fails.
For combining requested components: just OR the flags. kacs_get_sd(..., OWNER | DACL, ...) returns owner and DACL but not SACL or group.
SACL and LABEL are mutually exclusive #
SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined in one call. Setting both flags returns -EINVAL. The reasoning: LABEL_SECURITY_INFORMATION is a focused query for just the integrity label (which lives in the SACL); it has different access requirements than reading the full SACL. The kernel keeps the two paths separate.
kacs_set_sd #
The write syscall:
result = kacs_set_sd(dirfd, path, security_info, sd_buf, sd_len, flags)
| Parameter | Meaning |
|---|---|
dirfd, path | Target file. |
security_info | Which components to update. |
sd_buf, sd_len | The new SD bytes (self-relative format). |
flags | AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
The kernel:
- Resolves the path.
- Parses the SD blob. Rejects malformed SDs (size limit, bad ACL structure, etc.) with
-EINVAL. - Runs AccessCheck for the required rights per the components being updated.
- Validates additional rules — owner SID is the caller's own or a SE_GROUP_OWNER group (unless SeRestorePrivilege), MANDATORY-flagged resource attributes are not removed (unless SeTcbPrivilege), integrity label is not raised above caller's own (unless SeRelabelPrivilege).
- Writes the SD to the file's native storage.
- Returns 0 on success.
The write is atomic: either all requested components are updated or none are.
Access rules for writes #
| Component | Required right |
|---|---|
| Owner | WRITE_OWNER (plus the owner SID validation) |
| Group | WRITE_OWNER |
| DACL | WRITE_DAC |
| SACL | ACCESS_SYSTEM_SECURITY |
| LABEL (integrity label only) | WRITE_OWNER (plus integrity constraint) |
WRITE_DAC is the standard "modify DACL" right, implicitly granted to the owner. WRITE_OWNER is needed to change the owner field (and the validation rules apply per Ownership). ACCESS_SYSTEM_SECURITY is the SACL gate.
The integrity label #
LABEL_SECURITY_INFORMATION (0x10) is the focused write path for setting just the integrity label. The label lives in the SACL as a SYSTEM_MANDATORY_LABEL_ACE, but setting it via this flag is treated as a separate operation from setting the full SACL — with different access requirements:
- The right needed is
WRITE_OWNER, notACCESS_SYSTEM_SECURITY. - The caller cannot raise the integrity label above the calling token's own integrity level (without
SeRelabelPrivilege). - Lowering the integrity label to at or below the caller's own integrity level is allowed.
LABEL_SECURITY_INFORMATION and SACL_SECURITY_INFORMATION cannot be combined in one call — same rule as for reading.
The use case: a process that wants to lower its files' integrity labels without holding SeSecurityPrivilege. The label is in the SACL conceptually, but setting it gets the WRITE_OWNER gate rather than the SACL gate, because adjusting the label down is a less sensitive operation than rewriting the audit policy.
SD parsing and validation #
The provided SD blob must be:
- In self-relative format (
SE_SELF_RELATIVEflag set in control bits). - Within the 65,535-byte size limit.
- Internally consistent — the offsets in the header point to valid locations, the ACLs parse cleanly, the SIDs are well-formed.
Any failure of validation returns -EINVAL. The original SD on the file is unchanged.
Setting only some components #
The security_information flags tell the kernel which components of the provided SD to apply. A blob containing owner + DACL with only DACL_SECURITY_INFORMATION set updates only the DACL; the file's existing owner is preserved.
The blob structure must still be valid — components not being applied are typically absent (offset 0, PRESENT bit clear) in the blob, but the blob's header still needs to be a valid SD header.
This is the pattern for updating one part of an SD without touching the others. Read the SD (probe + fetch), update the relevant component, write back with only that component's flag set.
Ownership transfer rules #
Setting the owner via kacs_set_sd triggers the rules described in Ownership:
- The new owner must be the caller's own user_sid, or a SID in the caller's groups with
SE_GROUP_OWNERset, or the caller must holdSeRestorePrivilege. - The caller must have
WRITE_OWNERon the object, or holdSeTakeOwnershipPrivilege.
A failure here returns -EACCES.
The same rules apply whether you set ownership via OWNER_SECURITY_INFORMATION alone or in combination with other components.
What about file mode (the POSIX rwx bits)? #
The traditional POSIX file mode — chmod-style read/write/execute bits — does not exist in the same way under FACS. The Linux mode bits are stored alongside the SD as ordinary inode metadata, but they are not consulted by FACS for access control (except the execute bit as an exec prerequisite). The DACL is what decides access; the mode is informational only.
The mode-changing syscalls still work, gated on SD rights:
fchmod()succeeds only if the fd's granted mask includesWRITE_DAC(legacy opens request it as a compat right); otherwise-EACCES(-EBADFon O_PATH fds).chmod()runs a fresh access check requiringWRITE_DACon the file's SD.- In both cases the mode bits are updated but the SD is untouched. To change what actually decides access, call
kacs_set_sdwith a new DACL.
The mode is inert metadata; the SD is the truth.
Errors #
Common errors from both syscalls:
| Error | Cause |
|---|---|
-EACCES | Access check failed for the requested components. |
-EINVAL | Malformed SD blob, invalid security_information combination, size limit exceeded, or other validation failure. |
-EPERM | Owner SID validation failed without SeRestorePrivilege, or integrity label too high without SeRelabelPrivilege, or attempted MANDATORY attribute removal without SeTcbPrivilege. |
-ERANGE | (kacs_get_sd only) Buffer too small; required size returned. |
-ENOENT | The target path does not exist. |
-ELOOP | AT_SYMLINK_NOFOLLOW and the path is a symlink. |
Most failures are diagnostic and clear from the error code.
See also #
- Ownership — the owner-transfer rules kacs_set_sd enforces.
- The sd command — the shell wrapper for these syscalls.
- Opening files — supplying an SD at file creation instead.
Special cases
Peios / Peios Security Fundamentals / File access
The handle model handles most of file access cleanly. But files have edges — operations that bypass the model, semantics that don't fit, filesystems whose behaviour FACS cannot fully control. This page covers those edges.
Each of the special cases below is something that someone debugging FACS behaviour will eventually hit. Knowing the rules ahead of time keeps the gotchas from being surprises.
O_PATH — fds without a cached mask #
O_PATH is a Linux flag for opening a file in a "path-only" mode. The fd that comes back can be used to refer to the file (for openat(dirfd, ..., 0, fd), for fstatat, for path manipulation) but cannot be used for actual data operations.
Under FACS, an O_PATH open does not run a full AccessCheck. The fd is not FACS-managed; it has no cached granted mask. Operations that would normally consult the cache either work unconditionally (because they don't need any access) or use a fresh live AccessCheck (because they do):
| Operation on O_PATH fd | Behaviour |
|---|---|
fstat, fstatat | Works without access check. The fd is a path reference; getting stat info is allowed. |
openat using the fd as dirfd | Normal — runs AccessCheck for the new open. The dirfd's O_PATH status doesn't affect the new fd. |
kacs_get_sd with AT_EMPTY_PATH | Runs a live AccessCheck (the fd has no cached mask to consult). |
kacs_set_sd with AT_EMPTY_PATH | Same — live AccessCheck. |
read, write, mmap | Denied with EBADF. The fd cannot be used for data operations. |
fchmod, fchown, fgetxattr, fsetxattr, ioctl | Denied with EBADF. |
O_PATH is useful for path manipulation — keeping a reference to a directory while traversing the tree, or referring to a file by fd rather than by path. For these uses, the lack of a cached mask is irrelevant.
The catch: a process that wants to do anything with an O_PATH fd beyond path navigation needs to reopen it. The reopen is a fresh access check that decides what the resulting fd can actually do.
The exec dual gate #
Exec is the only operation in v0.20 that performs a use-time access check rather than relying on the cached mask. The reasoning: exec changes the process's identity (via the new binary's PIP) and effectively replaces it; the open-time decision is no longer the right one to use.
Specifically, execveat(AT_EMPTY_PATH) on an open fd:
- Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
- Requires both the
+xmode bit on the file andFILE_EXECUTEon the caller's access to be present.
The two checks answer two different questions:
+xon the file means this file is intended to be run as a program. It is a property of the file itself, set by whoever owns it — the file's author saying "this is an executable, not data". A file without+xis data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.FILE_EXECUTEon the access means this caller is allowed to execute this program. It is an access decision against the DACL — the question of whether this principal, on this token, is authorised to run this binary.
Both have to be true for exec to proceed. A data file (no +x) is not runnable by anyone; an executable file the caller is not authorised for is runnable in principle but not by this caller.
The dual gate is the v0.20 exception to the handle model. The reason for live evaluation here is that exec's consequences are large enough — the binary that runs is what the kernel will then trust at the verified PIP level — that running on a stale cache is the wrong default.
mmap(PROT_EXEC) is different from exec. It uses the cached mask (FILE_EXECUTE from the open), and additionally runs the LSV mitigation if enabled. mmap is not the same as exec; the dual-gate-with-live-check applies only to exec specifically.
Append-only handles #
A handle opened with FILE_APPEND_DATA granted but not FILE_WRITE_DATA is an append-only handle. The kernel enforces this strictly:
write()at the current offset is allowed only if the current offset is at end-of-file. Otherwise denied.pwrite()at any offset other than end-of-file is denied (RWF_APPEND-style).- Mapping the file shared writable (
mmap(MAP_SHARED, PROT_WRITE)) is denied. ftruncateto extend the file is denied (cannot rewind via truncate).fallocatemodes that would mutate (FALLOC_FL_PUNCH_HOLE, FALLOC_FL_COLLAPSE_RANGE, FALLOC_FL_ZERO_RANGE) are denied.
Operations that legitimately only append — POSIX write with O_APPEND, RWF_APPEND writes — work normally. The kernel makes sure the data lands at end-of-file.
The use case: log files that should be append-only. A logger holds a handle with FILE_APPEND_DATA but not FILE_WRITE_DATA; it can write log lines but cannot rewrite earlier ones. Even if the logger is compromised, the existing log entries are safe from modification through this handle.
Append-only is the FACS expression of the "secure log" pattern. It exists at the right-mask level, not as a separate file attribute.
sticky bit — no effect under FACS #
The traditional Unix sticky bit on a directory restricts file deletion within: only the owner of a file (or the owner of the directory) may unlink files in a sticky-bit directory. This is what makes /tmp work.
Under FACS, the sticky bit has no effect. Deletion is gated by FILE_DELETE_CHILD on the parent directory's SD. The sticky bit's semantics are not encoded.
The reason: FACS's access model is comprehensive enough that the sticky bit is unnecessary. A /tmp directory's DACL grants the directory's FILE_DELETE_CHILD to the appropriate principals (typically just the owner or the file owner, mediated through OWNER RIGHTS); this is the same effect the sticky bit had, expressed in the DACL.
For /tmp specifically, the Peios-default DACL provides equivalent semantics: each user can create files in /tmp (FILE_ADD_FILE granted to Authenticated Users), each file's owner can delete their own (FILE_DELETE_CHILD inherited per-file from OWNER RIGHTS ACEs), but a user cannot delete another user's files.
A directory whose mode includes the sticky bit (visible to ls -l as a t) is informational; the mode is derived from the SD by FACS. The bit can be set but has no enforcement consequence.
POSIX ACLs — replaced by KACS #
The Linux POSIX ACLs (set via setfacl and stored as system.posix_acl_access and system.posix_acl_default xattrs) are not honoured by FACS. They are replaced by KACS DACLs.
Specifically:
- Writes to
system.posix_acl_accessandsystem.posix_acl_defaultxattrs are unconditionally denied. - Existing POSIX ACL xattrs on files (carried over from a pre-Peios system, say) are ignored. FACS uses the KACS DACL exclusively.
The kernel will not silently translate POSIX ACLs to KACS DACLs. Migrating from a Linux system with POSIX ACLs requires re-writing the SDs in KACS form. Tools that can do this conversion exist in the migration tooling; the kernel does not do it on the fly.
fchown — gated on WRITE_OWNER #
The legacy fchown syscall changes a file's owner UID/GID. Under FACS, the family is permitted but gated on SD rights:
fchown()succeeds only if the fd's granted mask includesWRITE_OWNER; otherwise-EACCES.chown()andlchown()run a fresh access check requiringWRITE_OWNERon the file's SD.
The Linux uid/gid change does not alter the SD's owner SID. Changing the real owner goes through kacs_set_sd with OWNER_SECURITY_INFORMATION — the KACS semantics for ownership (covered in Managing file security) replace the legacy mode-and-owner duo.
The kernel surfaces the file's owner SID's projected UID as the result of stat-style queries, so ls -l shows a UID. But the UID is derived from the owner SID; setting it via fchown is not the way.
NFS — dual authority #
NFS client mounts are a special case. The underlying filesystem is on a remote server; the server enforces its own access control independent of what the client thinks. FACS on the client side cannot reach into the server's authorisation; it can only express its local view.
The pattern Peios uses: NFS client mounts are configured with the synthesize_ephemeral mount policy. The client synthesises a local SD for each file accessed (per the mount template), runs AccessCheck locally, and either authorises the operation or denies it. If authorised, the operation is forwarded to the server, which may then deny it for its own reasons.
The consequence: a locally-authorised open can still produce I/O errors when the server refuses. A caller may successfully open(O_RDONLY) against an NFS file (FACS's local synthesis decided to grant) but the read() returns -EACCES from the server.
This is "dual authority": the client and the server both have a say, and both have to permit. The client's denial is final on the client side; the server's denial is final on the server side. There is no single source of truth for what is allowed.
Other implications:
- Expect I/O errors from server-side denial. A successful open does not guarantee a successful read. Handle
EACCES(orEIO) from the operation, not just from the open. - The synthesised SD is local. Changes to the file's actual SD on the server are not visible to FACS's local synthesis. Tools that want to inspect the real SD need to query the server.
NFS server mounts (where Peios is the server) work the other direction: FACS enforces the SD on the local files; the protocol exposes it. The remote client's view of what is allowed is whatever the protocol negotiates, which may be limited by NFS-protocol-level restrictions but is ultimately decided by FACS.
/proc and /sys #
/proc and /sys are kernel-managed pseudo-filesystems. They are not FACS-managed in the standard sense — their mount policy is unmanaged, which means the FACS handle model does not apply.
What does happen:
- File operations under
/proc/<pid>/*route through the process's PSB / process SD checks (the two-check rule from PIP). Access is decided per-operation against the target process. /sys/kernel/security/kacs/*files have explicit SDs on them (set up by the kernel) and are accessed via the kernel's own check logic.- General
/procfiles (/proc/uptime,/proc/cpuinfo) are world-readable by convention. /syswrites are restricted toBUILTIN\AdministratorsandSYSTEMby hardcoded rule.
The result: operations on /proc and /sys work, but they don't go through the FACS handle model. The cached mask on the fd from opening one of these files is meaningless; the per-operation check is what gates access.
This is why cat /proc/self/status produces output without any apparent FACS involvement — the access is granted by the kernel's /proc-specific rules, and the kernel just makes data available.
The mount-policy classes are covered in Mount policies. unmanaged is the special class for kernel-managed filesystems; it cannot be set via the public ABI.
Whiteouts in renameat2 #
renameat2(RENAME_WHITEOUT) — the Linux-specific flag for creating a "whiteout" entry as part of a rename (used by overlay filesystems) — is supported on FACS-managed filesystems. The rename and the whiteout it leaves behind happen as one atomic operation, exactly as on stock Linux.
A whiteout is a chrdev(0,0) sentinel that overlay filesystems drop at the old name to mask a file in a lower layer. FACS authorises it the same way it authorises any new node: you need FILE_ADD_FILE on the source directory (where the whiteout lands), in addition to the usual rename rights. If you lack that right the whole rename is denied before anything is created, and the whiteout — like every freshly created node — inherits a security descriptor from its parent directory and is recorded in the audit trail.
This matters if you run overlayfs or union-style filesystems on FACS-managed storage: they work without the partial-support caveats earlier previews carried.
Summary table #
The special cases at a glance:
| Case | What is different |
|---|---|
| O_PATH | No cached mask; data operations denied; live check for SD ops |
| exec via execveat | Live AccessCheck plus Linux +x mode bit |
| Append-only handles | Write-at-end only; mmap-shared-writable and truncate denied |
| Sticky bit | No effect — the DACL is the gate |
| POSIX ACLs | Replaced by KACS; xattr writes denied |
| fchown / fchmod | Gated on WRITE_OWNER / WRITE_DAC; the mode bits are inert — the SD is the truth |
| NFS client | Dual authority; local FACS plus remote enforcement |
/proc and /sys | Unmanaged mount policy; per-operation check by kernel-specific rules |
| renameat2 RENAME_WHITEOUT | Supported; atomic, needs FILE_ADD_FILE on the source directory, whiteout inherits an SD |
Each is a deliberate decision. Some reflect Linux compatibility (POSIX ACLs replaced); some reflect security policy (exec dual gate, append-only enforcement); some reflect the model's limits (NFS dual authority). Knowing them ahead of time keeps them from being surprises.
Where to go next #
For the model these cases are edges of, read The handle model.
For how a mount's policy decides whether FACS applies at all, read Mount policies.
For the SD read/write syscalls referenced throughout, read Managing file security.