컴파일러가 싫다

1 day ago 3
  • Anubis는 웹사이트 보호용 작업 증명을 SHA-256 밖으로 확장하면서, 클라이언트와 서버가 같은 WebAssembly 검사 로직을 실행하도록 설계 중임
  • WebAssembly가 꺼진 환경도 배제하지 않기 위해 JavaScript 재컴파일 경로를 마련했지만, WebAssembly보다 느리고 JIT까지 꺼지면 더 느려질 수 있음
  • Linux 배포판의 wasm2js가 오래되어 Homebrew 버전과 출력이 달라졌고, 재현 가능한 빌드를 위해 wasi-sdk로 빌드한 wasm2js 를 번들링하게 됨
  • C/C++ 빌드는 __DATE__, __TIME__, $PATH의 wasm-opt, 예외 처리 코드의 포인터 순서 때문에 같은 입력에서도 바이트 단위 출력이 흔들릴 수 있음
  • 최종 구현은 --no-wasm-opt, setarch --addr-no-randomize, x86_64·arm64별 SHA-256 검증, CI 재빌드 확인으로 아키텍처 내부 결정성을 확보함

Anubis의 WebAssembly 작업 증명과 JavaScript 대체 경로

  • Anubis는 관리자가 SHA-256이 아닌 작업 증명 방식을 웹사이트 보호에 쓸 수 있도록 WebAssembly 기반 proof-of-work 검사를 추가하려 함
  • 핵심 목표는 검사 로직을 클라이언트와 서버에 따로 구현하지 않고 한 곳에만 정의하는 것임
    • 클라이언트와 서버는 같은 WebAssembly에 연결해 검사 로직을 실행함
    • 양쪽이 lockstep으로 동작하는지 확인하는 구조를 지향함
  • WebAssembly가 꺼진 클라이언트도 고려 대상임
    • 사용자를 사실상 웹사이트에서 배제하고 싶지 않다는 제약이 있음
    • Anubis는 사용자 경험, 관리자 경험, 개발자 경험 사이에서 균형을 잡아야 함
  • 선택한 우회책은 WebAssembly를 JavaScript로 다시 컴파일하는 방식임
    • The Birth and Death of JavaScript에서 영감을 얻음
    • 결과 JavaScript는 동등한 WebAssembly보다 느림
    • WebAssembly를 비활성화하면 JavaScript JIT도 함께 꺼지는 경우가 있어 더 느려질 수 있음
    • 저사양 하드웨어에서 기존 JavaScript보다 효율적인지는 추가 연구가 필요함

wasm2js를 번들링해야 했던 이유

  • 필요한 도구는 binaryen 프로젝트의 wasm2js임
  • wasm2js는 Linux 배포판에 패키지로 존재하지만, 배포판 버전이 오래되어 개발 환경의 Homebrew 버전과 같은 출력을 만들지 못했음
  • 재현 가능한 빌드에는 출력의 결정성이 필수임
    • Anubis 저장소에 커밋되는 wasm2js 바이너리를 사용자와 패키저가 신뢰하려면, 같은 버전을 직접 빌드해 같은 바이트를 얻을 수 있어야 함
    • 가능하다면 다른 사람의 머신에서도 같은 바이트가 나와야 함
  • 이를 위해 wasm2js를 wasi-sdk로 WebAssembly 대상에 맞춰 빌드한 사본을 포함함

C/C++ 빌드에서 재현성이 쉽게 깨지는 지점

  • 같은 소스 바이트와 같은 입력을 넣어도 컴파일러 출력이 항상 같은 바이트가 되지는 않음
  • C/C++에서는 __DATE__와 __TIME__ 같은 내장 매크로만으로도 비결정적 출력이 생김
    • 예제 hello.cpp는 빌드 시점의 날짜와 시간을 출력하도록 작성됨
    • 한 빌드는 Jun 18 2026 00:00:59를 출력했고, 다른 빌드는 Jun 18 2026 00:01:11을 출력함
    • 소스 코드는 같은 바이트였지만 컴파일러 출력은 달라짐
  • 작은 규모의 컴파일러라면 이론적으로 결정적일 수 있지만, 실제 컴파일러에는 더 복잡한 변수가 많음

Clang이 $PATH의 wasm-opt를 조용히 실행한 문제

  • binaryen에는 wasm2js 외에도 WebAssembly 컴파일러 출력을 최적화하는 wasm-opt가 있음
  • Clang은 빌드 중 wasm-opt를 shell out으로 실행함
    • 일반적으로는 성능 향상을 위한 합리적인 동작임
    • 이번에는 $PATH에 있는 wasm-opt 버전 차이가 재현성을 깨뜨림
  • DGX Spark의 wasm-opt는 /usr/bin/wasm-opt의 version 108이었고, 워크스테이션의 Homebrew wasm-opt는 version 130이었음
  • wasi-sdk와 binaryen은 WebAssembly Exceptions extension에 의존함
    • Can I use 기준 93.86%의 브라우저 사용자가 이를 지원하는 브라우저 엔진을 사용함
    • C++는 예외가 많이 쓰이는 언어라 WebAssembly 네이티브 예외 처리가 보일러플레이트를 줄일 수 있음
  • wasmtime과 wazero는 예외 지원을 명시적으로 켜야 함
    • wasmtime에는 -W exceptions=y를 넘길 수 있음
    • wazero에는 커스텀 러너 하네스가 필요함
  • arm 머신의 오래된 wasm-opt가 예외 처리 명령을 만나 종료하면서 빌드가 실패함
  • 링크 단계에 --no-wasm-opt를 넘겨 이 비재현성 경로를 제거함

주소 배치가 예외 처리 코드 생성에 미친 영향

  • 사용 중인 Clang 버전은 wasm2js 컴파일 과정의 예외 처리 경로에서 주소에 민감한 코드 생성을 보였음
  • 원시 포인터 값이 일부 try_table 블록의 출력 순서에 영향을 줌
    • 매 빌드마다 약 29바이트 차이가 발생함
    • 계산 자체는 거의 같지만 바이트 순서가 달라지고 catch 참조도 달라짐
  • arm64 머신에서 같은 고정 버전의 wasm2js를 빌드해도 포인터 반복 순서가 워크스테이션과 달라 같은 문제가 발생함
  • 우회책은 두 가지임
    • setarch --addr-no-randomize로 해당 빌드의 주소 공간 무작위화를 비활성화함
    • 신뢰하는 머신에서 x86_64와 arm64 각각의 known-good SHA-256 체크섬을 생성함
  • CI는 ./utils/wasm/wasm2js에서 ./build.sh를 실행한 뒤 체크섬을 검증함
    • shasums.x86_64와 일치하면 x86_64 체크섬 통과로 처리함
    • shasums.arm64와 일치하면 arm64 체크섬 통과로 처리함
    • 둘 다 일치하지 않으면 wasm-opt_130.wasm과 wasm2js_130.wasm의 SHA-256을 출력하고 실패함
  • 이 CI 작업은 x86_64와 arm64 호스트 양쪽에서 실행됨
  • 호스트 전체에 걸친 재현성은 아직 확보되지 않았고, 해당 문제는 upstream LLVM 버그로 남아 있음
  • 현재 상태에서는 적어도 아키텍처 내부에서는 빌드가 결정적으로 동작함
Read Entire Article