SECCOMP
Sandbox
샌드박스는 외부의 공격으로부터 시스템을 보호하기 위해서 설계된 기법이다. 샌드박스는 특정 종류의 작업만을 허용하는 Allow List와 특정 종류의 작업만을 거부하는 Deny List 두 가지를 선택해 적용할 수 있으며, 애플리케이션의 기능을 수행하는데에 있어서 꼭 필요한 시스템 콜 실행, 파일의 접근만을 허용한다. 이처럼 보호된 영역에서 애플리케이션의 기능이 수행되기 때문에 외부의 공격을 최소화할 수 있는 효과를 가진다.
SECCOMP - SECure COMPuting mode
- 리눅스 커널에서 프로그램의 샌드박싱 매커니즘을 제공하는 컴퓨터 보안 기능이다.
- 애플리케이션에 불필요한 시스템 콜의 호출을 방지할 수 있다.
- 애플리케이션에서 외부의 시스템 명령어를 실행하지 않는다면, execve같은 시스템 콜이 굳이 실행될 필요가 없다.
- 이런 시스템 콜을 공격 시에 일반적으로 임의의 명령어를 실행하기 위해서 사용된다. 따라서 굳이 실행할 필요가 없을 때, 이를 금지할 규칙을 생성하는 것이 시스템 차원에서 보안적으로 유리하다.
- execve 시스템 콜의 실행을 방지하는 정책 (샌드박싱 매커니즘)을 적용시, 외부 공격으로부터 execve 시스템 콜이 실행되면 애플리케이션이 즉시 종료된다.
int __secure_computing(const struct seccomp_data *sd) {
int mode = current->seccomp.mode;
int this_syscall;
...
this_syscall = sd ? sd->nr : syscall_get_nr(current, task_pt_regs(current));
switch (mode) {
case SECCOMP_MODE_STRICT:
__secure_computing_strict(this_syscall); /* may call do_exit */
return 0;
case SECCOMP_MODE_FILTER:
return __seccomp_filter(this_syscall, sd, false);
...
}
}
위의 코드는 SECCOMP 구성 코드이다.
(cf. SECCOMP 설치 명령어 - apt install libseccomp-dev libseccomp2 seccomp)
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 */
};
#endif
static 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();
#endif
do {
if (*allowed_syscalls == this_syscall) return;
} while (*++allowed_syscalls != -1);
#ifdef SECCOMP_DEBUG
dump_stack();
#endif
seccomp_log(this_syscall, SIGKILL, SECCOMP_RET_KILL_THREAD, true);
do_exit(SIGKILL);
}
위의 코드를 확인해보면 prctl함수를 이용해서 해당 모드를 적용하고 있다.
- model_syscalls - read,write,exit,sigreturn 시스템 콜의 번호를 저장하고 있는 변수, 애플리케이션의 호환 모드에 따라서 각 비트에 맞는 시스템 콜 번호를 저장한다.
- 이후 앱에서 시스템 콜이 호출되면 __secure_computing함수에 먼저 진입한다. 해당 함수는 전달된 시스템 콜 번호가 model_syscalls 혹은 model_syscalls2_32에 미리 정의된 번호와 일치하는지 검사한다. 만약 일치하지 않는다면, SIGKILL 시그널을 전달하고 SECCOMP_RET_KILL을 반환한다.
FILTER_MODE
- 원하는 시스템 콜의 호출을 허용하거나 거부할 수 있다.
- 적용 방법
- 라이브러리 함수를 이용
- Berkeley Packet Filter 문법을 통해 적용
1. 라이브러리 함수를 이용
함수 | 설명 |
---|---|
seccomp_init | SECCOMP 모드의 기본 값을 설정하는 함수이다. 임의의 시스템 콜이 호출 되면 이에 해당하는 이벤트가 발생한다. |
seccomp_rule_add | SECCOMP의 규칙을 추가한다. 임의의 시스템 콜을 허용하거나 거부할 수 있다. |
seccomp_load | 앞서 적용한 규칙을 애플리케이션에 반영한다. |
ALLOW LIST
// 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);
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);
}
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));
}
위의 코드는 seccomp 라이브러리 함수를 사용해 지정한 시스템 콜의 호출만을 허용하는 예제 코드이다.
코드의 sandbox() 함수는 seccomp_init의 인자로 전달한 SCMP_ACT_KILL을 통해서 모든 시스템 콜의 호출을 허용하지 않는 규칙을 생성한다. (일단 모든 시스템 콜의 호출을 금지한다.)
이렇게 생성된 규칙에 seccomp_rule_add 함수를 통해서, 세번 째 인자로 전달된 시스템 콜의 호출을 허용하는 코드를 명시하고, 해당 규칙을 적용한다. (마치 화이트리스트를 추가하는 것과 같다.)
이후 main함수에서는 인자를 전달하고 프로그램을 실행하면 “/bin/sh” 파일을 읽고 화면에 출력하지만, 인자를 전달하지 않을 경우 (bannded 함수가 실행된 후)fork
함수가 호출되어 곧바로 프로그램이 종료될 것이다.
DENY LIST
// Name: libseccomp_dlist.c
// Compile: gcc -o libseccomp_dlist libseccomp_dlist.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_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);
}
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));
}
위의 코드를 보면, ALLOW LIST의 경우와 마찬가지로 sandbox() 함수를 호출하고 있는 것을 확인할 수 있다.
그러나, 앞의 경우와는 달리 seccomp_init의 인자로 SCMP_ACT_ALLOW를 전달하고 있다. 이는 이전 SCMP_ACT_KILL과는 달리 모든 종류의 시스템 콜의 호출을 허용하는 규칙을 생성한다. (일단 모든 종류의 시스템 콜을 허용한다.)
이렇게 생성된 규칙에 seccomp_rule_add는 SCMP_ACT_KILL과 함께, 호출을 금지할 시스템 콜을 세번 째 인자로 전달한다. (마치 블랙리스트를 추가하는 것과 같다.)
main 함수는 앞서 만든 규칙을 적용하고나서 “/bin/sh” 파일을 읽고 화면에 출력하는 기능을 하는데, 앞에서 정의한 규칙상 파일을 열기 위해 필요한 함수인 open과 openat 시스템 콜의 호출을 거부한다.
(만약 open 또는 openat 시스템 콜이 호출되면 곧바로 프로그램이 종료된다.)
2. Berkeley Packet Filter (BPF)를 이용
- BPF는 리눅스 커널에서 제공하는 Virtual Machine으로, 본래는 네트워크 패킷을 분석하고 필터링하기 위한 목적으로 사용되었다.
- 그러나 BPF는 임의 데이터를 비교하고, 결과에 따라 특정 구문으로 분기하는 명령어를 제공하기 때문에, 특정 시스템 콜 호출 시에 어떻게 처리할지 명령어를 통해 구현할 수 있다. (아래 표는 이를 위한 명령어의 일부이다.)
명령어 | 설명 |
---|---|
BPF_LD | 인자로 전달된 값을 누산기(Accumulator)에 복사한다. 이를 통해 값을 복사한 후 비교 구문에서 해당 값을 비교할 수 있다. |
BPF_JMP | 지정한 위치로 분기한다. |
BPF_JEQ | 설정한 비교 구문이 일치할 경우, 지정한 위치로 분기한다. |
BPF_RET | 인자로 전달된 값을 반환한다. |
BPF Macro
BPF 코드를 직접 입력하지 않고 편리하게 원하는 코드를 실행할 수 있도록 하는 매크로를 제공한다.
BPF_STMT
- operand에 해당하는 값을 명시한 opcode로 값을 가져옵니다. opcode는 인자로 전달된 값에서 몇 번째 인덱스에서 몇 바이트를 가져올 것인지를 지정할 수 있습니다.
- BPF_STMT(opcode, operand)
BPF_JUMP
- BPF_STMT 매크로를 통해 저장한 값과 operand를 opcode에 정의한 코드로 비교하고, 비교 결과에 따라 특정 오프셋으로 분기합니다.
- BPF_JUMP(opcode, operand, true_offset, false_offset)
ALLOW LIST
아래는 BPF를 통해 지정한 시스템 콜의 호출만을 허용하는 예제 코드이다. 코드의 sandbox 함수를 살펴보면, filter 구조체에 BPF 코드가 작성되어 있는 것을 확인할 수 있다.
// Name: secbpf_alist.c
// Compile: gcc -o secbpf_alist secbpf_alist.c
#include <fcntl.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <unistd.h>
#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)
#define syscall_nr (offsetof(struct seccomp_data, nr))
#define arch_nr (offsetof(struct seccomp_data, arch))
/* architecture x86_64 */
#define ARCH_NR AUDIT_ARCH_X86_64
int sandbox() {
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),
/* List allowed syscalls. */
ALLOW_SYSCALL(rt_sigreturn),
ALLOW_SYSCALL(open),
ALLOW_SYSCALL(openat),
ALLOW_SYSCALL(read),
ALLOW_SYSCALL(write),
ALLOW_SYSCALL(exit_group),
KILL_PROCESS,
};
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, SECCOMP_MODE_FILTER, &prog) == -1) {
perror("Seccomp filter error\n");
return -1;
}
return 0;
}
void 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));
return 0;
}
다음은 위의 코드를 해석한 내용이다.
아키텍쳐 검사
#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), // arch_nr의 특정 위치의 바이트 값을 누산기에 가져온다.
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ARCH_NR, 1, 0), //ARCH_NR과 이를 비교해 분기한다.
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL), // 다른 경우 SECCOMP_RET_KILL을 반환한다.
위의 코드를 통해 현재 아키텍쳐가 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), // 같다면 SECCOMP_RET_ALLOW를 반환
ALLOW_SYSCALL(openat),
ALLOW_SYSCALL(read),
ALLOW_SYSCALL(write),
ALLOW_SYSCALL(exit_group),
KILL_PROCESS, // 같지 않은 시스템 콜이라면 여기에 도달해서 SECCOMP_RET_KILL을 반환하고 종료
호출된 시스템 콜의 번호를 저장하고, ALLOW_SYSCALL 매크로를 호출합니다.
해당 매크로는 호출된 시스템 콜이 인자로 전달된 시스템 콜과 일치하는지 비교하고 같다면 SECCOMP_RET_ALLOW를 반환한다. 만약 다른 시스템 콜이라면 KILL_PROCESS를 호출해 SECCOMP_RET_KILL을 반환하고 프로그램을 종료한다.
DENY LIST
다음은 BPF를 이용해서 지정한 시스템 콜을 호출하지 못하도록 설정한 예제 코드이다.
// Name: secbpf_dlist.c
// Compile: gcc -o secbpf_dlist secbpf_dlist.c
#include <fcntl.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <unistd.h>
#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)
#define syscall_nr (offsetof(struct seccomp_data, nr))
#define arch_nr (offsetof(struct seccomp_data, arch))
/* architecture x86_64 */
#define ARCH_NR AUDIT_ARCH_X86_64
int sandbox() {
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),
/* List allowed syscalls. */
DENY_SYSCALL(open),
DENY_SYSCALL(openat),
MAINTAIN_PROCESS,
};
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, SECCOMP_MODE_FILTER, &prog) == -1) {
perror("Seccomp filter error\n");
return -1;
}
return 0;
}
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));
return 0;
}
위의 코드를 해석한 내용을 다음과 같다. (아키텍쳐 검사에 관련한 코드는 위의 경우와 같아 내용을 생략한다.)
시스템 콜 검사
#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), // 같다면 SECCOMP_RET_KILL을 반환해 프로그램을 종료
MAINTAIN_PROCESS, // 아닐 시, SECCOMP_RET_ALLOW를 통해서 허용
호출된 시스템 콜의 번호를 저장하고, DENY_SYSCALL 매크로를 호출한다. 해당 매크로는 호출된 시스템 콜이 인자로 전달된 시스템 콜과 일치하는지 비교하고 같다면 SECCOMP_RET_KILL을 반환해 프로그램을 종료한다.