Anubis와 재현 가능한 WebAssembly 빌드의 과제

Anubis 프로젝트는 관리자가 SHA256이 아닌 다른 방식을 사용하여 웹사이트를 보호할 수 있도록 WebAssembly 기반의 작업 증명(PoW) 검사를 구현하고 있습니다. 클라이언트와 서버 모두에서 검사 로직에 대한 단일 진실 공급원(single source of truth)을 유지하기 위해 Anubis는 WebAssembly를 사용합니다. 하지만 WebAssembly가 비활성화된 사용자를 지원하기 위해, 프로젝트는 Binaryen 프로젝트의 wasm2js 도구를 사용하여 해당 WebAssembly를 JavaScript로 다시 컴파일합니다.

동일한 입력 바이트가 일관되게 동일한 출력 바이트를 생성하는 재현 가능한 빌드(reproducible builds)를 달성하는 것은 Anubis 저장소에 커밋된 wasm2js 바이너리의 신뢰성과 검증을 위해 매우 중요합니다. 그러나 이 과정에서 현대 컴파일러 툴체인 내의 상당한 비결정론적 요소가 드러났습니다.

컴파일러 비결정론의 원인

컴파일러는 항상 입력에 대한 결정론적 함수가 아닙니다. 여러 요인이 동일한 소스 코드가 서로 다른 빌드 또는 환경에서 다른 바이너리 출력을 생성하게 만들 수 있습니다.

빌드 시점 메타데이터

C/C++ 개발에서 비결정론의 가장 흔한 원인 중 하나는 __DATE____TIME__과 같은 내장 매크로의 사용입니다. 이러한 매크로는 실행 시의 정확한 시간을 바이너리에 기록하여, 소스 코드가 변경되지 않더라도 모든 빌드가 서로 다른 바이트를 생성하도록 보장합니다.

암시적 툴체인 의존성

컴파일러는 종종 시스템 $PATH에서 사용할 수 있는 외부 도구에 의존합니다. wasi-sdk의 경우, Clang은 WebAssembly 출력을 최적화하기 위해 wasm-opt를 암시적으로 호출할 수 있습니다. 이는 호스트 머신에 설치된 wasm-opt의 특정 버전에 대한 의존성을 생성합니다. 예를 들어, wasm-opt 버전 108이 설치된 머신에서의 빌드는 버전 130이 설치된 머신에서의 빌드와 결과가 다르거나 실패할 수 있으며, 특히 WebAssembly Exceptions 확장을 다룰 때 더욱 그렇습니다.

이를 완화하기 위해 Anubis 빌드 프로세스는 링크 단계에서 --no-wasm-opt 플래그를 사용하여 이러한 외부 의존성을 제거합니다.

아키텍처별 바이너리 불일치

메타데이터와 외부 도구가 제어되는 경우에도, 저수준 코드 생성은 호스트 아키텍처의 메모리 레이아웃에 따라 달라질 수 있습니다.

주소 민감형 코드 생성

Clang의 예외 처리 경로는 원시 포인터 값이 try_table 블록의 순서에 영향을 줄 수 있는 주소 민감형 코드 생성을 포함합니다. 이로 인해 빌드 간에, 또는 서로 다른 아키텍처(예: x86_64 대 arm64)에서 빌드할 때 서로 다른 포인터 반복 순서로 인해 바이너리가 몇 바이트씩 차이가 나게 됩니다.

이를 단일 아키텍처 내에서 해결하기 위해 다음과 같은 단계가 수행되었습니다:

  1. ASLR 비활성화: 빌드 중 주소 공간 무작위화(address-space randomization)를 방지하기 위해 setarch --addr-no-randomize를 사용합니다.
  2. 체크섬 검증: x86_64 및 arm64 아키텍처 모두에 대해 검증된 SHA256 체크섬을 생성합니다.
  3. CI 검증: 두 아키텍처에서 모듈을 다시 빌드하고 기록된 체크섬과 대조하여 검증하는 CI 작업을 구현합니다.

재현 가능성에 대한 커뮤니티의 관점

결정론적 빌드를 위한 노력은 개발자 커뮤니티 내에서 여러 기술적 논의를를 불러일으켰습니다:

  • Hermetic 빌드 시스템: 일부 기여자는 Nix와 같은 도구가 시간 호출을 캡처하여 상수로(epoch 0) 대체함으로써 결정론을 보장하는 샌드박스를 사용하여 이러한 문제를 해결한다고 제안했습니다.
  • LLVM 버그: 기술적 분석에 따르면 LLVM의 DenseMap에 대한 비결정론적 반복이 포인터 누출 문제의 근본 원인일 수 있으며, MapVector로 전환하여 결정론적 반복 순서를 보장할 것을 제안합니다.
  • PoW 윤리: 일부 사용자는 스크래핑을 방지하기 위해 클라이언트에게 PoW 루프를 실행하도록 강제하는 것이 에너지 소비 및 접근성 측면에 미치는 영향에 대해 우려를 제기했습니다.

"Clang이 포인터 주소로 인해 비결정론적 출력을 생성한다면 그것은 버그입니다... 가장 흔한 방식은 어떤 코드 경로가 비결정론적인 DenseMap을 반복하고 있는 경우입니다."

현재 빌드는 특정 아키텍처 내에서는 결정론적이지만, 아키텍처 간 재현 가능성을 달성하는 것은 여전히 LLVM의 상위 단계(upstream) 과제로 남아 있습니다.

Sources