From: Justin Suess <utilityemal77@gmail.com>
To: linux-security-module@vger.kernel.org, mic@digikod.net
Cc: m@maowtm.org, gnoack@google.com, gnoack3000@gmail.com,
matthieu@buffet.re, Justin Suess <utilityemal77@gmail.com>
Subject: [PATCH v9 5/9] landlock: Implement LANDLOCK_ADD_RULE_NO_INHERIT
Date: Sat, 20 Jun 2026 23:52:18 -0400 [thread overview]
Message-ID: <20260621035223.2651547-6-utilityemal77@gmail.com> (raw)
In-Reply-To: <20260621035223.2651547-1-utilityemal77@gmail.com>
Make %LANDLOCK_ADD_RULE_NO_INHERIT actually enforce its semantics:
- Tag the new rule's layer with @no_inherit and
@has_no_inherit_descendant so landlock_unmask_layers() stops walking
up the hierarchy once it has been seen, and so the rule's own object
is sealed against topology changes.
- Walk from the rule's path up to the VFS root in
landlock_append_fs_rule(), inserting a zero-access rule on each
ancestor with @has_no_inherit_descendant set, so topology changes
(rename, rmdir, link, ...) on any ancestor are denied too.
- Add deny_no_inherit_topology_change(), called from
current_check_refer_path(), hook_path_unlink() and hook_path_rmdir()
to enforce the seal at the LSM hook layer.
Signed-off-by: Justin Suess <utilityemal77@gmail.com>
---
Notes:
Changes since v8:
- Extracted the ancestor-sealing walk out of landlock_append_fs_rule()
into a new seal_ancestors() helper, removing the out_unlock goto.
- The seal loop now handles LANDLOCK_WALK_INTERNAL explicitly (skips
disconnected internal-mount roots and keeps walking), matching the
updated landlock_walk_path_up() behavior.
- Broadened current_check_refer_path() enforcement:
deny_no_inherit_topology_change() is now applied to old_dentry and
(when present) new_dentry unconditionally, covering hard links (link)
in addition to rename/exchange.
- Factored the unlink/rmdir hooks into a new current_check_remove()
helper instead of duplicating the subject lookup and deny call.
- Simplified deny_no_inherit_topology_change() to short-circuit and log
on the first sealed layer, dropping the sealed_layers accumulator and
the redundant no_inherit test.
- Bumped the Landlock ABI to version 11 and updated the base_test
abi_version expectation.
- Rebased onto mic/next.
security/landlock/access.h | 6 +
security/landlock/fs.c | 156 ++++++++++++++++++-
security/landlock/ruleset.c | 30 +++-
security/landlock/ruleset.h | 13 ++
security/landlock/syscalls.c | 2 +-
tools/testing/selftests/landlock/base_test.c | 2 +-
6 files changed, 202 insertions(+), 7 deletions(-)
diff --git a/security/landlock/access.h b/security/landlock/access.h
index d926078bf0a5..9df6c6de71e2 100644
--- a/security/landlock/access.h
+++ b/security/landlock/access.h
@@ -81,6 +81,12 @@ struct layer_mask {
*/
access_mask_t quiet : 1;
#endif /* CONFIG_AUDIT */
+ /**
+ * @no_inherit: Whether we have encountered a rule with the no-inherit
+ * flag for this layer, so that ancestor rules do not grant additional
+ * access rights.
+ */
+ access_mask_t no_inherit : 1;
} __packed __aligned(sizeof(access_mask_t));
/*
diff --git a/security/landlock/fs.c b/security/landlock/fs.c
index 34d1c245af92..44ccf6cede85 100644
--- a/security/landlock/fs.c
+++ b/security/landlock/fs.c
@@ -362,6 +362,70 @@ static enum landlock_walk_result landlock_walk_path_up(struct path *const path)
return LANDLOCK_WALK_CONTINUE;
}
+/**
+ * seal_ancestors - Seal every ancestor of @walker up to the VFS root
+ *
+ * @ruleset: Ruleset (must be locked by the caller) to insert the seal rules
+ * into.
+ * @walker: Path to the inode whose ancestors must be sealed. It is walked up
+ * to the real VFS root and is modified during the walk.
+ *
+ * Inserts a no-access rule tagged @has_no_inherit_descendant for each ancestor
+ * so that topology-changing operations (rename, rmdir, link, ...) on them are
+ * denied.
+ *
+ * On failure the walk stops early, so the ruleset may be left with the leaf
+ * rule and only some of its ancestors sealed. The caller must therefore
+ * discard the ruleset on error rather than enforce it, since an enforced but
+ * partially sealed ruleset would provide a weaker guarantee than intended.
+ *
+ * Return: 0 on success or a negative error code on failure.
+ */
+static int seal_ancestors(struct landlock_ruleset *const ruleset,
+ struct path *const walker)
+{
+ int err = 0;
+
+ path_get(walker);
+ for (;;) {
+ struct landlock_rule *ancestor_rule;
+ struct landlock_id ancestor_id = {
+ .type = LANDLOCK_KEY_INODE,
+ };
+ const enum landlock_walk_result res =
+ landlock_walk_path_up(walker);
+
+ /* Done once the real VFS root has been reached. */
+ if (res == LANDLOCK_WALK_STOP_REAL_ROOT)
+ break;
+ /*
+ * @walker advanced past the disconnected root of an internal
+ * mount, which does not name a sealable ancestor; keep walking
+ * up so that every ancestor up to the real root is still
+ * sealed.
+ */
+ if (res == LANDLOCK_WALK_INTERNAL)
+ continue;
+
+ /* LANDLOCK_WALK_CONTINUE: seal this ancestor. */
+ ancestor_id.key.object =
+ get_inode_object(d_backing_inode(walker->dentry));
+ if (IS_ERR(ancestor_id.key.object)) {
+ err = PTR_ERR(ancestor_id.key.object);
+ break;
+ }
+ ancestor_rule = landlock_insert_rule(ruleset, ancestor_id, 0, 0);
+ landlock_put_object(ancestor_id.key.object);
+ if (IS_ERR(ancestor_rule)) {
+ err = PTR_ERR(ancestor_rule);
+ break;
+ }
+ ancestor_rule->layers[0].flags.has_no_inherit_descendant = true;
+ }
+ path_put(walker);
+ return err;
+}
+
/*
* @path: Should have been checked by get_path_from_fd().
*/
@@ -391,6 +455,20 @@ int landlock_append_fs_rule(struct landlock_ruleset *const ruleset,
mutex_lock(&ruleset->lock);
rule = landlock_insert_rule(ruleset, id, access_rights, flags);
err = PTR_ERR_OR_ZERO(rule);
+ /*
+ * Sealing an inode also seals all of its ancestors against topology
+ * changes, so walk the path up to the VFS root and seal each ancestor
+ * too. This is best effort: a concurrent rename during the walk may
+ * leave the seal incomplete. Such changes to the hierarchy between
+ * ruleset construction and enforcement are outside of Landlock's
+ * threat model. See the "Filesystem inheritance suppression" section
+ * in Documentation/userspace-api/landlock.rst for the limitations.
+ */
+ if (!err && (flags & LANDLOCK_ADD_RULE_NO_INHERIT)) {
+ struct path walker = *path;
+
+ err = seal_ancestors(ruleset, &walker);
+ }
mutex_unlock(&ruleset->lock);
/*
* No need to check for an error because landlock_insert_rule()
@@ -1129,6 +1207,47 @@ log_fs_change_topology_dentry(const struct landlock_cred_security *const subject
});
}
+/**
+ * deny_no_inherit_topology_change - Deny topology changes on sealed paths
+ * @subject: Subject performing the operation.
+ * @dentry: Target of the topology modification.
+ *
+ * Return: -EACCES (and emits an audit record) if any of the subject's
+ * domain layers seal @dentry against topology changes: either @dentry
+ * itself has a %LANDLOCK_ADD_RULE_NO_INHERIT rule, or one of its
+ * descendants does (recorded via @has_no_inherit_descendant on the
+ * dentry's rule). Returns 0 otherwise.
+ */
+static int
+deny_no_inherit_topology_change(const struct landlock_cred_security *subject,
+ struct dentry *const dentry)
+{
+ const struct landlock_rule *rule;
+
+ if (WARN_ON_ONCE(!subject || !dentry || d_is_negative(dentry)))
+ return 0;
+
+ rule = find_rule(subject->domain, dentry);
+ if (!rule)
+ return 0;
+
+ for (size_t i = 0; i < rule->num_layers; i++) {
+ const struct landlock_layer *const layer = &rule->layers[i];
+
+ /*
+ * @has_no_inherit_descendant is a superset of @no_inherit: it is
+ * set on the rule's own object when the no-inherit rule is
+ * created, and on every ancestor. Testing it alone seals both.
+ */
+ if (layer->flags.has_no_inherit_descendant) {
+ log_fs_change_topology_dentry(subject, layer->level - 1,
+ dentry);
+ return -EACCES;
+ }
+ }
+ return 0;
+}
+
/**
* current_check_refer_path - Check if a rename or link action is allowed
*
@@ -1194,6 +1313,7 @@ static int current_check_refer_path(struct dentry *const old_dentry,
struct path old_parent_path;
struct layer_masks layer_masks_parent1 = {}, layer_masks_parent2 = {};
struct landlock_request request1 = {}, request2 = {};
+ int err;
if (!subject)
return 0;
@@ -1210,6 +1330,22 @@ static int current_check_refer_path(struct dentry *const old_dentry,
}
access_request_parent2 =
get_mode_access(d_backing_inode(old_dentry)->i_mode);
+ /*
+ * A no-inherit seal forbids any topology change of the sealed inode or
+ * its ancestors. Deny renaming or linking the source out of its
+ * hierarchy, as well as removing, overwriting or exchanging a sealed
+ * destination. This applies to both rename (@removable) and link
+ * (!@removable) operations, so it is checked unconditionally.
+ */
+ err = deny_no_inherit_topology_change(subject, old_dentry);
+ if (err)
+ return err;
+ if (!d_is_negative(new_dentry)) {
+ err = deny_no_inherit_topology_change(subject, new_dentry);
+ if (err)
+ return err;
+ }
+
if (removable) {
access_request_parent1 |= maybe_remove(old_dentry);
access_request_parent2 |= maybe_remove(new_dentry);
@@ -1586,16 +1722,32 @@ static int hook_path_symlink(const struct path *const dir,
return current_check_access_path(dir, LANDLOCK_ACCESS_FS_MAKE_SYM);
}
+static int current_check_remove(const struct path *const dir,
+ struct dentry *const dentry,
+ const access_mask_t access_request)
+{
+ const struct landlock_cred_security *const subject =
+ landlock_get_applicable_subject(current_cred(), any_fs, NULL);
+
+ if (subject) {
+ int err = deny_no_inherit_topology_change(subject, dentry);
+
+ if (err)
+ return err;
+ }
+ return current_check_access_path(dir, access_request);
+}
+
static int hook_path_unlink(const struct path *const dir,
struct dentry *const dentry)
{
- return current_check_access_path(dir, LANDLOCK_ACCESS_FS_REMOVE_FILE);
+ return current_check_remove(dir, dentry, LANDLOCK_ACCESS_FS_REMOVE_FILE);
}
static int hook_path_rmdir(const struct path *const dir,
struct dentry *const dentry)
{
- return current_check_access_path(dir, LANDLOCK_ACCESS_FS_REMOVE_DIR);
+ return current_check_remove(dir, dentry, LANDLOCK_ACCESS_FS_REMOVE_DIR);
}
static int hook_path_truncate(const struct path *const path)
diff --git a/security/landlock/ruleset.c b/security/landlock/ruleset.c
index b8a35675bcbf..ca7cfa45c90a 100644
--- a/security/landlock/ruleset.c
+++ b/security/landlock/ruleset.c
@@ -258,6 +258,10 @@ insert_rule(struct landlock_ruleset *const ruleset,
return ERR_PTR(-EINVAL);
this->layers[0].access |= (*layers)[0].access;
this->layers[0].flags.quiet |= (*layers)[0].flags.quiet;
+ this->layers[0].flags.no_inherit |=
+ (*layers)[0].flags.no_inherit;
+ this->layers[0].flags.has_no_inherit_descendant |=
+ (*layers)[0].flags.has_no_inherit_descendant;
return this;
}
@@ -311,12 +315,20 @@ landlock_insert_rule(struct landlock_ruleset *const ruleset,
const struct landlock_id id,
const access_mask_t access, const u32 flags)
{
+ const bool no_inherit = !!(flags & LANDLOCK_ADD_RULE_NO_INHERIT);
struct landlock_layer layers[] = { {
.access = access,
/* When @level is zero, insert_rule() extends @ruleset. */
.level = 0,
.flags = {
.quiet = !!(flags & LANDLOCK_ADD_RULE_QUIET),
+ .no_inherit = no_inherit,
+ /*
+ * The rule's own object is also sealed against
+ * topology changes, so mark it as if it had a
+ * no-inherit descendant.
+ */
+ .has_no_inherit_descendant = no_inherit,
},
} };
@@ -657,15 +669,25 @@ bool landlock_unmask_layers(const struct landlock_rule *const rule,
*/
for (size_t i = 0; i < rule->num_layers; i++) {
const struct landlock_layer *const layer = &rule->layers[i];
+ struct layer_mask *const layer_mask =
+ &masks->layers[layer->level - 1];
+
+ /*
+ * Skip layers that already have no_inherit set: these layers
+ * should not inherit access rights from ancestor directories.
+ */
+ if (layer_mask->no_inherit)
+ continue;
/* Clear the bits where the layer in the rule grants access. */
- masks->layers[layer->level - 1].access &= ~layer->access;
+ layer_mask->access &= ~layer->access;
#ifdef CONFIG_AUDIT
- /* Collect rule flags for each layer. */
if (layer->flags.quiet)
- masks->layers[layer->level - 1].quiet = true;
+ layer_mask->quiet = true;
#endif /* CONFIG_AUDIT */
+ if (layer->flags.no_inherit)
+ layer_mask->no_inherit = true;
}
for (size_t i = 0; i < ARRAY_SIZE(masks->layers); i++) {
@@ -731,6 +753,7 @@ landlock_init_layer_masks(const struct landlock_ruleset *const domain,
#ifdef CONFIG_AUDIT
masks->layers[i].quiet = false;
#endif /* CONFIG_AUDIT */
+ masks->layers[i].no_inherit = false;
}
for (size_t i = domain->num_layers; i < ARRAY_SIZE(masks->layers);
i++) {
@@ -738,6 +761,7 @@ landlock_init_layer_masks(const struct landlock_ruleset *const domain,
#ifdef CONFIG_AUDIT
masks->layers[i].quiet = false;
#endif /* CONFIG_AUDIT */
+ masks->layers[i].no_inherit = false;
}
return handled_accesses;
diff --git a/security/landlock/ruleset.h b/security/landlock/ruleset.h
index c927bcb82fa3..8d3dd551bf77 100644
--- a/security/landlock/ruleset.h
+++ b/security/landlock/ruleset.h
@@ -40,6 +40,19 @@ struct landlock_layer {
* down the file hierarchy.
*/
u8 quiet : 1;
+ /**
+ * @no_inherit: Prevents this rule from inheriting access rights
+ * from ancestor inodes. Only used for filesystem rules; set
+ * via %LANDLOCK_ADD_RULE_NO_INHERIT.
+ */
+ u8 no_inherit : 1;
+ /**
+ * @has_no_inherit_descendant: Marker used to deny topology
+ * changes on the rule's object: either the object itself has
+ * a no-inherit rule, or a descendant does. Only used for
+ * filesystem rules; set by Landlock, never by user space.
+ */
+ u8 has_no_inherit_descendant : 1;
} flags;
/**
* @access: Bitfield of allowed actions on the kernel object. They are
diff --git a/security/landlock/syscalls.c b/security/landlock/syscalls.c
index b847b0be1cf7..3fea5492df0d 100644
--- a/security/landlock/syscalls.c
+++ b/security/landlock/syscalls.c
@@ -169,7 +169,7 @@ static const struct file_operations ruleset_fops = {
* If the change involves a fix that requires userspace awareness, also update
* the errata documentation in Documentation/userspace-api/landlock.rst .
*/
-const int landlock_abi_version = 10;
+const int landlock_abi_version = 11;
/**
* sys_landlock_create_ruleset - Create a new ruleset
diff --git a/tools/testing/selftests/landlock/base_test.c b/tools/testing/selftests/landlock/base_test.c
index cbd3c1669951..b8b5fa1042ba 100644
--- a/tools/testing/selftests/landlock/base_test.c
+++ b/tools/testing/selftests/landlock/base_test.c
@@ -76,7 +76,7 @@ TEST(abi_version)
const struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE,
};
- ASSERT_EQ(10, landlock_create_ruleset(NULL, 0,
+ ASSERT_EQ(11, landlock_create_ruleset(NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION));
ASSERT_EQ(-1, landlock_create_ruleset(&ruleset_attr, 0,
--
2.54.0
next prev parent reply other threads:[~2026-06-21 3:52 UTC|newest]
Thread overview: 10+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-21 3:52 [PATCH v9 0/9] Implement LANDLOCK_ADD_RULE_NO_INHERIT Justin Suess
2026-06-21 3:52 ` [PATCH v9 1/9] landlock: Add and use landlock_walk_path_up() helper Justin Suess
2026-06-21 3:52 ` [PATCH v9 2/9] landlock: Add LANDLOCK_ADD_RULE_NO_INHERIT user API Justin Suess
2026-06-21 3:52 ` [PATCH v9 3/9] landlock: Return inserted rule from landlock_insert_rule() Justin Suess
2026-06-21 3:52 ` [PATCH v9 4/9] landlock: Move log_fs_change_topology_dentry() above current_check_refer_path() Justin Suess
2026-06-21 3:52 ` Justin Suess [this message]
2026-06-21 3:52 ` [PATCH v9 6/9] landlock: Add documentation for LANDLOCK_ADD_RULE_NO_INHERIT Justin Suess
2026-06-21 3:52 ` [PATCH v9 7/9] samples/landlock: Add LANDLOCK_ADD_RULE_NO_INHERIT to landlock-sandboxer Justin Suess
2026-06-21 3:52 ` [PATCH v9 8/9] selftests/landlock: Add selftests for LANDLOCK_ADD_RULE_NO_INHERIT Justin Suess
2026-06-21 3:52 ` [PATCH v9 9/9] landlock: Add KUnit tests " Justin Suess
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260621035223.2651547-6-utilityemal77@gmail.com \
--to=utilityemal77@gmail.com \
--cc=gnoack3000@gmail.com \
--cc=gnoack@google.com \
--cc=linux-security-module@vger.kernel.org \
--cc=m@maowtm.org \
--cc=matthieu@buffet.re \
--cc=mic@digikod.net \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox