
OwlAI에서 엑셀·워드·파워포인트·한글 문서는 모델이 쓴 python·node 스크립트가 만들고, 조회 결과의 후처리도 스크립트가 맡습니다. 이 스크립트는 사람이 검토하지 않고 실행됩니다. 스크립트를 돌리는 사이드카 컨테이너는 모든 사용자의 작업 폴더가 든 볼륨 하나를 붙이고 있어서, 컨테이너로 감싸기만 해서는 한 실행이 다른 사람의 산출물을 읽거나 덮어쓰는 것을 막지 못합니다. 그래서 실행마다 bubblewrap으로 새 네임스페이스를 만들어 그 실행의 폴더만 보이게 합니다.
이 격리는 이미지 안에서 완결되지 않습니다. bubblewrap이 기대는 user namespace는 호스트 커널의 기능이고, 그것을 허용할지는 호스트의 커널 설정과 보안 모듈(AppArmor·SELinux)이 정합니다. 그래서 같은 이미지가 보안 모듈이 꺼진 개발 PC에서는 통과하고, Ubuntu 24.04 서버에서는 모든 실행이 거부될 수 있습니다. 이 글은 실행 하나를 어떻게 가두는지, 그리고 격리가 서는지를 그 서버에서 실제 실행과 같은 명령으로 판정하고 서지 않으면 실행을 전부 거부하는 설계를 다룹니다.
컨테이너 하나가 모든 사용자의 폴더를 본다
검토 없이 도는 코드가 저지를 수 있는 일은 네 갈래입니다.
- 파일: 다른 사람이나 다른 조직의 산출물·첨부를 읽거나 덮어쓰고, 양식 원본을 망가뜨린다.
- 네트워크: 데이터를 외부 주소로 보내거나, 내부망 서비스와 클라우드 인스턴스 메타데이터 주소에 요청을 보낸다.
- 자원: 끝나지 않는 반복문, 메모리를 다 쓰는 할당, 프로세스를 계속 만드는 코드, 디스크를 채우는 파일로 다른 사용자의 실행까지 멈춘다.
- 커널: 커널 취약점으로 권한을 높여 컨테이너 밖으로 나간다.
악의가 없어도 이런 일은 생깁니다. 모델은 경로를 잘못 적기도 하고 문서에 숨겨 둔 지시를 따르기도 합니다(「권한은 코드로 막고, 판단은 모델에 맡긴다」). 격리는 실수든 유도된 행동이든 같은 결과를 내야 합니다.
인터프리터 안의 감시도, 최신 커널 기능도 설치형에는 맞지 않는다
파이썬 감사 훅(PEP 578)은 별도 커널 기능 없이 파일 쓰기 같은 이벤트를 거를 수 있습니다. 그런데 훅의 판정이 스크립트와 같은 인터프리터 안에서 일어나서, 스크립트가 전역 값을 바꿔치기하면 판정이 흔들리고, C 라이브러리의 쓰기는 이벤트를 남기지도 않습니다. 파이썬 공식 문서도 감사 훅은 샌드박스를 만드는 데 맞지 않는다고 적어 둡니다.
커널의 Landlock은 프로세스가 스스로 접근 범위를 좁히고 커널이 판정하므로 이 약점이 없습니다. 다만 필요한 기능(ABI 2)이 커널 5.19부터 있어서, 커널 4.18을 쓰는 RHEL 8 계열에서는 쓸 수 없습니다. 구축형 제품은 고객 서버의 배포판을 고를 수 없습니다.
bubblewrap은 권한 없는 user namespace 위에 새 마운트 네임스페이스를 만드는 도구입니다. user namespace는 커널 3.8부터 있는 기능이라, 커널 버전이 배포판을 통째로 가르지 않습니다. 남는 차이는 커널 설정과 보안 모듈인데, 이 차이를 다루는 방법은 뒤에서 설명합니다.
실행 하나는 자기 폴더만 보고, 네트워크가 없다
bubblewrap은 실행마다 시스템 디렉터리와 그 실행의 입력 폴더를 읽기 전용으로, 쓰기 폴더만 쓰기로 붙이고 나머지 파일시스템은 보이지 않게 합니다. 커널이 경로를 해석하는 단계에서 범위를 좁히므로, C 라이브러리의 쓰기나 직접 부른 시스템 호출도 같이 막힙니다.
스크립트가 읽을 수 있는 것은 그 실행(대화 하나나 플레이북 실행 하나)의 산출물, 첨부, 도구 결과 원본뿐이고, 플레이북 실행이면 플레이북에 올린 파일이 더해집니다. 같은 사용자의 다른 대화와 아직 전송하지 않은 첨부는 보이지 않습니다. 볼륨 안의 폴더는 조직, 사람, 실행 순으로 나뉘어 있어서 한 조직의 실행은 다른 조직의 폴더를 보지 못합니다.
bubblewrap은 마운트 말고도 실행마다 세 가지를 더 합니다.
- 네트워크·PID·IPC·UTS·cgroup 네임스페이스를 따로 둡니다. 스크립트에는 네트워크 장치가 없고, 사이드카 프로세스가 죽으면 샌드박스도 함께 죽습니다.
- 스크립트에 seccomp 필터를 다시 겁니다. 컨테이너의 seccomp는 bubblewrap이 네임스페이스를 만들 수 있게 일부 시스템 호출을 열어 두어야 해서, 신뢰할 수 없는 스크립트에는 더 좁은 필터를 씌웁니다. 이 필터는 새 user namespace를 다시 만드는 호출과 알려진 커널 권한 상승 경로를 막습니다.
/proc을 붙이지 않습니다. 스크립트는 다른 프로세스의 명령줄이나 환경 변수를 읽을 수 없습니다.
스크립트에 데이터가 필요하면 OwlAI 도구를 부릅니다. 그 호출은 실행을 맡긴 턴으로 돌아가 모델이 직접 부른 호출과 같은 권한 확인과 승인을 거칩니다(「스크립트 안에서 부른 도구도 호출마다 권한과 승인을 확인한다」). 스크립트가 바깥 주소나 내부 서비스에 직접 닿는 길은 없습니다.
컨테이너도 좁히고, 자원 상한은 사이드카가 정한다
bubblewrap 바깥의 컨테이너에는 다음 설정을 겁니다.
- 읽기 전용 루트 파일시스템, 실행 권한 없는 128MB
/tmp - 리눅스 권한(capability) 전부 제거, 권한 상승 금지(
no-new-privileges) - 도커 기본 seccomp 프로파일에 bubblewrap이 네임스페이스를 만드는 데 필요한 호출만 더한 프로파일. 원본에서 스크립트로 만들고 단위 테스트가 다시 만들어 대조함
- Ubuntu 23.10 이상에서는 동봉한 AppArmor 프로파일
- 게이트웨이가 없는 내부 전용 네트워크. 인터넷에도 메타스토어 데이터베이스에도 닿지 않음
자원 상한은 실행마다 사이드카가 겁니다. 호출하는 쪽이 보낸 값을 그대로 믿지 않습니다.
| 자원 | 상한 |
|---|---|
| 벽시계 시간 | 기본 60초, 요청해도 180초까지 |
| 주소 공간 | python 1GB, node 2GB |
| 파일 하나 | 64MB |
| 표준 출력·오류 | 각각 64KB까지만 돌려줌 |
| 프로세스 수 | 컨테이너 PID 상한 256 |
node만 2GB인 것은 node 엔진이 시작할 때 1GB가 넘는 가상 주소를 미리 잡아, 1GB 상한에서는 출력 없이 죽기 때문입니다. 도구를 부르는 스크립트는 승인과 도구 결과를 기다리는 동안에도 시간이 흐르므로, 벽시계 대신 CPU 시간 60초로 계산이 끝나지 않는 스크립트를 끊습니다.
프로세스 수는 RLIMIT_NPROC이 아니라 컨테이너의 PID 상한으로 셉니다. 컨테이너가 user namespace 없이 돌면 RLIMIT_NPROC은 호스트에서 같은 사용자 ID로 도는 스레드를 모두 셉니다. 이 상한을 걸면 정상 실행이 죽느냐 사느냐가 그 순간 호스트의 부하에 달립니다.
같은 이미지도 서버의 커널과 보안 모듈에 따라 격리가 서지 않는다
격리가 서는지는 호스트가 정합니다. 배포판별 VM에서 실제로 띄워 찾은 조건은 이렇습니다.
| 호스트 조건 | 격리에 일어나는 일 | 조치 |
|---|---|---|
| Ubuntu 23.10 이상 | 프로파일 없는 프로세스가 user namespace를 만들면 커널이 그 프로세스를 제한 프로파일로 옮겨 모든 실행이 거부됨 | 동봉 AppArmor 프로파일 적재 |
| RHEL/Rocky 8의 8.6 기본 커널 | 시스템 디렉터리를 붙이는 단계에서 실패 | 8.7 이상 커널 |
| RHEL/Rocky 8, 도커 SELinux 켜짐 | EL9 정책에 있는 규칙 하나가 없어 모든 실행이 거부됨 | 동봉 SELinux 정책 모듈 적재 |
| user namespace 수 상한이 0인 호스트(STIG·CIS 보안 강화 기준) | 네임스페이스를 만들 수 없음 | 상한을 0보다 크게 |
Ubuntu에서 호스트 전체의 user namespace 제한을 끄는 방법도 동작하지만, 그 서버의 모든 프로세스에서 보호가 꺼집니다. 그래서 bubblewrap에 필요한 것만 연 프로파일을 동봉했습니다.
이 표의 어느 조건도 보안 모듈이 꺼진 개발 PC에서는 드러나지 않습니다. WSL 같은 환경에서는 AppArmor가 user namespace 생성을 막지 않고, SELinux가 마운트를 막지 않습니다. 격리 옵션을 바꾼 뒤 그런 환경에서 돌린 시험은 초록이 나와도 실서버에서 통과한다는 증거가 되지 못합니다. 그래서 격리·보안 옵션을 바꾸면 배포판별 VM에서 실제 커널과 보안 모듈 위에 띄워 판정합니다. 기준은 둘입니다. 헬스 체크의 격리 상태가 정상인지, 그리고 실제 스크립트 실행이 한 번 이상 성공하는지입니다.
Ubuntu 24.04에 동봉 AppArmor 프로파일을 적재한 호스트에서 python·node로 docx·pptx·xlsx·hwpx를 만드는 실행 묶음은 문서 생성과 점검 8건이 모두 통과했습니다. 컨테이너에 도커 기본 seccomp만 둔 대조군에서는 같은 묶음이 전부 실패했습니다.
자가 점검은 실제 실행과 같은 명령으로 bubblewrap을 띄운다
고객 서버마다 VM 판정을 되풀이할 수는 없습니다. 그래서 사이드카가 자기가 도는 서버에서 격리를 스스로 확인합니다. 사이드카는 실제 실행과 같은 명령으로 bubblewrap을 한 번 띄우고 그 안에서 세 가지를 확인합니다.
- 샌드박스 볼륨을 실제 실행처럼 읽기 전용으로 붙여 목록을 읽을 수 있는지
- 전용 점검 폴더에 파일을 쓰고 지울 수 있는지
- 샌드박스 안에서
/proc이 보이지 않는지
결과는 헬스 체크에 실립니다. 격리가 서면 200을 내고, 서지 않으면 503을 내면서 bubblewrap 오류의 마지막 줄을 사유로 싣습니다. 503이면 컨테이너가 unhealthy가 되어 배포 판정이 실패하고, 사이드카는 격리가 설 때까지 모든 스크립트 실행을 거부합니다.
점검에 따로 만든 명령을 쓰면, 그 명령이 실제 실행과 다른 만큼 커널이 실제 실행에만 거부하는 조건을 놓칩니다. 헬스 체크는 정상이라고 답하는데 스크립트는 실패하고, 사용자 눈에는 모델의 코드가 틀린 것처럼 보입니다. 그래서 점검과 실행이 bubblewrap을 띄우는 명령 하나를 함께 씁니다.
설치 점검도 같은 자가 점검을 씁니다. 운영 스택을 올리기 전에, 반입한 사이드카 이미지를 네트워크 없는 일회용 컨테이너로 띄워 같은 점검을 한 번 돌립니다. 앞 표의 호스트 조건은 설치 점검이 항목마다 따로 판정합니다(「설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다」).
설계에서 고른 것
격리가 서지 않는 서버에서는 격리 없이 돌리는 대신 실행을 전부 거부하는 쪽을 골랐습니다. 대가는 준비가 덜 된 서버에서 문서 생성과 후처리가 모두 멈춘다는 것입니다. 그래서 설치 점검이 설치 전에 격리를 판정하고, 필요한 프로파일과 정책 모듈은 호스트 준비 스크립트가 맞춥니다. 그 대신 격리 없이 돈 실행은 어느 서버에서도 생기지 않습니다.
지원 범위는 VM에서 실제 실행으로 판정한 조합으로 좁혔고, 배포판 기본이 아닌 커널처럼 판정하지 않은 조합에는 설치 점검이 경고를 냅니다. 처음 보는 조합이 처음 보는 방식으로 격리를 막아도 자가 점검이 실제 실행과 같은 명령을 쓰므로 배포 판정에서 드러납니다.
/proc을 빼고 주소 공간을 좁게 잡은 대가도 있습니다. /proc이 없으면 node의 CPU 수 조회가 0을 돌려주는 식으로 일부 런타임 정보가 달라지고, 2GB 주소 공간에서는 WebAssembly 인스턴스화가 실패합니다. 그래서 문서 라이브러리를 새로 들일 때는 실제 샌드박스에서 이 두 조건으로 돌려 본 뒤 넣습니다.
OwlAI SaaS는 태스크 정의로 컨테이너별 seccomp 프로파일과 PID 상한을 지정할 수 없어서, 호스트 도커 데몬의 기본 seccomp를 같은 원칙으로 두고 PID 상한 대신 태스크의 CPU·메모리 상한과 실행 시간 상한으로 끝나지 않는 실행을 끊습니다. 실행마다 bubblewrap을 띄우는 명령과 스크립트의 seccomp 필터는 구축형과 같습니다.
커널 기능에 기대는 격리를 고객이 고른 서버에 배포한다면, 헬스 체크가 실제 실행과 같은 명령으로 격리를 띄우는지, 그리고 격리 옵션을 바꾼 시험을 보안 모듈이 켜진 실제 커널에서 돌렸는지부터 확인할 만합니다.
어떤 문제를 해결하나요?
보안과 감사의 다른 글

엔지니어링 노트 · 보안과 감사
외부 모델 대화에는 내부 모델 전용 DB의 이름도 싣지 않는다
고객사 망 안에 띄운 모델과 외부 API 모델을 한 설치에 함께 두면, 쓸 모델을 정해 두는 것만으로는 사내 데이터가 밖으로 나가는 일을 막지 못합니다.

엔지니어링 노트 · 보안과 감사
도구 결과에 나온 주소도 모델이 먼저 썼다면 열지 않는다
웹 페이지를 읽거나 주소에서 파일을 받는 도구는 대화에 먼저 나온 주소만 엽니다(「권한은 코드로 막고, 판단은 모델에 맡긴다」).

엔지니어링 노트 · 보안과 감사
답변에서 지운 전화번호도 모델은 이미 읽었다, 도구 결과에서 가린다
「지난주에 환불을 세 번 넘게 요청한 고객을 찾아 문의 내용을 정리해 줘」라는 질문은 숫자 하나로 끝나지 않습니다.
