3.9.5 File Descriptor Storage
3.9.5.1 Xattr protection #
FACS intercepts every raw xattr operation on the canonical descriptor
xattr — security.peios.sd, or system.ntfs_security on NTFS — and
denies all three directions.
Writes are denied: all modification goes through the set-security
interface. Removal is denied: a descriptor is never detached from
a file. And reads are denied, which is the least obvious of the
three and the most important. The raw xattr holds the entire
descriptor including the SACL, so allowing a read under
READ_CONTROL alone would leak SACL content that properly requires
ACCESS_SYSTEM_SECURITY. All reads go through kacs_get_sd, which
distinguishes the two.
3.9.5.2 Caching #
A validated, parsed descriptor object is cached in the inode's LSM blob, holding immutable self-relative bytes together with a prevalidated component layout — enough for AccessCheck readers never to reparse untrusted storage bytes.
Readers on the AccessCheck path use the RCU-published pointer. Once a current entry exists, a reader does not take the inode mutex merely to run AccessCheck. It either completes the evaluation inside an RCU read-side critical section, or pins the object with a refcount while still under RCU and drops the RCU lock before doing anything that can allocate, sleep or emit an audit event. A pin is acquired with a non-zero refcount check and dropped afterwards.
Writers allocate a new object, swap the pointer atomically, and free the old one after a grace period and after reader pins have drained. No partial read is possible.
Population is lazy, on first access. The xattr is read through an internal kernel path that bypasses the read-denial hook, and the parsed result is installed by compare-and-swap; a thread that loses the race frees its own copy.
Eviction frees the cached descriptor when the inode is evicted, through an RCU-safe callback with the same pin draining, so in-flight permission checks complete before the object goes away.
Invalidation happens on write, but not atomically with it. The
set-security path deliberately releases the inode security lock across
the xattr write and re-acquires it afterwards to publish the new
parsed object, because holding it across the write would invert the
i_rwsem ordering the access path requires. Two concurrent
set-security calls on one inode are therefore last-writer-wins rather
than serialised end to end, and there is a window in which the xattr
and the cache disagree. Readers are never blocked, and no reader sees
a partially written object — the exposure is which of two racing
writes lands, not a torn state.
3.9.5.3 Mount policy classes #
The superblock policy object carries the class (§3.9.1) and, for synthesise-class mounts, an optional mount-level default template.
The default classifier maps from the superblock's filesystem magic.
PROC_SUPER_MAGIC and SYSFS_MAGIC are unmanaged: these expose
kernel state through inode-shaped handles with no on-disk identity and
no descriptor to consult. NULL_FS_MAGIC is unmanaged too — nullfs is
the immutable, permanently empty filesystem the kernel mounts as the
mount-namespace root, with the mutable rootfs mounted on top of it. It
declares no xattr support at all, so it can never carry a descriptor,
and its single root inode is immutable and childless: nothing to stamp
and nothing to protect.
STRATAFS_SUPER_MAGIC is fixed at facs_deny_missing for the
superblock's lifetime, because StrataFS delegates every check to
current provider objects and must never synthesise a descriptor for
its merged namespace. An attempt to change it fails with
EOPNOTSUPP.
RAMFS_MAGIC, NFS_SUPER_MAGIC, MSDOS_SUPER_MAGIC,
EXFAT_SUPER_MAGIC, ISOFS_SUPER_MAGIC and CGROUP2_SUPER_MAGIC are
facs_synthesize_ephemeral — either no persistent backing at all,
or storage with no native descriptor slot.
Everything else, including TMPFS_MAGIC, SQUASHFS_MAGIC,
EXT4_SUPER_MAGIC and BTRFS_SUPER_MAGIC, defaults to
facs_deny_missing. These can all carry the descriptor xattr
natively and are expected to on every inode that participates in
access checks.
TMPFS_MAGIC covers both userspace tmpfs mounts and the kernel-mounted
instances established before any userspace runs. The latter are not
exempt from the default; they are handled by seeding.
3.9.5.4 Kernel-internal mounts #
Two filesystems are mounted by the kernel before any userspace process
exists and before anything can call kacs_set_mount_policy or
kacs_set_sd: the mutable root filesystem mounted by
init_mount_tree, a tmpfs mounted on top of the immutable nullfs
namespace root and made / by set_fs_root; and the devtmpfs
instance mounted by devtmpfs_init and populated by the kdevtmpfs
thread.
Both are TMPFS_MAGIC and therefore facs_deny_missing, and their
root inodes are kernel-created, never passing through a
userspace-supplied artifact, so they carry no descriptor at the moment
they become reachable. To make the class viable the kernel seeds one.
The rootfs root is seeded inside init_mount_tree, immediately after
vfs_kern_mount returns and before the mount is published into
init_mnt_ns, with the inode's i_rwsem held. The devtmpfs root is
seeded inside devtmpfs_init, after vfs_kern_mount and before
kdevtmpfs starts, likewise under i_rwsem. The nullfs root is not
seeded — it is unmanaged, empty, and incapable of xattr storage.
The seeded descriptor is byte-for-byte identical in both places: owner
and group SYSTEM (S-1-5-18), a DACL of one ACCESS_ALLOWED ACE
granting GENERIC_ALL to SYSTEM flagged
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE so that inheritance
derives a child descriptor for every inode created on the mount
afterwards, and no SACL.
The writes go through the kernel-internal xattr path, bypassing both the FACS denial hooks and the LSM setxattr permission hook. They consult no token — at the point either runs there may be no meaningful subject — and the seeded descriptor is the sole authority for the mount until trusted userspace replaces it. They depend on nothing beyond the LSM scaffold that allocates inode and superblock blobs.
These are not exempt from later management: trusted userspace can overwrite the per-inode descriptor or change the superblock's class once it holds the privileges.
3.9.5.5 Boot artifacts #
A filesystem shipped as a boot artifact — a squashfs concatenated into
the initrd, a vendor squashfs delivered as a package, a flashed
partition image — defaults to facs_deny_missing and the kernel does
not seed it. These are already-populated trees the kernel cannot
extend at mount time, and in the read-only case cannot extend at all.
The obligation falls on the build pipeline, which emits
security.peios.sd on every inode ordinary access checks will reach.
mksquashfs, the ext utilities and the standard userland xattr
surfaces all preserve security.* natively.
An artifact without descriptors is a packaging defect. FACS treats
every missing descriptor on a facs_deny_missing mount as a
corruption indicator and denies. The operator path is to rebuild the
artifact, or to adopt the superblock under a synthesise class.
3.9.5.6 Administration #
Trusted userspace adopts a mounted filesystem by calling
kacs_set_mount_policy on a descriptor naming any object on the
target superblock; O_PATH descriptors are valid targets. The change
applies to the superblock, not the pathname used to reach it.
The call requires enabled SeTcbPrivilege and marks it used. The
public ABI accepts only the three managed classes; unmanaged,
unknown values, nonzero reserved flags and malformed arguments all
fail closed.
The optional template is accepted only with a synthesise class. It is
a complete self-relative descriptor rather than a subset, passes
structural validation, and is at most 65535 bytes. A null pointer with
zero length clears it. Setting facs_deny_missing clears it and
rejects non-empty template input. Pointer and length mismatches and
invalid bytes fail before any state changes.
Policy changes are lazy. They do not walk the filesystem and do not stamp anything. The superblock carries a monotonic generation counter, incremented on every successful policy or template replacement. Missing-descriptor, ephemeral-synthetic and not-yet-written-back persistent-synthetic cache entries record the generation they came from and are discarded and repopulated when it changes. Xattr-backed and corrupt-descriptor caches are not made valid by a policy change, and open file descriptions keep their immutable masks.
3.9.5.7 Missing descriptors #
Under facs_deny_missing, no descriptor means deny. Two
exceptions keep the repair path open. SeChangeNotifyPrivilege
bypasses intermediate traverse checks including on directories with no
descriptor, though not explicit chdir(), chroot() or fchdir()
use-time checks. And O_PATH opens bypass the open hook entirely, so
a file with a missing descriptor can still be acquired as an O_PATH
reference — which is exactly the repair route: open(path, O_PATH)
then kacs_set_sd with AT_EMPTY_PATH under SeRestorePrivilege.
Under the synthesise classes, a missing descriptor is generated
from two sources in order. First, inheritance from the parent: if
the parent has one, the inheritance algorithm runs as though a new
file were being created. Otherwise the mount-level template,
applied where there is no parent descriptor — typically only at the
mount root. With no template configured, the fallback grants
GENERIC_ALL to SYSTEM and BUILTIN\Administrators and
GENERIC_READ | GENERIC_EXECUTE to Everyone, owned by SYSTEM with
SYSTEM as group.
Because these files already exist, the accessor is not their creator. Where inheritance needs creator inputs — owner, primary group, default DACL — a synthetic system-policy creator supplies them: the template's owner, group and DACL if one exists, the fallback's otherwise. The accessor's token never affects the synthesised descriptor, and the synthesis path takes no subject token at all.
Inheritance is recursive — a parent whose own descriptor is missing is
synthesised first, walking toward the mount root where the template
terminates it. The walk is bounded at 32 ancestor levels; a target
nested deeper than that below the nearest resolvable ancestor fails
closed with EACCES rather than synthesising.
An ephemeral synthesis is cached in the inode blob only and never written back, leaving the original filesystem unmodified. A persistent one is additionally written to the xattr so the medium acquires durable descriptors — but never inline.
3.9.5.8 Deferred write-back #
Synthesis runs holding the FACS inode lock, and writing the xattr
takes the inode's i_rwsem. Doing that inline would acquire i_rwsem
under the FACS lock, inverting the order the access path requires, and
would self-deadlock when synthesis is reached from a metadata
operation whose VFS caller already holds i_rwsem. Write-back
therefore runs with no FACS or VFS lock held.
Synthesis caches the descriptor immediately and marks the entry pending. The access decision is correct from that cached value the instant synthesis completes — correctness never depends on the xattr reaching disk. The write-back runs later from a task-work callback firing as the triggering syscall returns to userspace, so a persistent descriptor is normally on disk by the time the operation that first observed it missing returns.
A pending entry is generation-tagged exactly like an ephemeral one, so a policy or template change before the write-back discards it and re-synthesises; a stale pre-change descriptor is never pinned to disk.
Write-back is best-effort, and can be because the synthesised descriptor is a deterministic function of the parent or the template — the on-disk xattr is a cache of a recomputable value, not unique state. If it does not happen, because the entry was evicted or the task exited first, the identical descriptor is re-synthesised on next access and retried. A failed or skipped write-back never fails the operation that triggered synthesis. Kernel threads, and a failure to queue the callback, fall back to re-synthesis the same way.
Once written, the next cache miss reads it back as an ordinary xattr-backed descriptor: durable, no longer generation-tagged, and never synthesised again.
An ancestor synthesised only to supply inheritance inputs for a descendant is itself pending, and persists under the same rules when it is next accessed in its own right.
3.9.5.9 Corrupt descriptors #
A descriptor xattr that exists but fails structural validation is corrupt, and the policy is fail-closed: deny all access, do not call AccessCheck, and never treat a truncated DACL as an empty one.
Every encounter emits an audit event, fired exactly once per inode per cache population rather than per access, so a hot corrupt inode does not flood the log.
Recovery is a process holding SeRestorePrivilege calling
set-security to overwrite it. Offline repair tools can also rewrite
xattrs directly on an unmounted filesystem.
3.9.5.10 NFS client mounts #
NFS is the one managed class where the sole-authority guarantee does
not hold. The server enforces its own access control independently:
FACS evaluates locally against a synthesised descriptor, and the
server may deny I/O that FACS allowed. A locally authorized open()
can therefore produce a descriptor whose read() calls fail. This is
inherent to network filesystems with server-side enforcement, and
nothing suppresses the server's denial.