Processes often need to hand each other access to an already-open resource. A supervisor may accept a client connection and delegate it to a worker. A privileged helper may open a protected file, then give an unprivileged process access without revealing broader filesystem permissions. A service may create an anonymous in-memory file and transfer it to another process.
Sending the integer value of a file descriptor does not solve this problem. File descriptor numbers are meaningful only inside one process’s descriptor table. Descriptor 7 in one process can refer to a socket while descriptor 7 in another process refers to an unrelated file.
Unix domain sockets provide a different mechanism: ancillary data. With the SCM_RIGHTS control message, the kernel can transfer a reference to an open file description from one process to another. The receiver gets a new local file descriptor referring to the same underlying open resource.
That distinction—transferring a kernel reference rather than copying an integer—is the mental model that makes descriptor passing predictable.
A file descriptor number is only a local handle
Suppose process A has this descriptor:
process A
fd 5 ---> open file description ---> fileIf process A sends the bytes representing the integer 5 to process B, process B does not gain access to that open file description. Process B looks up 5 in its own descriptor table.
SCM_RIGHTS asks the kernel to duplicate the reference across process boundaries instead:
process A process B
fd 5 ----+ +---- fd 9
| |
+--> open file description ---> fileThe receiver’s descriptor number is chosen in the receiver’s descriptor table and does not have to match the sender’s number.
This is similar in effect to dup(), except the new descriptor is installed in another process.
The transferred descriptor refers to the same open file description as the sender’s descriptor. That matters because some state belongs to the open file description rather than to an individual descriptor. For regular files, the current file offset is one example. File status flags such as O_NONBLOCK also belong to the shared open file description. By contrast, the close-on-exec flag FD_CLOEXEC belongs to an individual descriptor.
Use a Unix domain socket as the transfer channel
Descriptor passing with SCM_RIGHTS is associated with Unix domain sockets. The simplest teaching setup is a connected pair created by socketpair():
int channel[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, channel) == -1) {
perror("socketpair");
return 1;
}socketpair() creates two connected sockets without requiring a filesystem pathname. That makes it useful when related processes can create the channel before fork().
For unrelated processes, you can instead use a Unix domain socket created with socket(), bind(), listen(), and accept(). The descriptor-transfer mechanism is the same after the connection is established.
SOCK_CLOEXEC sets FD_CLOEXEC on the two channel descriptors at creation time. This avoids unintentionally leaking the IPC channel into a later execve().
Send one descriptor with sendmsg()
Ordinary send() writes payload bytes. Descriptor passing requires sendmsg() because sendmsg() can carry both ordinary payload data and control messages.
Here is a small helper that sends one descriptor:
#include <sys/socket.h>
#include <string.h>
static int send_fd(int socket_fd, int fd_to_send)
{
char payload = 'F';
struct iovec iov = {
.iov_base = &payload,
.iov_len = sizeof(payload),
};
union {
char buffer[CMSG_SPACE(sizeof(int))];
struct cmsghdr align;
} control = {0};
struct msghdr message = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = control.buffer,
.msg_controllen = sizeof(control.buffer),
};
struct cmsghdr *header = CMSG_FIRSTHDR(&message);
header->cmsg_level = SOL_SOCKET;
header->cmsg_type = SCM_RIGHTS;
header->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(header), &fd_to_send, sizeof(fd_to_send));
return sendmsg(socket_fd, &message, 0) == -1 ? -1 : 0;
}There are several pieces here, but each has one job.
The iovec describes the ordinary one-byte payload. On Linux, Unix domain stream sockets require at least one byte of ordinary data when sending file descriptors. Including a payload byte is also a good portability habit for descriptor-passing code.
The control buffer stores a cmsghdr header plus the descriptor data. CMSG_SPACE(sizeof(int)) reserves enough space for both the control header and any alignment padding. CMSG_LEN(sizeof(int)) computes the actual length recorded in the control header.
The control message has:
cmsg_level = SOL_SOCKETcmsg_type = SCM_RIGHTS- data containing the descriptor integer from the sending process
Do not hand-calculate control-message offsets. The CMSG_* macros exist because headers and payloads may require alignment.
Receive the descriptor and validate the control message
The receiver calls recvmsg() with a control buffer large enough to hold the expected descriptor.
#include <errno.h>
#include <sys/socket.h>
#include <string.h>
static int receive_fd(int socket_fd)
{
char payload;
struct iovec iov = {
.iov_base = &payload,
.iov_len = sizeof(payload),
};
union {
char buffer[CMSG_SPACE(sizeof(int))];
struct cmsghdr align;
} control = {0};
struct msghdr message = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = control.buffer,
.msg_controllen = sizeof(control.buffer),
};
ssize_t received = recvmsg(socket_fd, &message, MSG_CMSG_CLOEXEC);
if (received == -1) {
return -1;
}
if (received == 0) {
errno = ECONNRESET;
return -1;
}
if (message.msg_flags & MSG_CTRUNC) {
errno = EMSGSIZE;
return -1;
}
struct cmsghdr *header = CMSG_FIRSTHDR(&message);
if (header == NULL ||
header->cmsg_level != SOL_SOCKET ||
header->cmsg_type != SCM_RIGHTS ||
header->cmsg_len != CMSG_LEN(sizeof(int))) {
errno = EPROTO;
return -1;
}
int received_fd;
memcpy(&received_fd, CMSG_DATA(header), sizeof(received_fd));
return received_fd;
}MSG_CMSG_CLOEXEC asks Linux to set FD_CLOEXEC on descriptors received through SCM_RIGHTS. This is preferable to receiving the descriptor and then setting the flag with a separate fcntl() call in multithreaded programs, because another thread could otherwise execute a new program during that gap.
The MSG_CTRUNC check is important. If the receiving control buffer is too small, Linux sets this flag to report that ancillary data was truncated. Treating a truncated control message as complete can make the protocol behave unpredictably.
The code also validates the control-message level, type, and length before copying out the descriptor. Ancillary data is a protocol input just like the ordinary message payload; do not assume it has the shape you expected.
Put the pieces together with a real file
The smallest useful demonstration is to open a file, transfer its descriptor, and read it through the received descriptor.
The example below uses mkstemp() to create a temporary file, immediately unlinks the pathname, writes a short message, and then transfers the open descriptor through a Unix domain socket pair.
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
/* send_fd() and receive_fd() are the helpers shown above. */
int main(void)
{
int channel[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, channel) == -1) {
perror("socketpair");
return 1;
}
char path[] = "/tmp/fd-pass-XXXXXX";
int source_fd = mkstemp(path);
if (source_fd == -1) {
perror("mkstemp");
return 1;
}
unlink(path);
const char text[] = "descriptor transfer\n";
if (write(source_fd, text, sizeof(text) - 1) != (ssize_t)(sizeof(text) - 1)) {
perror("write");
return 1;
}
if (lseek(source_fd, 0, SEEK_SET) == -1) {
perror("lseek");
return 1;
}
if (send_fd(channel[0], source_fd) == -1) {
perror("send_fd");
return 1;
}
int copy_fd = receive_fd(channel[1]);
if (copy_fd == -1) {
perror("receive_fd");
return 1;
}
close(source_fd);
char buffer[64];
ssize_t count = read(copy_fd, buffer, sizeof(buffer));
if (count == -1) {
perror("read");
return 1;
}
if (write(STDOUT_FILENO, buffer, (size_t)count) != count) {
perror("write stdout");
return 1;
}
close(copy_fd);
close(channel[0]);
close(channel[1]);
return 0;
}Closing source_fd after the transfer does not invalidate copy_fd. Both descriptors refer to the same open file description, and the underlying resource remains open while at least one reference remains.
This lifetime property is one of the main reasons descriptor passing is useful. The sender can delegate access and then release its own descriptor without requiring the receiver to reopen the resource by pathname.
Shared open-file state can surprise you
Because sender and receiver refer to the same open file description, they can also observe shared state.
For a regular file, reads and writes use the shared file offset unless you choose APIs such as pread() and pwrite() that take an explicit offset.
For example:
char first[4];
read(source_fd, first, sizeof(first));
if (send_fd(channel[0], source_fd) == -1) {
/* handle error */
}If the first read() advances the shared offset from 0 to 4, a normal read() through the received descriptor starts from offset 4, not from the beginning.
This is not a copy of the file being transferred. It is another reference to the same open state.
The same distinction matters for file status flags. Changing O_NONBLOCK through one descriptor changes the shared open-file status and can therefore affect operations through another descriptor referring to the same open file description.
If independently managed offsets or status flags are required, descriptor passing alone does not create that independence. The receiving process may need to open a separate resource when the underlying object supports reopening, or design the protocol around explicit-offset operations.
Treat descriptor ownership as part of the protocol
A descriptor is a resource with a lifetime, so the message protocol should define who closes what.
A useful ownership rule is:
before send: sender owns source_fd
after successful send: receiver will own its received descriptor
sender may keep or close source_fd according to the application protocol
receiver must close received_fd when finishedA successful sendmsg() does not automatically close the sender’s descriptor. If the sender is delegating ownership, it should close its local descriptor after the transfer succeeds.
Likewise, receiving a descriptor creates a new resource that must be closed on every error path after receipt. It is easy to leak descriptors when code validates a later part of a message and returns without closing already-received descriptors.
For protocols that send several descriptors, parse the complete ancillary-data sequence, record every successfully received descriptor, and make cleanup explicit if validation later fails.
Detect ancillary-data truncation
One subtle failure mode is allocating a control buffer that is smaller than the incoming descriptor list.
Linux reports this with MSG_CTRUNC. For SCM_RIGHTS, descriptors that do not fit are automatically closed in the receiving process, but the message is still incomplete from the application’s point of view.
That means this is risky:
recvmsg(socket_fd, &message, 0);
struct cmsghdr *header = CMSG_FIRSTHDR(&message);
/* use whatever happened to fit */The preferred pattern checks truncation first:
if (recvmsg(socket_fd, &message, MSG_CMSG_CLOEXEC) == -1) {
/* handle receive error */
}
if (message.msg_flags & MSG_CTRUNC) {
/* reject the incomplete control message */
}Size the control buffer from the maximum descriptor count your protocol permits, not from whatever count happens to appear in the first example.
A bounded protocol is easier to reason about than one that accepts an arbitrary number of descriptors.
Stream sockets do not preserve application message boundaries
SCM_RIGHTS is often demonstrated over SOCK_STREAM, but a Unix stream socket is still a byte stream. Ordinary payload writes are not preserved as one-to-one message records.
Ancillary data has special delivery behavior on Unix stream sockets, but it does not turn the entire application protocol into a record protocol. If you send metadata alongside descriptors, you still need framing for the ordinary payload.
For a protocol with clear record boundaries, SOCK_SEQPACKET can be worth considering on systems where your deployment requirements allow it. Unix SOCK_SEQPACKET preserves message boundaries while remaining connection-oriented.
Choose the socket type for the protocol you need rather than assuming descriptor passing itself provides framing.
Descriptor passing does not bypass authorization design
SCM_RIGHTS can intentionally transfer access that the receiver could not obtain by reopening a pathname. That is a feature, but it is also a security boundary.
Once a process receives a descriptor, it can generally perform operations allowed by that open file description and the underlying object. The sender should therefore decide whether the receiver is authorized to receive that capability before sending it.
Do not treat a descriptor transfer as merely sending metadata. It is delegation of access to a live kernel object.
For local services, this often means authenticating the peer through the surrounding Unix-socket design, process relationships, or credential mechanisms before transferring sensitive resources.
When descriptor passing is the right tool
SCM_RIGHTS is a good fit when a process already has an open kernel resource and another local process should use that same resource.
Common examples include:
- handing accepted sockets from a listener to worker processes;
- delegating access to a file opened by a more privileged helper;
- transferring pipes, event descriptors, or anonymous files;
- preserving access to an unlinked file without exposing a pathname;
- building local process architectures where capabilities are represented by open descriptors.
It is usually not the right tool when the receiver can safely and cheaply reopen the resource itself. Reopening by a stable pathname can produce simpler lifetime rules and independent open-file state.
It is also limited to local IPC. A file descriptor is a reference to kernel state on one machine; it cannot be transferred to another host through a TCP connection.
Common mistakes come from the wrong mental model
Most problems with descriptor passing follow from treating descriptors as plain integers.
Sending the integer value over a normal socket does not transfer access. Forgetting that the open file description is shared can produce unexpected offsets or nonblocking behavior. Ignoring MSG_CTRUNC can make the receiver accept incomplete ancillary data. Forgetting FD_CLOEXEC can leak descriptors into executed programs. Failing to define ownership can leak descriptors in long-running services.
The reliable model is simpler:
Unix socket + SCM_RIGHTS
|
v
kernel duplicates a reference
|
v
receiver gets its own descriptor number
for the same open file descriptionOnce that model is explicit, the design questions become concrete: which resource is being delegated, who may receive it, which open-file state is shared, who closes each descriptor, and how errors are handled.
Conclusion
Linux file-descriptor passing lets processes delegate already-open resources without converting them back into names or reopening them independently. SCM_RIGHTS does not send a meaningful descriptor number; it asks the kernel to install another reference to the same open file description in the receiving process.
Use sendmsg() and recvmsg() with properly sized ancillary buffers, validate control messages, reject MSG_CTRUNC, request close-on-exec on received descriptors, and make ownership part of the protocol.
The mechanism is low level, but the reusable idea is straightforward: a file descriptor can act as a local capability, and Unix domain sockets provide a controlled way to hand that capability from one process to another.