○ Sandbox
샌드박스 (Sandbox)는 외부의 공격으로부터 시스템을 보호하기 위해 설계된 기법이다. 샌드박스는 Allow List와 Deny List 두 가지를 선택해 적용할 수 있으며, 애플리케이션의 기능을 수행하는데에 있어서 꼭 필요한 시스템 콜 실행, 파일의 접근만을 허용한다.
sandbox는 웹 브라우저, 가상머신, OS (android, linux) 등의 환경에서 외부의 공격을 최소화하기위한 보안 기능이다.
sandbox 매커니즘은 Seccomp, AppArmor, SELinux, Firejail 등 운영체제 또는 기능에 따라 여러가지가 존재한다. 이 중에서 리눅스 커널에서 제공하는 seccomp에 대해 알아보자.
○ seccomp
seccomp은 리눅스 커널에서 제공하는 샌드박스 매커니즘 중 하나로, 시스템 콜 필터링을 통해 프로세스가 사용할 수 있는 시스템 콜을 제한하는 것이다. 이를 통해 프로세스가 시스템 리소스에 접근할 때 필요한 시스템 콜만을 사용할 수 있도록 제한함으로써 보안성을 강화할 수 있다.
만약, 프로그램에서 시스템 명령어를 실행하지 않는다면, execve와 같은 시스템 콜이 실행될 필요가 없다. 해당 시스템 콜은 공격자가임의의 명령어를 실행하기 위해 사용될 수 있다.
이때, seccomp로 execve 시스템 콜의 실행을 방지하는 정책을 적용하면 외부의 공격으로부터 execve 시스템 콜이 실행될 경우 프로세스를 즉시 종료한다. 따라서 취약점이 존재해도 외부의 공격으로부터 피해를 최소화할 수 있다.
SECCOMP는 2가지 모드가 존재한다.
1) STRICT_MODE
read, write, exit, sigreturn 시스템 콜의 호출만을 허용하여 이외의 시스템 콜의 호출 요청이 들어오면 SIGKILL 시그널을 발생하고 프로그램을 종료한다.
static const int mode1_syscalls[] = {
__NR_seccomp_read,
__NR_seccomp_write,
__NR_seccomp_exit,
__NR_seccomp_sigreturn,
-1, /* negative terminated */
};
#ifdef CONFIG_COMPAT
static int mode1_syscalls_32[] = {
__NR_seccomp_read_32,
__NR_seccomp_write_32,
__NR_seccomp_exit_32,
__NR_seccomp_sigreturn_32,
0, /* null terminated */
};
#endifstatic void __secure_computing_strict(int this_syscall) {
const int *allowed_syscalls = mode1_syscalls;
#ifdef CONFIG_COMPAT
if (in_compat_syscall()) allowed_syscalls = get_compat_mode1_syscalls();
#endifdo {
if (*allowed_syscalls == this_syscall) return;
} while (*++allowed_syscalls != -1);
#ifdef SECCOMP_DEBUG
dump_stack();
#endifseccomp_log(this_syscall, SIGKILL, SECCOMP_RET_KILL_THREAD, true);
do_exit(SIGKILL);
}
model_syscalls는 read, write, exit, sigreturn 시스템 콜의 번호를 저장하고 있는 변수다.
프로세스에서 시스템 콜이 호출되면 __secure_computing 함수에 먼저 진입한다. 해당 함수는 전달된 시스템 콜 번호가 model_syscalls 또는 model_syscalls_32에 미리 정의된 번호와 일치하는지 검사하고, 일치하지 않는다면 SIGKILL 시그널을 전달하고 SECCOMP_RET_KILL을 반환하고 프로세스를 종료한다.
2) FILTER_MODE
원하는 시스템 콜의 호출을 허용하거나 거부할 수 있다. 이를 적용하는 방법은 라이브러리 함수를 이용한 방법과 필터링에 주로 쓰이는 Berkeley Packet Filter (BPF)
문법을 통해 적용하는 방법 두 가지로 나뉜다. BPF는 필터링 규칙을 정의하고, 이 규칙을 사용하여 시스템 콜을 필터링한다.
이를 통해 시스템 콜 필터링을 빠르고 효과적으로 수행할 수 있다.
다음은 FILTER_MODE에서 사용하는 함수들의 목록이다.
함수명 | 기능 |
---|---|
seccomp_init | SECCOMP 모드의 기본 값을 설정하는 함수다. 임의의 시스템 콜이 호출되면 이에 해당하는 이벤트가 발생한다. |
seccomp_rule_add | SECCOMP의 규칙을 추가한다.임의의 시스템 콜을 허용하거나 거부할 수 있다. |
seccomp_load | 앞서 적용한 규칙을 애플리케이션에 반영한다. |
FILTER_MODE는 allow list와 deny list가 존재한다.
각각 어떤식으로 구현되는지 알아보자.
○ Allow List
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);
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);
seccomp_load(ctx);
}
먼저, SCMP_ACT_KILL을 통해 모든 시스템 콜의 호출을 허용하지 않는 규칙을 생성한다.
이렇게 생성된 규칙에 seccomp_rule_add()
를 통해 세 번째 인자로 전달된 시스템 콜의 호출을 허용하는 코드를 명시하고, 규칙을 적용한다.
○ Deny List
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(openat), 0);
seccomp_load(ctx);
}
SCMP_ACT_ALLOW를 통해 모든 시스템 콜의 호출을 허용하는 규칙을 생성한다.
이렇게 생성된 규칙에 seccomp_rule_add()
를 통해 세 번째 인자로 전달된 시스템 콜의 호출을 거부하는 규칙을 생성한다.
int main(int argc, char *argv[]) {
char buf[256];
int fd;
memset(buf, 0, sizeof(buf));
sandbox();
fd = open("/bin/sh", O_RDONLY);
read(fd, buf, sizeof(buf) - 1);
write(1, buf, sizeof(buf));
}
sandbox 함수를 생성하고 위와 같이 main()
에서 실행하면 이후에 호출되는 시스템 콜을 seccomp로 검증하고 규칙에서 허용하는 시스템 콜만 실행한다.
○ BPF
추가로 seccomp는 BPF를 이용해서도 정의할 수 있다.
BPF는 커널에서 지원하는 Virtual Machine (VM)으로, 본래에는 네트워크 패킷을 분석하고 필터링하는 목적으로 사용한다. 이는 임의 데이터를 비교하고, 결과에 따라 특정 구문으로 분기하는 명령어를 제공한다. 따라서 특정 시스템 콜 호출 시에 어떻게 처리할지 명령어를 통해 구현할 수 있다.
#define arch_nr (offsetof(struct seccomp_data, arch))
#define ARCH_NR AUDIT_ARCH_X86_64
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),
BPF로 아키텍처에 대한 검증 정의한 코드다.
아키텍처가 X86_64라면 다음 코드로 분기하고, 다른 아키텍처라면 SECCOMP_RET_KILL을 반환하고 프로그램을 종료한다.
#define ALLOW_SYSCALL(name) \
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_##name, 0, 1), \
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW
#define KILL_PROCESS \
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL)
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
ALLOW_SYSCALL(rt_sigreturn),
ALLOW_SYSCALL(open),
ALLOW_SYSCALL(openat),
ALLOW_SYSCALL(read),
ALLOW_SYSCALL(write),
ALLOW_SYSCALL(exit_group),
KILL_PROCESS,
BPF로 Allow List를 정의한 코드다.
ALLOW_SYSCALL 매크로를 호출하여 허용할 시스템 콜을 정의한다.
호출된 시스템 콜이 인자로 전달된 시스템 콜과 일치하는지 비교하고 같다면 SECCOMP_RET_ALLOW를 반환하고 시스템 콜을 실행한다. 다른 시스템 콜이라면 KILL_PROCESS를 호출해 SECCOMP_RET_KILL을 반환하고 프로그램을 종료한다.
#define DENY_SYSCALL(name) \
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_##name, 0, 1), \
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL)
#define MAINTAIN_PROCESS \
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW)
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
DENY_SYSCALL(open),
DENY_SYSCALL(openat),
MAINTAIN_PROCESS,
BPF로 Deny List를 정의한 코드다.
DENY_SYSCALL 매크로를 호출하여 거부할 시스템 콜을 정의한다. 호출된 시스템 콜이 인자로 전달된 시스템 콜과 일치하는지 비교하고 같다면 SECCOMP_RET_KILL을 반환해 프로그램을 종료한다. 다른 시스템 콜이라면 실행한다.
지금까지 설명한 seccomp로 시스템 콜에 대한 부분을 제어함으로써 보안을 강화할 수 있다. 하지만 seccomp도 우회할 수 있는 방법들이 존재한다.
○ Seccomp Bypass
1) 타 시스템 콜 호출
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
만약, open, read 시스템 콜을 거부하는 정책이 적용됐다고하자. 그렇다면 특정 파일을 열고 읽는 것은 아에 불가능할까? → NO!
같은 기능을 하는 다른 시스템 콜들이 존재한다.
open 대체로 사용할 수 있는 시스템 콜은 openat
이 있다. openat
은 두 번째 인자인 pathname이 절대 경로로 명시되어 있을 경우 첫 번째 인자인 dirfd가 무시된다. 따라서 해당 시스템 콜의 번호를 알아내고 두 번째 인자에 파일 경로 문자열의 주소를 전달하면 파일의 내용을 읽을 수 있다.
write 대체로 사용할 수 있는 시스템 콜은 sendfile
이 있다. 읽을 파일의 fd를 두 번째 인자인 in_fd에 삽입하고, 표준 출력(STDOUT)의 fd인 1을 out_fd에 삽입하면 파일의 내용을 읽을 수 있다.
이와 같은 예제처럼 같은 기능을 수행하는 다른 시스템 콜을 이용해서 bypass 할 수 있다.
2) Application Binary Interface (ABI)
시스템은 다양한 아키텍처가 존재한다. 따라서 아키텍처 별로 명령어 세트와 기능, 크기 등이 다르기 때문에 아키텍처에 맞는 것을 선택해 사용한다. 따라서 커널 코드는 이 모든 것을 고려한 코드로 작성되어 있다. 64 비트 운영 체제에서 32 비트 애플리케이션을 호환하는 것 또한 이에 포함된다.
중요한 것은 아키텍처 별로 시스템 콜 번호가 다른 점과 서로 다른 아키텍처를 호환하기 위한 코드를 이용해 우회를 할 수 있다는 것이다.
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
/* Invalid system call, but still a system call. */
regs->ax = __x64_sys_ni_syscall(regs);
}
코드는 시스템 콜을 호출하기위해 사용하는 함수의 일부다.
do_syscall_x64()
를 먼저 호출한다. 이때, 시스템 콜 호출이 실패하면 do_syscall_x32()
도 호출하는 것을 알 수 있다.
이런 점을 이용해서 do_syscall_x32()
를 통해 원하는 시스템 콜을 호출하는 방식으로 seccomp를 bypass 할 수 있다. 리눅스 커널은 x64와 x32를 구별하기 위해 시스템 콜 번호에 특정 값을 사용하는데, 이 값이 0x40000000이다.
static __always_inline bool do_syscall_x32(struct pt_regs *regs, int nr)
{
/*
* Adjust the starting offset of the table, and convert numbers
* < __X32_SYSCALL_BIT to very high and thus out of range
* numbers for comparisons.
*/
unsigned int xnr = nr - __X32_SYSCALL_BIT;
if (IS_ENABLED(CONFIG_X86_X32_ABI) && likely(xnr < X32_NR_syscalls)) {
xnr = array_index_nospec(xnr, X32_NR_syscalls);
regs->ax = x32_sys_call_table[xnr](regs);
return true;
}
return false;
}
do_syscall_x32()
를 보면 호출하는 시스템 콜 번호에서 __X32_SYSCALL_BIT 값인 0x400000000을 뺀 시스템 콜 번호를 사용한다.
seccomp는 시스템 콜 번호로 정의해서 시스템 콜에 대한 제어를 수행함으로 x64 기준의 번호만 seccomp로 지정되어있다면 x32에 해당하는 시스템 콜로 번호를 변환해서 호출하면 do_syscall_x32()
를 통해 원하는 시스템콜을 실행할 수 있다.
mov rax, 2
or rax, 0x40000000
lea rdi, [rip+path]
xor rsi, rsi
syscall
예를 들어 위와 같이 open 시스템 콜 번호인 2에 0x400000000을 연산함으로써 seccomp를 bypass하고 do_syscall_x32()
를 통해 open 시스템 콜이 호출되도록 유도할 수 있다.
flag 파일 위치는 Dockerfile을 통해 알 수 있다.
reference
[dreamhack] Background: SECCOMP