Go 프로그램에서 chroot, setresuid, setrlimit, pledge, unveil, seccomp, Landlock 등을 사용해 최소 권한 원칙을 적용하는 방법을 살펴봅니다.
컴퓨터 프로그램은 의도한 일과 의도하지 않은 일을 포함해 많은 일을 할 수 있습니다. 프로그램이 할 수 있는 일은 그 권한에 의해 제한됩니다. 대부분의 운영체제는 특정 사용자로 프로그램을 실행하므로, 프로그램은 그 사용자의 소중한 권한을 모두 가지게 됩니다.
구체적인 예를 들어 보겠습니다. 사용자가 SSH 개인 키를 어딘가에 두고 예를 들어 채팅 프로그램을 실행한다면, 이 프로그램은 그 키와 아무 관련이 없음에도 개인 키를 읽을 수 있습니다. 이 채팅 프로그램에 취약점이 있다고 가정하면, 공격자는 조작된 메시지를 통해 채팅 프로그램에게 개인 키를 외부로 유출하라고 지시할 수 있습니다.
문제의 핵심은 아닐지 몰라도, 피해의 근본 원인은 프로그램이 애초에 접근할 수 없어야 할 리소스에 접근할 수 있었다는 점입니다. 안전한 소프트웨어 작성은 이 글의 범위를 벗어나므로, 어떤 수단으로든 최소 권한 원칙을 강제했다면 그 개인 키는 지켜질 수 있었을 것입니다. 간단히 말해 각 구성 요소, 즉 채팅 소프트웨어는 필요한 권한만 가져야 하며 그 이상은 가지면 안 됩니다. 이 상태에 이르는 길은 여러 가지가 있을 수 있습니다. 예를 들어 개인 키를 다루는 작업과 채팅에 같은 사용자를 쓰지 않거나, 채팅 애플리케이션을 샌드박싱하는 방법이 있습니다.
소프트웨어를 개발할 때 개발자는 자신의 도구가 무엇을 할 수 있어야 하는지 알고 있어야 합니다. 그러면 시스템 기능의 도움을 받아 허용된 영역을 깎아내고 나머지는 모두 거부할 수 있습니다. 비유하자면, 보름달이 뜨기 전에 늑대인간이 스스로를 쇠사슬로 묶는 것과 같습니다.
지금 이 글을 읽으며 내 코드는 절대 실패하지 않을 테니 왜 이런 일을 해야 하냐고 생각하고 있다면, 오히려 바로 그런 경우에 특히 해야 합니다. 세상에 있는 대부분의 애플리케이션에서 문제는 망가질 수 있는가 가 아니라 언제 망가질 것인가 입니다. 저도 수년 동안 많은 버그를 만들었고, 손에 닿지 못한 익스플로잇들을 보아 왔기에, 앞으로의 모든 늑대인간을 스스로 쇠사슬로 묶으려 합니다.
스스로를 제한하는 소프트웨어의 핵심 개념은 한 번 포기한 권한은 다시 되찾을 수 없다는 것입니다. 예를 들어 프로그램이 스스로 파일 시스템 접근을 거부하면, 더 이상 어떤 파일도 열 수 없습니다.
소프트웨어는 특정 사용자로 시작하며, 때로는 제한된 네트워크 포트를 사용하기 위해 root 사용자로 시작하기도 합니다. 따라서 이 포트에서 듣기 시작한 뒤에는 이 권한을 버릴 수 있습니다. 예를 들어 파일 디스크립터는 유지한 채 비특권 사용자로 전환하는 식입니다. 그러면 소프트웨어는 이전에 바인드한 포트에서 계속 연결을 받을 수 있지만, 다른 제한된 포트에서 새로 수신을 시작할 수는 없습니다.
이런 제한은 소프트웨어를 설계할 때 고려되어야 합니다. 필요할 때마다 모든 리소스에 접근하는 대신, 스스로를 제한하기 전에 초반 단계에서 필요한 리소스를 확보해야 합니다. 또 하나의 다소 미심쩍은 비유를 들자면, 깔때기나 뒤집힌 원뿔을 떠올려 보십시오. 프로그램은 처음에 많은 권한을 가지고 시작하지만, 진행하면서 권한을 하나씩 내려놓아 결국 최소한만 남긴 채 계속 동작합니다.
먼저 고전적인 접근인 chroot 와 사용자/그룹 변경부터 시작해 보겠습니다. 이것을 고전적이라고 부르는 이유는 이 방식이 1990년대 초반까지 거슬러 올라가며, 모든 POSIX 유사 운영체제에서 동작하기 때문입니다. BSD, Linux 같은 것들을 떠올리면 됩니다.
불행히도 이 접근에는 필요한 시스템 호출이 root 사용자에게만 허용된다는 번거로움이 있습니다. 대부분의 경우 데몬에는 문제가 되지 않지만, GUI 같은 최종 사용자 애플리케이션에는 장애물이 됩니다. 어떤 SUID 파일 플래그 광기를 권하는 대신, root 없이 쓸 수 있는 안전한 대안은 뒤에서 다루겠습니다.
chroot(2)우선 chroot(2) 는 프로세스의 루트 디렉터리를 지정한 디렉터리로 바꿉니다. 예를 들어 / 는 /var/empty 가 되고, /etc/passwd 에 접근하면 실제로는 /var/empty/etc/passwd 를 열려고 시도합니다. chroot(2) 를 활성화하려면 프로세스가 그 안으로 chdir(2) 해야 합니다.
추가 조치를 하지 않으면 공격자는 chroot 를 탈출할 수 있습니다. 사실 chroot 는 보안 기능 그 자체는 아니지만, 이 글이 시도하는 것처럼 보안 기능을 만드는 데 사용할 수는 있습니다. 그럼에도 제한 사항은 꼭 인지해 두시기 바랍니다.
chroot 는 프로세스에 직접 영향을 줍니다. 어떤 파일과도 상호작용하면 안 된다면 /var/empty 나 막 만든 빈 디렉터리로 chroot 하는 것이 타당합니다. 디렉터리가 하나뿐이라면 그 디렉터리로 chroot 하는 것도 고려할 수 있습니다. 하지만 서로 다른 위치의 파일에 접근해야 한다면, 엄격한 chroot 는 부담이 될 수 있습니다. 이 결정은 상황마다 따로 내려야 합니다.
golang.org/x/sys/unix 를 임포트한 뒤에는, 아래 코드 조각만으로 프로세스를 /var/empty 로 chroot(2) 할 수 있습니다. OpenBSD의 hier(2)에 따르면 이곳은 “일반적인 chroot(2) 디렉터리”입니다.
if err := unix.Chroot("/var/empty"); err != nil {
log.Fatalf("chroot: %v", err)
}
if err := unix.Chdir("/"); err != nil {
log.Fatalf("chdir: %v", err)
}
setuid(2) 또는 setresuid(2)이제 프로세스는 빈 디렉터리로 chroot 되었지만, 그 외에는 여전히 root 로 실행되고 있습니다. root 권한을 포기하려면, 아무 특권도 없는 비특권 사용자로 사용자 권한을 전환하면 됩니다.
세월이 흐르면서 사용자 전환을 위한 여러 시스템 호출이 등장했으며, 시작점은 setuid(2) 입니다. 엄밀히 말해 POSIX의 일부는 아니지만, setresuid(2) 시스템 호출은 대부분의 운영체제에서 사용할 수 있습니다. 이 호출은 real, effective, saved 사용자 ID를 설정할 수 있으며, 이들 사이에는 미묘한 차이가 있습니다. 이런 차이는 SUID 애플리케이션을 개발하거나 사용할 때 드러날 수 있는데, 예를 들어 실제 사용자 ID는 일반 사용자이고 effective 사용자 ID는 root 일 수 있습니다. 하지만 여기서는 단순히 모든 권한을 비특권 사용자에게 넘기고 싶을 뿐이므로, 세 사용자 ID를 모두 같은 사용자로 설정하면 됩니다.
그룹에도 setresgid(2) 로 같은 원리가 적용됩니다. 또한 프로세스는 여러 그룹에 속할 수 있으므로, setgroups(2) 로 이 목록을 줄일 수 있습니다. 이렇게 하면 지정한 그룹의 권한만 적용되고, 사용자가 속한 다른 그룹 권한은 적용되지 않습니다.
예제를 위해 비특권 작업 사용자 하나를 만듭니다. Linux 에서는 다음과 같이 할 수 있습니다.
$ sudo useradd \
--home-dir /var/empty \
--system \
--shell /run/current-system/sw/bin/nologin \
--user-group \
demoworker
$ id demoworker
uid=992(demoworker) gid=987(demoworker) groups=987(demoworker)
이어서 다음의 짧은 코드 블록을 사용합니다.
// Prior chroot code
uid, gid := 992, 987
if err := unix.Setgroups([]int{gid}); err != nil {
log.Fatalf("setgroups: %v", err)
}
if err := unix.Setresgid(gid, gid, gid); err != nil {
log.Fatalf("setresgid: %v", err)
}
if err := unix.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("setresuid: %v", err)
}
이 코드 조각은 시연용으로는 잘 동작하지만, 실제로 사용자 ID 와 그룹 ID 를 직접 설정해야 하는 것은 다소 번거롭습니다. 따라서 os/user 를 감싼 짧은 헬퍼 함수를 작성해 코드가 직접 조회하도록 해 봅시다.
os/user 패키지에 대해 한 가지 주의할 점이 있습니다. 이 패키지는 현재 사용자를 캐시하며, 이 캐시를 간단히 무효화할 수 없습니다. 따라서 아래 헬퍼 함수 사용 이후 user.Current() 는 언제나 가장 먼저 이 함수를 실행한 사용자를 반환하게 됩니다.
// uidGidForUserGroup fetches an UID and GID for the given user and group.
func uidGidForUserGroup(username, groupname string) (uid, gid int, err error) {
userStruct, err := user.Lookup(username)
if err != nil {
return
}
userId, err := strconv.ParseInt(userStruct.Uid, 10, 64)
if err != nil {
return
}
groupStruct, err := user.LookupGroup(groupname)
if err != nil {
return
}
groupId, err := strconv.ParseInt(groupStruct.Gid, 10, 64)
if err != nil {
return
}
uid, gid = int(userId), int(groupId)
return
}
이 함수를 사용할 때는 또 하나의 주의가 필요합니다. 여기서 처음으로 chroot 가 우리 발목을 잡을 수 있습니다. 이 조회는 /etc/passwd 와 /etc/group 파일에 접근할 수 있어야 합니다. 따라서 이미 /var/empty 로 chroot 한 상태라면, 당연히 다음과 같이 실패합니다.
open /etc/passwd: no such file or directory
먼저 조회를 하고, 그 다음 chroot(2) 하고, 마지막으로 사용자/그룹 전환을 수행해야 합니다.
// Start with root privileges, do necessary lookups.
uid, gid, err := uidGidForUserGroup("demoworker", "demoworker")
if err != nil {
log.Fatalf("user/group lookup: %v", err)
}
// Drop into chroot
if err := unix.Chroot("/var/empty"); err != nil {
log.Fatalf("chroot: %v", err)
}
if err := unix.Chdir("/"); err != nil {
log.Fatalf("chdir: %v", err)
}
// Switch to an unprivileged user unable to escape chroot
if err := unix.Setgroups([]int{gid}); err != nil {
log.Fatalf("setgroups: %v", err)
}
if err := unix.Setresgid(gid, gid, gid); err != nil {
log.Fatalf("setresgid: %v", err)
}
if err := unix.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("setresuid: %v", err)
}
// Application code follows
chroot 를 하고 root 사용자 권한을 낮춘 뒤에도, 코드는 더 이상 특권 시스템 API 나 일부 파일에는 접근하지 못할 수 있지만, 여전히 계산은 계속할 수 있습니다. 예를 들어 파서가 billion laughs attack에 취약하다면, CPU 사용률 100% 나 심지어 메모리 고갈이 발생할 수 있습니다.
이런 다양한 종류의 리소스를 제한하는 오래된 POSIX 방식 중 하나가 setrlimit(2) 입니다. 대상 운영체제에 따라 서로 다른 리소스가 정의됩니다.
예시의 두 가지 공포 시나리오는 CPU 시간에 대한 RLIMIT_CPU 나 데이터에 대한 RLIMIT_DATA 로 대응할 수 있습니다.
RLIMIT_CPUCPU time 또는 process time은 단일 프로세스가 실제로 소비한 CPU 사이클의 양입니다. 프로세스가 쉬지 않고 무언가를 계산하면 이 카운터는 증가합니다. 반면 프로세스가 특정 이벤트를 기다리고 있다면 이 카운터도 멈춰 있습니다.
예시로, 잠시 대기한 뒤 쓸모없는 해시 계산으로 CPU 사이클을 태워 보겠습니다. CPU 시간은 1초로 제한합니다.
if err := unix.Setrlimit(
unix.RLIMIT_CPU,
&unix.Rlimit{Max: 1},
); err != nil {
log.Fatal("setrlimit: %v", err)
}
log.Println("CPU time != execution time, hanging low")
time.Sleep(5 * time.Second)
log.Println("STRESS!")
buff := make([]byte, 32)
for i := uint64(1); ; i++ {
_, _ = rand.Read(buff)
_ = sha256.Sum256(buff)
if i%100_000 == 0 {
log.Printf("Run %d", i)
}
}
이 예제를 main 함수에 넣어 실행해 보면, CPU 시간을 너무 많이 소비한 뒤 프로세스가 중단되는 모습을 볼 수 있습니다.
2025/01/26 20:17:18 CPU time != execution time, hanging low
2025/01/26 20:17:23 STRESS!
2025/01/26 20:17:23 Run 100000
[ . . . ]
2025/01/26 20:17:24 Run 800000
[1] 190379 killed ./02-01-setrlimit-cpu
RLIMIT_DATA이 두 번째 예제는 최대 데이터 세그먼트를 10MiB 로 제한합니다. 다시 말해 사용 가능한 메모리 양을 10MiB 로 제한합니다.
이 코드는 무한 루프 안에서 메모리를 할당하여 메모리 부족 상황을 유발합니다. 하지만 setrlimit(2) 호출 때문에 프로세스는 중단됩니다.
if err := unix.Setrlimit(
unix.RLIMIT_DATA,
&unix.Rlimit{Max: 10 * 1024 * 1024},
); err != nil {
log.Fatal("setrlimit: %v", err)
}
var blobs [][]byte
for i := uint64(1); ; i++ {
buff := make([]byte, 1024)
_, _ = rand.Read(buff)
blobs = append(blobs, buff)
if i%1_000 == 0 {
log.Printf("Allocated %dK", i)
}
}
그리고 이 경우는 메모리를 지나치게 먹기 때문에 중단됩니다.
2025/01/26 20:17:44 Allocated 1000K
2025/01/26 20:17:44 Allocated 2000K
fatal error: runtime: out of memory
[ . . . ]
이 두 예제는 하드 제한을 설정하며, 결국 프로세스를 중단시킵니다. 특히 RLIMIT_CPU 는 계속 증가하는 카운터이므로 결국 도달하게 됩니다.
그렇다면 좋은 값은 무엇일까요? 물론 상황에 따라 다릅니다.
확실히 하자면, 어떤 제한이든 설정하기로 했다면 정상 동작 중에는 아무튼 도달하지 않을 정도로 충분히 높게 설정하십시오. 무언가 잘못되어도, 그 제한은 여전히 안전망으로 남아 있을 것입니다.
소프트 제한은 어떨까요? 그것은 독자에게 남겨 두는 연습문제입니다.
지금까지의 내용은 대부분의 POSIX 유사 운영체제에서 동작해야 합니다. 장점은 이런 패턴을 프로그램에 적용하면, 존재하는 줄도 몰랐던 플랫폼에서도 작동할 수 있다는 점입니다.
하지만 root 로 시작하지 않고 일반 사용자로 시작하더라도 권한을 낮출 수 있게 해 주는 운영체제별 메커니즘도 있습니다. 제 개인적인 경험은 Linux 와 OpenBSD 에 한정되어 있으므로, 이 둘의 기능 몇 가지를 다루겠습니다. OpenBSD 가 사용하기 더 단순한 API 를 제공하므로, 먼저 그것부터 시작하겠습니다.
운영체제 커널은 어떤 리소스에 접근할 때 사용자 권한이 충분한지 확인합니다. 예를 들어 파일을 open(2) 하려 할 때 운영체제가 이를 거부할 수 있습니다. 이 검사는 open(2) 내부에서 일어납니다. 그런데 개발자가 애초에 그 프로그램은 절대로 파일을 열면 안 된다는 것을 안다면, 프로그램 자체가 open(2) 를 아예 사용할 수 없게 하면 어떨까요? 나중에 어떤 시스템 호출을 쓸 수 있는지 프로그램이 스스로 제한할 수 있게 해 주는 시스템 호출 필터링의 세계에 오신 것을 환영합니다.
좋아하는 시스템 호출이라는 게 있다면, 제 것은 아마 OpenBSD의 pledge(2) 일 것입니다. 이것은 공백으로 구분된 키워드, 즉 promises 에 기반하여 사용 가능한 시스템 호출을 제한하는 단순한 문자열 기반 API 를 제공합니다.
이 promises 는 시스템 호출 그룹의 이름입니다. 예를 들어 rpath 는 파일 시스템에 대한 읽기 전용 시스템 호출 그룹입니다. 여기에 exec 를 추가하면, 두 번째 매개변수로 주어진 자체 promise 와 함께 다른 프로그램을 실행할 수 있게 됩니다.
int pledge(const char *promises, const char *execpromises);
한 번 pledge(2) 를 하면 이를 되돌릴 수는 없고, 더 엄격하게만 만들 수 있습니다. 더 엄격하게 만든다는 것은 더 짧은 promises 목록으로 pledge(2) 를 다시 호출하는 것을 뜻합니다. 시스템 호출 promise 를 위반하면 프로세스는 종료되며, 다만 promise 에 error 가 포함되어 있으면 거부된 시스템 호출은 에러를 반환합니다.
이것도 시스템 호출이므로 Go 에서는 golang.org/x/sys/unix 에서 사용할 수 있습니다.
불행히도 웹의 Go Packages 문서는 일부 선택된 플랫폼의 문서만 렌더링하고 OpenBSD 는 포함하지 않습니다. 그래서 아래에 문서를 붙여 두겠습니다. 참고로 go doc 에서도 GOOS 나 GOARCH 환경 변수를 설정할 수 있습니다. 예를 들어 Linux 에서도 GOOS=openbsd go doc -all golang.org/x/sys/unix 가 동작합니다.
func Pledge(promises, execpromises string) error
Pledge implements the pledge syscall.
This changes both the promises and execpromises; use PledgePromises or
PledgeExecpromises to only change the promises or execpromises respectively.
For more information see pledge(2).
func PledgeExecpromises(execpromises string) error
PledgeExecpromises implements the pledge syscall.
This changes the execpromises and leaves the promises untouched.
For more information see pledge(2).
func PledgePromises(promises string) error
PledgePromises implements the pledge syscall.
This changes the promises and leaves the execpromises untouched.
For more information see pledge(2).
따라서 Go 에서는 pledge(2) 를 위한 함수가 세 가지 있습니다. 두 매개변수를 모두 설정하는 것 하나와, 첫 번째 또는 두 번째만 설정하는 것들입니다. 입력 파일을 다루는 단순한 프로그램 예제를 하나 만들어 봅시다. 파일을 읽을 수만 있게 하는 promise 를 먼저 걸고, 읽은 뒤에는 더 엄격한 promise 를 한 번 더 겁니다. 이 프로그램이 무엇을 하는지는 상상에 맡기겠습니다. 예를 들어 이미지를 다른 형식으로 변환해 stdout 으로 출력하는 프로그램일 수 있습니다.
// Start with limited privileges
if err := unix.PledgePromises("stdio rpath error"); err != nil {
log.Fatalf("pledge: %v", err)
}
// Read input file
f, err := os.Open("input")
if err != nil {
log.Fatalf("cannot open input: %v", err)
}
inputFile, err := io.ReadAll(f)
if err != nil {
log.Fatalf("cannot read input: %v", err)
}
if err := f.Close(); err != nil {
log.Fatalf("cannot close input: %v", err)
}
// Drop further, reading files is no loner necessary
if err := unix.PledgePromises("stdio error"); err != nil {
log.Fatalf("pledge: %v", err)
}
// Do some computation based on the input
예제가 보여 주듯 pledge(2) 사용은 쉽고도 단순합니다. 아마 그 때문에 OpenBSD 와 함께 배포되는 대부분의 프로그램에는 pledge 가 적용되어 있고, 포팅된 소프트웨어에도 관련 패치가 많이 있습니다. 단 하나의 명령으로 이렇게 많은 권한을 버릴 수 있습니다. 인상적입니다.
이 글은 공격당한 채팅 프로그램이 사용자의 SSH 개인 키를 유출하는 구성된 예제로 시작했습니다. 프로그램이 필요한 파일 시스템 경로만 약속하고, 그 외의 모든 접근을 거부할 수 있다면 어떨까요? OpenBSD의 unveil(2) 은 시스템 호출에 대해 pledge(2) 가 하는 것과 비슷하게 이 문제를 다룹니다.
여러 번의 unveil(2) 호출은 프로그램이 접근할 수 있는 unveiled 경로들의 허용 목록을 만듭니다. 각 호출은 경로 하나와 권한 종류를 추가합니다. 읽기, 쓰기, 실행, 생성입니다. 그리고 두 개의 빈 매개변수로 마무리 호출을 하면 이것이 강제됩니다.
따라서 채팅 프로그램이 관련 디렉터리들에 대해 unveil(2) 을 사용했다면, 그리고 그 안에 ~/.ssh 가 분명히 포함되지 않았다면, 이 익스플로잇은 완화되었을 것입니다.
int unveil(const char *path, const char *permissions);
이 시스템 호출 역시 Go 에서 golang.org/x/sys/unix 로 사용할 수 있습니다.
func Unveil(path string, flags string) error
Unveil implements the unveil syscall. For more information see unveil(2).
Note that the special case of blocking further unveil calls is handled by
UnveilBlock.
func UnveilBlock() error
UnveilBlock blocks future unveil calls. For more information see unveil(2).
즉 Go 에서는 여러 번 unix.Unveil(...) 을 호출한 뒤 마지막에 unix.UnveilBlock() 을 호출하면 됩니다.
채팅 프로그램 예제를 계속 사용해 보겠습니다. 프로그램이 어딘가의 Download 디렉터리에 촌스러운 밈을 저장할 수 있도록 그 디렉터리에 대한 읽기/쓰기/생성 접근만 허용한다고 합시다. 그 외의 모든 파일 시스템 요청은 거부되어야 합니다.
// Restrict read/write/create file system access to the ./Download directory.
// This does not include exec!
if err := unix.Unveil("Download", "rwc"); err != nil {
log.Fatalf("unveil: %v", err)
}
if err := unix.UnveilBlock(); err != nil {
log.Fatalf("unveil: %v", err)
}
// Buggy application starts here: allowing path traversal
userInput := "../.ssh/id_ed25519"
f, err := os.Open("Download/" + userInput)
if err != nil {
log.Fatalf("cannot open file: %v", err)
}
defer f.Close()
privateKey, err := io.ReadAll(f)
if err != nil {
log.Fatalf("cannot read: %v", err)
}
log.Printf("looks familiar?\n%s", privateKey)
이 코드를 시험해 보면, 파일이 실제로 존재하더라도 고전적인 경로 순회 공격이 완화된 것을 확인할 수 있습니다.
$ ./04-openbsd-unveil
2025/01/26 22:11:57 cannot open file: open Download/../.ssh/id_ed25519: no such file or directory
$ ls -l Download/../.ssh/id_ed25519
-rw------- 1 user user 420 Jan 26 22:10 Download/../.ssh/id_ed25519
대성공입니다!
이제 운영체제를 바꿔, 잠시 Linux 커널에 집중해 봅시다.
OpenBSD 의 시스템 호출 필터링 섹션도 시스템 호출이 프로그램과 커널 사이에서 리소스 접근을 위한 관문이라는 몇 문장으로 시작했습니다. Linux 에도 똑같이 적용되며, 사실 거의 모든 운영체제에 해당합니다. 불필요한 시스템 호출을 거부한다는 것은 곧 권한을 제한한다는 뜻입니다.
Linux 는 매우 강력한 도구인 Seccomp BPF를 제공합니다. 이것은 각 프로세스가 커널에 프로그램 하나를 제공하여 어떤 시스템 호출을 허용할지 결정하게 합니다. 이 프로그램은 Berkeley Packet Filter(BPF) 이며, 시스템 호출 번호와 일부 인수를 받습니다. 따라서 시스템 호출의 특정 매개변수만 허용하거나 거부하는 것도 가능합니다.
이 대단한 유연성에는 당연히 장단점이 있습니다. 아주 구체적인 필터를 만들고 싶을 때도 있겠지만, 대충 빠르게 만드는 필터가 더 흔할지도 모릅니다. 적어도 사용자 공간 개발자로서의 제 경험상, 특히 Go 에서는 대개 시스템 호출과 직접 상호작용하지 않기 때문에 좀 더 거친 필터를 선호하는 편입니다.
그렇다면 어디서 시작해야 할까요? Go 에서 순수한 Seccomp BPF 경험을 원한다면, cgo 없이 순수 Go 로 작성된 github.com/elastic/go-seccomp-bpf 패키지가 있습니다. 위에서 소개한 것처럼 시스템 호출 단위의 세밀한 필터를 개발할 수 있습니다.
처음 이것을 사용했을 때, 제 Go 프로그램용 필터를 어디서부터 작성해야 할지 전혀 감이 없었습니다. 모든 것을 거부하는 필터로 시작해서 Linux 의 auditd(8) 를 사용하며 아마도 필요한 모든 시스템 호출을 찾아가는 식으로 조금씩 진전했습니다. 하지만 그 뒤 Go 나 의존성을 업데이트하면 다른 코드가 생기고, 당연히 다른 시스템 호출이 생길 수 있다는 사실을 깨달아야 했습니다. 또 다른 제약은 아키텍처마다 사용 가능한 시스템 호출이 조금씩 다르다는 점입니다.
그래서 좀 더 넓은 필터 목록을 가지고 놀기 시작했고, 결국 systemd의 SystemCallFilter에 있는 집합들을 “빌려” 오게 되었습니다. 시스템 관리자는 이미 이 기능을 알고 있을 수도 있습니다. systemd 가 관리하는 각 서비스에 대해 시스템 호출 목록을 통해 사용 가능한 시스템 호출을 제한할 수 있으며, pledge(2) 와 꽤 비슷합니다. 그러다 보니 저는 결국 바로 이 용도를 위한 작은 라이브러리 github.com/oxzi/syscallset-go를 만들게 되었습니다.
개발자 관점에서 보면, 이것은 그룹을 통해 허용된 시스템 호출 목록을 스스로 만들 수 있는 단순한 문자열 기반 API 를 제공합니다. 솔직히 말해 저는 systemd 의 코드를 Go 로 가져와 앞서 언급한 go-seccomp-bpf 라이브러리로 날게 만든 것뿐입니다. 하지만 동작합니다.
// Start with limited privileges
if err := syscallset.LimitTo("@system-service"); err != nil {
log.Fatalf("seccomp: %v", err)
}
// Read input file
f, err := os.Open("input")
if err != nil {
log.Fatalf("cannot open input: %v", err)
}
inputFile, err := io.ReadAll(f)
if err != nil {
log.Fatalf("cannot read input: %v", err)
}
if err := f.Close(); err != nil {
log.Fatalf("cannot close input: %v", err)
}
// Drop further, reading files is no loner necessary
if err := syscallset.LimitTo("@basic-io"); err != nil {
log.Fatalf("seccomp: %v", err)
}
// Do some computation based on the input
졸지 않고 읽은 독자라면 이 코드가 익숙하게 느껴질 수 있습니다. 위의 pledge(2) 데모 코드와 거의 동일하기 때문입니다. 다만 몇 가지 작은 차이는 있습니다. 첫째이자 가장 분명한 차이는 필터가 다르다는 점입니다. OpenBSD 에는 사용한 @system-service 같은 메타 필터가 없고, 이것은 흔히 쓰이는 많은 시스템 호출을 담고 있습니다. 또한 OpenBSD의 pledge(2) 에는 금지된 시스템 호출을 에러로 실패하게 해 주는 편리한 error 그룹이 있었습니다. 그렇지 않으면 커널이 프로세스를 종료합니다. 이 동작은 여기에도 적용되며, 잘못 한 번만 디뎌도 즉시 프로세스 종료로 처벌받게 됩니다.
대칭성을 위해 Linux 에서 unveil(2) 에 대응하는 기능도 이어서 살펴봐야 합니다. 실제로 있습니다. 게다가 훨씬 더 많은 것을 할 수 있습니다. Landlock LSM을 소개합니다.
Landlock 는 처음에는 unveil(2) 과 같은 문제, 즉 파일 시스템 접근 제한을 해결하기 위해 시작했지만, 최근에는 특정 네트워크 격리도 가능하도록 확장되었습니다. 하지만 코드를 통해 살펴보는 편이 낫겠지요. github.com/landlock-lsm/go-landlock 라이브러리를 사용해 보겠습니다.
// Restrict file system access to the ./Download directory.
if err := landlock.V5.BestEffort().RestrictPaths(
landlock.RWDirs("Download"),
); err != nil {
log.Fatalf("landlock: %v", err)
}
// Buggy application starts here: allowing path traversal
userInput := "../.ssh/id_ed25519"
f, err := os.Open("Download/" + userInput)
if err != nil {
log.Fatalf("cannot open file: %v", err)
}
defer f.Close()
privateKey, err := io.ReadAll(f)
if err != nil {
log.Fatalf("cannot read: %v", err)
}
log.Printf("looks familiar?\n%s", privateKey)
이 코드도 익숙해 보일 수 있습니다. 앞서 나온 unveil(2) 예제를 조금 바꾼 버전이기 때문입니다.
언급할 만한 점이 하나 있다면 V5.BestEffort() 부분일 것입니다. Landlock 자체는 버전이 있으며, Linux 릴리스와 함께 기능이 확장됩니다. 하지만 오래된 대상과도 호환되는 Go 프로그램을 만들기 위해 BestEffort 부분은 대상 커널이 지원하는 수준으로 자동으로 내려갑니다. 이것이 원치 않는다면 V5.RestrictPaths 를 직접 사용하면 됩니다. 이 글을 읽는 시점에 최신 버전이 무엇이든 마찬가지입니다.
현재 시점에서 Landlock 의 네트워크 연결 제한 기능은 아직 다소 제한적이지만, 그만큼 API 는 단순합니다. Linux 에서 완전한 네트워크 제한 도구 모음을 찾고 있다면, cgroup eBPF 기반 네트워크 필터링도 살펴볼 만합니다.
그렇다면 확실히 가능한 것은 무엇일까요? 애플리케이션은 포트 기준으로 들어오는 TCP 트래픽과 나가는 TCP 트래픽을 모두 제한할 수 있습니다. 더 간단히 말해, 특정 TCP 포트를 허용할 수 있습니다.
// Restrict outbound TCP connections to port 443.
if err := landlock.V5.BestEffort().RestrictNet(
landlock.ConnectTCP(443),
); err != nil {
log.Fatalf("landlock: %v", err)
}
// HTTP should fail, while HTTPS should work.
for _, proto := range []string{"http", "https"} {
_, err := http.Get(proto + "://pkg.go.dev/")
log.Printf("%q worked: %t\t%v", proto, err == nil, err)
}
이 작은 예제는 나가는 TCP 연결을 443 포트로만 허용하며, 실패하는 HTTP 연결이 포트 80 이라서 이를 보여 줍니다. 물론 이것이 애플리케이션을 HTTPS 만 사용하도록 안전하게 제한하는 방법은 아닙니다.
./06-02-linux-landlock-tcp
2025/01/27 21:35:32 "http" worked: false Get "http://pkg.go.dev/": dial tcp [2600:1901:0:f535::]:80: connect: permission denied
2025/01/27 21:35:32 "https" worked: true <nil>
그리고 마지막으로, 규칙 옵션으로 landlock.BindTCP 도 있어 바인드할 수 있는 TCP 포트를 제한할 수 있습니다. 공격자가 셸을 띄울까 우려되는 경우 특히 유용할 수 있습니다.
이제 애플리케이션이 스스로 권한을 제한할 수 있는 모든 가능한 선택지를 다 다뤘을까요? 당연히 아닙니다. Linux 의 cgroups 는 광범위한 제한을 허용하는데, 그것조차 전혀 다루지 않았습니다. 그리고 FreeBSD 의 capsicum(4) 같은 다른 운영체제도 있습니다.
이 글의 주된 목표는 권한 제한을 위해 사용할 수 있는 꽤 단순한 API 들이 있다는 것을 보여 주는 것이었습니다. OpenBSD 자체는 단순한 API 를 제공하고, Linux 에서는 여기서 소개한 두 개의 Go 라이브러리가 이런 거대하고 매우 설정 가능한 기능을 한 줄짜리로도 쓸 수 있게 만들어 줍니다.
그리고 setrlimit(2) 이 있습니다. 쓰고 싶을 수도 있고, 무시하고 싶을 수도 있습니다. 물론 root 로 제한된 chroot(2)/setresuid(2) 춤도 있습니다.
따라서 개발자인 여러분에게는 선택지가 있습니다. 보신 것처럼, 앞으로 벌어질지도 모를 장난으로부터 소프트웨어를 보호하기 위해 여기서 소개한 메커니즘 몇 가지를 추가하는 것은 꽤 쉽습니다. 여러분의 프로그램 공격 표면을 줄이기 위해 꼭 한번 시도해 보시기를 권합니다.
이 글에서 사용한 예제와 그 밖의 더 많은 예제는 다음 git 저장소에서 볼 수 있습니다. https://codeberg.org/oxzi/go-privsep-showcase. 도움이 되기를 바랍니다.