Linux filesystem development
 help / color / mirror / Atom feed
From: Tao Cui <cui.tao@linux.dev>
To: Christian Brauner <brauner@kernel.org>
Cc: linux-fsdevel@vger.kernel.org, linux-kernel@vger.kernel.org,
	Tycho Andersen <tycho@tycho.pizza>,
	Alexander Mikhalitsyn <aleksandr.mikhalitsyn@canonical.com>,
	Andrei Vagin <avagin@gmail.com>,
	cui.tao@linux.dev, Tao Cui <cuitao@kylinos.cn>
Subject: [PATCH 2/2] selftests/namespaces: test nsfs pid-translation access gate
Date: Fri, 30 Oct 2026 21:04:22 +0800	[thread overview]
Message-ID: <20261030130422.738277-3-cui.tao@linux.dev> (raw)
In-Reply-To: <20261030130422.738277-1-cui.tao@linux.dev>

From: Tao Cui <cuitao@kylinos.cn>

Add a selftest for the nsfs pid translation ioctls: a privileged
holder of a pidns fd must still be able to translate, an
unprivileged receiver of the fd must get -EACCES on all four ioctls
in both translation directions, and same-task translation must keep
working.

Signed-off-by: Tao Cui <cuitao@kylinos.cn>
---
 tools/testing/selftests/namespaces/.gitignore |   1 +
 tools/testing/selftests/namespaces/Makefile   |   1 +
 .../namespaces/nsfs_pid_translation_test.c    | 399 ++++++++++++++++++
 3 files changed, 401 insertions(+)
 create mode 100644 tools/testing/selftests/namespaces/nsfs_pid_translation_test.c

diff --git a/tools/testing/selftests/namespaces/.gitignore b/tools/testing/selftests/namespaces/.gitignore
index 995b082357fa..5c522d2f06a6 100644
--- a/tools/testing/selftests/namespaces/.gitignore
+++ b/tools/testing/selftests/namespaces/.gitignore
@@ -11,3 +11,4 @@ stress_test
 listns_pagination_bug
 regression_pidfd_setns_test
 uid_map_range_test
+nsfs_pid_translation_test
diff --git a/tools/testing/selftests/namespaces/Makefile b/tools/testing/selftests/namespaces/Makefile
index 9b22d12708e1..8333aa694ad6 100644
--- a/tools/testing/selftests/namespaces/Makefile
+++ b/tools/testing/selftests/namespaces/Makefile
@@ -3,6 +3,7 @@ CFLAGS += -Wall -O0 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
 LDLIBS += -lcap
 
 TEST_GEN_PROGS := nsid_test \
+		  nsfs_pid_translation_test \
 		  file_handle_test \
 		  init_ino_test \
 		  ns_active_ref_test \
diff --git a/tools/testing/selftests/namespaces/nsfs_pid_translation_test.c b/tools/testing/selftests/namespaces/nsfs_pid_translation_test.c
new file mode 100644
index 000000000000..e3a19318be5c
--- /dev/null
+++ b/tools/testing/selftests/namespaces/nsfs_pid_translation_test.c
@@ -0,0 +1,399 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test that the nsfs pid translation ioctls enforce ptrace read
+ * access on the task whose pid they translate: a "container" task
+ * tree in a new pid namespace passes its pidns fd over SCM_RIGHTS to
+ * an unprivileged attacker, who must not be able to use it as a pid
+ * oracle, while a privileged holder of the same fd still can.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <unistd.h>
+#include <linux/nsfs.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include "../kselftest.h"
+
+/* fall back when the build host's uapi headers predate the ioctls */
+#ifndef NS_GET_PID_FROM_PIDNS
+#define NS_GET_PID_FROM_PIDNS	_IOR(NSIO, 0x6, int)
+#endif
+#ifndef NS_GET_TGID_FROM_PIDNS
+#define NS_GET_TGID_FROM_PIDNS	_IOR(NSIO, 0x7, int)
+#endif
+#ifndef NS_GET_PID_IN_PIDNS
+#define NS_GET_PID_IN_PIDNS	_IOR(NSIO, 0x8, int)
+#endif
+#ifndef NS_GET_TGID_IN_PIDNS
+#define NS_GET_TGID_IN_PIDNS	_IOR(NSIO, 0x9, int)
+#endif
+
+#define ATTACKER_UID	65534
+#define PROBE_PIDS	8
+
+#define ATTACK_PASS	0
+#define ATTACK_LEAK	1	/* ioctl succeeded for an unseen task */
+#define ATTACK_ERRNO	2	/* rejected but errno != EACCES */
+#define ATTACK_SETUP	3	/* could not drop privileges */
+
+/* container-side setup exit codes, reported through the container fork */
+#define CONT_EXIT_UNSHARE	10
+#define CONT_EXIT_FORK		11
+#define CONT_EXIT_HANDSHAKE	12
+
+/* watchdog: a hang kills the test instead of blocking kselftest */
+#define TEST_TIMEOUT	60
+
+static void send_fd(int sock, int fd, int payload)
+{
+	struct msghdr msg = {};
+	struct iovec iov = { &payload, sizeof(payload) };
+	union {
+		struct cmsghdr cm;
+		char buf[CMSG_SPACE(sizeof(int))];
+	} u;
+	struct cmsghdr *cm;
+
+	msg.msg_iov = &iov;
+	msg.msg_iovlen = 1;
+	msg.msg_control = u.buf;
+	msg.msg_controllen = sizeof(u.buf);
+	cm = CMSG_FIRSTHDR(&msg);
+	cm->cmsg_level = SOL_SOCKET;
+	cm->cmsg_type = SCM_RIGHTS;
+	cm->cmsg_len = CMSG_LEN(sizeof(fd));
+	memcpy(CMSG_DATA(cm), &fd, sizeof(fd));
+	if (sendmsg(sock, &msg, 0) < 0)
+		perror("sendmsg");
+}
+
+static int recv_fd(int sock, int *payload)
+{
+	int fd = -1;
+	struct msghdr msg = {};
+	struct iovec iov = { payload, sizeof(*payload) };
+	union {
+		struct cmsghdr cm;
+		char buf[CMSG_SPACE(sizeof(int))];
+	} u;
+	struct cmsghdr *cm;
+
+	msg.msg_iov = &iov;
+	msg.msg_iovlen = 1;
+	msg.msg_control = u.buf;
+	msg.msg_controllen = sizeof(u.buf);
+	if (recvmsg(sock, &msg, 0) < 0)
+		return -1;
+	cm = CMSG_FIRSTHDR(&msg);
+	if (!cm)
+		return -1;
+	memcpy(&fd, CMSG_DATA(cm), sizeof(fd));
+	return fd;
+}
+
+/*
+ * Child: drop all privileges, receive the container pidns fd and try
+ * to use it as a translation oracle for container pids 1..PROBE_PIDS
+ * and, in the reverse direction, our own pid and the container init's
+ * initial-namespace pid.  EPERM is tolerated defensively alongside
+ * EACCES.
+ */
+static int attacker(int sock)
+{
+	int fd;
+	int i;
+	int leaked = 0;
+	int bad_errno = 0;
+	int init_host_pid;
+	long ret;
+
+	if (setresgid(ATTACKER_UID, ATTACKER_UID, ATTACKER_UID) ||
+	    setresuid(ATTACKER_UID, ATTACKER_UID, ATTACKER_UID))
+		return ATTACK_SETUP;
+
+	fd = recv_fd(sock, &init_host_pid);
+	if (fd < 0)
+		return ATTACK_SETUP;
+
+	for (i = 1; i <= PROBE_PIDS; i++) {
+		ret = ioctl(fd, NS_GET_PID_FROM_PIDNS, i);
+		if (ret >= 1)
+			leaked++;
+		else if (ret < 0 && errno != EACCES && errno != EPERM &&
+			 errno != ESRCH)
+			bad_errno++;
+
+		ret = ioctl(fd, NS_GET_TGID_FROM_PIDNS, i);
+		if (ret >= 1)
+			leaked++;
+		else if (ret < 0 && errno != EACCES && errno != EPERM &&
+			 errno != ESRCH)
+			bad_errno++;
+	}
+
+	/* reverse probe 1: translating ourselves - any result is a leak */
+	ret = ioctl(fd, NS_GET_PID_IN_PIDNS, getpid());
+	if (ret >= 1)
+		leaked++;
+	else if (ret < 0 && errno != EACCES && errno != EPERM && errno != ESRCH)
+		bad_errno++;
+
+	ret = ioctl(fd, NS_GET_TGID_IN_PIDNS, getpid());
+	if (ret >= 1)
+		leaked++;
+	else if (ret < 0 && errno != EACCES && errno != EPERM && errno != ESRCH)
+		bad_errno++;
+
+	/* reverse probe 2: the container init addressed by its
+	 * initial-namespace pid - a gate pasted into only one branch still
+	 * translates here and must be caught as a leak
+	 */
+	ret = ioctl(fd, NS_GET_PID_IN_PIDNS, init_host_pid);
+	if (ret >= 1)
+		leaked++;
+	else if (ret < 0 && errno != EACCES && errno != EPERM && errno != ESRCH)
+		bad_errno++;
+
+	ret = ioctl(fd, NS_GET_TGID_IN_PIDNS, init_host_pid);
+	if (ret >= 1)
+		leaked++;
+	else if (ret < 0 && errno != EACCES && errno != EPERM && errno != ESRCH)
+		bad_errno++;
+
+	close(fd);
+
+	if (leaked)
+		return ATTACK_LEAK;
+	if (bad_errno)
+		return ATTACK_ERRNO;
+	return ATTACK_PASS;
+}
+
+int main(void)
+{
+	int syncsock[2];
+	int sk[2];
+	int nsfd = -1;
+	int st;
+	pid_t cont = -1;
+	pid_t att;
+	char path[64];
+	const char *bail_msg = NULL;
+
+	ksft_print_header();
+	ksft_set_plan(4);
+
+	if (geteuid())
+		ksft_exit_skip("test requires root, run with -C or as root\n");
+
+	alarm(TEST_TIMEOUT);
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, syncsock)) {
+		ksft_print_msg("socketpair(sync) failed: %s\n",
+			       strerror(errno));
+		bail_msg = "socketpair(sync) failed";
+		goto out;
+	}
+
+	/* "container": task tree in a new pidns; stays alive */
+	cont = fork();
+	if (cont < 0) {
+		ksft_print_msg("fork(container) failed: %s\n",
+			       strerror(errno));
+		bail_msg = "fork(container) failed";
+		goto out;
+	}
+	if (cont == 0) {
+		pid_t inner;
+
+		/* die with the test: no orphaned pause() children */
+		prctl(PR_SET_PDEATHSIG, SIGKILL);
+
+		close(syncsock[0]);
+		if (unshare(CLONE_NEWPID)) {
+			perror("unshare(CLONE_NEWPID)");
+			_exit(CONT_EXIT_UNSHARE);
+		}
+		inner = fork();		/* container init */
+		if (inner < 0)
+			_exit(CONT_EXIT_FORK);
+		if (inner == 0) {
+			int j;
+
+			prctl(PR_SET_PDEATHSIG, SIGKILL);
+			/* two more tasks so the 1..8 probe range
+			 * covers several live tasks plus free slots
+			 */
+			for (j = 0; j < 2; j++) {
+				pid_t sub = fork();
+
+				if (sub < 0)
+					break;
+				if (sub == 0) {
+					prctl(PR_SET_PDEATHSIG, SIGKILL);
+					pause();
+				}
+			}
+			pause();
+		}
+		{
+			/* initial-namespace pid of the container init,
+			 * sent as a native pid_t
+			 */
+			if (write(syncsock[1], &inner, sizeof(inner)) !=
+			    (ssize_t)sizeof(inner))
+				_exit(CONT_EXIT_HANDSHAKE);
+		}
+		pause();
+		_exit(0);
+	}
+	close(syncsock[1]);
+	{
+		if (read(syncsock[0], &cont, sizeof(cont)) != (ssize_t)sizeof(cont)) {
+			/* a rejected unshare(CLONE_NEWPID) (no
+			 * CAP_SYS_ADMIN, common in CI) is an environment
+			 * limitation, not a test failure
+			 */
+			int cst;
+
+			kill(cont, SIGKILL);
+			waitpid(cont, &cst, 0);
+			if (WIFEXITED(cst) &&
+			    WEXITSTATUS(cst) == CONT_EXIT_UNSHARE)
+				ksft_exit_skip("unshare(CLONE_NEWPID) not permitted\n");
+			ksft_print_msg("container setup failed: exit %d\n",
+				       WIFEXITED(cst) ? WEXITSTATUS(cst) : -1);
+			bail_msg = "container setup failed";
+			goto out;
+		}
+	}
+	close(syncsock[0]);
+
+	/* cont is the container init: its /proc/<pid>/ns/pid is the
+	 * container pidns (the unshare() caller stays in the parent ns)
+	 */
+	snprintf(path, sizeof(path), "/proc/%d/ns/pid", cont);
+	nsfd = open(path, O_RDONLY);
+	if (nsfd < 0) {
+		ksft_print_msg("open(%s) failed: %s\n", path, strerror(errno));
+		bail_msg = "open(container pidns) failed";
+		goto out;
+	}
+
+	/* 1. privileged holder of the fd can still translate pid 1 */
+	{
+		long ret = ioctl(nsfd, NS_GET_PID_FROM_PIDNS, 1);
+		long tgid = ioctl(nsfd, NS_GET_TGID_FROM_PIDNS, 1);
+
+		if (ret >= 1 && tgid >= 1)
+			ksft_test_result_pass("root translation of container init\n");
+		else
+			ksft_test_result_fail("root translation of init failed: %ld %ld %d\n",
+					      ret, tgid, errno);
+	}
+
+	/* 2. unprivileged receiver of the fd is denied everywhere */
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sk)) {
+		ksft_print_msg("socketpair(attacker) failed: %s\n",
+			       strerror(errno));
+		bail_msg = "socketpair(attacker) failed";
+		goto out;
+	}
+
+	att = fork();
+	if (att < 0) {
+		ksft_print_msg("fork(attacker) failed: %s\n",
+			       strerror(errno));
+		bail_msg = "fork(attacker) failed";
+		goto out;
+	}
+	if (att == 0) {
+		int rc;
+
+		close(sk[0]);
+		close(nsfd);
+		rc = attacker(sk[1]);
+		close(sk[1]);
+		_exit(rc);
+	}
+	close(sk[1]);
+	/* cont = container init's host pid, passed for the IN_PIDNS
+	 * probes on a task the attacker must not see
+	 */
+	send_fd(sk[0], nsfd, cont);
+	close(sk[0]);
+	waitpid(att, &st, 0);
+
+	switch (WIFEXITED(st) ? WEXITSTATUS(st) : -1) {
+	case ATTACK_PASS:
+		ksft_test_result_pass("unprivileged translation denied with EACCES\n");
+		break;
+	case ATTACK_LEAK:
+		ksft_test_result_fail("unprivileged translation succeeded (info leak)\n");
+		break;
+	case ATTACK_ERRNO:
+		ksft_test_result_fail("translation denied with unexpected errno\n");
+		break;
+	case ATTACK_SETUP:
+	default:
+		ksft_test_result_skip("attacker setup failed (no uid %d?)\n",
+				      ATTACKER_UID);
+		break;
+	}
+
+	/* root-side re-check: rules out a false "denied" PASS when the
+	 * container init died early during the attacker run
+	 */
+	{
+		long ret = ioctl(nsfd, NS_GET_PID_FROM_PIDNS, 1);
+
+		if (ret >= 1)
+			ksft_test_result_pass("container init alive after attacker run\n");
+		else
+			ksft_test_result_fail("container init gone: %ld %d\n",
+					      ret, errno);
+	}
+
+	close(nsfd);
+	nsfd = -1;
+	kill(cont, SIGKILL);
+	waitpid(cont, &st, 0);
+
+	/* 4. same-task query still works after the gate (sanity) */
+	{
+		int self_ns = open("/proc/self/ns/pid", O_RDONLY);
+		long ret;
+
+		if (self_ns < 0) {
+			ksft_test_result_skip("open(/proc/self/ns/pid) failed\n");
+		} else {
+			ret = ioctl(self_ns, NS_GET_PID_FROM_PIDNS, getpid());
+			if (ret == getpid())
+				ksft_test_result_pass("self translation still allowed\n");
+			else
+				ksft_test_result_fail("self translation: ret=%ld errno=%d (%s)\n",
+						      ret, errno, strerror(errno));
+			close(self_ns);
+		}
+	}
+
+	ksft_finished();
+	return 0;
+
+out:
+	if (nsfd >= 0)
+		close(nsfd);
+	if (cont > 0)
+		kill(cont, SIGKILL);
+	ksft_exit_fail_msg("%s\n", bail_msg ? bail_msg : "unexpected failure");
+}
-- 
2.43.0


      parent reply	other threads:[~2026-10-30 13:04 UTC|newest]

Thread overview: 3+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-30 13:04 [PATCH 0/2] nsfs: gate pid-translation ioctls behind ptrace read access Tao Cui
2026-10-30 13:04 ` [PATCH 1/2] nsfs: require ptrace read access for pid translation ioctls Tao Cui
2026-10-30 13:04 ` Tao Cui [this message]

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=20261030130422.738277-3-cui.tao@linux.dev \
    --to=cui.tao@linux.dev \
    --cc=aleksandr.mikhalitsyn@canonical.com \
    --cc=avagin@gmail.com \
    --cc=brauner@kernel.org \
    --cc=cuitao@kylinos.cn \
    --cc=linux-fsdevel@vger.kernel.org \
    --cc=linux-kernel@vger.kernel.org \
    --cc=tycho@tycho.pizza \
    /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