1P by GN⁺ | ★ favorite | 댓글 1개
  • 공급망 공격은 소프트웨어 배포 비용이 매우 낮아지고 빌드·배포 자동화가 널리 쓰이면서 더 큰 문제로 커졌음
  • 1970년대에는 재사용 가능한 소프트웨어를 만들기 어려운 소프트웨어 위기가 있었지만, 지금은 패키지 저장소와 패키지 관리자가 이름과 버전만으로 코드를 가져오고 빌드함
  • 자동 의존성 갱신은 CI를 통해 악성 변경이 빠르게 퍼지게 만들며, 좋은 공급망 공격은 CI 러너가 실행되는 속도로 확산됨
  • 모든 의존성을 프로젝트 저장소에 함께 넣는 벤더링은 저장소를 키우지만, 자동 변경을 막고 의존성의 규모와 비용을 더 잘 보이게 함
  • 모든 소프트웨어에 맞는 해법은 아니지만, 많은 작은 소프트웨어는 외부에서 갑자기 바뀔 수 있는 의존성을 2~3개 수준으로 줄이는 이점을 얻을 수 있음

문제

  • 공급망 공격은 소프트웨어나 유지보수의 본질만 바뀌어서가 아니라, 소프트웨어 공유와 배포의 비용 모델이 매우 낮아지면서 점점 더 큰 문제가 됨
  • 배포 비용이 너무 낮아져 낭비가 있더라도 자동화를 많이 쓰게 되었고, 자동화 자체는 유용함
  • 몇 달마다 새로운 공급망 공격이 발생해 세계 코드의 큰 부분을 망가뜨리는 일이 생김

어떻게 여기까지 왔나

  • 1960년대 후반과 1970년대 초반에는 사람들이 재사용 가능한 소프트웨어를 만드는 방법을 잘 몰랐고, 이를 소프트웨어 위기라고 불렀음
  • 소프트웨어 수요는 지수적으로 증가했지만, 요구되는 복잡도에 맞는 새 소프트웨어를 만드는 능력은 그보다 느리게 증가했음
  • 이 시기는 모듈성, 구조적 프로그래밍 같은 연구로 이어졌고, 1990년 이후 만들어진 거의 모든 프로그래밍 언어 모듈 시스템은 Modula-2까지 계보를 거슬러 올라갈 수 있음
  • 1990년대와 2000년대에는 인터넷이 더 강력한 해법을 만들었고, 소프트웨어 빌드와 배포가 저렴해졌으며 실제로 쓰고 싶은 소프트웨어 상당수는 오픈소스였음
  • CPAN, CTAN, Linux 배포판을 바탕으로 많은 패키지 저장소패키지 관리자가 생겼고, 이 도구들은 매니페스트 파일, 이름, 대개 임의적인 버전 번호만으로 소프트웨어를 찾고 가져오고 빌드함
  • 수작업 통합에서 자동 의존성으로

    • 과거 복잡한 소프트웨어 시스템을 만드는 좋은 방법은 작동하는 조각들을 손으로 신중하게 조립하는 것이었고, Linux 배포판이 기본적으로 이런 일을 함
    • 2003년에 SDL을 모든 기능과 함께 빌드하려면 며칠이 걸릴 만큼 고통스러웠고, 그런 시절을 그리워할 필요는 없음
    • Linux 배포판이 알려진 기본 환경으로 있으면 많은 맞춤형 소프트웨어는 자체 세계 안에서 동작하고 시스템의 다른 부분을 크게 신경 쓰지 않아도 됨
    • 다른 소프트웨어와 통신할 때는 잘 알려진 프로토콜을 쓰는 파일이나 네트워크 소켓을 통하는 경우가 많음
    • Rust나 Go로 처음부터 빌드되거나 Docker 컨테이너로 배포되는 좋은 소프트웨어가 많아졌고, 이런 소프트웨어는 시스템 라이브러리와 거의 상호작용하지 않음
    • OS 배포판이 제공하는 소프트웨어 집합에 맞추기보다, 필요한 라이브러리를 빌드 시스템이 직접 가져오는 방식이 널리 쓰임
  • 반대 방향의 위기

    • 현재는 1970년대와 반대로 사람들이 소프트웨어를 너무 많이 재사용해 프로그램이 더 나빠지는 위기가 생김
    • 소프트웨어 배포는 여전히 매우 저렴하지만, 소프트웨어를 사용하는 데에는 여전히 비용이 있음
    • 오랫동안 가장 큰 비용은 소프트웨어를 빌드하고 컴퓨터에서 실행되게 하는 복잡성이었지만, 그 문제는 상당 부분 자동화로 사라졌음
    • 이제 훨씬 더 많은 소프트웨어를 빌드·배포·사용하게 되었고, 그 비용은 의존성 지옥, 비대화, 긴 빌드 시간, 패키지나 패키지 관리자의 실종 같은 형태로 나타남
    • 가장 큰 문제는 공급망 공격
  • 공급망 공격의 확산 구조

    • 공급망 공격은 오픈소스 소프트웨어만큼 오래된 문제임
    • 과거 Linux 커널에 uid == 0 대신 uid = 0을 넣으려던 악성 패치 시도는 야생에서 목격된 첫 악성 커널 패치였고, 공급망 공격 시도에 해당함
    • 최근 10년 동안 공급망 공격이 더 크고 문제가 된 이유는 빌드 시스템이 소스 코드를 가져오고 배포하도록 자동화되었기 때문임
    • CI 시스템은 보통 모든 코드 변경이나 큰 변경에서 실행되고, 이런 변경은 해당 코드에 의존하는 모든 사람에게 자동으로 사용 가능해짐
    • 의존하는 쪽의 CI 시스템도 변경을 가져와 새로 들어간 악성 코드를 포함하게 되고, 좋은 공급망 공격은 CI 러너가 실행되는 속도로 산불처럼 퍼짐
    • 의존성 쿨다운처럼 공급망 공격을 늦추는 방법도 있지만, 정책과 책임 소재를 둘러싼 논쟁이 생김

해법

  • npm, cargo 같은 빌드 시스템이 매번 네트워크 위치에서 의존성을 자동으로 가져오게 하지 말고, 모든 의존성을 소프트웨어와 함께 넣는 방식이 핵심임
  • 프로젝트에 모든 의존성을 벤더링하고, 업스트림 소스 제어 내용을 git 저장소에 복사해 커밋함
  • 업스트림 업데이트가 있으면 내려받아 다시 복사하고, 수작업이 지겨워지면 빌드 도구가 이를 자동화하게 하면 됨
  • 이미 lockfile이 있다면, 소스 제어 안의 전체 소스 트리와 연결되게 만들면 됨
  • 모든 소스 코드 줄을 강하게 통제하는 방식으로 소유함
  • 비용과 트레이드오프

    • 저장소는 커지지만 디스크 공간은 저렴함
    • 전송 비용은 디스크보다 덜 저렴하지만, 이 논의에서는 감수해야 할 요소로 남음
    • 빌드 시간은 커질 것처럼 보이지만, 어차피 그 의존성들을 다시 빌드하고 있었기 때문에 반드시 늘어나지는 않음
    • 코드 재사용은 더 어려워질 수 있으며, 공유 프로토콜 라이브러리를 쓰는 클라이언트와 서버 같은 프로그램에서는 실제 문제가 될 수 있음
    • 그런 프로그램은 이미 버전 불일치 문제를 갖고 있고 이를 처리해야 하므로, 실제로 주의를 기울이게 만드는 일이 장기적으로 더 나쁘지는 않음
  • 공급망 공격의 방화선

    • 의존성을 자동으로 갱신하지 않으면 생태계의 모든 패키지가 공급망 공격의 방화선이 됨
    • 같은 방식은 버그 수정과 패치 전파도 막지만, 중요한 수정이라면 어차피 사람이 수동으로 찾아보게 됨
    • 사람이 찾아보지 않는 수정은 대개 중요하지 않은 경우가 많음
    • semver나 “서로 다른 두 코드가 같은 방식으로 동작해야 한다”는 개념을 빌드 시스템에서 버리고, 모든 버전 번호를 서로 무관한 고유한 것으로 다뤄도 비슷한 효과를 낼 수 있음
    • semver의 문제는 실제 현실이 아니라 사람의 의도를 표현하며, 그마저도 어느 정도 올바르게 쓰일 때만 작동한다는 점임
    • 버전 번호를 고유하게 다루는 방식은 의존성이 사라지거나, 변조되거나, 패키지 내용이 다른 방식으로 훼손되는 문제를 해결하지 못함
  • 의존성 가시성

    • 모든 의존성을 벤더링하면 자동 변경을 늦추는 것 외에도 의존성 사용 비용이 조금 올라감
    • 비용 증가는 회복 불가능한 수준이 아니며, 업스트림 코드를 사용할 때 조금 더 생각하게 만드는 정도임
    • 새 의존성을 추가할 때 “정말 필요한가”를 다시 묻게 만드는 부드러운 장치가 됨
    • 의존성의 가시성이 높아지고, 의존성 뒤에 숨은 비대함이 덜 숨겨짐
    • 200줄 정도일 것 같은 단순 라이브러리를 추가했는데 50,000줄이었다면, 멈춰서 이유를 물어야 한다는 점이 더 명확해짐
    • 의존성의 마법 같은 성격이 줄어들고, 코드베이스 안의 버그가 다른 사람의 코드로 이어지는 경로를 더 쉽게 추적할 수 있음
  • 의존성 트리와 공유 문제

    • 모든 것을 기본으로 벤더링하면 더 평평하고 넓은 의존성 트리를 유도할 수 있음
    • C++의 Boost나 Qt 같은 거대 라이브러리 수준까지 가는 것은 바람직하지 않음
    • 그런 거대 라이브러리는 작은 C/C++ 라이브러리를 만들고 쓰는 일이 너무 어렵기 때문에 존재함
    • Boost나 Qt 같은 것을 직접 빌드 방법까지 파악하기보다, Linux 배포판 같은 시스템 통합자가 한 번만 해주는 편이 낫다는 전제가 있음
    • 실제 단점은 전이 의존성이 공유되지 않는다는 점임
    • lib A와 lib B가 모두 Z에 의존할 때 중복 제거는 불가능하지 않지만 더 어려워지고, 사람이 직접 하거나 더 정교한 도구가 필요함
    • 전이 의존성이 공유될 때도 문제가 생기며, 전이 의존성을 갖는 것 자체가 문제의 일부임
    • 라이브러리가 전이 의존성을 지정하도록 허용하는 일은 프로그램에 대한 통제를 다른 사람에게 넘기는 행위가 됨

분석

  • 모든 소프트웨어가 이 방식을 쓸 수 있는 것은 아님
  • 웹앱 백엔드 배포의 일부로 Redis 전체를 벤더링하고 빌드하는 방식은 특별히 합리적이지 않음
  • 다만 배포가 Ansible이나 Docker 이미지 등으로 자동화되어 있다면, 이미 사실상 비슷한 일을 하고 있을 가능성이 있음
  • 이 방식이 견딜 수 있는 복잡도에는 상한이 있지만, Google과 Facebook 같은 거대 모노레포 기업은 그 상한이 생각보다 높을 수 있음을 보여줌
  • 어느 시점에서는 의존성이 운영체제와 만나며, 운영체제는 자체 문제가 많은 큰 의존성임
  • 웹 백엔드용 unikernel 아이디어는 매력적이지만, 실제 도구 문제가 있고 아직 그 단계에 도달하지 못함
  • Linux 배포판과 빌드 환경

    • 이 방식은 Linux 배포판이나 BSD 같은 완전한 상호작용 시스템을 만드는 방법이 아님
    • 그런 시스템은 함께 동작해야 하는 많은 프로그램과 라이브러리가 있으므로 다른 문제에 해당함
    • 이 원칙을 끝까지 밀어붙이면 Nix나 Guix 같은 방식에 가까워짐
    • “빌드 환경”을 올바르게 조립해야 한다는 개념은 “소프트웨어를 어떻게 빌드할 것인가”라는 문제를 게으르고 불충분하게 푼 방식에 가까움
    • 이 개념은 어떤 미니컴퓨터에서 소프트웨어를 한 번 빌드한 뒤 바이너리로 널리 공유하던 시절의 잔재임
    • 오늘날에는 1970년대보다 훨씬 더 많은 소프트웨어를 즉석에서 빌드함
  • 적용 가능한 범위

    • 이 방식은 만능 해법이 아니지만, 많은 소프트웨어는 적용할 수 있고 이점을 얻을 수 있음
    • 대부분의 소프트웨어는 작고, 큰 프로젝트는 이미 이런 문제를 많이 해결해야 함
    • 순수 계산만 하거나 파일과 네트워크 소켓 같은 기본적이고 이식 가능한 I/O로만 외부와 접촉하는 라이브러리가 많이 있음
    • 압축 라이브러리, libcurl, TUI 라이브러리, Django 같은 예시는 벤더링 대상으로 다룰 수 있음
    • 벤더링하면 버전 충돌이나 갑작스러운 패치로 들어온 버그 때문에 새 시스템에 배포하거나 빌드할 때 원인을 알 수 없게 깨지는 일을 거의 피할 수 있음
    • 목표는 외부에서 예고 없이 바뀔 수 있는 의존성을 200~300개가 아니라 많아도 2~3개 수준으로 줄이는 것임

결론

  • 의존성 자동 갱신을 줄이고 프로젝트가 직접 의존성 소스까지 보유하면 공급망 공격의 자동 확산을 늦출 수 있음
  • 의존성 사용 비용을 조금 높이고 가시성을 높이면, 불필요한 재사용과 숨은 비대함을 더 쉽게 발견할 수 있음
  • 이 방식은 모든 시스템에 맞지 않지만, 작은 소프트웨어와 많은 라이브러리에는 실용적인 이점이 있음

댓글과 토론

Lobste.rs 의견들
  • Zig 패키지 관리자는 꽤 괜찮은 절충안이라고 봄
    모든 패키지가 콘텐츠 해시로 고정돼서 기본적으로 잠금 파일이 있는 셈이고, “상위 저장소가 갑자기 악성으로 바뀌는” 문제는 피하면서도 “상위 저장소가 사라지는” 문제는 남아 있음
    다만 전역/로컬 캐시가 모두 있고 콘텐츠 해시 기반이라, 상위 저장소가 사라지면 로컬 사본의 tarball을 필요한 곳에 던져 넣으면 됨
    “소스를 벤더링하기”와 “단순하고 재사용 가능한 소프트웨어” 사이의 좋은 절충으로 보임

    • 그 방식을 모든 소프트웨어로 확장할 수도 있고 꽤 멋질 것 같음
      모든 소스를 콘텐츠 주소 지정 저장소에 두고, 각 프로그램은 입력들의 해시를 바탕으로 해시하면 됨
    • 대체로 동의하지만, 그 구성을 어떻게 공격할 수 있을지는 조금 궁금함
      아마 잠금 파일을 수정하거나 해시 충돌을 찾아야 할 텐데, 둘 다 쉬워 보이진 않음
      다만 cargo 생태계에 익숙하다 보니 완전히 마음에 들지는 않음. 의존성을 올리면 그 전이 의존성들도 별다른 말 없이 함께 올라가는 경향이 있고, 의미적 버전 범위에 맞는 다른 것들도 같이 바뀌기 때문임
  • “공급망 공격”이라고 하기엔, 제안과 대가가 있는 서명된 계약이 없으니 공급망은 아니라고 봄
    별개로, 의존성이 아래에서 바뀌지 않게 보장한다는 관점에서는 해시가 들어간 잠금 파일이나 Go의 최소 버전 선택 방식이 의존성 벤더링과 동일함
    벤더링에는 마찰이 생긴다는 차이는 이해하지만, 극단으로 가면 직접 구현하거나 더 나쁘게는 의존성을 즉흥 생성 코드로 만들게 되므로, 도메인 전문가가 작성하고 충분히 검증된 소프트웨어를 쓰는 편이 낫다고 봄
    Facebook에서 이쪽 일을 했는데, 그곳의 서드파티 의존성 관리는 누구에게도 권하고 싶지 않음. 특정 Rust 크레이트의 직접 의존성은 fbsource 전체에서 의미적 버전이 호환되지 않는 버전이 동시에 최대 두 개까지만 허용됨. 의존성을 업데이트하려면 fbsource 전체를 업데이트하는 부담을 떠안아야 함
    Facebook에는 맞는 방식일 수 있지만, 특별히 훌륭하거나 지속 가능하다고 보긴 어려움

    • 궁금한데 왜 “최대 두 개”인지 모르겠음. 예전 버전에서 새 버전으로 점진적 마이그레이션을 하기 위해서인가?
      “특별히 훌륭하거나 지속 가능하지 않다”는 건 정책 자체보다는 규모의 함수에 가깝다고 의심함. 여러 버전을 허용하면 또 다른 문제가 생기는데, TypeScript를 제외한 대부분의 현대 언어가 주로 또는 전적으로 명목적 타입을 쓰기 때문에, 깨지는 변경마다 “semver trick”을 쓰지 않으면 버전 간 타입 재사용이 막힘
      Log4Shell 때는 버전이 많고 여러 곳에 흩어진 회사들이, 버전 수가 적거나 고정해 둔 회사들보다 업그레이드에 더 고생했던 기억이 뚜렷함
    • 맞음, 그럼 의존성 공격이라고 부르면 되겠음 <3
  • The Third Networking Truth에 따르면 “충분한 추진력이 있으면 돼지도 잘 난다. 하지만 그것이 꼭 좋은 생각이라는 뜻은 아니다”
    Google/Facebook 같은 곳에서 인용되는 많은 관행은 그 회사들이 충분한 추진력을 투입할 수 있기 때문에만 작동함
    예를 들어 그런 곳 중 일부는 모노레포와 의존성 관련 선택을 지원하기 위해, 내가 다니는 회사 전체 인원보다 더 큰 팀을 붙이는 걸 알고 있음. 그들은 감당할 수 있지만, 우리 대부분은 감당하기 어려움

  • 좋은 견해임. “모든 의존성을 벤더링하면 의존성을 쓰는 비용이 올라간다”는 점에 강하게 동의함
    다만 libcurl을 복사해 붙여 넣지는 말아야 함. 대부분의 라이브러리에는 괜찮은 전략이지만, 적대적 입력을 다루는 C 프로그램에는 좋은 조언이 아님. 운영체제가 libcurl을 안전하게 유지하는 것보다 더 잘할 수는 없음
    한 번도 생각 못 했던 점은 apt 같은 최종 사용자용 패키지 관리자가 먼저 나오고, 언어 수준 패키지 관리자가 나중에 나온 게 적어도 조금 이상하다는 것임
    이게 실제로 많은 문제를 일으켰다고 봄. 2000년대 초반 rubygems를 보면 프로젝트별 관리가 아니라 시스템 전체 설치가 기본인 식으로 “Ruby용 apt”를 만들려 했던 게 꽤 명확함. 그 실수의 피해를 되돌리는 데 bundler를 추가하며 수십 년이 걸렸지만, 처음부터 프로젝트 격리 필요성을 인정했다면 bundler는 필요 없었을 것임
    Python은 아직도 이 혼란을 수습 중이고, Perl도 아마 그럴 것 같지만 자세히는 모름

    • 그러니까 한계는 있긴 한 셈임 :-) 정확히 어디에 선을 그을지가 어려울 뿐임
      역사적으로 패키지 관리자는 원래 시스템을 빌드하는 방식이었고, 그런 시스템에는 여러 사용자, 데스크톱 환경, 함께 동작하는 많은 소프트웨어가 있었음
      소프트웨어 빌드에는 시간과 메모리가 많이 들었고, 디스크와 RAM에 비해 소프트웨어가 아주 많았으니 라이브러리 재사용이 중요했음
      웹앱이 부상하면서 중요한 컴퓨터 대부분은 평생 소수의 프로그램만 돌리는 서버가 됐고, 디스크와 RAM이 충분히 싸져서 코드 바이너리 크기는 덜 중요해짐
      시스템을 만드는 도구들은 시대 변화에 그만큼 따라가지 못했고, 그래서 소프트웨어를 만드는 대부분의 사람은 공유 라이브러리가 많은 거대한 상호연결 시스템이 아니라 단일 프로그램을 잘 만드는 도구만 필요로 하게 됨
      이 역사와 나란히 “C에는 제대로 된 모듈 시스템이 없다”는 흐름도 있지만, 여기서는 덜 중요함
  • 틀릴 수도 있지만, 복사해 온 의존성에 버그가 있어도 스캐너가 감지하지 못하는 단점이 있을 것 같음
    그렇다면 원래라면 알림을 받았을 잠재 문제가 조용히 남아 있을 수 있음

    • 이런 스캐너들이 만들어내는 오탐 수를 보면 오히려 장점일 수도 있음
      스캐너는 문제가 될 수 있는 것을 보여주는 데는 매우 유용하지만, 스캐너가 문제라고 생각했으나 실제로는 아닌 것을 고치느라 예정된 일을 갑자기 미루게 만들 때는 매우 골치 아픔
  • 제안대로 소프트웨어에 모든 의존성을 포함하고, 상위 소스 관리를 git 저장소에 복사해 커밋하고, 수작업이 지겨우면 빌드 도구가 자동화하게 만들면, 결국 한 바퀴 돌아서 다시 서드파티 소프트웨어를 보지 않고 포함하는 셈 아닌가?

    • 계속 읽어보면, 빌드 시스템에서 의미적 버전이나 “서로 다른 두 코드가 같게 동작해야 한다”는 개념을 버리고 모든 버전 번호를 서로 고유하고 무관한 것으로 다뤄도 같은 효과를 낼 수 있다고 함
      하지만 그 방식은 의존성이 사라지거나 변조되는 문제, 또는 누군가 패키지 내용을 다른 방식으로 손대는 문제를 해결하지 못함. 최적화에 가깝고, 내 생각엔 성급한 최적화임. 언젠가 그렇게 갈 수는 있겠지만 시작점으로 삼으면 안 됨