1P by GN⁺ | ★ favorite | 댓글 1개
  • store 기반 패키지 관리자인 Nix는 /nix/store 같은 고정 prefix에 패키지를 두도록 설계되어, 기존 Nix 설치나 root 권한 없이 다른 위치에 store를 두려는 rootless Nix 환경에서 제약이 커짐
  • --store /tmp/...chroot·mount namespace를 함께 쓰면 기존 /nix/store 빌드와 같은 해시를 유지해 cache.nixos.org 같은 바이너리 캐시를 계속 활용할 수 있음
  • namespace 없이 local?store=/tmp/...로 store prefix를 바꾸면 해시가 달라지고, 단순한 hello 빌드도 의존성 그래프 전체 무효화와 GCC 재컴파일로 이어질 수 있음
  • 제안의 핵심은 ELF RUNPATH에 절대 경로 대신 Linux 동적 링커가 지원하는 $ORIGIN 기반 상대 경로를 써서 store 위치 변경이 해시와 재컴파일로 번지지 않게 하는 것임
  • 실제 재배치 가능성을 막는 병목은 커널이 ELF PT_INTERP와 스크립트 shebang에서 $ORIGIN을 지원하지 않는 점이며, 커널 패치·정적 래퍼·언어별 상대 경로·relocatable = true; 메타데이터가 해결 방향으로 제안됨

고정 store prefix와 rootless Nix의 충돌

  • Nix와 Guix 같은 store 기반 시스템은 모든 패키지를 정해진 prefix 아래 저장함
    • Nix는 /nix/store
    • Guix는 /gnu/store
  • 이 구조에서는 바이너리나 라이브러리 경로를 재작성하기 쉬움
    • 예를 들어 /bin/bash/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash 같은 전체 store 경로로 바꿀 수 있음
  • 다른 위치에 store를 두고 싶은 상황도 있음
    • Nix가 이미 설치되어 있지 않은 환경
    • 필요한 권한이 없는 환경
    • 이런 경우가 “rootless Nix” 문제로 이어짐
  • Nix는 현재도 다른 store 경로를 지정할 수 있지만, 방식에 따라 해시 유지 여부가 갈림
    • nix build nixpkgs#hello/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/에 설치함
    • nix build --store /tmp/fzakaria/store nixpkgs#hellochroot와 mount namespace를 사용해 /tmp/fzakaria/store/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/에 설치함
    • 두 경우 모두 해시 zi2bj2hlavv8q743li2s9diqbcpmrf9b가 같음
  • 해시가 같으면 https://cache.nixos.org 같은 바이너리 substituter의 사전 계산된 derivation을 활용할 수 있음

namespace 없이 store를 바꿀 때의 비용

  • Bazel이나 Buck2 같은 도구는 자체 샌드박싱을 위해 namespace를 이미 사용할 가능성이 있음
    • 이런 생태계에 Nix를 통합하려 하면 중첩 user namespace와 mount 제한 때문에 실용성이 크게 떨어짐
  • chroot와 mount namespace 없이도 대체 store prefix를 지정할 수는 있지만, 해시가 달라지는 결함이 있음
    • 예시 명령은 --store 'local?store=/tmp/fzakaria/store&state=/tmp/fzakaria/state&log=/tmp/fzakaria/log'를 사용함
    • 결과 hello 경로는 /tmp/fzakaria/store/qv3fhi1j9gh27fyds5n5b16yia8i6zn5-hello-2.12.3
    • 해시는 기존 zi2...가 아니라 qv3fhi1j9gh27fyds5n5b16yia8i6zn5로 바뀜
  • 단순한 store prefix 문자열 변경이 의존성 그래프 전체를 연쇄 무효화함
    • 다른 폴더에서 “Hello World”를 출력하려는 것만으로 GCC를 4시간 컴파일하게 될 수 있음
    • 이 경우 공개 캐시를 활용할 수 없음
  • 이 한계는 현재 Nix 문서에도 명시되어 있음

$ORIGIN이 해결하는 부분과 남는 커널 한계

  • 문제의 원인은 store prefix가 derivation 자체의 일부라서 해시 계산에 영향을 준다는 점임
  • 전체 store prefix를 모든 곳에 쓰지 않고 상대 경로를 쓰면 해시 변화를 피할 수 있음
  • ELF 바이너리의 RUNPATH는 적용 가능한 지점 중 하나임
    • 현재 helloRUNPATH 예시는 /nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib
    • Linux 로더는 실행 파일이 있는 디렉터리를 뜻하는 $ORIGIN을 지원함
    • 따라서 RUNPATH$ORIGIN/../../57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib처럼 쓸 수 있음
    • 이렇게 하면 store 위치가 바뀌어도 해시가 바뀌지 않고 재컴파일도 필요 없어짐
  • 하지만 동적 링커가 RUNPATH를 읽기 전에 Linux 커널이 먼저 동적 링커 자체를 로드해야 함
    • 이 경로는 ELF의 PT_INTERP 헤더에 저장됨
    • 예시는 /nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib/ld-linux-x86-64.so.2
    • 현재 Linux 커널은 PT_INTERP에서 $ORIGIN을 지원하지 않음
  • 스크립트 shebang도 같은 제약을 가짐
    • 예시는 #!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash
    • 커널은 #!를 파싱할 때 절대 경로를 기대함
    • shebang에서도 현재 $ORIGIN 지원이 없음
  • 현재 작업 디렉터리 기준 상대 경로는 사용할 수 있지만, 다른 위치에서 스크립트를 실행하면 깨지므로 신뢰하기 어려움

재배치 가능한 바이너리로 가는 제안

  • 진정한 재배치 가능한 바이너리를 만들려면 커널 제약을 우회하거나 바꿔야 함
  • 제안된 접근은 세 가지임
    • Linux 커널을 패치해 PT_INTERP와 shebang에서 $ORIGIN을 지원함
    • 모든 바이너리를 작은 정적 바이너리로 감싸고, 래퍼가 자기 위치를 계산한 뒤 동적 링커를 실행함
    • 파일 위치도 언어별 상대 경로 기능을 활용하도록 바꿈
      • Python에서는 __file__로 자기 자신 기준 파일 접근을 할 수 있음
  • 가장 적합한 접근으로는 Linux 커널 지원 확장이 제안됨
    • NixOS 머신에서는 Nix로 커널을 패치해 해당 지원을 추가할 수 있음
  • 추가로 각 derivation에 재배치 가능 여부를 나타내는 relocatable = true; 메타데이터를 넣는 방안이 제안됨

댓글과 토론

Lobste.rs 의견들
  • Linux 커널에서 PT_INTERP$ORIGIN 지원이 들어가면 좋겠음. 예전에 정적 래퍼 바이너리로 해봤고, 다른 시도도 여럿 봤는데(좋은 예시), 전부 훌륭하고 멋진 해킹이지만 결국 해킹임
    보안상 함의는 아직 제대로 이해하지 못했어서, 정리된 설명이 있으면 도움이 될 듯함
    Solaris는 이를 지원하는 것 같은데, 안전하게 하는 방법이 있을지도 모름. 근거를 찾기 어렵지만 execve(2) 매뉴얼의 ENOEXEC에는 setuid/setgid 프로세스 이미지 파일의 PT_INTERP 프로그램 헤더가 상대 경로를 갖거나 $ORIGIN 토큰을 쓰면 실패한다고 되어 있음

  • 많은 소프트웨어가 빌드 시점에 박힌 경로나 상수를 갖고 있어서, 제대로 동작하려면 결국 다시 컴파일해야 하지 않나?

    • 이미 그런 경우가 많고, 주로 shebang 줄에서 그렇다. Nix에는 빌드 시점에 이런 값을 치환하는 도우미가 많이 들어 있음
    • 맞지만, 이 문제는 로컬 저장소와 무관하게 원래부터 있었음. 아마 하위 derivation들은 {foo}를 통해 outPath를 소비할 텐데, 상위 의존성 중 하나를 로컬 저장소로 바꾸면 다시 빌드해야 함
  • musl용 dcrt1 패치(rcombs 작성)가 이 문제를 사용자 공간에서 해결함

  • /origin에 마운트된 파일 시스템을 만들어서 $ORIGIN으로 해석되게 할 수는 없을까? 그러면 추가 문법 없이 shebang과 ELF에서 모두 동작할 듯함

    • /origin을 만들고 마운트할 수 있다면, 그냥 /nix를 만들고 nix-daemon을 실행할 수도 있지 않나?
    • 내 생각엔 NixOS 밖에서도 호환되게 하려면, 추가 파일 시스템이나 데몬 설치·설정 없이 가는 게 목표일 듯함
  • 바이너리가 상대 경로로, 아마 안전하지 않고 자체 제공된 로더를 지정할 수 있다면 보안상 위험하지 않을까?

    • 이 위협 모델에서 무엇을 신뢰하고 무엇을 신뢰하지 않는 건가? 어차피 그 바이너리를 실행할 거라면, 단지 검증된 로더로 실행하겠다는 건가?
    • libc.so.6 같은 다른 동적 링크 라이브러리의 검색 경로를 정하는 환경 변수보다 왜 더 덜 안전하다고 봐야 하나?