[SECCOMP] prctl를 이용한 SECCOMP 정리
목차
0x00. Introduction
SECCOMP(SECure COMPuting mode)는 프로세스 샌드박싱을 제공하는 기능이다. 자세하게는 프로세스에서 실행되는 syscall을 제한시켜, 호출 가능한 것 이외의 syscall이 호출될 경우 프로세스를 종료시킨다(SIGKILL). 다른 보호 기법들처럼 컴파일 시 적용하는게 아니라 소스코드 단에서 설정하는 코드를 넣어주어야 한다.
예전에는 /proc/<pid>/seccomp 파일의 값을 통해 활성화 했었던 것 같으나, prctl 혹은 sys_seccomp를 통해서 설정되는 것으로 바뀌었다. (언제부터인지는 모르겠다…)
본 포스트에서는 prctl 함수를 통한 SECCOMP 기법을 기술한다.
0x01. prctl 함수
int ;
prctl 함수는 프로세스나 쓰레드의 속성을 관리하기 위한 함수이다(PRocess ConTroL). 자세하게 설명할 SECCOMP 적용 이외에도 프로세스 이름을 얻는다든가, 엔디안 상태를 얻는다든가하는 다양한 행위를 할 수 있다. 원하는 행위에 따라 가변 인자를 받아서 동작하는 구조이다.
/* Values to pass as first argument to prctl() */
/* Get/set current->mm->dumpable */
/* Get/set unaligned access control bits (if meaningful) */
/* Set/Get process name */
/* Get/set process endian */
/* Get/set process seccomp mode */
// -----------------------------------------
// -----------------------------------------
prctl.h를 확인해보면 첫 번째 인자인 option에 들어가는 매크로의 값들을 확인할 수 있다. 이 중에서 SECCOMP와 관련이 있는 PR_SET_NO_NEW_PRIVS와 PR_SET_SECCOMP에 대해 자세히 알아보자.
PR_SET_NO_NEW_PRIVS
int ;
현재 프로세스의 no_new_privs 속성을 value 값으로 설정해주는 동작을 수행한다.
이 속성은 리눅스 커널 4.10 이후부터는 /proc/<pid>/status의 NoNewPrivs 필드에서 확인할 수 있다고 한다. 이 속성이 1으로 설정되어있으면 현재 프로세스와 자식 프로세스에서 새로운 권한을 주는 명령을 수행할 수 없게 된다. 다만 권한을 제거하는 명령은 여전히 수행할 수 있다고 한다.
이 속성이 왜 중요한지는 SECCOMP_MODE_FILTER 파트에서 서술하겠다.
PR_SET_SECCOMP
int ;
드디어 SECCOMP를 실제 적용하는 부분으로, 두 가지 모드가 존재한다.
/* Valid values for seccomp.mode and prctl(PR_SET_SECCOMP, <mode>) */
seccomp.h를 확인해보면 모드가 각각 매크로로 정의되어있다.
SECCOMP_MODE_STRICT
read, write, exit, sigreturn 네 가지 syscall만 가능한 모드로 이미 가능한 syscall이 정해져있기 때문에 세 번째 인자가 필요없다.
int
예시 코드를 컴파일해서 실행한 결과, open에서 SIGKILL이 발생했다.
SECCOMP_MODE_FILTER
사용자가 직접 어떤 syscall을 차단할지 룰 셋을 만들어서 SECCOMP를 설정하는 모드이다. 앞서 언급한 PR_SET_NO_NEW_PRIVS를 통해 no_new_privs 속성이 설정되어야 filter 모드를 실행할 수 있다.
이 때 룰 셋은 Berkeley Packet Filter(BPF)라는 어셈블리같은 문법을 사용하는데, seccomp-tools 부분에서 자세하게 다뤄보자. 다음은 write syscall을 호출하지 못하게 필터링한 모드의 예시 코드이다.
static unsigned char filter =
;
int
)
예시 코드를 컴파일해서 실행한 결과, write에서 SIGSYS가 발생했다. Strict mode에서의 SIGKILL과는 다른 메세지가 출력되길래 디버깅을 해봤는데 filter mode에서는 SIGSYS로 인해 프로세스가 종료되는 것을 확인했다.
0x02. seccomp-tools
SECCOMP_MODE_FILTER의 예시 코드를 보면, filter 배열에 필터링 룰을 바이트 코드처럼 바꾸어서 넣어야 한다. 하지만 아무리 숙련자라고 하더라도 원하는 BPF 룰을 자유자재로 바이트 코드화 하기는 어렵다. 이럴 때 쓰기 좋은 것이 바로 seccomp-tools이다.
seccomp-tools 설치는 위와 같이 하면 된다.
)
Usage에서 확인할 수 있듯이 asm, disasm, dump, emu 기능을 지원하고 있다.
asm
BPF로 작성한 룰을 바이트 코드로 변환해주는 기능으로 BPF는 주로 다음과 같이 작성한다.
A = arch
if (A != ARCH_X86_64) goto dead
A = sys_number
if (A >= 0x40000000) goto dead
if (A == write) goto ok
if (A == close) goto ok
if (A == dup) goto ok
if (A == exit) goto ok
return ERRNO(5)
ok:
return ALLOW
dead:
return KILL
뜬금없이 A라는 변수가 등장해서 처음에 뭐지 싶었는데, 그냥 임의 변수라고 생각하면 편하다.
이제 이 내용을 파일로 저장해서 seccomp-tools의 인자로 전달해주면 된다.
;
#include <linux/seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
) {
;
;
if()) { ); ); }
if( &)) { ); ); }
}
예시를 보면 결과를 다양한 포맷으로 출력할 수 있는데, -f 옵션에 raw, c_array, c_source, assembly 등 바로 사용하기 좋은 포맷들이 있다.
disasm
asm과는 반대로 바이트 코드 형식의 BPF를 필터링 룰로 변환해준다. 입력 파일로 바이트 코드가 저장된 파일을 인자로 주면 된다.
=================================
)
)
)
)
)
)
)
|
=================================
)
)
)
)
)
)
)
asm기능과 연계해서 출력값을 raw 포맷으로 지정한 뒤 파이프라인을 연결해주면 BPF 룰이 제대로 작성되었는지 확인할 수 있다.
dump
바이너리 내에 적용되는 BPF 룰을 출력해주는 기능이다. 동작 방식이 궁금해서 찾아보니 ptrace를 이용하여 동적으로 분석해주는 모양이다.
단 첫 번째 prctl(PR_SET_SECCOMP)를 기준으로 룰을 출력해주기 때문에 prctl 함수가 여러번 호출된다면 실제와 다를 수 있다. 그럴 때는 -l 혹은 --limit 옵션을 줘서 검사할 prctl 함수의 개수를 늘릴 수 있다.
또한 -p 혹은 --pid 옵션을 줘서 현재 실행중인 프로세스에 걸려있는 룰을 확인할 수도 있다.
=================================
)
)
=================================
)
)
dump 기능을 이렇게 활용할 수 있다.
emu
emu는 룰셋을 에뮬레이팅해서 syscall이 잘 호출되는지 혹은 잘 차단되는지를 확인하기 좋은 기능이다.
bash에서 사용하면 색깔이 입혀져서 출력이 나오기 때문에 보기 편하다.


0x03. Expected Vulnerability
당연히 코딩을 어떻게 하느냐에 따라 다르겠지만 발생할 법한 취약점들을 생각해보았다. 좋은 아이디어나 댓글이 있다면 추가할 예정이다.
x32 Syscall
앞선 seccomp-tools disasm의 예시에서 이런 룰이 있었다.
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x08 0xc000003e if (A != ARCH_X86_64) goto 0010
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x06 0x00 0x40000000 if (A >= 0x40000000) goto 0010
0001 라인은 아키텍처가 X86_64인지 확인하는 로직이고, 0003 라인의 sys_number가 0x40000000보다 큰지는 왜 확인하는걸까?
이유는 X86_64 아키텍처의 호환성에 있다. 64비트 아키텍처에서 이전 32비트에서 사용하던 명령어들을 그대로 사용할 수 있게끔 개발되었는데, 이런걸 x32 ABI라고 한다. 때문에 64비트 아키텍처에서도 32비트의 syscall을 호출할 수 있는데, 그 방법이 리눅스에서는 64bit syscall number에 0x40000000을 더하는 것이다.
실제 리눅스 커널에서 32비트 syscall을 호출했을 때 호출되는 do_syscall_x32 함수의 코드를 보자.
static __always_inline bool
함수의 첫 줄에서 xnr = nr - __X32_SYSCALL_BIT으로 할당이 되는데, 이 때 __X32_SYSCALL_BIT 값이 사전 정의된 0x40000000이라고 한다.
따라서 어떤 64비트 바이너리의 BPF 룰에 syscall number가 0x40000000보다 작은지 검증하는 로직이 존재하지 않는다면, 특정 syscall이 차단되었다고 하더라도 x32 ABI를 이용하여 syscall number에 0x40000000을 더해서 32비트 아키텍처의 syscall을 호출할 수 있다.
Filter Overwrite
가장 단순하게 떠오른 생각으로 메모리에 있는 BPF 필터 룰 부분을 SECCOMP가 설정되기 전에 원하는 값으로 덮을 수 있을 때 발생할 수 있는 취약점이다. 원하는 syscall을 호출할 수 있도록 룰을 바꿔준다든가, 룰을 return ALLOW로 도배한다든가하는 방법이 있을 것이다.
이와 관련된 문제가 dreamhack.io에 있으니 풀어보길 추천한다.
SECCOMP Bypass
PR_SET_SECCOMP 하다가 알게 된 사실인데, BPF 룰이 좀 잘못되어있으면 prctl 함수가 에러만 리턴하고 프로세스를 종료시키지는 않는다.
if ()
|
=================================
)
잘못된 BPF룰의 예시로, 잘 보면 0001 라인에서 goto 0005라고 되어있다. wrong.txt를 구성할 때 goto에서 생각 없이 return KILL이 위치하게 될 3번 라인으로 가라고 했는데, 알고보니 상대주소 개념으로 값을 넣어주어야 했다.
예를 들어 현재 라인인 0001에서 goto 0이면 다음 라인인 0002, goto 1이면 다음 X 2 라인인 0003으로 가라는 식으로 해석되어서, goto 3은 0005 라인으로 가라는 명령어가 되었다. wrong.txt에는 0005라인이 존재하지 않으니 prctl의 옵션으로 전달 시 에러가 발생한다.
잘못된 룰을 적용했을 때 이런 식으로 SECCOMP 에러가 발생한다. 결과적으로 write syscall을 차단하려는 룰이 적용되지 않아 입력받은 문자열을 STDOUT에 출력할 수 있게 되었다.
따라서 Filter Overwrite처럼 필터 전체를 덮어쓰지 못하더라도 몇 바이트로 룰 자체를 말이 안되게 할 수 있다면 에러는 발생하되 프로세스는 유지되므로 SECCOMP bypass가 가능할 것이다.