호스트 함수가 샌드박스를 무너뜨린다: WASM 플러그인 격리 경계를 설계하는 법
저도 처음에 WASM 샌드박스를 설계할 때 "선형 메모리 격리가 있으니 게스트 코드는 어차피 자기 영역 밖을 못 건드리겠지"라고 안심했습니다. 그런데 설계를 파고들수록 불편한 진실이 하나 보였습니다. 샌드박스의 실질적인 보안 경계는 WASM 런타임이 아니라, 호스트가 명시적으로 노출한 함수 집합에 의해 결정됩니다. 실제로 지금까지 보고된 WASM 관련 격리 우회 사례 중 상당수는 선형 메모리 격리 시맨틱 자체가 아니라 호스트 측 구현—externref나 리소스 핸들의 잘못된 처리, WASI 경로 변환 로직 결함, capability 위임 실수—에서 시작되었습니다. 런타임의 메모리 모델은 견고했지만, 그 위에 얹은 호스트 함수 표면이 넓거나 오류가 있었던 것입니다.
서드파티 플러그인을 실행하거나, 테넌트별 격리 환경을 제공하거나, AI 에이전트가 생성한 코드를 in-process로 안전하게 실행해야 하는 상황이라면 이 설계 결정들이 직접 와닿을 겁니다. 호스트 함수 노출 범위를 어떻게 좁히는지, 메모리 경계를 어떻게 구조화하는지, 그리고 멀티테넌트 환경에서 리소스 고갈을 어떻게 방어하는지 — 이 세 축이 WASM 샌드박스 설계의 뼈대입니다.
WASM 격리가 실제로 어떻게 작동하는가
세 가지 보안 원칙
WASM 플러그인 샌드박스는 세 가지 레이어로 격리를 구성합니다.
선형 메모리 경계(Linear Memory Boundary) — WASM 모듈은 런타임이 bounds-check를 강제하는 선형 메모리 공간만 접근할 수 있습니다. 게스트의 포인터 버그가 호스트 메모리를 오염시킬 수 없는 이유입니다. C/C++로 작성된 게스트 코드가 선형 메모리 내에서 use-after-free를 일으킬 수는 있지만, 그 피해는 해당 모듈의 메모리 공간 안에 갇힙니다. Canonical ABI로 문자열이나 레코드를 주고받을 때 발생하는 ptr/len 해석 문제는 대부분 이 경계 그 자체가 아니라 호스트 코드의 취급 방식에서 생깁니다. wit-bindgen이 생성한 바인딩을 쓰는 편이 손으로 파싱하는 것보다 훨씬 안전합니다.
호스트 함수 노출(Host Function Exposure) — WASM 모듈은 기본적으로 어떤 시스템 리소스에도 접근할 수 없습니다. 호스트가 import로 명시적으로 등록한 함수만 호출할 수 있습니다. 이게 바로 "공격 표면을 설계로 제한할 수 있다"는 의미입니다.
역량 기반 보안(Capability-Based Security) — WASI는 모듈이 ambient authority(주변 권한)를 갖지 않도록 설계되었습니다. 파일시스템, 네트워크, 시스템 클록 등 모든 리소스 접근은 호스트가 명시적으로 부여한 capability 핸들을 통해서만 이루어집니다.
이 그림을 보면 "선형 메모리 격리가 있으니 안전하겠지"라는 생각이 들 수 있지만, HF(호스트 함수 레지스트리)가 지나치게 넓으면 게스트가 그 경로로 호스트 전체를 뒤집을 수 있습니다.
Component Model이 경계를 구조화하는 방식
기존 단일 WASM 모듈 방식과 Component Model의 차이는 격리 단위에 있습니다. Component Model에서는 각 컴포넌트가 독립된 선형 메모리와 최소 권한 집합을 갖습니다. 이미지 처리 컴포넌트가 침해당해도 데이터베이스 컴포넌트의 메모리나 capability에 접근할 수 없습니다.
WIT(WebAssembly Interface Types) 는 이 경계를 타입 안전하게 정의하는 IDL입니다. 아래는 멀티테넌트 플러그인 시스템에서 호스트가 노출할 인터페이스를 WIT로 정의한 예시입니다.
// plugin-host.wit
package myhost:plugin@0.1.0;
interface host-logging {
log: func(level: u8, message: string);
}
interface host-kv {
get: func(key: string) -> option<string>;
set: func(key: string, value: string) -> result<_, string>;
delete: func(key: string) -> result<_, string>;
}
world plugin-sandbox {
// 호스트가 게스트에게 제공하는 함수만 import로 선언
import host-logging;
import host-kv;
// 게스트가 반드시 구현해야 하는 인터페이스
export handle-request: func(payload: list<u8>) -> list<u8>;
}이렇게 WIT로 world를 선언하면, 게스트가 접근할 수 있는 호스트 함수가 문서화되면서 동시에 컴파일 타임에 강제됩니다. wit-bindgen으로 Rust, Go, C 등 언어별 바인딩을 자동 생성할 수 있어 Canonical ABI를 손으로 해석할 필요가 없어집니다.
호스트 함수 노출 범위를 좁히는 설계
최소 권한으로 호스트 함수 등록하기
Wasmtime을 예로 들면, 호스트 함수를 등록할 때 Linker에 명시적으로 추가해야 합니다. 아래는 Core WASM Linker 기반의 개념적 예시입니다. Component Model을 사용한다면 wasmtime::component::Linker와 bindgen! 매크로 조합을 대신 써야 하며, 두 API를 혼용하지 않도록 주의하세요.
use wasmtime::*;
use wasmtime_wasi::WasiCtxBuilder;
fn create_sandboxed_store(engine: &Engine) -> Result<Store<MyHostState>> {
// WASI context: 파일시스템과 네트워크를 기본적으로 닫아둠
let wasi_ctx = WasiCtxBuilder::new()
.inherit_stdio() // 표준 출력만 허용
// .inherit_network() <- 의도적으로 비활성화
// .preopened_dir(...) <- 필요한 경우 특정 경로만 열어줌
.build();
let state = MyHostState {
wasi: wasi_ctx,
limiter: MemoryLimiter { max_pages: 1024 }, // 아래 정의
tenant_id: "tenant-42".into(),
};
let mut store = Store::new(engine, state);
// fuel: 게스트가 실행할 수 있는 명령어 수 예산. 소진되면 트랩 발생.
// 뒤 CPU 고갈 방어 섹션에서 자세히 다룹니다.
store.set_fuel(10_000_000)?;
store.fuel_async_yield_interval(Some(10_000))?;
// 메모리 상한 강제는 ResourceLimiter를 통해서만 가능합니다.
store.limiter(|state| &mut state.limiter);
Ok(store)
}
fn build_linker(engine: &Engine) -> Result<Linker<MyHostState>> {
let mut linker: Linker<MyHostState> = Linker::new(engine);
// WASI 기본 인터페이스 추가 (필요한 것만)
wasmtime_wasi::add_to_linker_async(&mut linker, |s| &mut s.wasi)?;
// 커스텀 호스트 함수: Core WASM Linker의 모듈명은 단순 문자열입니다.
// 게스트가 (module='host_kv', name='get')로 import 하도록 맞춥니다.
linker.func_wrap_async(
"host_kv",
"get",
|mut caller: Caller<'_, MyHostState>, (ptr, len): (i32, i32)| {
Box::new(async move {
let key = read_string_from_memory(&mut caller, ptr, len)?;
// 테넌트 ID 기반 키 네임스페이싱을 여기서 강제
let namespaced_key = format!("tenant:{}:{}", caller.data().tenant_id, key);
let _value = caller.data().kv_store.get(&namespaced_key).await;
// 결과를 게스트 메모리에 써주는 로직...
Ok(())
})
},
)?;
Ok(linker)
}핵심은 inherit_network()를 명시적으로 호출하지 않으면 네트워크 접근이 없다는 점입니다. WASI는 기본 deny-all로 동작합니다. 그리고 KV 접근 함수 안에서 테넌트 ID로 키를 네임스페이싱하는 부분 — 이걸 호스트 함수 레이어에서 강제하면 게스트가 다른 테넌트의 데이터에 접근하는 경로 자체를 막을 수 있습니다.
메모리 상한이 왜 Config::memory_reservation이 아니냐면, 이 옵션은 인스턴스별 가상 메모리 예약량(성능 튜닝용)이지 게스트가 사용할 수 있는 최대 메모리를 강제하는 스위치가 아니기 때문입니다. 실제 상한은 ResourceLimiter를 구현해서 store에 붙여야 합니다.
use wasmtime::{ResourceLimiter, StoreLimits};
pub struct MemoryLimiter { pub max_pages: usize }
impl ResourceLimiter for MemoryLimiter {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
Ok(desired <= self.max_pages * 64 * 1024) // 페이지=64KiB
}
fn table_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
Ok(desired <= 10_000)
}
}WASI 파일시스템 preopen: 경로를 명시적 서브트리로 제한
파일시스템 접근이 필요한 플러그인이라면, 전체 경로를 열어주는 대신 특정 서브트리만 preopen으로 노출하는 패턴을 씁니다.
let wasi_ctx = WasiCtxBuilder::new()
.preopened_dir(
"/data/tenants/tenant-42/workspace", // 실제 호스트 경로
"/workspace", // 게스트에서 보이는 가상 경로
DirPerms::READ | DirPerms::WRITE,
FilePerms::READ | FilePerms::WRITE,
)?
.build();게스트 입장에서는 /workspace만 존재합니다. /etc, /var, 다른 테넌트의 디렉터리는 게스트 세계에 아예 존재하지 않습니다.
Multi-Memory로 격리 단위를 더 세밀하게
Multi-Memory는 W3C WebAssembly의 phase 4 제안 중 하나로, 단일 모듈 안에서도 여러 독립 선형 메모리를 허용합니다. 예를 들어 민감 데이터를 처리하는 메모리와 일반 연산 메모리를 분리하면, 버퍼 오버플로가 발생해도 피해 반경을 해당 메모리 공간으로 제한할 수 있습니다.
2026년 기준 Multi-Memory는 주요 브라우저와 일부 런타임에서 지원 상태가 갈리는 제안 단계이므로, 프로덕션 플러그인 시스템에 곧바로 도입하기보다는 Component Model 수준의 컴포넌트 간 격리를 먼저 챙기는 편이 현실적입니다.
Extism으로 추상화 레이어 올리기
Wasmtime 위에 직접 Linker를 조립하는 게 번거롭다면, Extism이 좋은 선택지입니다. Extism은 여러 WASM 런타임 위에 호스트-플러그인 간 데이터 교환을 위한 ABI를 얹고, 다양한 언어의 호스트 SDK를 제공합니다.
// Go 호스트에서 Extism 플러그인 실행 (개념적 예시)
ctx := context.Background()
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{Path: "plugin.wasm"},
},
}
// 허용할 호스트 함수를 명시적으로 등록
// namespace를 지정해 게스트 import 경로와 매칭합니다.
kvGet := extism.NewHostFunctionWithStack(
"kv_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// 테넌트 격리 로직을 여기서 강제
},
[]extism.ValType{extism.ValTypeI64},
[]extism.ValType{extism.ValTypeI64},
)
kvGet.SetNamespace("host_kv")
hostFunctions := []extism.HostFunction{kvGet}
plugin, err := extism.NewPlugin(ctx, manifest, extism.PluginConfig{
EnableWasi: true,
}, hostFunctions)
if err != nil {
return fmt.Errorf("plugin init: %w", err)
}
defer plugin.Close(ctx)Extism의 장점은 특정 WASM 런타임에 종속되지 않으면서도 샌드박스 격리를 유지한다는 점입니다. 단, ready-made ABI를 쓰면 Canonical ABI 세부 제어가 줄어드는 트레이드오프가 있습니다.
멀티테넌트 환경에서 반드시 챙겨야 할 리소스 제한
CPU 고갈 방어: fuel 메커니즘
WASM은 in-process 격리이기 때문에 컨테이너처럼 cgroup으로 CPU를 잘라내기 어렵습니다. 관련해서 arXiv:2509.11242는 현재 WASM/WASI/WASIX 런타임들의 시스템 인터페이스가 CPU 사이클, 디스크 I/O, 대역폭, 엔트로피 풀 같은 공유 OS 리소스 격리에서 여전히 취약점이 있음을 지적합니다(구체적 저하율은 워크로드와 공격 시나리오에 따라 달라지므로 논문의 실험 표를 직접 확인하는 편이 정확합니다).
Wasmtime의 fuel은 게스트가 실행할 수 있는 명령어 수 예산을 설정하는 메커니즘입니다. 명령어가 실행될 때마다 fuel이 차감되고, 소진되면 트랩이 발생해 실행이 중단됩니다. 무한 루프나 계산 폭탄 형태의 CPU 고갈을 방어하는 핵심 도구입니다.
// fuel 설정: 1천만 명령어로 제한
store.set_fuel(10_000_000)?;
// 비동기 환경에서는 fuel 소진 시 주기적으로 yield 하도록 설정
// 이 값을 낮게 잡으면 스케줄링 공정성이 좋아지고, 크게 잡으면 오버헤드가 줄어듭니다.
store.fuel_async_yield_interval(Some(10_000))?;메모리 상한은 앞서 정의한 ResourceLimiter로 강제합니다. fuel과 memory limiter를 함께 걸어야 CPU/메모리 양쪽의 고갈 시나리오를 커버할 수 있습니다.
호스트 함수 설계의 의사결정 흐름
새로운 호스트 함수를 추가하기 전에 이런 흐름으로 검토해보시면 좋습니다.
트레이드오프: 무엇을 얻고 무엇을 잃는가
| 항목 | WASM 샌드박스 | 컨테이너 격리 | MicroVM |
|---|---|---|---|
| 메모리 풋프린트 | 낮음 (in-process 런타임) | 수십~수백 MB | 수백 MB |
| 콜드 스타트 | 매우 낮음 (모듈 인스턴스화 수준) | 수백 ms | 수백 ms |
| 언어 중립성 | 높음 (Rust, C/C++, Go, TinyGo, AssemblyScript 등) | 높음 | 높음 |
| 마이크로아키텍처 공격 방어 | 미흡 (Spectre 계열은 WASM 시맨틱이 전역적으로 해결하지 못함) | 미흡 | 강함 (하드웨어 격리) |
| 리소스 고갈 방어 | 런타임 설정 필요 (fuel, ResourceLimiter) | cgroup으로 제한 | 강함 |
| 호스트 함수 감사 가능성 | 높음 (명시적 등록) | 낮음 | 낮음 |
정량적 벤치마크가 필요하다면 각 런타임의 공식 벤치마크 리포지토리나 논문의 재현 가능한 결과를 직접 확인하는 편이 안전합니다. 서드파티 요약 블로그의 숫자는 버전과 워크로드가 명확하지 않은 경우가 많습니다.
실무에서 자주 만나는 실수들
1. 호스트 함수를 "편의상" 넓게 열어주기
"어차피 내부에서만 쓰니까"라는 이유로 exec_command 같은 함수를 등록하면, 그 순간 샌드박스는 사실상 없는 것과 같습니다. WIT world에 포함된 모든 함수는 잠재적 공격 표면이라는 인식이 필요합니다.
2. fuel 없이 멀티테넌트 배포
단일 테넌트 환경에서 테스트할 때는 문제없다가, 멀티테넌트 환경에서 악의적이거나 버그 있는 플러그인이 무한 루프로 CPU를 독점하는 상황이 생깁니다. fuel과 ResourceLimiter는 선택이 아니라 필수입니다.
3. Config::memory_reservation으로 상한을 걸었다고 착각하기
이 옵션은 성능 튜닝용 예약량이지 상한 강제 스위치가 아닙니다. 상한은 반드시 ResourceLimiter::memory_growing에서 판단해야 합니다.
4. JIT 런타임을 최신으로 유지하지 않기
JIT 컴파일러 버그는 샌드박스 탈출의 주요 경로입니다. 보안이 매우 중요한 환경이라면 JIT 대신 인터프리터 모드나 AOT 컴파일 결과의 검증된 캐시를 사용하는 것도 선택지입니다(성능 트레이드오프가 있습니다).
5. WASM 단독 격리를 적대적 멀티테넌트에 그대로 쓰기
Spectre, 데이터-전용 사이드 채널 공격은 WASM 샌드박스 시맨틱이 전역적으로 해결하지 못합니다. 익명 사용자가 임의 코드를 올리는 수준의 적대적 환경이라면, WASM 런타임을 MicroVM(예: Firecracker) 위에 얹는 이중 격리 아키텍처를 검토할 만합니다. "표준"이라고 단정하긴 어렵지만, 실제 서비스형 코드 실행 플랫폼들이 채택해온 접근입니다.
런타임 선택: 2026년 기준 간단 비교
| 런타임 | Component Model 지원 | 특화 영역 |
|---|---|---|
| Wasmtime | 레퍼런스 구현 | 플러그인 시스템, 보안 중시 |
| Wasmer | 지원 | 크로스 플랫폼 임베딩 |
| WasmEdge | 지원 | AI/ML, 엣지 컴퓨팅 |
WASI 공식 사이트에 각 릴리스의 상태가 정리되어 있으니, 프로젝트 시작 시점에 지원 매트릭스를 직접 확인하시는 것을 권합니다. WASI 0.3에서 가장 큰 변화는 Component Model에 네이티브 비동기 지원(async func, stream<T>, future<T>)이 들어와 플러그인이 데이터베이스나 HTTP 같은 비동기 I/O를 언어별 프리미티브로 표현할 수 있게 된 점입니다.
// WASI 0.3 비동기 함수 선언 예시
interface data-fetch {
fetch-records: async func(query: string) -> result<list<string>, string>;
}마무리: WIT world는 API 계약서가 아니라 보안 계약서다
WASM 플러그인 샌드박스에서 붙잡아야 할 판단은 하나로 압축됩니다. 런타임이 관리하는 것은 메모리 페이지의 경계이고, 우리가 관리해야 하는 것은 권한의 경계다. 이 둘은 다른 레이어에 있습니다.
그러니 WIT world를 API 계약서가 아니라 보안 계약서로 다루는 편이 자연스럽습니다. 함수 하나를 추가하는 결정은 새 엔드포인트를 여는 결정이 아니라 새 신뢰 경로를 여는 결정이고, 그렇다면 세 가지가 따라옵니다. world는 API처럼 시맨틱 버전으로 관리해야 하고, 각 함수는 부여하는 권한 범위·테넌트 격리 강제 지점·리소스 상한을 명시한 문서를 동반해야 하며, 삭제는 축소가 아니라 파괴적 변경으로 취급해야 합니다. 이렇게 다루면 "필요할 것 같아서" 추가된 함수가 몇 달 뒤 공격 표면이 되는 흐름이 자연스럽게 차단됩니다.
메모리 격리·fuel·ResourceLimiter·preopen·Component Model — 이 모든 도구가 유효하지만, 결국 샌드박스의 강도는 WIT world에 어떤 함수가 들어 있는가로 수렴합니다. 새 host function을 추가하기 전에, 그 함수가 계약서의 어느 조항인지 스스로 명명할 수 있는지를 먼저 확인해보세요.
참고 자료
- WebAssembly Component Model in Go Backends: Sandboxed Plugin Execution, Host ABI Design, and the Isolation Tradeoff
- WASM on the Backend in 2025: Sandboxing, Performance, and Deployment Trade-offs
- WASI and the WebAssembly Component Model: Current Status
- WASI 공식 사이트
- WASI Roadmap
- The Wasm Breach: Escaping Backend WebAssembly Sandboxes
- Exploring and Exploiting the Resource Isolation Attack Surface of WebAssembly Containers (arXiv:2509.11242)
- WebAssembly Security 공식 문서
- Multi-Memory Proposal (W3C WebAssembly)
- Building Native Plugin Systems with WebAssembly Components
- Extism 공식 문서 - FAQ
- Wasmtime 공식 문서 - ResourceLimiter
- In-Process WebAssembly Sandboxes for Agent-Generated Code
- WaSC: Hardening WebAssembly Sandboxes via System Interface Decoupling (ACM)