1P by GN⁺ | ★ favorite | 댓글 1개
  • Anubis는 웹사이트 보호용 작업 증명을 SHA-256 밖으로 확장하면서, 클라이언트와 서버가 같은 WebAssembly 검사 로직을 실행하도록 설계 중임
  • WebAssembly가 꺼진 환경도 배제하지 않기 위해 JavaScript 재컴파일 경로를 마련했지만, WebAssembly보다 느리고 JIT까지 꺼지면 더 느려질 수 있음
  • Linux 배포판의 wasm2js가 오래되어 Homebrew 버전과 출력이 달라졌고, 재현 가능한 빌드를 위해 wasi-sdk로 빌드한 wasm2js 를 번들링하게 됨
  • C/C++ 빌드는 __DATE__, __TIME__, $PATHwasm-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 바이너리를 사용자와 패키저가 신뢰하려면, 같은 버전을 직접 빌드해 같은 바이트를 얻을 수 있어야 함
    • 가능하다면 다른 사람의 머신에서도 같은 바이트가 나와야 함
  • 이를 위해 wasm2jswasi-sdk로 WebAssembly 대상에 맞춰 빌드한 사본을 포함함

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

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

Clang이 $PATHwasm-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.wasmwasm2js_130.wasm의 SHA-256을 출력하고 실패함
  • 이 CI 작업은 x86_64와 arm64 호스트 양쪽에서 실행됨
  • 호스트 전체에 걸친 재현성은 아직 확보되지 않았고, 해당 문제는 upstream LLVM 버그로 남아 있음
  • 현재 상태에서는 적어도 아키텍처 내부에서는 빌드가 결정적으로 동작함

댓글과 토론

Lobste.rs 의견들
  • clang이 몰래 $PATHwasm-opt를 실행한다는 건 처음 알았고, 정말 말이 안 된다고 느낌
    이 때문에 zig cc에도 영향이 있는지 확인했는데, 다행히 clang링커 드라이버로 쓸 때만 실행돼서 해당되지 않았음
    clang이 주소 배치에 의존해 순서를 정한다면 개인적으로는 버그로 보고, 최신 릴리스에서도 재현된다면 그렇게 신고할 듯함

    • Xe가 다른 곳에서 업스트림에 보고하겠다고 했고, 이건 확실히 LLVM 결정성 버그
      이런 문제를 없애려는 노력은 수년간 이어져 왔음
    • Windows에서 clang.exe크로스 컴파일러로 안정적으로 써보면 더 미칠 지경임
      clang은 네이티브 시스템용으로 빌드할 거라고 가정하는 방식이 500가지쯤 있음
  • 비판하려는 건 아니고, 오픈소스이며 OP가 인기 있는 서비스를 무료로 제공한다는 점은 존중함
    그래도 웹이 이렇게 변하는 건 정말 싫음. 웹사이트에 들어갈 때마다 Anubis 로딩 페이지가 번쩍 뜨는 일이 흔해졌는데, 인기 웹사이트마다 작업 증명 스플래시 화면을 보여주는 웹을 원하는 건지 모르겠음
    AI 크롤러가 계속 몰려오니 대안이 뭔지도 모르겠지만, 작업 증명이 실제로 AI 크롤러를 막는다는 증거가 있는지도 의문임. 이들은 엄청난 자금을 갖고 있고, 페이지를 읽는 데 이미 훨씬 많은 계산을 하고 있으니 작업 증명 풀이 비용은 아주 작은 수준처럼 보임

    • 작업 증명이 AI 크롤러를 막는다는 증거는 있음. 여기에도 관련 글이 여러 번 올라왔음
      Anubis 파일럿에서는 원치 않는 트래픽에 대해 확실히 효과적인 억제 수단이었고, 기본에 가까운 규칙만으로 세 애플리케이션 요청의 약 90% 를 계속 차단했다고 함. DDR은 71.0%, ArcLight는 94.6%, Catalog는 92.4%였음
      5월 30일 봇 트래픽이 급증해 6월 3일 Anubis 적용 전까지 카탈로그가 사실상 서비스 불능이 됐고, 6월 1일 정점에는 210만 고유 IP에서 340만 HTTP 요청, 페이지 로딩 70초 이상까지 치솟았음. 6월 4일 Anubis 적용 후에는 사용자에게 다시 서비스 가능해졌고, 애플리케이션이 처리한 총 요청은 12.5만 건, 페이지 로딩은 2.12초로 개선됨
      https://lobste.rs/s/ncyfcp/anubis_pilot_project_report_june_2025
      또 다른 사례에서도 Anubis 배포 직후 문제가 해결됐고, 모니터링에서 정확한 시점을 볼 수 있었으며 이후 알림이 하나도 없었다고 함. 공격은 계속 중이었지만 서버 부하는 최저 수준이었고, Anubis는 AI 스크래퍼 차단뿐 아니라 DDoS 보호로도 작동했다고 봄
      https://lobste.rs/s/67ijih/day_anubis_saved_our_websites_from_ddos
    • 꼭 작업 증명이 필요하진 않음. https://shithub.us상태 없는 JS-free 차단기를 배포했는데, 스크래퍼가 우회하려면 단순 작업 증명만큼은 비용이 들 가능성이 큼
      https://orib.dev/tmp/bandwidth.png
    • 크롤러가 정확히 무엇이고 무엇을 위한 것인지는 아무도 모르지만, 많은 크롤러가 꽤 게을러서 특이한 처리를 잘 못 하는 듯함
      어떤 사람들은 meta refresh 태그나 눌러야 하는 버튼만으로도 막음. 그래서 Anubis는 작동하지만, 핵심은 작업 증명 자체가 아니라 예상 밖의 동작이라는 데 있음
    • 이런 웹은 확실히 원하지 않음
      JavaScript를 끈 브라우저로 웹을 쓰던 시절보다 더 괴로워지고 있음. 웹은 그냥 문서 중심이었으면 하는데, 이제는 어디서나 Cloudflare, Anubis, 캡차 관문을 지나야 함
    • 어떤 APT든 Anubis 응답 계산을 가속할 수 있다는 건 처음부터 알려져 있었음. 작년의 개념 증명에도 이렇게 적혀 있음
      봇은 언제나 WAF를 우회할 방법을 찾고, 실제 사용자는 로딩 화면에서 CPU 사이클을 낭비하게 된다는 내용임
  • 안타깝지만 놀랍진 않음. 컴파일러 도구체인은 “로컬 문맥이 그냥 정확해야 한다”는 식의 말도 안 되는 암묵적 의존에 기대 온 역사가 아주 김
    다만 LLVM은 그런 의존을 제거하는 데 앞장서 온 쪽이라 clang에서 이런 걸 보니 묘함. 덕분에 예를 들어 Rust 컴파일러는 별도의 크로스 컴파일러 개념 없이도 가능해졌음
    기존 빌드 도구에 기대지 않고 OS를 부트스트랩해보면 바로 드러남. 커널을 만들고, 그 커널용 libc와 컴파일러를 만들어 실행하고, 새 OS 위에서 전부 다시 빌드하는 과정은 암묵적 가정으로 가득 찬 터무니없이 복잡하고 예민한 작업임
    OS와 컴파일러 개발자에게만 주로 닿는 드문 문제라 좋은 도구나 모범 사례도 거의 없고, 컴파일러+OS 조합마다 실제로 전체를 이해하는 사람은 전 세계에 5명쯤일 것 같음

    • 이 얘기를 듣고 놀랐음. LLVM은 GCC와 달리 호스트와 대상을 추상화해서 크로스 컴파일을 염두에 두고 설계된 줄 알았음
      Zig 도구체인도 그런 기능 일부를 LLVM에서 얻은 줄 알았고, 물론 더 깔끔하게 분리하려고 많은 작업을 한 건 이해함. 지금은 LLVM을 더 이상 쓰지 않는지도 궁금함
      그런데 clang에도 같은 문제가 있다면, LLVM에서 더 깨끗한 구조를 물려받은 게 아니었나 싶음
  • Nix를 쓰는 걸로 아는데, 환경 변동성의 일부라도 줄이기 위해 Nix를 언급하거나 사용하지 않은 이유가 궁금함
    예를 들어 $PATHwasm-opt 문제 같은 건 Nix로 완화할 수 있었을 것 같은데, 썼는데 내가 놓친 건가?

  • 순진하게는 wasm을 asm.js로 옮기는 일이 “쉬울” 거라고 생각했는데, 오늘 새로 배움

    • 나도 그렇게 생각했음. 안타깝게도 실제로는 생각보다 훨씬 더 복잡함
  • 블로그 제목은 클릭베이트 같지만, 내용은 좋음
    클릭베이트는 정말 싫음