Linux SCM_PIDFD: Track the Process Handle Associated with an AF_UNIX Message
Receive the message-associated pidfd on Linux AF_UNIX with SO_PASSPIDFD, validate ancillary data, and separate lifecycle tracking from authentication.
Linux SO_PASSPIDFD asks an AF_UNIX receiving socket to attach an SCM_PIDFD control message to received traffic. When pidfd preparation succeeds, the control-message payload is a pid file descriptor installed in the receiver’s descriptor table for the PID recorded with that message’s credentials - normally the sending process’s thread-group ID (TGID). In Linux 6.5, a preparation failure can instead appear as a negative error value in that integer payload, so validate it before treating it as a descriptor. The value is not the sender’s numeric descriptor, a file descriptor transferred by SCM_RIGHTS, or a PID string that the receiver must look up later. This distinction matters: a sender allowed to supply SCM_CREDENTIALS with CAP_SYS_ADMIN in the user namespace that owns its active PID namespace can name another process PID, and the kernel may associate that PID with the message. SCM_PIDFD therefore does not, by itself, prove which process executed sendmsg().
This is useful when a service tracks the lifecycle of the process represented by a local IPC message. A numeric PID can be reused after exit. A pidfd refers to the kernel PID object recorded for the message, so monitoring that descriptor does not accidentally switch to a later process assigned the reused PID. It still does not authenticate the caller of sendmsg(), authorize an operation, or prove that the payload is trustworthy. Treat the descriptor as a lifecycle handle for the message-associated PID, not as an identity assertion or authorization credential.
Pin the Linux interface before relying on it
The upstream change added the SO_PASSPIDFD socket option and the read-only SCM_PIDFD ancillary type. The symbols and receive path are present in the Linux 6.5 source tree; the tagged implementation checks ancillary-buffer space, prepares a pidfd from the message’s stored struct pid, and installs it in the receiving task when it can emit the control message. SO_PASSPIDFD is a kernel UAPI socket-option constant: the generic header assigns 76, while the v6.5 PA-RISC and SPARC headers assign architecture-specific values. SCM_PIDFD is defined in the kernel’s internal include/linux/socket.h, not in the v6.5 UAPI header; glibc 2.39 exposes it from its Linux socket header under _GNU_SOURCE, while older libc headers may not. The example therefore requires headers that define both symbols; do not paper over missing definitions with a guessed value. Probe the running kernel with setsockopt() instead of trusting uname alone.
There are two timing points. In Linux 6.5, AF_UNIX records the message’s struct pid when a message is queued if SO_PASSPIDFD or SO_PASSCRED is enabled on either the sending socket or its peer at send time. The receiver emits SCM_PIDFD only when its receive socket has SO_PASSPIDFD enabled at recvmsg() time. Setting the option on an accepted socket after messages have already been queued without PID metadata cannot reconstruct that metadata. For SOCK_STREAM or SOCK_SEQPACKET, configure SO_PASSPIDFD on the listener before accept(); setting it before listen() is a simple conservative ordering, while Linux 6.5 copies the listener’s pass flags into the accepted socket in accept(). There is a useful pre-accept exception: unix_stream_connect() creates the pending peer with unix_create1(net, NULL, ...), and sock_init_data() leaves its sk_socket null. At send time, unix_passcred_enabled() treats !other->sk_socket as enabled, so data queued to that not-yet-accepted peer still captures the message’s struct pid; after accept() copies the listener’s pass flags, the receiver can emit its pidfd for that queued data. An application-level ready handshake may still be appropriate to keep request processing behind application initialization, but it is not required solely to preserve this PID metadata in the reviewed v6.5 path. A datagram receiver should enable the option on its socket before traffic is queued. With SOCK_STREAM, ancillary data is associated with a position in a byte stream, so application framing still needs deliberate handling; a successful sendmsg() does not create a stream record by itself.
Enable reception and reserve control space
This focused receiver setup assumes a connected Linux AF_UNIX SOCK_SEQPACKET socket and a protocol that requires a non-empty request payload, expects the pidfd control message, and forbids SCM_RIGHTS. Rejecting an empty record avoids returning zero while transferring a pidfd, which a caller might otherwise mistake for end-of-stream. The control buffer also has room to inspect and close a bounded number of unexpected passed descriptors. If the protocol legitimately accepts other ancillary types, define and validate those types explicitly rather than silently ignoring them.
#define _GNU_SOURCE
#include <stdbool.h>
#include <errno.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#ifndef SO_PASSPIDFD
#error "Use Linux UAPI headers that define SO_PASSPIDFD"
#endif
#ifndef SCM_PIDFD
#error "Use GNU libc headers that define SCM_PIDFD"
#endif
#define MAX_UNEXPECTED_FDS 16
int enable_pidfd_receive(int socket_fd) {
int enabled = 1;
return setsockopt(socket_fd, SOL_SOCKET, SO_PASSPIDFD,
&enabled, sizeof(enabled));
}
ssize_t receive_request(int socket_fd, char *payload, size_t payload_size,
int *credential_pidfd) {
if (credential_pidfd == NULL) {
errno = EINVAL;
return -1;
}
*credential_pidfd = -1;
if (payload == NULL || payload_size == 0) {
errno = EINVAL;
return -1;
}
struct iovec iov = {
.iov_base = payload,
.iov_len = payload_size,
};
union {
struct cmsghdr alignment;
unsigned char bytes[CMSG_SPACE(sizeof(int)) +
CMSG_SPACE(sizeof(int) * MAX_UNEXPECTED_FDS)];
} control = {0};
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control.bytes;
msg.msg_controllen = sizeof(control.bytes);
ssize_t received;
do {
received = recvmsg(socket_fd, &msg, MSG_CMSG_CLOEXEC);
} while (received == -1 && errno == EINTR);
if (received == -1)
return -1;
bool empty_record = received == 0;
int pidfd = -1;
bool pidfd_seen = false;
int unexpected_fds[MAX_UNEXPECTED_FDS];
size_t unexpected_fd_count = 0;
bool malformed = false;
for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg != NULL;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_len < CMSG_LEN(0)) {
malformed = true;
continue;
}
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_PIDFD) {
if (cmsg->cmsg_len != CMSG_LEN(sizeof(int))) {
malformed = true;
continue;
}
int received_pidfd;
memcpy(&received_pidfd, CMSG_DATA(cmsg), sizeof(received_pidfd));
if (!pidfd_seen) {
pidfd = received_pidfd;
pidfd_seen = true;
} else {
if (received_pidfd >= 0)
close(received_pidfd);
malformed = true;
}
} else if (cmsg->cmsg_level == SOL_SOCKET &&
cmsg->cmsg_type == SCM_RIGHTS) {
size_t data_size = cmsg->cmsg_len - CMSG_LEN(0);
if (data_size % sizeof(int) != 0)
malformed = true;
size_t count = data_size / sizeof(int);
const unsigned char *fd_bytes = (const unsigned char *)CMSG_DATA(cmsg);
for (size_t i = 0; i < count; ++i) {
int passed_fd;
memcpy(&passed_fd, fd_bytes + i * sizeof(passed_fd),
sizeof(passed_fd));
if (unexpected_fd_count < MAX_UNEXPECTED_FDS) {
unexpected_fds[unexpected_fd_count++] = passed_fd;
} else {
close(passed_fd);
malformed = true;
}
}
malformed = true; /* This request protocol does not allow SCM_RIGHTS. */
} else {
malformed = true;
}
}
bool control_truncated = (msg.msg_flags & MSG_CTRUNC) != 0;
bool payload_truncated = (msg.msg_flags & MSG_TRUNC) != 0;
if (control_truncated || payload_truncated || malformed || empty_record ||
!pidfd_seen || pidfd < 0) {
for (size_t i = 0; i < unexpected_fd_count; ++i)
close(unexpected_fds[i]);
if (pidfd >= 0)
close(pidfd);
if (control_truncated || payload_truncated)
errno = EMSGSIZE;
else if (malformed)
errno = EPROTO;
else if (empty_record)
errno = EPROTO;
else if (!pidfd_seen)
errno = ENOMSG;
else
errno = -pidfd; /* v6.5 may carry pidfd_prepare()'s negative errno. */
return -1;
}
*credential_pidfd = pidfd;
return received;
}
Use CMSG_SPACE() for ancillary allocation and validate the control-message level, type, and exact payload length before accepting the pidfd. MSG_CMSG_CLOEXEC requests atomic close-on-exec for file descriptors received in ancillary data, including any SCM_RIGHTS descriptors the sample rejects. Linux 6.5’s SCM_PIDFD receive helper independently creates pidfds with O_CLOEXEC; do not attribute that property to the recvmsg() flag. The sample still closes unexpected delivered SCM_RIGHTS descriptors before rejecting the request. Reject both MSG_CTRUNC and MSG_TRUNC: the former means ancillary data did not fit, while the latter means a SOCK_SEQPACKET record payload was cut to the supplied buffer. Linux closes excess rights descriptors that do not fit, while descriptors represented in delivered cmsgs are installed and must be closed by the receiver on rejection. The v6.5 pidfd receive helper can also put a negative pidfd_prepare() error value in the SCM_PIDFD integer payload; it does not install an fd for that value. The sample treats it as an I/O failure, never as a descriptor, and keeps that distinct from a missing cmsg. A production protocol that intentionally accepts other ancillary types needs explicit size bounds, semantics, and cleanup for each one.
Use the descriptor for lifecycle, not identity policy
Once validated, the returned descriptor can be polled for process termination. In the reviewed Linux 6.5 path, pidfd_prepare(scm->pid, 0, ...) creates a process/thread-group pidfd, not a PIDFD_THREAD handle. The matching v6.5 pidfd_poll() reports POLLIN/EPOLLIN when the whole thread group exits; the sending thread exiting while other threads remain is not sufficient. The current pidfd_open(2) manual page also documents POLLHUP/EPOLLHUP after a task is reaped; do not attribute that post-reap event to the cited v6.5 poll implementation, which does not set it in this path. This is useful for associating a short-lived worker with an in-flight request or releasing state when the represented process exits, subject to the SCM_CREDENTIALS caveat above.
#include <poll.h>
struct pollfd process_state = {
.fd = credential_pidfd,
.events = POLLIN,
};
int ready = poll(&process_state, 1, 0);
if (ready > 0 && (process_state.revents & (POLLIN | POLLHUP))) {
/* The referenced thread group exited; reconcile associated state. */
}
close(credential_pidfd);
Do not read bytes from the pidfd as though it were a status stream. Use the documented pidfd operations and interpret readiness as a lifecycle signal. If signaling is part of the design, pidfd_send_signal() avoids retargeting a reused PID, but the usual permission checks still apply. Close the descriptor when the association ends; keeping one pidfd per unbounded client connection can exhaust the service’s descriptor limit.
SCM_PIDFD is also not a replacement for SO_PASSCRED, SO_PEERCRED, socket-file permissions, or application authorization. The kernel commit describes it as the pidfd analogue of SCM_CREDENTIALS, but it does not turn a local process handle into a user identity or a permission grant. Even a received SCM_CREDENTIALS value is subject to the kernel’s documented privileged-sender rules; a PIDFD for that value must not be treated as proof that the corresponding process performed the send. Verify peer and request properties under the service’s trust model, then use the pidfd for stable lifecycle tracking of the PID represented by the message. An attacker with permission to connect may still send a syntactically valid request, and a pidfd does not make that request safe.
Diagnose the complete control-message path
When no usable SCM_PIDFD arrives, verify the socket family, that the receiver option was enabled before recvmsg(), and that sender metadata could have been captured when the message was queued. For a listener, enable the option before accepting traffic; setting it only on an accepted socket does not retroactively add identity metadata to queued messages. Check that the target kernel implements the option and that the program uses headers defining the symbols. Test in an isolated Unix-socket pair and log the kernel release, socket type, option-probe result, recvmsg() return value, msg_flags, and observed cmsg type and length. Do not log payload secrets or treat a missing pidfd as permission to fall back to an unchecked PID lookup.
For compatibility, make support an explicit capability. If the option is unavailable, choose a separate protocol with a documented fallback such as credentials plus a carefully designed identity/lifetime check, or refuse that feature. Do not silently compile with a guessed numeric constant: option numbers can vary across architecture UAPI headers, and the cmsg type is a separate value from the socket option. Keep the fallback and the pidfd path visibly distinct in tests.
The operational boundary is straightforward: enable the receive option, reserve and validate ancillary space, reject truncation, manage the returned descriptor, and authorize requests independently. That provides stable lifecycle tracking for the PID associated with a message without claiming that the handle authenticates the sendmsg() caller.
Related:
- Linux SCM_RIGHTS: Secure File-Descriptor Handoff over Unix Sockets
- systemd Socket Activation: Designing a Correct Listener Handoff
Sources:
- Linux upstream change introducing SO_PASSPIDFD and SCM_PIDFD
- Linux v6.5 SCM receive implementation and SO_PASSPIDFD receive gate
- Linux v6.5 AF_UNIX pass-option inheritance on accept
- Linux v6.5 AF_UNIX connection creation before accept
- Linux v6.5 AF_UNIX socket creation passes its socket pointer to
sock_init_data - Linux v6.5 AF_UNIX send-side and peer-side metadata-capture conditions
- Linux v6.5 AF_UNIX datagram credential capture immediately before queueing
- Linux v6.5 AF_UNIX stream credential capture immediately before queueing
- Linux v6.5
sock_init_datapasses its socket argument tosk_set_socket - Linux v6.5
sk_set_socketstores its pointer argument, includingNULL - Linux v6.5 kernel-internal SCM_PIDFD definition
- Linux v6.5 pidfd preparation with O_CLOEXEC
- Linux v6.5 generic socket-option UAPI definitions
- Linux v6.5 PA-RISC socket-option UAPI definitions
- Linux v6.5 SPARC socket-option UAPI definitions
- glibc 2.39 Linux socket header: GNU
SCM_PIDFD - Linux
recvmsg(2)manual:MSG_CMSG_CLOEXEC,MSG_CTRUNC, andMSG_TRUNC - Linux v6.5 SCM_RIGHTS delivery and receive-buffer truncation
- Linux v6.5 SCM cookie teardown releases held file references
- Linux v6.5 SCM_CREDENTIALS validation and capability checks
- Linux v6.5 SCM_CREDENTIALS PID association in the send path
- Linux v6.5 pidfd polling
- Linux v6.5 pidfd preparation and thread-group validation
- Linux pidfd_open(2) manual
- Linux pidfd_send_signal(2) manual
- Linux unix(7) manual
SCM_PIDFD is not currently specified by the cited unix(7) or recvmsg(2) manual pages. This article documents that specific ABI from the upstream kernel change, Linux v6.5 implementation, and glibc header; the man pages cover only the surrounding credential, ancillary-data, and pidfd behavior.