LSM hooks, SELinux, AppArmor, and policy

Explain Linux Security Module hooks and policy decisions in SELinux and AppArmor.

13 min readChapter 74 of 168Advanced
In this chapter

Before you start#

Review credentials and capabilities, pathname resolution, and RCU and references. All source examples use upstream Linux v6.12; active LSMs, policy, boot configuration and distribution modifications determine a running system's behavior. Synthetic policy examples are labeled as such.

Learning goals#

Trace an LSM file access hook, distinguish DAC from mandatory access control, explain SELinux label and AppArmor profile decisions, and interpret a denial without treating a sample log as proof of a running policy.

Source walkthrough#

LSM hooks, SELinux, AppArmor, and policy
VFS and DAC
Ownership, modes, ACLs, namespace and object context
LSM dispatch
Registered hooks can reject before later checks
SELinux
Subject SID, object SID, class and permissions
AppArmor
Credential label, mediated path and profile rules
Conceptual model. Conceptual source-based model, not a captured execution; actual checks, configuration and policy vary.

Linux Security Modules (LSMs) attach security decisions and object metadata to selected kernel operations. A hook is a defined point where registered implementations can inspect a request and return a result. The LSM hook declarations, security dispatch, and LSM-specific implementations describe actual boundaries. The existence of a hook does not prove every possible operation is mediated by that hook or that every LSM has registered an implementation for it.

Discretionary access control (DAC) includes ownership, mode bits and ACL-related decisions under the relevant filesystem and namespace context. Mandatory access control (MAC) adds policy decisions that a subject cannot necessarily override by owning a file. A request can satisfy DAC and still fail an LSM check. Capabilities can affect some DAC decisions, but possession of a capability is not a blanket guarantee that SELinux, AppArmor or another enabled LSM will allow the operation. The actual order and combination of checks depend on the operation; do not assume a single LSM call replaces all VFS authorization.

The kernel maintains hook lists grouped by hook type in security_hook_heads. LSM initialization registers implementations using hook descriptors; configured and selected modules and their ordering determine which hooks run (v6.12 LSM setup, kernel LSM usage). The active list is visible on many systems through securityfs, but an LSM's presence does not identify its loaded policy or prove a particular request passed its hook. Some modules are stackable subject to the version's infrastructure and configuration; a claimed combination must be checked on the actual system. LSM-specific per-object security blobs attach state to credentials, inodes, files and other objects. The blob's contents and lifetime are owned by the relevant security infrastructure and module, not a userspace pointer to arbitrary policy memory.

File open and return aggregation#

In security.c, the exact v6.12 excerpt below is from security_file_open, GPL-2.0-or-later; surrounding declaration and final brace are omitted:

c
	ret = call_int_hook(file_open, file);
	if (ret)
		return ret;

	return fsnotify_open_perm(file);

The first statement dispatches the registered file_open hooks for this struct file. A nonzero result is returned immediately; the later notification permission check is not reached on that branch. If the hooks return zero, the function calls fsnotify_open_perm, whose result is the final result of this wrapper. A zero from the LSM hooks therefore does not establish that the entire open operation must succeed. For integer hooks, the dispatch machinery iterates registered hook implementations and stops on a nondefault return for the hook; its default and hook definitions matter. Do not describe all LSM hooks as aggregating results identically.

security_file_open return branches
security_file_open return branches1. Open path to security_file_open: file reaches the open check. 2. security_file_open to LSM file_open hooks: call_int_hook(file_open). 3. LSM file_open hooks to security_file_open (return): nonzero: return the error at once. 4. security_file_open to fsnotify_open_perm: zero from the hooks: fsnotify permission check. 5. fsnotify_open_perm to security_file_open (return): result becomes the wrapper result. 6. security_file_open to Open path (return): open continues or fails.Open pathsecurity_file_openLSM file_open hooksfsnotify_open_permfile reaches theopen checkcall_int_hook(file_open)nonzero: return theerror at oncezero from the hooks: fsnotify permissioncheckresult becomes the wrapper resultopen continues orfails
  1. Open pathsecurity_file_open

    file reaches the open check

  2. security_file_openLSM file_open hooks

    call_int_hook(file_open)

  3. LSM file_open hookssecurity_file_open

    Return. nonzero: return the error at once

  4. security_file_openfsnotify_open_perm

    zero from the hooks: fsnotify permission check

  5. fsnotify_open_permsecurity_file_open

    Return. result becomes the wrapper result

  6. security_file_openOpen path

    Return. open continues or fails

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Open path → security_file_open: file reaches the open check
  2. security_file_open → LSM file_open hooks: call_int_hook(file_open)
  3. LSM file_open hooks → security_file_open (return): nonzero: return the error at once
  4. security_file_open → fsnotify_open_perm: zero from the hooks: fsnotify permission check
  5. fsnotify_open_perm → security_file_open (return): result becomes the wrapper result
  6. security_file_open → Open path (return): open continues or fails
Source map. Linux v6.12 security_file_open source map of the return branches; the rest of VFS open is omitted and the sequence is not a captured trace.

The file points to a VFS object with a path and associated credentials, including file->f_cred from the open context. It holds references under VFS lifecycle rules. A struct inode describes the filesystem object, and a path includes mount and dentry context. These are not interchangeable objects, and a pathname is not a stable substitute for an inode reference across rename or mount changes. LSM file and inode blobs can cache decisions or hold labels; their allocation, locking and release follow module-specific code. A reader must respect the lifetime and synchronization of the objects passed to the hook rather than storing unreferenced pointers after return.

Another wrapper, security_inode_permission, returns zero early for a private inode and otherwise calls the registered inode-permission hooks. That early return is a scope limitation for this wrapper, not a claim that every operation on that inode is globally unrestricted. The permission mask identifies requested access and may include flags concerning blocking. DAC and this hook participate in a larger VFS decision; a failure in either or in later checks can prevent the operation.

In security/security.c, v6.12, SPDX GPL-2.0-or-later, the wrapper is (exact, unabridged):

c
int security_inode_permission(struct inode *inode, int mask)
{
	if (unlikely(IS_PRIVATE(inode)))
		return 0;
	return call_int_hook(inode_permission, inode, mask);
}

The early return applies only to a private inode, and it skips every registered inode-permission hook for that inode. The excerpt does not show which inodes are private or which operations reach this wrapper, so it cannot support a claim that the early return is safe for any particular filesystem.

SELinux: subject, object, class and AVC#

SELinux associates security labels with subjects and objects and represents them internally with security identifiers, or SIDs (SELinux Project kernel notes). Policy defines permissions for a subject SID, object SID and object class. The Access Vector Cache (AVC) caches policy decisions; it is a cache of policy results, not a substitute for the policy. A policy reload or relevant state change can invalidate assumptions about a previously observed decision. Whether a denial is enforcing, permissive or audited depends on configuration and policy; an audit record is evidence about a decision, not itself the policy source.

In selinux/hooks.c, selinux_inode_permission obtains the current subject SID, reduces the access mask to relevant operations, obtains inode security state, maps requested file access to access-vector permissions and queries the AVC. This exact v6.12 GPL-2.0-only excerpt omits surrounding declarations and auditing:

c
	isec = inode_security_rcu(inode, no_block);
	if (IS_ERR(isec))
		return PTR_ERR(isec);

The first statement retrieves the inode's security state with awareness of whether blocking is permitted. It may return an encoded error pointer. The conditional returns that error instead of dereferencing it. On success, subsequent code uses the subject SID, inode SID, security class and requested permissions for avc_has_perm_noaudit, then determines appropriate auditing. A nonblocking walk can require a retry in a blocking context when the necessary security state cannot be obtained under the current constraints; the precise error must be interpreted at the caller. The function's early return for a mask with no relevant access bits is not an authorization for a later read or write.

Consider a synthetic example: a file has mode 0644, its owner and group allow an unprivileged user's read through ordinary DAC, and the task's SELinux domain lacks read permission to the file's labeled type. The DAC component can allow while SELinux denies. A hypothetical AVC line might report denied { read }, a subject context, target context and file class. That says which policy relationship was denied in the example; it does not prove a real system emitted that line, that the target path alone is correct, or that granting a broad rule is safe. Inspect the actual labels, policy, operation, and full audit context before diagnosing a real denial.

AppArmor: profiles, paths and cached file state#

AppArmor associates a task with a label representing one or more profiles (v6.12 AppArmor implementation). Its mediation commonly evaluates path-related operations against profile policy, with namespace, mount and object context. “Path based” does not mean that the kernel blindly trusts a userspace string or that a renamed object has one permanent global path. A path is resolved in a mount context and policy evaluation must deal with the particular operation and object. Profile transitions on exec and policy replacement introduce their own lifetime and revalidation considerations.

In apparmor/lsm.c, apparmor_file_open first returns zero if the filesystem is not path mediated by that hook. For an exec-open case it caches permissions granted by the prior exec check as described by the source; it does not independently rerun every exec decision in that branch. For other mediated files it obtains the newest credential label from file->f_cred, checks whether it is confined, derives an idmapped owner and mode condition, and calls aa_path_perm for the requested open permissions. It then drops the conditional label reference. An unconfined label or nonmediated filesystem taking an early path does not imply that all other LSMs, DAC or later checks allow the operation.

AppArmor's file context caches an allowed mask and label information. That cached mask is not a transferable authorization token: later file-permission checks evaluate the current task label and requested permissions through their own path. Inspect the relevant path before assuming an open-time result covers every future use (v6.12 AppArmor file permission hooks). Profile replacement and label lifetime are handled through the module's reference and synchronization rules. A path can also change after opening, and a file descriptor can be passed to another task with a different label. The fact that one task opened a file does not prove all later tasks have identical policy permission to use it.

For a second synthetic example, a task's profile permits reading /srv/app/config but denies writing it. DAC permits both. An attempted write can fail in AppArmor even though the mode bits allow it. A hypothetical AppArmor log naming operation="open", a profile, path and denied write mask would point to that profile decision, but exact log fields and audit policy vary. It does not establish that the file should be writable; investigate the application's intended behavior and the deployed profile. Do not create a blanket allow rule merely to silence the event.

Scope, stacking and optional BPF LSM#

Hook placement defines what a particular LSM can see and influence. The same high-level application action can invoke several kernel operations, each with different hooks, or fail before reaching a particular hook. The first rejecting integer hook can prevent later hook callbacks in the same dispatch from running, so absence of another module's log does not prove that module would have allowed the request. Conversely, a zero return from one hook does not authorize an operation rejected elsewhere. Inspect the v6.12 hook declaration, dispatch and actual enabled LSMs for the operation under study.

BPF LSM is optional, configuration- and privilege-dependent. Its attachable hooks and policy behavior depend on the running kernel and loaded programs; it is not automatically active merely because the BPF subsystem exists. BPF LSM, SELinux and AppArmor can each mediate different aspects of a system, with stacking constraints. A policy decision must be attributed to the actual hook and policy evidence, not to a generic idea of “Linux security.”

Following an access decision across objects#

Consider a task opening an existing regular file for reading. The pathname walk identifies a mount and dentry under the caller's namespace; the VFS obtains the inode and applies relevant access checks. The eventual struct file holds an opened path and file credentials. Different hooks inspect different stages and objects. An inode-permission hook sees an inode and requested mask, whereas a file-open hook receives a file with path and credential context. Neither object should be converted into a permanent label by saving an unreferenced pointer after the operation. A concurrent rename can change pathname relationships without turning an open file reference into a reference to an unrelated object.

SELinux and AppArmor decision inputs
SELinux inputs
Subject SID, object SID, object class and requested permissions
SELinux cache
The AVC caches a policy result; audit output is a separate decision
AppArmor inputs
Label from file->f_cred, path, mapped mask and idmap condition
AppArmor cache
The file context mask belongs to that open file and is rechecked on later use
Conceptual model. Conceptual comparison of inputs and caches in the Linux v6.12 SELinux and AppArmor source paths; not a policy dump of any running system.

In the SELinux branch, the current SID is the subject of the inode check while the inode's security structure supplies an object SID and class. The requested mask is translated into the access-vector permissions meaningful for that class. If obtaining inode security state returns an error pointer, the hook returns that error before the AVC lookup. If it succeeds, the AVC result and auditing logic are separate: a policy decision can be cached, and whether a record is emitted depends on audit rules. A report saying “the AVC denied a read” should include which subject and target were involved, the actual permission, and the enforcing or permissive context; it should not be generalized to all accesses to the inode.

For AppArmor, the open callback's use of file->f_cred matters because that stored credential is part of the file's open context. The callback obtains a current version of its label with a conditional reference and later releases that reference. It computes a condition from the inode and mount idmap, and passes the path, mapped permissions and label to the policy function for a mediated, confined file. The file_ctx cache is attached to the file lifetime; it is not an unrestricted authorization token transferable to any subject. Follow the later access and revalidation path when a descriptor crosses tasks or profile state changes. These are source-path observations, not claims that every AppArmor version or filesystem has identical mediation.

Practice#

Read-only Linux observation lab

On an authorized Linux host or disposable guest, inspect available status without loading policy, changing enforcement, or using sudo:

sh
uname -r
if [ -r /sys/kernel/security/lsm ]; then cat /sys/kernel/security/lsm; else echo 'active LSM list unavailable'; fi
if command -v getenforce >/dev/null 2>&1; then getenforce; else echo 'getenforce unavailable'; fi
if command -v aa-status >/dev/null 2>&1; then aa-status 2>&1 || true; else echo 'aa-status unavailable'; fi

The LSM list is a report of active modules if securityfs exposes it; it is not a complete policy dump. getenforce reports an SELinux mode where supported, not the permission for a specific file. aa-status may require access unavailable to an unprivileged user and can return an error; do not elevate privilege to make it succeed. Missing securityfs, commands, LSMs or permission are legitimate outcomes. Record the running kernel and tool versions where available; the commands are userspace tools and not the pinned v6.12 implementation.

Source-only alternative: use the supplied DAC-allowed/SELinux-denied and DAC-allowed/AppArmor-denied examples. For each write the subject, target, operation, DAC result and hypothetical MAC result, and mark the log as synthetic. Add the security_file_open failure branch: a nonzero hook result returns before fsnotify_open_perm. Deliver that table or an observed status inventory, without changing a profile or treating an absent audit record as proof of authorization.

Knowledge check#

  1. Why can a DAC-readable file still be denied by SELinux?
  2. What happens when an LSM file_open hook returns an error in the quoted wrapper?
  3. Why does inode_security_rcu require error handling before the SID is used?
  4. Why is an AppArmor file-open cache not a universal promise that all later tasks may use the fd?
  5. Does an active LSM list show the exact policy decision for a particular operation?

Answers#

  1. SELinux makes an additional policy decision using the subject SID, target SID, object class and requested permission. DAC success does not imply that policy grants the request.
  2. security_file_open returns the error immediately and does not reach its subsequent fsnotify permission check on that path. Other parts of the open operation have their own checks.
  3. It can return an encoded error, including when the required state cannot be obtained under the specified context. Dereferencing an error pointer as inode security data is invalid.
  4. A later task can have a different label or request, and the module can require revalidation. File, label and path context have their own lifetime and policy rules.
  5. No. It identifies active modules where available; loaded policy, labels, requested operation and other enforcement layers determine a specific decision.

Checkpoint#

Submit both synthetic DAC/MAC tables and trace one successful and one failing security_file_open branch. Ready means hook scope, object credentials, SELinux SID/class permission, AppArmor label/path and reference cleanup are distinguished. Retry using the quoted wrappers if a zero from one hook is treated as universal authorization. This is not certification.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.