Landlock File System Composition Model
The semantics of Landlock’s file system policies should be clear from the documentation, but it can be easy to lose sight of the big picture when digging around in the implementation in the kernel. This page presents a more formal model for how Landlock’s file system access rights are determined, which an implementation can be checked against.
In Landlock, the effective file system access rights for a given file path /p1/p2/.../pn
are composed at the time of resolving a specific path. p0 denotes the root of the file system hierarchy, /
.
At policy enablement time, it is infeasible for Landlock to walk the entire affected file system hierarchy and annotate every file. Instead, Landlock stores the association of specific inodes and their access rights which are applied to the file hierarchies beneath these inodes.
An additional problem is that inodes can technically change their position within the file hierarchy even after the ruleset is enforced. (This can happen, for instance, if they get moved around by more privileged processes.) Landlock must be able to deal with that situation as well and be able to adapt when the file hierarchy changes, while still enforcing the policy as intended.
Sets of access rights can only be interpreted in context
The sets of file system access rights (access_mask_t
in the code) can only be interpreted in the context in which these sets are used. There are three variants that are helpful to distinguish:
An inode i’s access rights within a given layer l
Mathematical notation: al(pi)
A set of access rights attached to an inode in a given layer, by means of adding a
LANDLOCK_RULE_PATH_BENEATH
rule (see LandlockFileSystemControl).
A path’s effective access rights within a given layer l
Mathematical notation: al(p0…n)
A set of access rights that are allowed for a full file path p0…n within a given layer.
This is calculated from the inode access rights of the path’s path components p0, …, pn
A path’s effective access rights across all of a processes’ layers
Mathematical notation: a0…m(p0…n)
A set of access rights that are allowed for a full file path p0…n across all enforced policies in a process.
This is the definite set of access rights that finally determines for a given operation whether Landlock will allow or deny it when it is attempted by a process.
Inheritance of access rights along the file hierarchy
Without loss of generality, we will now look only at one single file path in the file hierarchy, with the path components p0, p1, …, pn.
al(pi) denotes the configured access rights in layer l for an individual path component pi (identified by its inode
).
The access rights for a single inode like p2 are modeled as a set of distinct access right elements:Example: Access rights for a single inode
In a given layer l, the effective access rights for a file path p0, p1, …, pn are the union of the access rights of the individual path components in that layer:
An access right is allowed for the entire path
whenever it is allowed for one of the path components: In this example, the Example: Access rights inherited from parent directories to children
Inode
READ_FILE
WRITE_FILE
EXECUTE_FILE
al(p0)
x
al(p1)
al(p2)
x
al(p3)
Full path: al(p0…3)
x
x
READ_FILE
access right is inherited from p0,
and WRITE_FILE
is inherited from p2. The EXECUTE_FILE
access right
is not allowed.
= al(p0) ∪ al(p1) ∪ al(p2) ∪ al(p3)
= { READ_FILE } ∪ ∅ ∪ { WRITE_FILE } ∪ ∅
= { READ_FILE, WRITE_FILE }
How nested Landlock domains compose
A stack of nested Landlock policies is called layers in the implementation. It is a design goal that additional layers can only restrict the operations that are possible, or in other words, to perform a file operation that corresponds to an access right, that access right must be allowed for that file path in all of the layers. When modeling access rights as sets, the mathematical operation for that is the intersection.
Therefore:
Throughout all layers 0…m, the effective access rights for a file path p0…n are the intersection of the access rights of that path in the individual layers l in [1…m]:
= ⋂l=0m al(p0…n)
= ⋂l=0m (⋃ i=0n al(pi))
What this means for Landlock’s implementation
In the implementation, Landlock can take all shortcuts as long as the above semantics are fulfilled.
Noteworthy: The implementation is tied to using inodes to identify path components and to store their associated access rights within layers. This association stays around even when these inodes change their positions in the file hierarchy (e.g. when a more privileged process moves them around).
It might be tempting to implement a Landlock layer like this (Go pseudocode): and then to implement the stacking of new layers as a merge operation which intersects the access rights inode by inode: A counterexample for this is a scenario where: In both of these layers individually, both reading and writing will be permitted in However, when merging the layers with the faulty merging approach, the inode-wise intersections of the access rights for Example: A wrong way to compose Landlock domains
type Layer struct {
// A map from inode to a set of access rights.
AccessRights map[Inode]AccessRightSet
}
func Merge(origl Layer, newl Layer) Layer {
l := Layer{}
for i := range origl.AccessRights {
l.AccessRights[i] = intersect(origl.AccessRights[i], newl.AccessRights[i])
}
return l
}
READ_FILE
on /
and WRITE_FILE
on /home
.WRITE_FILE
on /
and READ_FILE
on /home
./home
and the files beneath./
and /home
are both the empty set of access rights, and neither READ_FILE
nor WRITE_FILE
will be allowed for /home
.
A mathematically correct (but possibly slower) way to compose Landlock domains is to keep all the information around in a linked list of individual layers. Merging can then be done by linking Some interesting properties of this approach are: Comparison to real implementation:
The actual current Landlock implementation is much more complex - it takes into account the access rights that are actually needed for the operation that is being attempted, and tries to stop the walk early as soon as these are fulfilled in every layer. It also merges the inode information for all layers into one RB-tree. This makes merging more expensive, but should make lookup cheaper. (I am personally critical of whether this effort is worth it though - the implementation does after all introduce more branches and the inodes whose walk we are skipping might already be in hot caches.)Example: A correct way to compose Landlock domains
type Layer struct {
// A map from inode to a set of access rights.
AccessRights map[Inode]AccessRightSet
// A link to the parent policy, if present.
Parent *Layer
}
Layer
objects through their Parent
pointer.
All of the real calculation only happens at evaluation time:func EffectiveAccessRights(Layer *dom, Inode[] pathComponents) AccessRightSet {
effective := ALL_KNOWN_ACCESS_RIGHTS
for l := dom; l != nil; l = l.Parent {
effectiveWithinLayer := NO_ACCESS_RIGHTS
for _, inode := range pathComponents {
effectiveWithinLayer = union(effectiveWithinLayer, l.AccessRights[inode]) // (1)
}
effective = intersection(effective, effectiveWithinLayer) // (2)
}
}
EffectiveAccessRights()
is a direct implementation of the formula from above:
In (1), the union of inode access rights forms the effective access rights for a path
within a layer. In (2), the effective access rights for a path globally is built as the
intersection of access rights of that path within the layers.Layer
objects are immutable and can be shared across processes (with a refcount to free them when unused). While each individual task only has one linear linked list of layers, they form a tree when seen globally for a system.
Possibility of deny-listing
Landlock currently implements an allow-list model. – Only the access rights that are specifically listed for an inode will be allowed.
Occasionally, the topic comes up to maybe do the inverse and let users specify denied access rights for paths instead.
If that was needed, I believe the formula for the per-layer access rights would have to change, but we should still be able to build the intersection between the effective access rights for a given path across the different layers.