컨테이너 런타임이나 브라우저 샌드박스가 “이 프로세스는 open, socket 같은 시스템 콜을 못 쓰게 막는다”고 할 때, 그 뒤에는 대부분 seccomp-bpf가 있다. Docker나 systemd의 SystemCallFilter=도 결국 libseccomp을 감싼 것일 뿐, 커널이 실제로 보는 건 BPF 바이트코드 하나뿐이다. 이 글에서는 libseccomp 없이 prctl과 raw cBPF 필터만으로 시스템 콜 화이트리스트를 직접 만들어보고, 화이트리스트에 없는 시스템 콜을 호출했을 때 커널이 실제로 어떻게 반응하는지 확인한다.
핵심 개념
| 동작(action) | 설명 |
|---|---|
SECCOMP_RET_ALLOW | 시스템 콜을 그대로 실행 |
SECCOMP_RET_ERRNO | 커널이 실제로 실행하지 않고 지정한 errno만 반환 (호출자는 실패로만 인지) |
SECCOMP_RET_TRAP | SIGSYS 시그널을 보내 유저 핸들러가 처리하게 함 |
SECCOMP_RET_KILL_PROCESS | 프로세스를 즉시 종료 (가장 엄격) |
필터는 struct sock_filter 배열, 즉 cBPF(classic BPF) 프로그램이다. 커널은 시스템 콜이 들어올 때마다 struct seccomp_data(시스템 콜 번호와 인자)를 이 프로그램에 입력으로 넣고 반환된 action에 따라 처리하며, 필터를 걸기 전엔 반드시 PR_SET_NO_NEW_PRIVS를 켜야 setuid 바이너리가 필터를 우회해 권한 상승하는 걸 막을 수 있다.
실전: 화이트리스트 필터 직접 작성하기
write/exit/close 등 최소한의 시스템 콜만 허용하고, 나머지는 EPERM으로 막는 필터를 만든다.
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stddef.h>
#define ALLOW(nr) \
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, nr, 0, 1), \
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW)
static int install_filter(void) {
struct sock_filter filter[] = {
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr)),
ALLOW(SYS_write),
ALLOW(SYS_exit_group),
ALLOW(SYS_exit),
ALLOW(SYS_rt_sigreturn),
ALLOW(SYS_close),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
};
struct sock_fprog prog = {
.len = sizeof(filter) / sizeof(filter[0]),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { perror("no_new_privs"); return 1; }
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) { perror("seccomp"); return 1; }
return 0;
}
int main(void) {
int fd = open("/etc/hostname", O_RDONLY);
printf("open() 결과(필터 전): fd=%d\n", fd);
if (fd >= 0) close(fd);
if (install_filter() != 0) return 1;
printf("필터 적용 후: write는 허용되어 이 메시지가 보인다\n");
fd = open("/etc/hostname", O_RDONLY);
if (fd == -1) {
printf("open() 결과(필터 후): errno=%d (%s)\n", errno, strerror(errno));
} else {
printf("open() 결과(필터 후): fd=%d (차단 안 됨, 예상과 다름)\n", fd);
}
return 0;
}$ gcc -o seccomp_demo seccomp_demo.c && ./seccomp_demo
open() 결과(필터 전): fd=3
필터 적용 후: write는 허용되어 이 메시지가 보인다
open() 결과(필터 후): errno=1 (Operation not permitted)
필터를 걸기 전에는 open이 정상적으로 fd 3을 반환하지만, 필터를 건 뒤에는 화이트리스트에 없는 openat(glibc가 open()을 openat()으로 구현)이 커널 단계에서 차단되어 errno=EPERM으로 실패한다. write는 화이트리스트에 있으므로 printf 출력 자체는 계속 정상적으로 보인다.
실전: SECCOMP_RET_KILL_PROCESS로 강제 종료하기
이번엔 기본 action을 SECCOMP_RET_ERRNO 대신 SECCOMP_RET_KILL_PROCESS로 바꿔, 화이트리스트에 없는 시스템 콜을 호출하는 순간 프로세스 전체가 죽는 걸 확인한다.
static void install_kill_filter(void) {
struct sock_filter filter[] = {
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, offsetof(struct seccomp_data, nr)),
ALLOW(SYS_write),
ALLOW(SYS_fstat),
ALLOW(SYS_getrandom),
ALLOW(SYS_brk),
ALLOW(SYS_mmap),
ALLOW(SYS_exit_group),
ALLOW(SYS_rt_sigreturn),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL_PROCESS),
};
struct sock_fprog prog = { .len = sizeof(filter)/sizeof(filter[0]), .filter = filter };
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}
int main(void) {
pid_t pid = fork();
if (pid == 0) {
install_kill_filter();
printf("자식: 필터 적용 완료, 화이트리스트에 없는 open() 호출\n");
fflush(stdout);
open("/etc/hostname", O_RDONLY);
printf("자식: 여기까지 오면 안 됨\n");
return 0;
}
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
printf("부모: 자식이 시그널로 종료됨 (signal=%d)\n", WTERMSIG(status));
}
return 0;
}$ gcc -o seccomp_kill seccomp_kill.c && ./seccomp_kill
자식: 필터 적용 완료, 화이트리스트에 없는 open() 호출
부모: 자식이 시그널로 종료됨 (signal=31)
signal 31은 SIGSYS다. fstat/getrandom/brk/mmap을 허용 목록에서 하나씩 빼며 strace로 확인해보니, open()이 아니라 printf가 내부적으로 쓰는 fstat(stdout 종류 확인)이나 getrandom(malloc의 랜덤 가드값)에서 먼저 죽었다 — write 하나만 허용해서는 printf 한 줄도 못 찍고 죽는다는 뜻이다.
주의사항
- 이 데모 필터는 시스템 콜 번호만 검사한다. 실제로는 아키텍처(
struct seccomp_data.arch)를AUDIT_ARCH_X86_64와 먼저 비교해야 한다 — 그렇지 않으면 32비트 syscall 진입 경로로 필터를 우회할 여지가 생긴다. - 시스템 콜 인자 값까지 검사하려면
seccomp_data.args[]의 오프셋을 읽어야 하는데, 64비트 인자가 상위/하위 32비트로 나뉘어 저장되므로 엔디안을 고려해 두 워드를 각각 비교해야 한다. - 필터는 한 번 설치하면 프로세스가 종료될 때까지 제거할 수 없고,
fork/exec이후 자식 프로세스에도 그대로 상속된다. - 실제 화이트리스트는 짐작보다 훨씬 넓어야 한다. glibc의 malloc/stdio 같은 기본 동작만으로도
fstat/getrandom/brk/mmap같은 시스템 콜이 필요하므로, 눈대중으로 목록을 짜지 말고strace로 실제 호출을 먼저 뜬 뒤 화이트리스트를 채우는 게 안전하다. - 실제 프로덕션에서는 이런 raw BPF 배열을 직접 관리하기보다
libseccomp의seccomp_rule_add()같은 고수준 API로 규칙을 이름 기반으로 작성하는 게 안전하다 — 이 글은 그 내부 동작 원리를 보여주는 목적이다.
마무리
seccomp-bpf는 결국 “시스템 콜 번호를 입력받아 action을 반환하는 BPF 프로그램”이라는 단순한 모델이다. Docker의 기본 seccomp 프로파일, systemd의 SystemCallFilter=, Chrome의 렌더러 샌드박스 모두 이 메커니즘 위에 정책만 다르게 얹은 것이다. 원리를 한 번 직접 구현해보면 왜 컨테이너 이미지에서 특정 시스템 콜이 막혀 있는지, 왜 --security-opt seccomp=unconfined 같은 옵션이 위험한지 훨씬 명확해진다.