
자작 AI 하네스(Harness)에서의 강제 접근 제어 샌드박스 설계 및 구현 (Windows AppContainer)
요약
AI 하네스 내에서 프롬프트 인젝션 및 악성 코드 실행에 대비한 Windows AppContainer 기반의 강제 접근 제어 샌드박스 설계 및 구현 사례를 소개합니다. OS 커널 수준의 보안 경계를 활용하여 자식 프로세스의 권한을 최소화하고 피해 범위를 제한하는 데 중점을 둡니다.
핵심 포인트
- 프롬프트 인젝션 방어 불가 전제하에 정책 기반 접근 제어 구현
- 사용자 모드 후킹 대신 Windows AppContainer를 통한 커널 수준 보안 경계 활용
- 최소 권한 원칙에 따른 특권 분리 및 비관리자 권한 하네스 운영
- 격리 강도에 따른 Tier별 실행 환경(Tier0~Tier1) 제공
AI 하네스(Harness)에서의 보안을 고려하여 구현해 보았습니다.
보안에 있어서 특허 같은 것들은 너무 장난스러우니, 특허를 뺏기기 전에 돈을 내지 않고도 사용할 수 있도록 오픈해 버리자는 사상입니다.
프롬프트 인젝션 (Prompt Injection)을 완전히 방지하는 것은 불가능하므로, 인젝션이 발생하더라도 정책 기반의 접근 제어 (Policy-based access control)를 수행한다면, 설령 OS를 파괴하라는 지시가 있더라도 하네스가 생성한 자식 프로세스로부터는 읽거나 쓸 수 없으므로 파괴를 막을 수 있을 것입니다.
AI 하네스에서의 강제 접근 제어를 Windows (AppContainer 및 WPF)로 구현해 보았습니다.
네트워크 (도메인 기반 제어) · 파일 시스템에 대한 강제 접근 제어와, 파일 시스템 오버레이를 통한 지연 쓰기 (CoW, Copy-on-Write) 구현을 진행하고 있습니다.
OSS(Open Source Software) 업계에서는 아마 아무도 하지 않고 있을 것이라, 아마 논문 이외에는 첫 시도일 것이라고 생각합니다.
(소스는 향후 공개 예정)
「자식 프로세스가 약속을 지켜줄 것」을 전제로 하지 않는다는 점을 중시했습니다.
LLM이 발행하는 커맨드는 셸(Shell)이나 빌드 도구, 그리고 그 손자 프로세스(npm install의 postinstall이나, cargo build의 build script, git의 hook 등)까지 포함합니다. 이것들은 OS의 API를 직접 호출할 수 있기 때문에, 하네스 측에서 "얌전하게 행동해 줘"라고 부탁하는 것은 의미가 없습니다.
「악성 코드는 약속을 지키지 않는다」는 전제로 설계해야 합니다.
따라서 보안 경계(Security boundary)는 반드시 OS 커널이 강제하는 것으로 해야 한다는 것이 대원칙입니다.
사용자 모드(User mode)의 API 후킹(Hooking)만으로 「여기서부터는 안 됩니다」를 구현하려고 하면, 후킹 자체를 무효화하거나 후킹하지 않은 API를 호출하는 방식으로 쉽게 우회됩니다.
그래서 후킹은 보안 경계로 사용하지 않습니다. 사용하는 것은 어디까지나 편의성을 위해서입니다 (후술할 CoW 모드에서 설명하겠습니다).
또 다른 사상은 「기동 자체를 막는 것」이 아니라 「기동해도 사용할 수 있는 기능을 줄이는 것」이라는 접근 방식입니다. 진정으로 「수상한 실행 파일은 기동시키지 않는다」를 구현하려면 WDAC나 AppLocker, minifilter 드라이버와 같은 커널 레벨의 메커니즘이 필요하며, 이는 자작 하네스의 스코프를 넘어섭니다.
그래서 기동은 허용하되, 읽을 수 있는 곳 · 쓸 수 있는 곳 · 연결할 수 있는 곳을 미리 좁혀둠으로써, 설령 악성 코드가 동작하더라도 피해 범위를 작게 만드는 현실적인 라인을 목표로 했습니다.
또한, 하네스 본체는 비관리자 권한 상태로 동작시키는 것도 중요합니다.
LLM의 루프나 도구 호출의 파싱 등, 공격 면(Attack surface)이 큰 프로세스에 관리자 권한을 부여하고 싶지 않습니다.
관리자 권한이 필요한 조작 (ACL 부여나 네트워크 필터 설치 등)은 전용의 작은 특권 분리 헬퍼 프로세스로 분리해 두었습니다.
이른바 권한 분리, 최소 권한 제어 (Principle of Least Privilege)의 관점입니다.
공격 가능한 면은 시간과 코드 양에 의해 결정되므로 하네스로부터 분리합니다.
run_shell로 자식 프로세스를 실행할 때, 격리 강도가 다른 여러 Tier를 준비해 두었으며, 환경에 따라 자동으로 선택됩니다 (플래그로 명시적으로 낮출 수도 있습니다).
Tier0 (보험): 격리 없음. cwd의 구속과 환경 변수의 strip 정도는 수행하지만, 범위 외 쓰기 · 읽기 · 네트워크는 OS 경계로서 보호하지 않습니다. 아무것도 사용할 수 없을 때의 마지막 보험입니다.
Tier1 (Restricted Token + Low Integrity): Windows의 제한 토큰(Restricted Token)과 저정합성(Low Integrity) 레벨로, 범위 외 쓰기는 거부할 수 있습니다. 다만 저IL(Low IL)은 중IL(Medium Integrity)의 파일을 읽을 수 있기 때문에, 읽기 기밀성은 지킬 수 없습니다. 네트워크도 그대로 통과됩니다.
Tier2a (AppContainer): 이번 본론입니다. Package SID와 Capability를 통해 OS가 직접 파일 · 네트워크의 접근 가부(可否)를 판정하게 합니다. Windows의 기본 Tier로 설정되어 있습니다.
Tier2b (Linux bwrap): Linux에서는 동일한 사상을 user/mount/network의 namespace와 overlay로 실현합니다. bubblewrap을 사용합니다.
Tier3 (Hyper-V + Incus): 가장 강력한 경계입니다. 호스트 측에 Hyper-V 외층 VM을 세우고, 그 안의 Incus 컨테이너에서 실행합니다. 기동 비용이 높기 때문에 명시적 옵트인(Opt-in) 방식으로 설정했습니다.
Windows 환경에서는 플래그 없이 Tier2a를 기본적으로 프로브(Probe)하며, 사용할 수 없는 경우 기동 자체를 거부하도록 설정했습니다 (조용히 Tier1이나 Tier0로 떨어져서 "격리하고 있다고 착각"하게 되는 상황이 가장 무섭기 때문입니다).
Tier1까지 떨어뜨리려면 --tier1을 명시해야 합니다.
Windows에서 비관리자(Non-administrator) 상태로 프로세스를 격리하는 수단은 몇 가지 검토했습니다.
- Restricted Token + Low Integrity (Tier1)만으로는 위에서 언급한 대로 읽기 기밀성과 네트워크 출구를 보호할 수 없습니다.
bindflt(Bind Filter)는 특정 폴더를 다른 폴더로 통째로 바꿔서 보여주는 기능으로, 읽기/쓰기를 한꺼번에 리다이렉트(Redirect)할 수는 있지만, "읽기는 원래 위치, 쓰기만 다른 위치"와 같은 구분(Copy-on-Write)은 불가능합니다.wcifs(Windows Container Isolation FS)는 그러한 구분이 가능하지만, 대상 프로세스가 실제 Windows Container의 silo 문맥(Context)에 있다는 것이 전제되어 있어 단순히 프로세스를 기동하는 것만으로는 사용할 수 없습니다. 게다가 관리자 권한이 필요합니다.
결국, 비관리자 상태를 유지하면서 package SID • capability SID를 부여하여 OS 측에서 액세스 가능 여부를 판정하게 할 수 있는 AppContainer를 중심으로 삼기로 했습니다.
AppContainer는 기본적으로 대부분의 외부 파일을 읽을 수 없으므로 (deny-by-default), 파일 시스템 제어는 "필요한 곳에만 구멍을 뚫는" allowlist 방식으로 이루어집니다.
fs.read: 읽기만 허용fs.read_exec: 읽기 및 실행을 허용 (외부 도구의 위치 등)fs.read_write: 읽기 및 쓰기를 허용 (캐시나 툴체인(Toolchain)의 작업 디렉터리 등)
이것들은 하네스(Harness) 기동 전의 preflight 단계에서, package SID에 대해 ACL (액세스 제어 목록)의 ACE (액세스 허가 엔트리)를 추가하는 방식으로 구현되어 있습니다. 사용자 소유의 경로는 하네스 스스로 부여하지만, Program Files와 같은 시스템 보호 경로는 비관리자 토큰에 WRITE_DAC 권한이 없기 때문에 특권 분리 헬퍼(Privilege Separation Helper)에 모아서 위임합니다.
또한, .harness (하네스 자신의 설정 디렉터리)는 모드와 관계없이 항상 package SID의 ACE를 박탈합니다.
LLM이 (인젝션(Injection)을 통해) ".harness/settings.json을 수정해서 allowlist를 완화해"와 같은 지시를 실행하려고 해도, 자식 프로세스 측에서는 애초에 OS 레벨에서 쓸 수 없습니다.
네트워크는 여러 층을 겹쳐 두었습니다.
- AppContainer capability:
internetClientcapability를 가지지 않은 자식 프로세스는 직접 소켓(Socket)을 열려고 해도 OS에 의해 거부됩니다. 이것이 가장 기초적인 기반입니다. - 앱 단위의 allowlist: 신뢰하는 명령(예:
git)만 이 capability를 가진 클래스에 넣습니다. 다만 capability는 프로세스 트리(Process Tree)에 상속되므로, 허용하는 명령은 가능한 한 좁게 제한합니다. - 협조 프록시 (Cooperative Proxy):
HTTP_PROXY등을 읽는 도구에 대해 도메인 단위의 허용/거부 및 감사를 수행합니다. 단, raw socket으로 직접 나가는 프로세스에는 효과가 없습니다. - WFP (Windows Filtering Platform): package SID를 조건으로 하는 패킷 필터로, raw socket의 우회로(Backdoor)에도 대응합니다.
기본은 당연히 거부(Deny)이며, 아무것도 허용하지 않았다면 자식 프로세스는 외부와 일절 연결되지 않습니다.
기본 Tier2a에서는 workspace의 ACL을 읽기/쓰기 모두 허용된 상태로 유지합니다.
따라서 하네스의 도구 쓰기는 CoW (Copy-on-Write)가 기본입니다.
즉, 워크스페이스에 대한 쓰기는 사후 승인이 없는 한 그대로 덮어쓸 수 없도록 되어 있습니다.
한편, 셸 명령을 실행하는 run_shell 명령은 자식 프로세스를 생성하므로 협조적인 제어는 통하지 않습니다.
따라서 run_shell이 생성하는 자식 프로세스의 쓰기는 "워크스페이스 또는 허용된 경로 이외에는 쓸 수 없다"는 의미에서는 보호되지만, 하네스 자신의 파일 편집 도구(Edit/Write에 해당하는 것)가 통과하는 리뷰·undo 메커니즘에는 올라타지 않습니다.
커맨드(Command)를 통한 쓰기만 "보이지 않는" 것은 기분이 찜찜하기 때문에, 옵트인(Opt-in) 방식으로 이를 해소하는 --cow 모드를 만들었습니다.
수행하는 작업은 간단하며, 다음과 같은 흐름으로 진행됩니다.
- workspace 본체의 ACL을 읽기 전용(RO, Read-Only)으로 변경합니다.
- workspace 외부에 준비한 "upper" 영역(
%LOCALAPPDATA%하위 디렉토리)에만 읽기/쓰기 권한을 부여합니다. - 자식 프로세스를
CREATE_SUSPENDED(일시 중지 상태)로 실행하고, Job Object에 할당한 뒤, Redirector DLL이라는 것을 대상 프로세스에 인젝션(Injection)합니다. - DLL 초기화가 끝났다는 신호(Named Pipe 경유)를 기다린 후,
ResumeThread로 프로세스를 재개합니다.
이 Redirector DLL이 ntdll의 NtCreateFile / NtOpenFile을 후킹(Hooking, retour라는 Rust의 inline hook 크레이트 사용)하고 있으며, workspace 하위로의 쓰기를 감지하면 대상 파일을 upper 측으로 복사한 뒤 그쪽으로 쓰기 대상을 바꿔치기합니다. 이른바 Copy-on-Write의 "Copy" 부분을 이 후킹이 담당하고 있습니다.
이 부분이 설계 사상 섹션에서 썼던 원칙의 실천 편입니다.
이 DLL 후킹은 어디까지나 "편의성"만을 위해 존재하며, "안전성"의 근거로는 일절 사용하지 않습니다.
만약 DLL 인젝션이 실패하더라도, 혹은 어떤 이유로든 후킹이 무효화되거나 회피되더라도, workspace 본체의 ACL은 RO(읽기 전용) 상태를 유지하므로 쓰기를 시도하면 OS 레벨에서 ACCESS_DENIED가 발생합니다. 즉, 후킹이 작동하든 안 하든 workspace가 물리적으로 파괴되는 일은 없습니다 (fail-closed).
후킹이 담당하는 것은 "파괴되지 않도록 하는 것"이 아니라, "파괴하지 않고 copy-on-write로서 쓸 수 있게 하는" 투명성(Transparency) 부분뿐입니다.
경계는 ACL, 투명성은 후킹으로 정리하고, 후킹을 안전성의 경계로 삼지 않습니다.
이러한 구분은 매우 중요합니다. "DLL 인젝션에 실패해도 fail-closed로 안전하므로, 즉시 자식 프로세스를 종료하고 경고를 띄우면 된다"라고 설계하면 그만이기 때문입니다.
다만, 정책(Policy)으로 허용된 파일은 읽을 수 있으므로, ".ssh 안의 내용을 읽어줘"와 같은 지시를 받은 AI가 있을 경우, .ssh를 fs.read에 넣어버리면 읽을 수 있게 됩니다.
모든 것은 강제 접근 제어(Mandatory Access Control) 정책에 달려 있습니다.
즉, 이 메커니즘은 "프롬프트 인젝션(Prompt Injection)을 탐지한다"거나 "악성 지시를 영리하게 간파한다"는 이야기가 전혀 아니며, "설령 AI가 악성 지시에 따르더라도, OS 레벨에서 허가하지 않은 일은 할 수 없다"라는 확실한 선을 긋기 위한 메커니즘입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기