스크랩

관리자 권한 없이 내 프로그램을 가두는 법: 소프트웨어 샌드박싱 기초 정리(2025)

관리자 권한 없이 내 프로그램을 가두는 법: 소프트웨어 샌드박싱 기초 정리(2025)

한눈에 보는 핵심

  • 샌드박싱은 프로그램이 스스로 권한을 낮추는 일입니다. 관리자가 세우는 보안 정책을 대체하는 게 아니라, 그 위에 한 겹을 더 얹는 보완재입니다.
  • 현실적인 격리 경계는 스레드가 아니라 프로세스입니다. 애플리케이션을 여러 프로세스로 구획화하고, 각 구획에 서로 다른 권한을 주는 설계가 출발점입니다.
  • 구획 간 통신은 액터 모델 + 메시지 전달로 풀 수 있고, UNIX 소켓으로 넘기는 **파일 디스크립터를 케이퍼빌리티(capability)**로 쓰면 필요한 자원만 골라서 건네줄 수 있습니다.
  • 플랫폼별로는 FreeBSD의 Capsicum이 압도적으로 단순하고, Linux는 Seccomp + Landlock 조합으로 비슷한 목표에 접근합니다.

이 글은 Emilua(액터 기반 Lua 런타임)의 샌드박싱 구현 경험을 바탕으로, 여기저기 흩어져 있던 Linux/FreeBSD 샌드박싱 지식을 정리한 내용을 소개합니다.


1. 샌드박싱이란 정확히 무엇인가

Julien Tinnes와 Chris Evans가 2009년에 정리한 정의가 지금도 가장 쓸모 있습니다. 샌드박싱은 다음 세 조건을 만족해야 합니다.

  1. 프로그램으로 프로세스의 권한을 제한할 수 있어야 한다
  2. 시스템 관리자 권한 없이 가능해야 한다
  3. 프로그램이 자발적으로 권한을 낮추는 **재량적 권한 축소(discretionary privilege dropping)**여야 한다

반면 CNSS의 2022년 용어집은 샌드박스를 "허가받은 자원 외에는 접근하지 못하게 하는 제한된 실행 환경" 정도로 훨씬 넓게 정의합니다. 즉 샌드박싱의 필수 조건에 업계 합의는 없습니다. 이 글에서 다루는 범위는 어디까지나 세 번째 항목, 재량적 권한 축소입니다.

2. 관리자용 격리와 프로그램의 권한 축소는 다릅니다

흔한 오해 하나: "UNIX 파일 권한으로 다 되는 거 아닌가?"

파일 권한은 관리자가 서비스를 격리하는 도구입니다. 애플리케이션 내부의 모든 보안 정책을 표현하는 수단이 아닙니다.

  • 외부 프로그램이 파일 권한을 마음대로 바꿀 수 있게 하면, 관리자의 정책 자체가 무력화됩니다.
  • Twitter 피드의 공개 범위나 Identi.ca 메시지 수신 정책처럼, 애플리케이션이 구성하는 세계의 권한은 애초에 파일 권한과 모양이 맞지 않습니다.
  • Firefox가 실행하는 DRM 플러그인에게 "Firefox가 접근 가능한 모든 파일"(사실상 홈 디렉터리 전체)을 줄 이유는 전혀 없습니다.

setuidgid 같은 전통적 도구도 관리자용 인터페이스라 이 용도에는 맞지 않습니다. FreeBSD의 Capsicum, Linux의 Seccomp 같은 확장 인터페이스가 바로 이 간극을 메우기 위해 등장했습니다.

3. 왜 root 없이 권한을 줄여야 하나

"suid 도우미로 chroot를 걸면 되지 않나?" 하는 접근에는 명확한 문제가 있습니다.

  • 잠깐이라도 시스템 전체의 관리자 권한으로 올라갔다 내려오는 구조입니다.
  • 모든 프로그램이 suid 실행 파일을 설치하도록 허용할 수는 없습니다.
  • 최소 권한 원칙은 권한을 늘렸다 줄이는 게 아니라, 처음부터 줄이는 것을 요구합니다.

사용자 네임스페이스는 만능이 아닙니다

Linux 사용자 네임스페이스는 내부 프로세스에 해당 네임스페이스의 관리자 권한을 부여합니다. 문제는 그 과정에서 원래 root만 닿던 커널 경로가 일반 사용자에게 열린다는 점입니다.

  • 그런 전제를 고려하지 않고 작성된 커널 코드가 실제로 존재하고, 과거에 보안 문제로 이어졌습니다.
  • Andy Lutomirski는 CLONE_NEWUSER로 네트워크 네임스페이스의 CAP_NET_ADMIN을 얻어 일반 사용자가 iptables를 설정할 수 있는 상황을 큰 위험으로 평가했습니다.

그래서 사용자 네임스페이스는 Docker처럼 신뢰하는 컨테이너 도구에 한정하는 편이 적절하고, 범용 샌드박싱 인터페이스로는 부적합하다는 결론이 나옵니다. Landlock은 애초에 커널 공격 표면을 크게 늘리지 않도록 설계됐고, 네임스페이스 사용을 제한하는 추가 장치도 개발 중입니다.

Emilua도 초기 수년간은 Linux 네임스페이스에 집중했지만, 이후 다른 방법으로 전환했습니다. 지금도 네임스페이스를 지원하긴 하지만 "컨테이너 도구 제작용"으로 남겨두고, 애플리케이션 샌드박싱에는 다른 메커니즘을 씁니다.

4. 실용적인 격리 경계는 프로세스입니다

주류 운영체제에서 권한 경계는 프로세스입니다. 커널은 자격 정보(credential)를 확인해 **암묵적 권한(ambient authority)**으로 새 자원을 얻을 수 있는지 판단합니다.

Linux는 자격 정보를 스레드별로 관리하긴 합니다. 하지만 이를 안전한 격리 경계로 삼기는 어렵습니다.

  • glibc는 스레드 간 자격 정보를 동기화하는 추가 작업을 수행합니다.
  • GNOME의 스레드 단위 접근은 CVE-2023-43641에서 실제로 문제가 드러났습니다.

Adam Langley가 구상한 스레드 격리는 이론상 가능하지만 구현 비용이 극단적입니다.

  • 신뢰하지 않는 스레드마다 신뢰하는 도우미 스레드를 두고, 소켓 쌍으로 시스템 호출 요청을 받아 검증
  • 모든 메모리가 적대적일 수 있으므로 도우미는 CPU 레지스터만 신뢰해야 함 → 스택을 쓰는 C 대신 신중히 작성한 어셈블리 필요
  • mmap, mprotect 등을 통한 위험한 메모리 변경도 차단해야 함

결국 프로세스로 나누는 게 현실적입니다. 그리고 프로세스로 나눈 순간, 구획화된 애플리케이션 개발 = 분산 애플리케이션 개발이 됩니다. Capsicum 연구가 출발한 지점도 정확히 여기입니다.

5. 액터 모델과 케이퍼빌리티 기반 보안

분산 설계의 대표 선수는 액터 모델입니다. Erlang이 고가용성과 내결함성에 활용해 온 바로 그것이죠.

액터의 기본 성질

  • 자체 상태를 관리하고, 다른 액터를 생성할 수 있음
  • 다른 액터에 메시지를 보내고, 메시지 안에 다른 액터의 주소를 담을 수 있음

구현에 필요한 조건 (인터페이스만으로는 부족합니다)

  • 생성 함수는 새 액터의 주소를 반환하고, 그 주소는 전송과 메시지 데이터 양쪽에 쓸 수 있어야 함
  • 수신 함수는 호출한 액터의 큐에서 메시지를 읽고, 현재 액터의 주소도 얻을 수 있어야 함
  • 액터끼리 메모리를 공유하지 않고, 같은 액터가 여러 스레드에서 동시에 실행되지 않아야 함
  • 단, 스레드 사이를 이동하는 실행은 허용 — 이 직렬 실행 특성은 Boost.Asio의 strand와 동일합니다

Emilua에서의 모습

핵심 인터페이스는 spawn_vm(module), actor.send(msg), inbox.receive() 세 가지입니다.

  • 숫자·문자열·테이블뿐 아니라 응답을 받을 다른 액터나 **자신의 inbox**도 메시지에 넣을 수 있습니다.
  • spawn_vm에 subprocess = {}를 지정하면 여러 구현 중 별도 프로세스 기반 액터가 선택됩니다.

샌드박싱에서는 프로세스 하나 = 액터 하나로 두고 UNIX 도메인 소켓으로 메시지를 주고받습니다.

  • 생성 시 상속한 소켓이 곧 액터 주소 역할을 합니다.
  • UNIX 소켓의 파일 디스크립터 전달을 통해 다른 액터의 주소도 넘길 수 있습니다.
  • 수신함의 디스크립터는 남에게 넘기지 않으므로, 구조적으로 다중 생산자 / 단일 소비자 채널이 됩니다.

파일 디스크립터로 넘길 수 있는 것들

생각보다 범위가 넓습니다.

  • 파일, 디렉터리, 파이프, 소켓
  • /dev/random, GPU 통신용 장치 노드, 공유 메모리인 memfd
  • pidfd/procdesc 같은 프로세스 핸들, eventfd 같은 동기화 객체, eBPF 프로그램

케이퍼빌리티 기반 보안은 "자원 참조"와 "접근권"을 한 덩어리로 다룹니다. 덕분에 디스크립터가 엉뚱한 액터로 새지 않는 구조인지를 분석적으로 따져볼 수 있습니다. (액터 모델과 케이퍼빌리티 보안을 언어 차원에서 결합한 사례로 Pony가 있습니다.)

일반적인 액터 주소는 위조가 가능하다는 약점이 있지만, 여기서는 위조 불가능한 토큰 대신 채널 그 자체를 주소로 쓰기 때문에 이 문제가 해소됩니다. 그 결과 이런 질문에 답할 수 있게 됩니다.

  • 이 액터가 해당 자원에 실질적으로 접근 가능한가?
  • 어떤 샌드박스도 파일과 소켓에 동시에 접근하지 못하게 구성할 수 있는가?

주소 전달은 트리 구조뿐 아니라 임의로 변하는 연결 구조까지 지원하며, 수십 년치 연구와 학습 자료가 뒷받침합니다.

6. 파일 디스크립터를 케이퍼빌리티로 쓸 때의 조건

케이퍼빌리티는 단순한 참조가 아니라 그 자원에 작업을 수행할 권한까지 포함하는 개념입니다. UNIX는 보통 디스크립터를 생성할 때 권한을 검사하고, 이미 열린 디스크립터를 쓸 때는 다시 검사하지 않습니다.

  • 일반 사용자는 /root나 /etc/shadow를 직접 열 수 없습니다.
  • 하지만 root가 연 /etc/shadow의 디스크립터를 상속받은 뒤 일반 사용자로 실행한 grep은 내용을 읽을 수 있습니다.

이 관례가 유지되어야 하는 이유는 suid 실행 파일의 디스크립터 상속 쪽에서 더 분명해집니다.

  • 일반 사용자는 자기 디스크립터를 su 같은 특권 프로그램의 표준 입출력으로 넘길 수 있습니다.
  • 이때 단순 write의 효과가 호출자의 높은 권한 때문에 더 강해진다면, 상속 자체가 보안 구멍이 됩니다.
  • 실제로 Linux의 새 마운트 API도 호출자 자격 정보에 의존하는 작업을 write로 처리하려던 초기 설계가 거부되고, 별도 fsconfig 호출을 쓰는 형태로 수용됐습니다.
  • 시스템에서 suid를 금지하더라도 커널은 이 호환성 제약을 계속 유지합니다.

⚠️ ioctl이라는 중요한 예외

신뢰하지 않는 프로세스에서 받은 디스크립터에 ioctl을 실행하는 것은 위험합니다.

  • Emilua가 쓰는 Boost.Asio의 부적절한 FIONBIO 사용은 이미 수정됐으며, 해당 변경에는 Boost 1.86 이상이 필요합니다.
  • Linux의 isatty()도 내부적으로 ioctl을 쓰므로, 비표준 작업에는 주의가 필요합니다.

7. FreeBSD Capsicum: 놀랍도록 단순한 모델

Capsicum은 FreeBSD 9.0부터 포함됐고, 파일 디스크립터를 케이퍼빌리티로 쓰도록 정식 지원합니다.

  • cap_enter() 한 번이면 그 프로세스의 암묵적 권한이 차단됩니다.
  • 이후 자원 접근은 이미 열린 디스크립터를 통해서만 가능하고, 새 자원은 다른 프로세스가 전달해 줘야 합니다.
  • 이름으로 파일을 여는 open이나 외부 소켓 엔드포인트 연결은 실패합니다.

Emilua는 서브프로세스 초기화 코드에서 C.cap_enter()를 호출할 수 있어, 복잡한 라이브러리 초기화나 시스템 자원 획득보다 먼저 권한을 줄일 수 있습니다.

코드량으로 보는 차이

2010년 Capsicum 연구가 Chromium 샌드박스를 각 방식으로 구현해 비교한 결과입니다.

| 방식 | 코드량 | | --- | --- | | Windows ACL/SID | 22,350줄 | | Linux Seccomp + 사용자 공간 시스템 호출 래퍼 | 11,301줄 | | Linux chroot | 605줄 | | Mac OS X Seatbelt | 560줄 | | Linux SELinux | 200줄 | | FreeBSD Capsicum | 100줄 |

다만 이 비교에서 chroot, Seatbelt, SELinux 방식은 샌드박스 제한이 충분하지 않다고 평가됐습니다. 이후 설계의 기준선이 된 것은 Capsicum 모델입니다.

더 세밀한 조정

  • cap_rights_limit으로 디스크립터별 권한을 한 번 더 줄일 수 있습니다. 예를 들어 세마포어 대기는 허용하되 신호 올리기는 금지하는 식의 구분이 가능합니다.
  • 원문 예제는 작업자 20개에게 송신권만 있는 소켓을 전달해, 한 작업자가 공유 채널의 송신을 종료해 버리지 못하게 만듭니다.
  • openat()는 케이퍼빌리티 모드에서도 쓸 수 있으며, 상대 경로는 주어진 디렉터리 디스크립터 아래에서만 해석됩니다.

8. 비블로킹 I/O도 보안 경계의 일부입니다

의외로 놓치기 쉬운 지점입니다. 샌드박스에서 받은 디스크립터를 부주의하게 사용하면 스레드가 멈춰 서비스 거부(DoS)로 이어질 수 있습니다. UNIX 비블로킹 I/O의 문제는 오래전부터 알려져 있었죠.

심지어 close()도 블로킹될 수 있습니다.

  • POSIX는 close()의 블로킹을 허용합니다.
  • Linux에서는 디스크립터 닫기 자체는 성공한 것으로 취급하므로, 오류 확인에 매달리지 않는 접근이 권장됩니다.
  • 진짜 문제는 닫는 데 오래 걸리는 파일입니다.

정리하며

샌드박싱은 "라이브러리 하나 붙이면 끝"나는 기능이 아니라 설계 결정에 가깝습니다. 그래도 다행인 건, 기존 코드를 전부 다시 쓰지 않아도 libc 함수 가로채기와 자원 접근 중개만으로 상당 부분 적용할 수 있다는 점입니다.

마지막으로 꼭 기억할 구분이 하나 있습니다.

  • 신뢰하는 코드의 취약점을 막는 것
  • 처음부터 신뢰하지 않는 플러그인을 가두는 것

이 둘은 완전히 다른 위협 모델입니다. 어느 쪽을 방어하는지 먼저 정하고 나서 도구를 고르는 게, 잘못된 샌드박스를 정성껏 만드는 것보다 훨씬 낫습니다.

참고: 원문의 일부 Lua 예제는 당시 미출시였던 Emilua 0.11이 필요하며, 개발 브랜치의 최신 커밋으로 실행할 수 있습니다.


원문: https://news.hada.io/topic?id=34061