C 라이브러리 내부의 단 하나의 널 포인터 역참조가 전체 애플리케이션을 중단시킬 수 있다. 해당 라이브러리가 사용자 입력을 파싱하거나, 이미지를 압축 해제하거나, 트워크 프로토콜을 처리한다면, 잘못된 하나의 패킷이 크래시를 일으킬 수 있다. 컨테이너는 이 문제를 해결하지만, 하나의 의존성을 위해 Docker를 실행하는 것은 화분을 위해 경호원을 고용하는 것과 같다. 오버헤드 없는 격리가 필요하다.

WebAssembly와 WASI가 가장 실용적인 답이다. 라이브러리를 WASM으로 컴파일하고 샌드박스화된 런타임 내에서 실행하면, 게스트가 세그멘테이션 오류를 일으켜도 호스트 프로세스는 살아남는다. 메모리에는 경계 검사가 이루어진다. 파일시스템 접근은 캐퍼빌리티 기반이다. 그리고 샌드박스는 라이브러리 호출이지 시스템 호출이 아니다.

C 라이브러리 샌드박싱이 실제로 의미하는 것

C에는 메모리 안전성이 없다. 의존성 내의 buffer overflow은 스택을 손상시키거나, 실행을 가로채거나, 호스트를 중단시킬 수 있다. ASAN과 같은 기존 완화책은 runtime에 버그를 찾지만, 오버헤드를 추가하고 프로세스의 나머지 부분으로부터 실패를 격리하지 않는다. seccomp와 Landlock은 시스템 호출을 제한할 수 있지만, Linux 전용이고 일부 작업에는 root가 필요하며, 허용된 시스템 호출 내에서의 memory corruption은 막지 못한다.

WebAssembly는 다른 접근 방식을 취한다. 명시적 메모리 경계, 구조화된 제어 흐름, 경계 외 접근에 대한 정의되지 않은 동작이 없는 가상 명령어 집합으로 컴파일된다. Wasmtime이나 WAMR 같은 WASM 런타임은 실행 전에 바이트코드를 검증한다. 게스트가 소유하지 않은 메모리에 접근하면 런타임은 트랩을 발생시킨다. 호스트는 계속 실행된다.

이것은 에뮬레이션이 아니다. 최신 WASM 런타임은 Cranelift나 LLVM을 통해 네이티브 머신 코드로 컴파일한다. 성능 저하는 일반적으로 계산 집약적 코드의 경우 1.1배에서 2배 사이이다. I/O 집약적 라이브러리의 경우 차이는 종종 잡음 속에서 사라진다.

WASI가 파일시스템 접근을 캐퍼빌리티로 바꾸는 방식

WASI는 WebAssembly System Interface이다. 샌드박스화된 모듈에 POSIX와 유사한 API를 제공하지만, 모든 리소스는 캐퍼빌리티이다. 전역 파일시스템은 없다. 게스트가 ./data/input.txt를 읽게 하려면, 해당 디렉터리를 명시적으로 프리오픈하고 파일 디스크립터를 전달해야 한다.

다음은 파일을 읽어 체크섬을 반환하는 최소한의 C 라이브러리이다:

// libchecksum.c
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint32_t checksum_file(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return 0;

    uint32_t sum = 0;
    int c;
    while ((c = fgetc(f)) != EOF) {
        sum += (uint32_t)c;
    }
    fclose(f);
    return sum;
}

WASI SDK로 WebAssembly에 컴파일한다:

# Install wasi-sdk from https://github.com/WebAssembly/wasi-sdk
/opt/wasi-sdk/bin/clang \
  --target=wasm32-wasi \
  -Wl,--export=checksum_file \
  -o libchecksum.wasm libchecksum.c

결과 .wasm 파일은 샌드박스화된 모듈이다. 런타임이 명시적으로 접근을 허용하지 않는 한 어떤 파일도 열 수 없다. 선형 메모리 영역 외부에 메모리를 할당할 수 없다. WASI가 execve를 정의하지 않으므로 임의의 셸 명령도 실행할 수 없다.

Rust에서 샌드박스화된 라이브러리 실행하기

Wasmtime은 C 및 Python 바인딩을 갖춘 Rust로 작성된 프로덕션용 WASM 런타임이다. 다음은 컴파일된 C 라이브러리를 로드하고, ./data/에 대한 읽기 전용 접근을 허용하며, checksum_file을 호출하는 호스트 프로그램이다:

use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};

fn main() -> anyhow::Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "libchecksum.wasm")?;

    // Build a WASI context that preopens ./data as /data inside the guest
    let mut builder = WasiCtxBuilder::new();
    builder.preopen_dir("./data", "/data", wasmtime_wasi::DirPerms::READ_ONLY, wasmtime_wasi::FilePerms::READ_ONLY)?;
    let wasi = builder.build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;

    // Call the exported function with a guest-allocated string
    let checksum_fn = instance.get_typed_func::<(i32, i32), i32>(&mut store, "checksum_file")?;

    // Write the path string into guest memory
    let memory = instance.get_memory(&mut store, "memory").unwrap();
    let path = b"/data/input.txt\0";
    let ptr = 0;
    memory.write(&mut store, ptr, path)?;

    let result = checksum_fn.call(&mut store, (ptr as i32, path.len() as i32))?;
    println!("Checksum: {}", result);

    Ok(())
}

게스트 C 코드는 자신만의 32비트 선형 메모리 내에서 실행된다. memory.write 호출은 경로 문자열을 해당 샌드박스화된 주소 공간에 복사한다. checksum_file이 자신의 메모리 끝을 넘어 읽으려 하면 Wasmtime이 트랩을 발생시킨다. /etc/passwd를 열려 하면 WASI는 ENOENT를 반환한다. 왜냐하면 해당 경로는 프리오픈되지 않았기 때문이다.

메모리 모델이 실제로 보호하는 곳

WASM 모듈은 일반적으로 64KB에서 시작하여 필요에 따라 확장되는 단일 연속 선형 메모리를 사용한다. 모든 memory access은 런타임에 의해 경계 검사된다. C 라이브러리의 buffer overflow은 호스트의 스택이나 힙을 손상시킬 수 없다. 임의의 코드로 점프할 수 없다. 할당된 자신의 영역 내 메모리에만 접근할 수 있다.

이것은 완전한 프로세스 격리보다 약하다. 게스트와 호스트는 동일한 OS 프로세스를 공유한다. CPU 수준 사이드채널 공격이나 추측 실행 버그가 이론적으로 경계를 넘어 데이터를 유출할 수 있다. 하지만 “이 C 라이브러리에 버그가 있어 크래시할 수 있다”는 위협 모델에 대해서는 WASM 격리가 진짜이며 실용적이다.

오류 발생 시 트랩 동작이 핵심 장점이다. 게스트가 널 포인터를 역참조하거나 버퍼를 오버플로하면, Wasmtime이 이를 포착하여 오류를 반환한다. 호스트 프로세스는 세그멘테이션 오류를 일으키지 않는다. 웹 서버는 재시작되지 않는다. 오류를 로깅하고 다음으로 넘어간다.

작업 속도를 늦출 트레이드오프

WASM은 투명하지 않다. 스레드, 시그널, setjmp/longjmp, 원시 mmap을 사용하는 C 코드는 다시 작성하지 않고는 WASI로 컴파일되지 않는다. WASI SDK는 선택적 플래그를 통해 pthread를 지원하지만, 여전히 실험적이며 호스트 런타임이 이를 활성화해야 한다.

FFI 경계는 수동이다. .wasm 파일을 C나 Rust 프로젝트에 링크하여 일반 라이브러리처럼 함수를 호출할 수 없다. 인수를 선형 메모리로 마샬링하고, 인덱스별로 함수를 호출하고, 결과를 다시 읽어야 한다. wit-bindgen 같은 도구는 인터페이스 정의에서 접착 코드를 생성하지만, 여전히 복잡성 비용을 지불해야 한다.

성능은 상황에 따라 다르다. 정수 중심 코드는 WASM으로 네이티브에 가까운 속도로 컴파일된다. 빈번한 호스트 호출을 하거나, 많이 할당하거나, SIMD에 의존하는 코드는 더 큰 속도 저하를 겪는다. Wasmtime은 SIMD 제안을 지원하지만, 모든 대상이 이를 균일하게 지원하는 것은 아니다.

마지막으로, WASI는 여전히 발전 중이다. WASI Preview 1은 안정적이고 널리 지원된다. Preview 2는 더 깔끔하지만 아직 보편적이지 않은 컴포넌트 모델 인터페이스를 도입한다. 오늘 출시한다면 Preview 1에 머무르자.

알아두면 좋은 대안

WebAssembly가 너무 많은 장치처럼 느껴진다면, 다른 트레이드오프를 가진 더 가벼운 선택지가 있다.

seccomp-bpf는 Linux 프로세스의 시스템 호출을 화이트리스트할 수 있게 한다. 빠르고 커널에 의해 강제된다. 하지만 Linux 전용이며, 허용된 시스템 호출 내에서의 memory corruption은 막지 못하고, 올바른 seccomp 필터 작성은 악명 높게 오류가 발생하기 쉽다.

Landlock은 권한 없이 파일시스템 접근을 제한하는 더 새로운 Linux 기능이다. seccomp보다 단순하고 “이 라이브러리가 내 SSH 키를 읽을 수 있는가” 문제를 잘 처리한다. 하지만 여전히 Linux 전용이며 메모리를 샌드박스화하지 않는다.

별도의 프로세스fork나 워커 풀은 진정한 OS 격리를 제공한다. 자식 프로세스의 세그멘테이션 오류는 자식만 죽인다. 단점은 직렬화 오버헤드와 프로세스 관리 복잡성이다. 높은 처리량 시나리오에서는 호출당 프로세스가 확장되지 않는다.

WebAssembly는 그 중간에 위치한다. seccomp보다 강한 격리, Landlock보다 이식성이 높음, 별도의 프로세스보다 가벼움. 올바른 선택은 병목이 시스템 호출 지연 시간, 메모리 안전성, 배포 복잡성 중 어디에 있는지에 달려 있다.

실용적인 시작점

애플리케이션을 다시 작성할 필요는 없다. 신뢰할 수 없는 입력을 처리하는 C 의존성 하나를 골라 WASM으로 컴파일하고, 작은 호스트 심 뒤에 감싸라.

# 1. Install wasi-sdk (or use the prebuilt release)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-24/wasi-sdk-24.0-x86_64-linux.tar.gz
tar xzf wasi-sdk-24.0-x86_64-linux.tar.gz

# 2. Compile your C library
./wasi-sdk-24.0/bin/clang --target=wasm32-wasi \
  -O2 -Wl,--export-all -o parser.wasm parser.c

# 3. Run it with wasmtime
wasmtime run --dir=./input::/input parser.wasm /input/untrusted.dat

--dir=./input::/input 구문은 호스트의 ./input 디렉터리를 게스트 내부의 /input에 마운트한다. 게스트는 파일시스템의 다른 어떤 것도 볼 수 없다. parser.c에 buffer overflow이 포함되어 있다면 코어 덤프가 아닌 트랩 메시지를 받게 된다.

호스트 통합에는 Rust, C 또는 Python의 Wasmtime 임베딩 API를 사용하라. 하나의 내보낸 함수로 시작하라. 수동 마샬링이 부족해지면 wit-bindgen을 추가하라. 핫 패스에서 WASM을 확정하기 전에 오버헤드를 프로파일하라.

FAQ

C++ 라이브러리도 같은 방식으로 샌드박싱할 수 있는가?

그렇다. WASI SDK에는 clang++가 포함되어 있고 C++17의 대부분을 지원한다. 예외와 RTTI는 작동하지만, 스레드 로컬 스토리지에는 일부 제한이 있다. 동일한 플래그로 --target=wasm32-wasi를 지정하여 컴파일하라.

네트워크가 필요한 라이브러리는 어떻게 하는가?

WASI Preview 1에는 소켓이 포함되지 않는다. 일부 런타임은 비표준 확장을 제공하거나, 가져온 함수를 통해 호스트를 경유하여 네트워크 요청을 프록시할 수 있다. Preview 2에는 소켓이 추가되지만, 런타임 전반에 걸쳐 지원이 아직 롤아웃되고 있다.

컴파일된 WASM 바이너리의 크기는 어느 정도인가?

일반적으로 동일한 C 코드의 스트립된 네이티브 바이너리 크기의 2배에서 5배 정도이다. Wasmtime은 첫 실행 시 네이티브 코드를 컴파일하고 캐시할 수 있으므로, 시작 오버헤드는 바이너리 버전당 한 번의 비용이다.

이것이 컨테이너를 대체하는가?

아니다. WASM은 프로세스 수준 샌드박스이다. 컨테이너는 OS 수준 격리이다. 프로세스 내의 신뢰할 수 없는 코드를 격리하려면 WASM을 사용하라. 프로세스를 시스템의 나머지 부분과 격리하려면 컨테이너를 사용하라. 둘은 중첩 가능하며, 서로 다른 위협 모델에 대응한다.

게스트가 WASI 취약점을 통해 탈출할 수 있는가?

공격 표면은 WASM 런타임과 WASI 구현이며, 게스트 코드 자체는 아니다. Wasmtime은 Rust로 작성되어 런타임의 많은 메모리 안전성 버그를 제거한다. WebAssembly 사양 또한 형식 검증을 거친다. 어떤 샌드박스도 완벽하지는 않지만, WASM 런타임은 대부분의 네이티브 라이브러리보다 더 나은 보안 기록을 가지고 있다.

C의 세그멘테이션 오류는 생산 중단이 아닌 통제된 실패여야 한다. 전체 OS 격리가 필요할 때 컨테이너가 올바른 답이다. 단 하나의 라이브러리가 프로세스를 장악하는 것을 막아야 할 때, WebAssembly는 더 빠르게 배포되고, 더 쉽게 추론되며, 진정으로 효과적이다.