Lobsters

Software sandboxing: The basics (2025)

소프트웨어 샌드박싱의 기초 — 프로세스, 액터 모델, 파일 디스크립터 capability

Emilua 개발자가 2025년 관점에서 소프트웨어 샌드박싱을 설계하는 기본 원칙을 설명합니다. 프로세스 분리와 액터 모델, 파일 디스크립터를 capability로 사용하는 방법을 거쳐 FreeBSD Capsicum을 핵심 사례로 제시하고, Linux namespaces·seccomp·Landlock의 역할과 한계도 비교합니다.

AI 요약

이 글은 Emilua에 샌드박싱 지원을 구현하면서 얻은 경험을 바탕으로, 애플리케이션 개발자가 직접 적용할 수 있는 소프트웨어 샌드박싱의 기본 설계 원칙을 설명합니다. 글쓴이는 2009년 Hack In The Box Malaysia에서 Julien Tinnes와 Chris Evans가 제시한 정의, 즉 샌드박싱을 “시스템의 관리 권한 없이, 프로그램으로, 선택적으로 프로세스의 권한을 낮추는 능력”으로 보는 관점에서 출발합니다. 이는 시스템 관리자가 설정한 전역 정책을 대체하는 개념이 아니라, 애플리케이션 내부에서 서로 다른 구성 요소의 권한을 추가로 제한하는 방법입니다.

■ 시스템 관리용 격리와 애플리케이션용 샌드박싱

UNIX의 파일 권한이나 서비스 계정 분리는 시스템 관리자가 데몬을 격리하는 데 유용하지만, 애플리케이션이 실행 중인 제3자 코드를 제한하는 인터페이스로는 부족하다고 설명합니다. 예를 들어 Firefox가 DRM 플러그인을 실행할 때, 해당 플러그인이 Firefox가 접근할 수 있는 사용자의 HOME 디렉터리 전체를 읽지 못하게 해야 할 수 있습니다. setuidgid 같은 전통적인 도구는 시스템 관리자가 서비스의 권한을 정하는 용도이지, 소프트웨어 개발자가 실행 중인 구성 요소별 권한을 세밀하게 낮추는 용도로 설계되지 않았습니다.

이 간극을 메우기 위해 운영체제는 FreeBSD의 Capsicum이나 Linux의 Seccomp 같은 확장 인터페이스를 제공합니다. 과거에는 적절한 인터페이스가 없어서 특권이 필요한 suid helper와 chroot jail을 조합하는 방식이 사용되기도 했지만, 이는 프로그램이 일시적으로 시스템 전체의 관리자 권한을 획득하게 만들 수 있습니다. 글은 권한은 늘어나는 방향이 아니라 오직 줄어드는 방향이어야 한다는 최소 권한 원칙(Principle of Least Privilege)을 강조합니다.

Linux user namespace에 대한 비판도 제시합니다. Docker가 Linux namespaces를 서비스 격리 수단으로 널리 알렸지만, 중첩된 user namespace 안에서는 프로세스가 해당 네임스페이스의 superuser로 동작합니다. 그 결과 원래 관리자만 접근할 것이라고 가정하고 작성된 커널 코드 경로가 일반 사용자에게 노출될 수 있습니다. 글은 Andy Lutomirski의 발언을 인용하면서, CLONE_NEWUSER를 통해 다른 network namespace에 CAP_NET_ADMIN 권한을 얻고 iptables까지 조작할 수 있는 구조는 큰 위험이며, 그 경로에 권한 상승 취약점이 전혀 없을 것이라고 믿기 어렵다고 설명합니다. user namespace를 신뢰된 컨테이너 도구로 제한하는 것은 가능하지만, 일반적인 애플리케이션 샌드박싱 인터페이스로 Linux namespaces에 의존하는 것은 좋지 않다는 주장입니다. 반면 Landlock은 커널 공격 표면을 기하급수적으로 늘리지 않도록 설계된 최신 Linux 샌드박싱 인터페이스로 소개됩니다.

■ 프로세스 경계와 분산 애플리케이션 구조

주류 운영체제에서 권한 경계는 기본적으로 프로세스 단위에 놓여 있으며, 커널은 프로세스의 credential을 기준으로 새로운 자원 획득을 허용할지 결정합니다. Linux는 credential을 thread 수준에서 관리하지만, 실제 설계는 프로세스 단위에 맞춰야 하므로 glibc가 여러 스레드 사이에서 credential을 동기화하는 추가 작업을 수행합니다. 글은 GNOME 개발자들이 thread 단위 설계가 가능하다고 판단했다가 CVE-2023-43641로 문제가 드러난 사례를 언급합니다.

Adam Langley가 제안한 대안은 신뢰할 수 없는 각 스레드에 같은 프로세스 안의 trusted helper thread를 두고, socket pair로 시스템 콜 요청을 전달하는 방식입니다. helper는 시스템 콜 번호를 검증한 뒤 대신 호출하지만, 모든 메모리를 적대적인 데이터로 간주해야 합니다. C 코드가 스택을 사용하거나 인자를 스택으로 전달할 수 있기 때문에 trusted thread의 코드를 어셈블리로 작성해야 하고, mmap과 mprotect 같은 가상 메모리 조작도 제한해야 합니다. 글쓴이는 이론적으로는 가능하지만 경제적으로 지나치게 비싸 실용화되기 어렵다고 봅니다.

따라서 현실적인 설계 단위는 별도의 프로세스입니다. 먼저 프로그램을 여러 프로세스 또는 compartment로 나누고, 각 프로세스에 서로 다른 권한을 부여한 뒤, 프로세스 사이의 통신을 설계해야 합니다. FreeBSD Capsicum 연구진의 표현처럼, compartmentalized application development는 서로 다른 프로세스에서 실행되는 구성 요소가 메시지 전달로 통신하는 distributed application development가 될 수밖에 없습니다.

■ 액터 모델을 프로세스 통신에 적용하기

글은 프로세스 사이의 통신 모델로 actor model을 사용합니다. 액터는 내부 상태를 관리하고, 다른 액터를 생성하며, 메시지를 보내고, 메시지 안에 다른 액터의 주소를 포함할 수 있습니다. 구체적인 구현에는 액터를 생성해 주소를 반환하는 함수, 메시지를 보내는 함수, 현재 액터의 inbox에서 메시지를 받는 함수가 필요합니다. 액터끼리는 메모리를 공유하지 않고, 한 액터가 동시에 두 스레드에서 실행되지 않는다는 규칙도 중요합니다.

Emilua에서는 이 모델이 다음 세 가지 기본 동작으로 표현됩니다. spawn_vm(module)은 새 액터를 만들고, actor.send(msg)는 메시지를 전달하며, inbox.receive()는 현재 액터의 대기 메시지를 읽습니다. 일반적인 VM 내부 액터가 아니라 프로세스 기반 샌드박스를 만들려면 spawn_vm에 subprocess = {}를 지정합니다. 이때 UNIX domain socket을 액터 주소로 사용하고, 파일 디스크립터 전달 기능을 통해 한 액터의 주소를 다른 메시지에 포함할 수 있습니다. inbox의 파일 디스크립터는 다른 액터로 보내지 않아 MPSC 채널처럼 사용할 수 있습니다.

이 구조는 권한이 제한된 프로세스에 자원을 전달하는 문제도 함께 해결합니다. UNIX에서 “Everything is a file”이라는 표현처럼 파일 디스크립터로 표현할 수 있는 자원에는 파일과 디렉터리뿐 아니라 pipe, socket, device node, shared memory(memfd), 프로세스 핸들(pidfd와 procdesc), 동기화 객체(eventfd), eBPF 프로그램까지 포함될 수 있습니다. 프로세스가 어떤 파일 디스크립터를 전달받았는지를 추적하면, 어떤 액터가 어떤 자원에 접근할 수 있는지 보안 모델로 표현할 수 있습니다.

■ 파일 디스크립터와 capability-based security

글은 이 설계를 capability-based security와 결합합니다. capability는 자원에 대한 참조일 뿐 아니라 그 자원에 적용되는 접근 권한까지 포함합니다. 액터 모델의 주소는 위조될 수 있지만, UNIX domain socket 채널을 사용하면 실제 자원에 대한 접근 권한이 파일 디스크립터에 묶이므로 이 간극을 줄일 수 있다고 설명합니다. 이에 따라 “액터 A가 자원 X에 실질적으로 접근할 수 있는가”, “샌드박스된 액터가 파일과 소켓에 동시에 접근하는 구성을 애초에 만들 수 없게 할 수 있는가” 같은 질문을 capability 관점에서 검토할 수 있습니다.

UNIX는 일반적으로 파일 디스크립터를 새로 만들 때 권한을 검사하고, 이미 열린 디스크립터를 사용하는 순간에는 같은 방식의 권한 검사를 반복하지 않습니다. 글은 root가 /etc/shadow를 연 뒤 setpriv로 UID와 GID를 일반 사용자로 바꾸고 grep에 파일 디스크립터를 상속시키는 예를 보여줍니다. 일반 사용자가 직접 /etc/shadow를 열면 Permission denied가 발생하지만, root가 이미 연 디스크립터를 상속받으면 grep은 내용을 읽습니다. 이는 기존 파일 디스크립터가 capability처럼 동작한다는 점을 보여줍니다.

suid 바이너리도 같은 원칙을 확인하는 사례로 사용됩니다. 권한이 상승한 su 프로세스가 표준 입력과 출력을 통해 전달받은 파일 디스크립터를 읽거나 쓸 수 있다는 점을 보여주지만, 새로운 커널 인터페이스는 호출자 프로세스의 credential을 이용해 위험한 쓰기 권한을 우회하지 못하도록 설계되어야 합니다. 글은 Linux 파일시스템 마운트 관련 시스템 콜 패치가 초기에는 write를 사용해 호출자의 권한 검사를 수행하려 했기 때문에 거부되었고, 이후 fsconfig을 도입한 설계로 수정되어 받아들여진 사례를 듭니다.

다만 ioctl은 예외적으로 위험합니다. 신뢰할 수 없는 프로세스에서 받은 파일 디스크립터에 ioctl을 수행하면 예상하지 못한 권한 조작이 일어날 수 있기 때문입니다. Emilua가 사용하는 Boost.Asio도 과거에는 FIONBIO를 부적절하게 사용했지만, 논의 이후 Boost 1.86부터는 올바르게 동작하도록 변경되었습니다. Linux에서 isatty()조차 ioctl로 구현되므로, 표준적인 파일 읽기·쓰기와 비표준 조작을 구분해 검토해야 한다고 설명합니다.

■ Capsicum의 단순한 권한 축소

FreeBSD의 Capsicum은 파일 디스크립터를 capability로 사용하는 모델을 운영체제 수준에서 지원합니다. cap_enter()를 호출하면 프로세스의 ambient authority가 사라지고, 이후 모든 시스템 접근은 이미 보유한 파일 디스크립터를 통해서만 수행해야 합니다. 파일을 열거나 경로를 사용해 소켓을 연결하려는 작업은 실패하며, 새로운 자원이 필요하면 inbox를 통해 다른 프로세스에 요청해야 합니다. 이름이나 경로를 통해 외부 자원을 찾는 능력을 없애는 방식이므로, 샌드박스 경계를 설명하기가 단순합니다.

Capsicum은 cap_rights_limit()으로 개별 파일 디스크립터의 권한도 더 낮출 수 있습니다. 예를 들어 여러 worker가 하나의 UNIX seqpacket socket을 통해 메시지를 보내더라도, worker가 공용 채널을 종료하지 못하도록 send 권한만 남기고 shutdown-send 권한을 제거할 수 있습니다. Capsicum 모드에서도 open()은 사용할 수 없지만 openat()은 허용되며, 지정한 directory file descriptor 아래의 계층으로만 상대 경로를 해석하도록 제한합니다.

글은 Chromium에 여러 샌드박싱 방식을 적용하는 데 필요한 코드 규모를 비교한 표도 제시합니다. Windows ACLs와 SIDs는 22,350줄, Linux chroot는 605줄, Mac OS X Seatbelt은 560줄, Linux SELinux는 200줄, Linux seccomp와 사용자 공간 시스템 콜 래퍼는 11,301줄이 필요했으며, FreeBSD Capsicum은 100줄이 필요했습니다. 글쓴이는 이 비교에서 Capsicum이 가장 적은 코드로 명확한 권한 축소를 제공한다고 평가하고, 하나의 샌드박싱 메커니즘만 공부한다면 Capsicum을 보라고 권합니다.

마지막으로, 제한된 프로세스에서 받은 파일 디스크립터를 처리할 때 호출 스레드가 블로킹되지 않도록 주의해야 한다고 덧붙입니다. POSIX에서는 close()도 블로킹할 수 있고, Linux에서는 대체로 항상 성공한다고 알려져 있으므로 오류 확인에 집착하지 말아야 한다는 실무적인 주의점도 제시합니다. 이 글의 전체적인 설계 방향은 권한을 가진 프로세스가 모든 자원에 접근하도록 두는 대신, 미리 열어 둔 자원만 파일 디스크립터로 전달하고 프로세스 사이를 메시지로 연결하는 것입니다.

■ Lobsters 반응

• @matthias — OpenBSD의 pledge와 unveil이 빠진 이유가 정말 궁금합니다. 이 둘은 Chromium이나 Firefox 같은 큰 애플리케이션뿐 아니라 작은 애플리케이션에서도 작동하는 샌드박싱의 대표적인 사례입니다.

• @sjamaan — Capsicum이 나왔을 때 논문을 읽고 “그래, 이게 올바른 방법이다!”라고 생각했던 기억이 납니다. 안타깝게도 널리 채택되지는 않았습니다. 왜 그런지 이해하지 못하겠습니다.

• @yorickpeterse — 간단한 이유는 Capsicum을 사용하려면 프로그램을 재구성해야 하기 때문입니다. 파일 디스크립터를 미리 열어 두고 openat 같은 것을 사용해야 할 뿐 아니라, libcasper가 제공하는 FreeBSD 전용 함수도 사용해야 합니다. 반면 Landlock이나 unveil을 사용하면 10~20줄 정도만 작성하면 됩니다. Capsicum에서는 프로그램 전체를 근본적으로 다시 작성해야 합니다.

• @muvlon — Capsicum이 널리 퍼지지는 않았지만, capability는 지금 조금씩 다시 주목받고 있습니다. WebAssembly의 시스템 인터페이스인 WASI는 0.1에서 전통적인 UNIX 스타일 API로 시작했지만, 이제는 방향을 크게 바꿔 capability를 전면적으로 채택하고 있습니다. WebAssembly 자체는 처음부터 ambient authority가 전혀 없고 전역적으로 보이는 것이 없기 때문에, 그 구조에서도 capability를 사용하는 것이 타당합니다.

원문: Emilua Blog / 번역·요약: Trawling