Re: [PATCH v2 4/6] selftests/landlock: Test LANDLOCK_SCOPE_SYSV_MSG_QUEUE

Günther Noack <[email protected]>
Newsgroups org.kernel.vger.linux-security-module,org.kernel.vger.linux-kernel
Message-ID <[email protected]>
Hello!

On Mon, Jul 27, 2026 at 07:08:31PM -0400, Justin Suess wrote:
> Add selftests for SysV message queue scoped right.
> 
> Use the existing scoped domain harness for msgget, and another fixture
> for testing msgsnd, msgrcv and msgctl.
> 
> Pass the msqid around for coverage of non-msgget syscalls, since calling
> msgget while already restricted would fail and prevent testing the
> operation under test.
> 
> Denials are checked against -EACCES rather than -EPERM: msgget,
> msgsnd, msgrcv and msgctl(IPC_STAT) all reach the Landlock scope
> check via ipcperms(), whose callers map every non-zero return into
> -EACCES before propagating it to user space.
> 
> Track the created msqid in the fixture and remove it from
> FIXTURE_TEARDOWN_PARENT() so that queues are reclaimed even when a
> failed assertion aborts a test, and so that the removal is never
> subject to the scoping under test.
> 
> Also add CONFIG_SYSVIPC to the selftest config fragment since the new
> test requires SysV IPC support.
> 
> Signed-off-by: Justin Suess <[email protected]>
> ---
>  tools/testing/selftests/landlock/config       |   1 +
>  .../landlock/scoped_sysv_msg_queue_test.c     | 265 ++++++++++++++++++
>  2 files changed, 266 insertions(+)
>  create mode 100644 tools/testing/selftests/landlock/scoped_sysv_msg_queue_test.c
> 
> diff --git a/tools/testing/selftests/landlock/config b/tools/testing/selftests/landlock/config
> index 8fe9b461b1fd..8acb03464df4 100644
> --- a/tools/testing/selftests/landlock/config
> +++ b/tools/testing/selftests/landlock/config
> @@ -15,5 +15,6 @@ CONFIG_SECURITY=y
>  CONFIG_SECURITY_LANDLOCK=y
>  CONFIG_SHMEM=y
>  CONFIG_SYSFS=y
> +CONFIG_SYSVIPC=y
>  CONFIG_TMPFS=y
>  CONFIG_TMPFS_XATTR=y
> diff --git a/tools/testing/selftests/landlock/scoped_sysv_msg_queue_test.c b/tools/testing/selftests/landlock/scoped_sysv_msg_queue_test.c
> new file mode 100644
> index 000000000000..91a560c957e6
> --- /dev/null
> +++ b/tools/testing/selftests/landlock/scoped_sysv_msg_queue_test.c
> @@ -0,0 +1,265 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * Landlock tests - SysV Message Queue Scoping
> + *

Extraneous blank comment line here; did you mean to add a Copyright
line here?

> + */
> +
> +#define _GNU_SOURCE
> +#include <errno.h>
> +#include <fcntl.h>
> +#include <linux/landlock.h>
> +#include <sys/ipc.h>
> +#include <sys/msg.h>
> +#include <sys/types.h>
> +#include <sys/wait.h>
> +#include <unistd.h>
> +
> +#include "common.h"
> +#include "scoped_common.h"
> +
> +/*
> + * Removes the message queue identified by @msqid, ignoring any error since
> + * the caller might no longer have permission to operate on it (for example,
> + * after entering a scoped domain).
> + */
> +static void cleanup_msg_queue(int msqid)
> +{
> +	if (msqid >= 0)
> +		msgctl(msqid, IPC_RMID, NULL);
> +}
> +
> +FIXTURE(scoped_domains)
> +{
> +	int msqid;
> +};
> +
> +#include "scoped_base_variants.h"
> +
> +FIXTURE_SETUP(scoped_domains)
> +{
> +	self->msqid = -1;
> +	drop_caps(_metadata);
> +}
> +
> +/*
> + * The queue is removed by the (never sandboxed) test harness parent, which
> + * also runs when an assertion aborts the test after the queue got created.
> + */
> +FIXTURE_TEARDOWN_PARENT(scoped_domains)
> +{
> +	cleanup_msg_queue(self->msqid);
> +}
> +
> +/*
> + * Parent creates a SysV message queue, then the child tries to associate
> + * with it via msgget(2).  When the child is in a domain that scopes message
> + * queues and the parent is not in that same scope, the association must be
> + * denied with -EACCES (msgget runs the scope check via ipcperms(), which
> + * masks every denial as -EACCES).
> + */
> +TEST_F(scoped_domains, check_access_msg_queue)
> +{
> +	pid_t child;
> +	int status;
> +	int pipe_parent[2], pipe_child[2];
> +	char buf;
> +	key_t key;
> +	bool can_associate;
> +
> +	/*
> +	 * The child can associate with the parent's queue unless the child
> +	 * is in a scoped domain that does not include the parent (i.e. the
> +	 * parent is outside the child's domain).
> +	 */
> +	can_associate = !variant->domain_child;
> +
> +	/*
> +	 * Picks a per-test key derived from PID to avoid collisions.  Stale
> +	 * queues from a previous run are unlikely but handled by removing
> +	 * any matching entry before applying any scope.
> +	 */
> +	key = (key_t)(getpid() & 0x7fffffff);

Is that supported? :)

Michael Kerrisk's Linux Programming Interface book only covered ftok()
and IPC_PRIVATE.  In my understanding, if you pass IPC_PRIVATE to
msgget(), you are guaranteed to get a new queue?  You are using that
in the other test below as well.  Isn't that what you want?


> +	cleanup_msg_queue(msgget(key, 0));
> +
> +	if (variant->domain_both)
> +		create_scoped_domain(_metadata, LANDLOCK_SCOPE_SYSV_MSG_QUEUE);
> +
> +	ASSERT_EQ(0, pipe2(pipe_parent, O_CLOEXEC));
> +	ASSERT_EQ(0, pipe2(pipe_child, O_CLOEXEC));
> +
> +	child = fork();
> +	ASSERT_LE(0, child);
> +	if (child == 0) {
> +		int ret;
> +
> +		EXPECT_EQ(0, close(pipe_child[0]));
> +		EXPECT_EQ(0, close(pipe_parent[1]));
> +
> +		if (variant->domain_child)
> +			create_scoped_domain(_metadata,
> +					     LANDLOCK_SCOPE_SYSV_MSG_QUEUE);
> +
> +		/* Signals readiness to the parent. */
> +		ASSERT_EQ(1, write(pipe_child[1], ".", 1));
> +		EXPECT_EQ(0, close(pipe_child[1]));
> +
> +		/* Waits for the parent to have created the queue. */
> +		ASSERT_EQ(1, read(pipe_parent[0], &buf, 1));
> +		EXPECT_EQ(0, close(pipe_parent[0]));
> +
> +		ret = msgget(key, 0);
> +		if (can_associate) {
> +			ASSERT_LE(0, ret);
> +		} else {
> +			ASSERT_EQ(-1, ret);
> +			/*
> +			 * msgget uses ipcperms(), which masks every LSM
> +			 * denial as -EACCES regardless of the value the
> +			 * LSM hook returns.
> +			 */
> +			ASSERT_EQ(EACCES, errno);
> +		}

Testing nit: The checks on the msgget() results should probably be an
EXPECT_*() variant. ASSERT_*() should only be used when continuing to
run the test doesn't otherwise make sense.

Compare https://google.github.io/googletest/reference/assertions.html:

    The majority of the macros listed below come as a pair with an
    EXPECT_ variant and an ASSERT_ variant. Upon failure, EXPECT_
    macros generate nonfatal failures and allow the current function
    to continue running, while ASSERT_ macros generate fatal failures
    and abort the current function.


> +
> +		_exit(_metadata->exit_code);
> +		return;
> +	}
> +	EXPECT_EQ(0, close(pipe_child[1]));
> +	EXPECT_EQ(0, close(pipe_parent[0]));
> +
> +	if (variant->domain_parent)
> +		create_scoped_domain(_metadata, LANDLOCK_SCOPE_SYSV_MSG_QUEUE);
> +
> +	/* Waits for the child to be ready. */
> +	ASSERT_EQ(1, read(pipe_child[0], &buf, 1));
> +	EXPECT_EQ(0, close(pipe_child[0]));
> +
> +	self->msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0600);
> +	ASSERT_LE(0, self->msqid);
> +
> +	/* Releases the child. */
> +	ASSERT_EQ(1, write(pipe_parent[1], ".", 1));
> +	EXPECT_EQ(0, close(pipe_parent[1]));
> +
> +	ASSERT_EQ(child, waitpid(child, &status, 0));
> +
> +	if (WIFSIGNALED(status) || !WIFEXITED(status) ||
> +	    WEXITSTATUS(status) != EXIT_SUCCESS)
> +		_metadata->exit_code = KSFT_FAIL;
> +}
> +
> +/*
> + * The msg_queue_associate hook (exercised by msgget(2)) is covered by the
> + * scoped_domains fixture above.  The remaining hooks all funnel through the
> + * same scope check, so it suffices to verify that each operation is denied
> + * when the child is scoped relative to the queue's creator.
> + *
> + * To attribute a denial to the operation under test (and not to a preceding
> + * msgget(2) call), the parent creates the queue and the child inherits the
> + * msqid across fork(2), bypassing msg_queue_associate.
> + */
> +enum msg_op {
> +	MSG_OP_SND,
> +	MSG_OP_RCV,
> +	MSG_OP_CTL,
> +};
> +
> +FIXTURE(scoping_msg_ops)
> +{
> +	int msqid;
> +};
> +
> +FIXTURE_VARIANT(scoping_msg_ops)
> +{
> +	enum msg_op op;
> +};
> +
> +/* clang-format off */
> +FIXTURE_VARIANT_ADD(scoping_msg_ops, msgsnd) {
> +	/* clang-format on */
> +	.op = MSG_OP_SND,
> +};
> +
> +/* clang-format off */
> +FIXTURE_VARIANT_ADD(scoping_msg_ops, msgrcv) {
> +	/* clang-format on */
> +	.op = MSG_OP_RCV,
> +};
> +
> +/* clang-format off */
> +FIXTURE_VARIANT_ADD(scoping_msg_ops, msgctl) {
> +	/* clang-format on */
> +	.op = MSG_OP_CTL,
> +};
> +
> +FIXTURE_SETUP(scoping_msg_ops)
> +{
> +	self->msqid = -1;
> +	drop_caps(_metadata);
> +}
> +
> +/* See the scoped_domains teardown comment. */
> +FIXTURE_TEARDOWN_PARENT(scoping_msg_ops)
> +{
> +	cleanup_msg_queue(self->msqid);
> +}
> +
> +TEST_F(scoping_msg_ops, deny_op)
> +{
> +	struct msgbuf {
> +		long mtype;
> +		char mtext[1];
> +	} msg = { .mtype = 1 };
> +	struct msqid_ds ds;
> +	pid_t child;
> +	int status;
> +	int ret = 0;
> +
> +	/*
> +	 * The child inherits the msqid across fork(2), so no key is needed:
> +	 * IPC_PRIVATE always creates a new queue and cannot collide with
> +	 * queues left over by other processes.
> +	 */
> +	self->msqid = msgget(IPC_PRIVATE, 0600);
> +	ASSERT_LE(0, self->msqid);
> +
> +	/* Preloads a message so msgrcv(2) would otherwise succeed. */
> +	ASSERT_EQ(0, msgsnd(self->msqid, &msg, sizeof(msg.mtext), 0));
> +
> +	child = fork();

I do not understand why this test needs to fork() at all, tbh.
If you were to

* create the queue with msgget(),
* add a message with msgsnd(),
* then enter the Landlock domain (still in the same process),
* and then check for the operation failure

Wouldn't that also fail?  After all, the message queue was created at
a point in time when the process was still unrestricted, so we should
be comparing to the Landlock state at that earlier point in time, no?

> +	ASSERT_LE(0, child);
> +	if (child == 0) {
> +		create_scoped_domain(_metadata, LANDLOCK_SCOPE_SYSV_MSG_QUEUE);
> +
> +		switch (variant->op) {
> +		case MSG_OP_SND:
> +			ret = msgsnd(self->msqid, &msg, sizeof(msg.mtext), 0);
> +			break;
> +		case MSG_OP_RCV:
> +			ret = msgrcv(self->msqid, &msg, sizeof(msg.mtext), 0,
> +				     IPC_NOWAIT);
> +			break;
> +		case MSG_OP_CTL:
> +			ret = msgctl(self->msqid, IPC_STAT, &ds);
> +			break;
> +		}
> +		ASSERT_EQ(-1, ret);

If these operations are all supposed to fail (and presumably be
no-ops), is it necessary to still use a fixture with multiple cases
for that?  (The alternative would be to make this a regular TEST()
without fixtures and do the three failing operations one after
another. It would save you a lot of macro boilerplate and the special
enum, and would make the test more direct IMHO.)

> +		/*
> +		 * msgsnd, msgrcv and msgctl(IPC_STAT) all reach the
> +		 * Landlock scope check via ipcperms(), whose callers map
> +		 * any non-zero return into -EACCES before propagating it
> +		 * to user space.
> +		 */
> +		ASSERT_EQ(EACCES, errno);
> +
> +		_exit(_metadata->exit_code);
> +		return;
> +	}
> +
> +	ASSERT_EQ(child, waitpid(child, &status, 0));
> +
> +	if (WIFSIGNALED(status) || !WIFEXITED(status) ||
> +	    WEXITSTATUS(status) != EXIT_SUCCESS)
> +		_metadata->exit_code = KSFT_FAIL;
> +}
> +
> +TEST_HARNESS_MAIN
> -- 
> 2.54.0
> 

–Günther
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.