- 공급망 공격은 소프트웨어 배포 비용이 매우 낮아지고 빌드·배포 자동화가 널리 쓰이면서 더 큰 문제로 커졌음
- 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개 수준으로 줄이는 것임
결론
- 의존성 자동 갱신을 줄이고 프로젝트가 직접 의존성 소스까지 보유하면 공급망 공격의 자동 확산을 늦출 수 있음
- 의존성 사용 비용을 조금 높이고 가시성을 높이면, 불필요한 재사용과 숨은 비대함을 더 쉽게 발견할 수 있음
- 이 방식은 모든 시스템에 맞지 않지만, 작은 소프트웨어와 많은 라이브러리에는 실용적인 이점이 있음