seccomp-bpf로 리눅스 커널 시스템 콜 화이트리스트 필터링하기

컨테이너 런타임이나 브라우저 샌드박스가 “이 프로세스는 open, socket 같은 시스템 콜을 못 쓰게 막는다”고 할 때, 그 뒤에는 대부분 seccomp-bpf가 있다. Docker나 systemd의 SystemCallFilter=도 결국 libseccomp을 감싼 것일 뿐, 커널이 실제로 보는 건 BPF 바이트코드 하나뿐이다. 이 글에서는 libseccomp 없이 prctl과 raw cBPF 필터만으로 시스템 콜 화이트리스트를 직접 만들어보고, 화이트리스트에 없는 시스템 콜을 호출했을 때 커널이 실제로 어떻게 반응하는지 확인한다.

핵심 개념

동작(action)설명
SECCOMP_RET_ALLOW시스템 콜을 그대로 실행
SECCOMP_RET_ERRNO커널이 실제로 실행하지 않고 지정한 errno만 반환 (호출자는 실패로만 인지)
SECCOMP_RET_TRAPSIGSYS 시그널을 보내 유저 핸들러가 처리하게 함
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 배열을 직접 관리하기보다 libseccompseccomp_rule_add() 같은 고수준 API로 규칙을 이름 기반으로 작성하는 게 안전하다 — 이 글은 그 내부 동작 원리를 보여주는 목적이다.

마무리

seccomp-bpf는 결국 “시스템 콜 번호를 입력받아 action을 반환하는 BPF 프로그램”이라는 단순한 모델이다. Docker의 기본 seccomp 프로파일, systemd의 SystemCallFilter=, Chrome의 렌더러 샌드박스 모두 이 메커니즘 위에 정책만 다르게 얹은 것이다. 원리를 한 번 직접 구현해보면 왜 컨테이너 이미지에서 특정 시스템 콜이 막혀 있는지, 왜 --security-opt seccomp=unconfined 같은 옵션이 위험한지 훨씬 명확해진다.

참고

답글 남기기