memfd_create()의 이름을 다른 process에 알려 주는 것만으로 같은 object를 열 수는 없습니다. 그 이름은 주로 debugging용입니다. 이 실습의 목표는 독립 실행한 client에 UNIX domain socket의 SCM_RIGHTS로 실제 descriptor 참조를 전달하고, 그 object를 각 process가 mapping해 한 번의 request/reply를 완료하는 것입니다. 기본적인 fork() mapping 상속 실습과는 다른 문제입니다.
코드는 책의 listing을 복원하지 않고 새로 작성했습니다. 실습 묶음의 목표는 Linux 4.17 이상, glibc 2.27 이상, 같은 ABI로 build한 신뢰하는 process 두 개입니다. memfd_create() 자체는 Linux 3.17, glibc wrapper는 2.27부터 제공됩니다. 여기서는 executable memory나 HugeTLB를 요청하지 않습니다. API 설명은 Linux man-pages 6.19를 확인했습니다. memfd_create(2)
Descriptor 번호가 아니라 object 참조를 전달합니다
Server가 가진 fd 값이 5라고 해도 client의 fd 5는 다른 file일 수 있습니다. 숫자를 payload에 그대로 적어 보내는 것으로는 descriptor를 공유할 수 없습니다. SCM_RIGHTS는 socket ancillary data를 통해 같은 open file description에 대한 참조를 수신 process의 descriptor table에 설치합니다. 수신한 번호는 달라질 수 있습니다. unix(7)
이 예제는 /proc/PID/fd/N 문자열을 전달하지 않습니다. 별도의 process가 pathname을 다시 열 때까지 server의 PID·descriptor 번호가 같은 뜻을 유지해야 한다는 조건을 피하고, kernel의 descriptor 전달 동작을 직접 사용합니다. 다만 전달받은 참조의 lifetime은 여전히 application이 관리해야 합니다.
크기 seal과 write 제한은 서로 다릅니다
Server는 MFD_CLOEXEC | MFD_ALLOW_SEALING으로 object를 만들고, shared structure 크기로 늘린 뒤 다음 세 seal을 추가합니다.
| Seal | 이 실습에서 막으려는 operation |
|---|---|
F_SEAL_SHRINK |
peer가 object를 줄여 mapping 접근 조건을 무너뜨리는 것 |
F_SEAL_GROW |
합의한 structure 크기를 넘어 object가 커지는 것 |
F_SEAL_SEAL |
이후 seal을 추가해 계약을 바꾸는 것 |
Seal은 이미 추가한 것을 제거할 수 없습니다. F_SEAL_SEAL은 제거 방지가 아니라 추가 방지입니다. 이 조합은 기존 byte의 write를 막지 않으며, 이 실습에서는 semaphore와 value를 계속 수정해야 하므로 F_SEAL_WRITE를 넣지 않습니다. Shared writable mapping이 있는 상태에서 F_SEAL_WRITE를 추가하려 하면 EBUSY가 될 수 있습니다. F_GET_SEALS(2const)
크기가 고정됐어도 peer가 내용을 부정하게 수정할 가능성까지 사라지지는 않습니다. Sealing은 synchronization이나 authentication의 대체물이 아닙니다.
Shared structure의 protocol은 끝까지 정의합니다
exchange.h는 request, reply, done의 세 semaphore와 정수 value를 정의합니다. Server만 sem_init(..., 1, 0)을 수행합니다. Client는 초기화된 object를 받은 뒤 바로 그 semaphore를 사용하며 재초기화하지 않습니다.
#ifndef LAB_EXCHANGE_H
#define LAB_EXCHANGE_H
#include <errno.h>
#include <semaphore.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
struct exchange {
sem_t request;
sem_t reply;
sem_t done;
uint32_t value;
};
static inline int exchange_init(struct exchange *box)
{
box->value = 0;
if (sem_init(&box->request, 1, 0) == -1)
return -1;
if (sem_init(&box->reply, 1, 0) == -1) {
int saved = errno;
sem_destroy(&box->request);
errno = saved;
return -1;
}
if (sem_init(&box->done, 1, 0) == -1) {
int saved = errno;
sem_destroy(&box->reply);
sem_destroy(&box->request);
errno = saved;
return -1;
}
return 0;
}
static inline int exchange_wait(sem_t *sem)
{
struct timespec deadline;
if (clock_gettime(CLOCK_REALTIME, &deadline) == -1)
return -1;
deadline.tv_sec += 15;
int result;
do {
result = sem_timedwait(sem, &deadline);
} while (result == -1 && errno == EINTR);
return result;
}
static inline int exchange_server(struct exchange *box)
{
if (exchange_wait(&box->request) == -1)
return -1;
if (box->value != 21) {
errno = EPROTO;
return -1;
}
box->value *= 2;
if (sem_post(&box->reply) == -1)
return -1;
return exchange_wait(&box->done);
}
static inline int exchange_client(struct exchange *box)
{
box->value = 21;
if (sem_post(&box->request) == -1 ||
exchange_wait(&box->reply) == -1)
return -1;
if (box->value != 42) {
errno = EPROTO;
return -1;
}
puts("reply=42");
return sem_post(&box->done);
}
static inline int exchange_destroy(struct exchange *box)
{
int result = 0;
if (sem_destroy(&box->done) == -1)
result = -1;
if (sem_destroy(&box->reply) == -1)
result = -1;
if (sem_destroy(&box->request) == -1)
result = -1;
return result;
}
#endif
순서는 client의 21 write → request post → server의 request wait → 42 write → reply post → client의 reply wait와 검사 → done post입니다. 이 단계에서 공유 data에 동시에 접근하는 writer는 없습니다. 각 wait에는 15초 deadline이 있고, EINTR이면 같은 deadline으로 재시도합니다. Deadline은 CLOCK_REALTIME 기준이므로 clock 변경을 다루는 production timeout 정책까지 구현한 것은 아닙니다. sem_init(3), sem_wait(3)
이후 client는 munmap()과 close(fd)를 마치고 control socket을 마지막에 닫습니다. Server는 done뿐 아니라 socket EOF까지 확인합니다. 이 최종 handshake가 있어야 협력적인 client가 semaphore를 더 이상 쓰지 않는 시점에 destroy할 수 있습니다. Timeout으로 이 확인을 얻지 못했다면 semaphore destroy는 건너뛰고 자신의 mapping·descriptor를 정리합니다. Waiter가 남은 semaphore의 destroy는 undefined behavior입니다. 이 abort 정책은 Linux/glibc 대상이며 다른 POSIX implementation의 자원 관리로 일반화하지 않습니다. sem_destroy(3)
다음 channel.h는 connection의 준비와 종료 확인에 사용합니다. Data 계산은 shared memory에서 수행하며 socket에 request 값이나 reply 값을 싣지 않습니다.
#ifndef LAB_CHANNEL_H
#define LAB_CHANNEL_H
#include <errno.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <time.h>
#include <unistd.h>
static inline int channel_wait(int fd)
{
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
return -1;
long long end = (long long)now.tv_sec * 1000 + now.tv_nsec / 1000000 + 15000;
for (;;) {
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
return -1;
long long left = end - ((long long)now.tv_sec * 1000 + now.tv_nsec / 1000000);
if (left <= 0) {
errno = ETIMEDOUT;
return -1;
}
struct pollfd event = {.fd = fd, .events = POLLIN};
int result = poll(&event, 1, (int)left);
if (result > 0)
return 0;
if (result == -1 && errno != EINTR)
return -1;
}
}
static inline int channel_open(const char *path, int server)
{
struct sockaddr_un address = {.sun_family = AF_UNIX};
if (strlen(path) >= sizeof(address.sun_path)) {
errno = ENAMETOOLONG;
return -1;
}
memcpy(address.sun_path, path, strlen(path) + 1);
int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
if (fd == -1)
return -1;
int bound = 0;
if (server) {
if (bind(fd, (struct sockaddr *)&address, sizeof(address)) == -1)
goto fail;
bound = 1;
if (listen(fd, 1) == -1)
goto fail;
printf("READY %s\n", path);
fflush(stdout);
} else if (connect(fd, (struct sockaddr *)&address, sizeof(address)) == -1) {
goto fail;
}
return fd;
fail:;
int saved = errno;
close(fd);
if (bound)
unlink(path);
errno = saved;
return -1;
}
static inline int channel_accept(int listener)
{
if (channel_wait(listener) == -1)
return -1;
return accept4(listener, NULL, NULL, SOCK_CLOEXEC);
}
static inline int channel_send(int fd, const void *data, size_t length)
{
ssize_t count;
do {
count = send(fd, data, length, MSG_NOSIGNAL);
} while (count == -1 && errno == EINTR);
if (count == (ssize_t)length)
return 0;
if (count >= 0)
errno = EPROTO;
return -1;
}
static inline int channel_receive(int fd, void *data, size_t length)
{
if (channel_wait(fd) == -1)
return -1;
ssize_t count = recv(fd, data, length, MSG_TRUNC | MSG_DONTWAIT);
if (count == (ssize_t)length)
return 0;
if (count >= 0)
errno = EPROTO;
return -1;
}
static inline int channel_finished(int fd)
{
if (channel_wait(fd) == -1)
return -1;
char byte;
ssize_t count = recv(fd, &byte, 1, MSG_DONTWAIT);
if (count == 0)
return 0;
if (count > 0)
errno = EPROTO;
return -1;
}
static inline int channel_remove(int listener, const char *path)
{
int result = 0;
if (close(listener) == -1)
result = -1;
if (unlink(path) == -1)
result = -1;
return result;
}
#endif
Ancillary data도 길이와 개수를 검사합니다
fd_transfer.h는 sendmsg()와 recvmsg()의 전체 구현입니다. CMSG_SPACE()는 buffer 공간을 잡고, CMSG_LEN()은 실제 control message 길이를 설정합니다. Control buffer는 cmsghdr에 맞춰 align된 union으로 두고, fd의 byte 복사에는 memcpy()를 사용합니다. cmsg(3)
#ifndef LAB_FD_TRANSFER_H
#define LAB_FD_TRANSFER_H
#include "channel.h"
static inline int descriptor_send(int peer, int fd)
{
char tag = 'F';
struct iovec vector = {.iov_base = &tag, .iov_len = 1};
union {
struct cmsghdr alignment;
unsigned char bytes[CMSG_SPACE(sizeof(int))];
} control = {0};
struct msghdr message = {.msg_iov = &vector, .msg_iovlen = 1,
.msg_control = control.bytes,
.msg_controllen = sizeof(control.bytes)};
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, sizeof(fd));
ssize_t count;
do {
count = sendmsg(peer, &message, MSG_NOSIGNAL);
} while (count == -1 && errno == EINTR);
if (count == 1)
return 0;
if (count >= 0)
errno = EPROTO;
return -1;
}
static inline int descriptor_receive(int peer)
{
if (channel_wait(peer) == -1)
return -1;
char tag = 0;
struct iovec vector = {.iov_base = &tag, .iov_len = 1};
union {
struct cmsghdr alignment;
unsigned char bytes[CMSG_SPACE(4 * sizeof(int))];
} control = {0};
struct msghdr message = {.msg_iov = &vector, .msg_iovlen = 1,
.msg_control = control.bytes,
.msg_controllen = sizeof(control.bytes)};
ssize_t count = recvmsg(peer, &message, MSG_CMSG_CLOEXEC | MSG_DONTWAIT);
if (count == -1)
return -1;
int received = -1, descriptors = 0;
int invalid = count != 1 || tag != 'F' ||
(message.msg_flags & (MSG_TRUNC | MSG_CTRUNC));
for (struct cmsghdr *header = CMSG_FIRSTHDR(&message); header;
header = CMSG_NXTHDR(&message, header)) {
if (header->cmsg_level != SOL_SOCKET || header->cmsg_type != SCM_RIGHTS ||
header->cmsg_len < CMSG_LEN(0)) {
invalid = 1;
continue;
}
size_t bytes = header->cmsg_len - CMSG_LEN(0);
if (bytes % sizeof(int) != 0)
invalid = 1;
for (size_t offset = 0; offset + sizeof(int) <= bytes;
offset += sizeof(int)) {
int fd;
memcpy(&fd, CMSG_DATA(header) + offset, sizeof(fd));
if (++descriptors == 1)
received = fd;
else
close(fd);
}
}
if (invalid || descriptors != 1) {
if (received != -1)
close(received);
errno = EPROTO;
return -1;
}
return received;
}
#endif
정상 message는 F 한 byte와 descriptor 하나입니다. Receiver는 payload truncation, control truncation, 다른 message type과 여분의 descriptor를 거절합니다. 이미 수신된 여분의 fd도 닫습니다. Buffer 밖으로 잘린 SCM_RIGHTS의 초과 descriptor는 Linux가 수신 측에서 자동으로 닫는 동작과 구분해야 합니다. 수신 buffer 안에 들어온 fd의 cleanup은 application의 책임입니다. unix(7)
MSG_CMSG_CLOEXEC는 수신된 descriptor에도 close-on-exec를 설정합니다. Server의 MFD_CLOEXEC만으로 다른 process에 새로 설치되는 descriptor의 속성까지 해결했다고 가정하지 않습니다. recv(2)
Server가 준비한 뒤 client가 독립적으로 mapping합니다
memfd_server.c는 크기 설정과 sealing, mapping과 semaphore 초기화를 모두 끝낸 뒤 연결을 받습니다. 공개 전에 오류가 나면 초기화한 semaphore를 정리할 수 있습니다. 전달을 시도한 뒤에는 peer가 사용 중일 가능성이 있으므로 정상 종료 handshake의 성공 여부를 기준으로 destroy를 결정합니다.
#define _GNU_SOURCE
#include "channel.h"
#include "exchange.h"
#include "fd_transfer.h"
#include <fcntl.h>
#include <stdlib.h>
#include <sys/mman.h>
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s SOCKET\n", argv[0]);
return EXIT_FAILURE;
}
int result = EXIT_FAILURE, listener = -1, peer = -1;
int initialized = 0, published = 0;
int fd = memfd_create("exchange-lab", MFD_CLOEXEC | MFD_ALLOW_SEALING);
struct exchange *box = MAP_FAILED;
if (fd == -1 || ftruncate(fd, (off_t)sizeof(*box)) == -1)
goto out;
if (fcntl(fd, F_ADD_SEALS, F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL) == -1)
goto out;
box = mmap(NULL, sizeof(*box), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (box == MAP_FAILED || exchange_init(box) == -1)
goto out;
initialized = 1;
listener = channel_open(argv[1], 1);
if (listener == -1)
goto out;
peer = channel_accept(listener);
if (peer == -1)
goto out;
published = 1;
if (descriptor_send(peer, fd) == -1 || exchange_server(box) == -1 ||
channel_finished(peer) == -1)
goto out;
puts("exchange complete");
result = EXIT_SUCCESS;
out:
if (result != EXIT_SUCCESS)
perror("memfd server");
if (initialized && (!published || result == EXIT_SUCCESS) &&
exchange_destroy(box) == -1) {
perror("sem_destroy");
result = EXIT_FAILURE;
}
if (box != MAP_FAILED && munmap(box, sizeof(*box)) == -1) {
perror("munmap");
result = EXIT_FAILURE;
}
if (fd != -1 && close(fd) == -1) {
perror("close");
result = EXIT_FAILURE;
}
if (peer != -1 && close(peer) == -1) {
perror("peer close");
result = EXIT_FAILURE;
}
if (listener != -1) {
if (channel_remove(listener, argv[1]) == -1) {
perror("listener cleanup");
result = EXIT_FAILURE;
}
}
return result;
}
memfd_client.c는 받은 descriptor의 size와 필수 seal을 검사합니다. 단순히 SCM_RIGHTS가 왔다는 사실만으로 원하는 형태의 object라고 믿지 않습니다. 확인 후 MAP_SHARED로 mapping합니다.
#define _GNU_SOURCE
#include "channel.h"
#include "exchange.h"
#include "fd_transfer.h"
#include <fcntl.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s SOCKET\n", argv[0]);
return EXIT_FAILURE;
}
int result = EXIT_FAILURE, fd = -1;
int peer = channel_open(argv[1], 0);
struct exchange *box = MAP_FAILED;
struct stat info;
if (peer == -1 || (fd = descriptor_receive(peer)) == -1 ||
fstat(fd, &info) == -1)
goto out;
int required = F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL;
int seals = fcntl(fd, F_GET_SEALS);
if (seals == -1)
goto out;
if (info.st_size != (off_t)sizeof(*box) || (seals & required) != required) {
errno = EPROTO;
goto out;
}
box = mmap(NULL, sizeof(*box), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (box == MAP_FAILED || exchange_client(box) == -1)
goto out;
result = EXIT_SUCCESS;
out:
if (result != EXIT_SUCCESS)
perror("memfd client");
if (box != MAP_FAILED && munmap(box, sizeof(*box)) == -1) {
perror("munmap");
result = EXIT_FAILURE;
}
if (fd != -1 && close(fd) == -1) {
perror("close");
result = EXIT_FAILURE;
}
if (peer != -1 && close(peer) == -1) {
perror("peer close");
result = EXIT_FAILURE;
}
return result;
}
이 예제는 같은 user가 private directory에서 실행하는 한 server와 한 client를 전제합니다. Socket peer 인증, 다중 client, version negotiation과 untrusted writer 격리는 없습니다. sem_t의 representation까지 공유하므로 서로 다른 libc·architecture의 호환 format도 아닙니다.
실행과 lifetime을 함께 확인합니다
다섯 파일 exchange.h, channel.h, fd_transfer.h, memfd_server.c, memfd_client.c를 같은 labs directory에 두고 compile합니다.
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -pthread memfd_server.c -o memfd_server
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -pthread memfd_client.c -o memfd_client
run_dir=$(mktemp -d ./memfd-run-XXXXXX)
./memfd_server "$run_dir/control.sock" &
server=$!
READY가 보인 뒤 같은 shell에서 다음 부분을 실행합니다. Server는 연결을 15초 동안 기다립니다.
./memfd_client "$run_dir/control.sock"
wait "$server"
rmdir "$run_dir"
Client의 reply=42와 server의 exchange complete가 정상 교환의 표시입니다. Descriptor 전달 이후 server가 자신의 fd를 닫는다고 이미 수신한 client fd가 없어지는 것은 아닙니다. Mapping도 object 참조를 유지할 수 있습니다. 모든 descriptor·mapping·전달 중인 참조가 없어져야 anonymous object를 회수할 수 있습니다. 이 때문에 memfd에는 이름을 지우는 shm_unlink() 단계가 없습니다. Socket pathname의 제거는 그와 별개의 cleanup입니다. memfd_create(2)
Protocol 실패 시 client는 자신의 mapping과 descriptor를 닫고 종료합니다. Server도 timeout으로 자신의 참조를 놓습니다. Peer가 계속 참조를 붙잡고 있으면 server가 단독으로 회수를 강제할 수는 없습니다. 또한 SIGKILL로 server의 cleanup이 생략되면 socket pathname은 남을 수 있으므로, 해당 실행의 process 종료와 pathname의 owner를 확인한 뒤 그 socket만 제거해야 합니다.
확인한 범위
2026-11-03, x86-64 WSL2 Linux 6.18.33.2-microsoft-standard-WSL2, GCC 15.2.0, glibc 2.43에서 compile하고 독립 process의 왕복을 여덟 번 실행했습니다. Client 선실행, 연결 부재 timeout, socket 충돌, descriptor를 수신하고 request 없이 연결을 끊은 peer도 시험했습니다. 마지막 경우 server는 timeout으로 실패하면서 socket을 제거했습니다.
별도의 수신 시험에서는 필수 seal 세 개의 존재와 ftruncate(fd, 0)의 EPERM을 확인했습니다. Descriptor 없는 message, 너무 많은 descriptor로 control buffer를 넘긴 message, seal 없는 object를 client가 거절하는 것도 확인했습니다. 모든 경우의 fd 누수를 장기간 측정한 시험은 아니며, 정상 process 종료 후의 자원 정리와 오류 거절을 확인한 범위입니다.
여러 client, credential 인증, 악의적인 shared data 변경, SIGKILL crash recovery, 다른 libc와 architecture는 미검증입니다. 실제로 확인한 kernel version 한 개를 Linux 전체 version의 실행 보장으로 넓히지는 않겠습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access edition, 2025, §14.7.4. 위 코드는 독립 예제입니다.
- Linux man-pages 6.19,
memfd_create(2),F_GET_SEALS(2const),unix(7),cmsg(3),recv(2),sem_destroy(3). 2026-11-03 열람.