다음에 올 것을 만들어가는 기술에 대한 심층 기사.

PyPI 공급망 공격: LiteLLM이 남긴 교훈

LiteLLM PyPI 침해로 모든 wheel에 자격 증명 탈취 코드가 담겼습니다. 해시 고정과 lockfile이 막을 수 있던 것과 없던 것을 정리합니다.

봉인된 상자들이 놓인 컨베이어 벨트, 그중 변조된 상자 하나가 나머지 상자들로 전선을 뻗고 있는 모습.
패키지 하나가 벨트 위에서 오염되면, 그 뒤에 있는 모든 것이 조용히 오염됩니다.

세 개의 파일입니다. 제가 본 가장 심각한 Python 공급망 공격 중 하나의 전체 흔적이죠. 12바이트짜리 cook.pth, 22KB짜리 cook.py, 25KB짜리 sitecustomize.py입니다. LiteLLM 1.107.0이 PyPI에 올라왔을 때 이 세 파일은 일곱 개 wheel 모두에 들어 있었고, Python 시작 과정의 동작 방식 때문에 감염된 호스트의 모든 Python 프로세스에서 실행됐습니다. 앱, CI 러너, pip 자체, 심지어 에디터의 언어 서버까지요. litellm을 import할 필요도, 실행할 필요도 없었습니다. 어떤 Python 인터프리터든 그냥 시작하기만 하면 충분했습니다.

LiteLLM 팀이 침해 사실을 확인했고 PyPI는 몇 시간 안에 해당 릴리스를 회수했지만, 그 시간도 충분히 길었습니다. 악성 Docker 이미지(latest 태그 포함)는 약 두 시간 반 동안 살아 있었습니다. 그 기간에 CI가 이미지를 받아갔다면, 탈취 코드는 이미 SSH 키, 클라우드 자격 증명, 브라우저 비밀번호, CI/CD 시크릿, AI 도구 토큰을 수집해 암호화한 뒤 공격자가 통제하는 도메인으로 전송했을 겁니다. 타이포스쿼팅이나 유사 이름 패키지가 아니었습니다. 진짜 패키지였고, 진짜 파이프라인이 빌드했고, 진짜 릴리스 프로세스로 서명된 것이었습니다. 이 사실만으로도 의존성 신뢰를 어떻게 생각해야 할지 다시 고민하게 됩니다.

세 개의 파일이 공식 릴리스 파이프라인을 장악한 방법

공격자는 PyPI를 해킹하지 않았고, 공개 저장소에 커밋을 몰래 밀어 넣지도 않았습니다. 업스트림 GitHub 소스는 깨끗합니다. LiteLLM의 사고 보고서에 따르면, 공격자는 인터넷에 노출된 내부 LiteLLM 배포 환경의 로그에서 수집된 것으로 보이는 개발자 세 명의 GitHub CLI 자격 증명을 가지고 있었습니다. 조직 소유자 토큰으로 브랜치 보호 규칙을 우회하고, 비공개 릴리스 워크플로가 BerriAI/litellm 대신 유사한 이름의 저장소 Berri-AI/litellm을 바라보도록 바꿨습니다. 두 번째 개발자의 자격 증명이 승인 게이트를 통과시켰고, 이후 공식 파이프라인이 공격자의 아티팩트를 빌드하고 서명해 그대로 배포했습니다.

실제 주입은 놀라울 만큼 작았습니다. sdist의 pyproject.toml에 추가된 한 줄이 전부였습니다:

[tool.maturin]
python-source = "."
data = "cook"            # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"]   # legitimate, present upstream

그 data = "cook" 지시문은 maturin에게 cook/ 디렉터리를 wheel의 purelib 경로에 포함하라고 지시하고, pip은 그것을 충실히 site-packages에 설치합니다. sdist 자체에는 그 폴더가 빠져 있어서, 릴리스를 훑어보는 사람은 알아보기 힘든 한 줄만 보고 끝났을 겁니다. 페이로드는 소스가 아니라 빌드 결과물 속에 숨어 있었습니다. 대부분이 진실의 원천으로 여기는 git 히스토리를 검토했더라도 아무것도 보이지 않았을 겁니다.

.pth 파일과 sitecustomize.py가 악성코드에게 천국인 이유

Python 시작 시퀀스의 두 가지 기능이 이 공격을 유난히 위협적으로 만들었습니다. 솔직히 대부분의 Python 개발자가 한 번도 생각해 보지 않은 기능들이죠. 첫째, site-packages에 있는 .pth 파일은 인터프리터 시작 시 처리되고, import로 시작하는 줄은 실행됩니다. 12바이트짜리 cook.pth에는 import cook 한 줄만 들어 있었는데, 그것만으로 머신의 모든 Python 프로세스에서 탈취 코드가 실행되기에 충분했습니다. 둘째, Python은 경로에서 sitecustomize 모듈을 찾으면 시작 시 자동으로 import합니다. 공격자는 자신의 모듈을 심었고, 이 모듈은 분리된 데몬 프로세스를 포크해 한 시간마다 GitHub에 호스팅된 텍스트 파일로 연락하고, 공격자가 지정한 코드를 내려받아 exec()로 실행했습니다.

일반적인 악성 패키지가 오염된 setup.py나 __init__.py의 잘못된 import를 통해 실행되는 것보다 훨씬 심각합니다. 그런 경우는 패키지를 설치하거나 import할 때 실행됩니다. 이번에는 Python에서 무엇이든 실행할 때 실행됐습니다. CI에서 돌린 pytest, 무슨 일이 있었는지 확인하려고 친 pip list, IDE 안의 black 포매터까지. 모든 호출이 함정을 다시 장전했습니다.

탈취 코드가 실제로 무엇을 모았는지 짚어볼 만합니다. 목록 자체가 현대 개발자의 일상을 보여주는 지도 같거든요. AWS, Azure, gcloud 자격 증명, kubeconfig, GitHub CLI 토큰, SSH 및 GPG 키, 브라우저 비밀번호 저장소, 셸 히스토리, GitLab 러너와 Jenkins의 CI 시크릿, Terraform 자격 증명, /proc/<pid>/environ을 통해 읽은 다른 프로세스의 환경 변수, AWS 인스턴스 메타데이터 자격 증명, 그리고 씁쓸하게도 Claude Code, Cursor, Copilot 등 십여 개 AI 코딩 도구의 토큰, 그리고 LiteLLM 자체 프록시 데이터베이스에 저장된 LLM 제공자 키까지 포함됐습니다. pip install 한 번이면 공격자는 엔지니어로서의 여러분의 정체성 전체를 손에 넣게 됩니다.

litellm을 import할 필요는 없었습니다. 호스트에서 어떤 Python 인터프리터든 시작하기만 하면 탈취 코드가 실행됐습니다.

해시 고정과 lockfile: 필요하지만 충분하지는 않다

모든 공급망 보안 체크리스트는 lockfile과 해시 고정에서 시작하고, 또 그래야 합니다. pip install --require-hashes로 해시를 고정하거나 잠긴 해시를 지원하는 uv나 Poetry 같은 도구를 쓰면, 검증한 뒤에 몰래 바뀐 wheel은 거부됩니다. 레지스트리 변조, 미러 오염, 아티팩트 교체 같은 공격군에 대해 실질적인 보호가 됩니다.

하지만 이번 사건에서 실제로 무엇을 했는지 솔직히 보면, 적어도 가장 먼저 업그레이드한 사람에게는 아무것도 못 했습니다. 해시 고정이 보장하는 것은 오늘 설치한 아티팩트와 내일 설치할 아티팩트가 바이트 단위로 동일하다는 것뿐입니다. 이번 공격에서 악성 wheel은 공식 아티팩트였습니다. 퍼스트파티가, 파이프라인이 빌드했고, 일관되게 해시가 맞았습니다. 누군가 평소처럼 업그레이드를 돌려 새 버전을 확정하고 업데이트된 lockfile을 커밋하는 순간, 악성 해시는 초록불 CI 체크와 함께 저장소에 고정됐습니다. 해시 고정은 변경으로부터 보호해 주지만, 의도에 대해서는 아무 말도 해 주지 않습니다.

이런 사건에서 해시 고정이 실제로 빛을 발하는 곳은 피해 범위(blast radius)입니다. 해시를 고정하고 의도적인 업그레이드 주기를 가진 팀은 모든 릴리스를 출시 즉시 자동으로 따라가지 않습니다. 정책이 "주간 PR로 의존성을 업그레이드하고, 치명적 수정이 아닌 것은 며칠간 유예 기간을 둔다"라면, PyPI에서 13:23 UTC에 제거된 것은 여러분의 업그레이드 창이 열리기도 전의 일입니다. 지루한 프로세스가 영리한 도구를 이기는 경우가 우리가 인정하고 싶은 것보다 많습니다.

그러니 lockfile은 계속 쓰세요. 해시를 포함해서 생성하세요. 다만 침해된 메인테이너 파이프라인에 대한 방어책이라고 스스로에게 말하지는 마세요. 그것은 드리프트와 변조에 대한 방어이고, 나쁜 릴리스에 대한 속도 방지턱일 뿐입니다. 그게 전부입니다.

튼튼한 금고 문 옆의 약한 벽에 구멍이 뚫려 있는 모습.
해시 고정은 정문을 잠그지만, 이번 공격은 벽을 뚫고 들어왔습니다. 공격 대상은 빌드 파이프라인 자체였습니다.

출처 증명(Provenance Attestation)이 있었다면 이번 공격은 잡혔을 것이다

불편한 반대편 진실도 있습니다. 이번 공격에는 눈에 띄는 탐지 가능한 이상 징후가 있었고, 그것을 잡아낼 생태계 도구는 지금도 존재합니다. 악성 wheel은 공개 저장소에서 빌드된 것이 아니었습니다. 공격자가 만든 유사 이름 저장소에서 빌드됐습니다. "이 아티팩트가 주장하는 커밋과 저장소에서 빌드되었는가?"를 묻는 검증 단계가 있었다면 요란하게 실패했을 겁니다.

바로 이것이 출처 증명이 제공하는 기능입니다. PyPI는 PEP 740 attestation을 지원합니다. Trusted Publisher를 통해 GitHub Actions에서 패키지를 배포하면, 워크플로가 아티팩트를 그것을 만든 정확한 저장소, 워크플로, 커밋에 묶는 서명된 진술을 생성할 수 있습니다. 소비자 측 검사는 기계적으로 이뤄질 수 있습니다. attestation을 검증하고 소스 저장소가 Berri-AI/litellm이 아니라 BerriAI/litellm인지 확인하면 됩니다. Sigstore 번들, SLSA provenance, pip-audit 계열의 검증 도구가 모두 이 계열에 속합니다.

현재의 간극은 채택과 강제 적용입니다. attestation은 배포자 입장에서 선택 사항이고, 존재하더라도 설치 시점에 검증하는 사람은 거의 없습니다. 바로 이 부분이 바뀌어야 합니다. 이는 누군가 실제로 확인할 때만 작동하는 신뢰 인프라에 대해 예전에 했던 이야기와도 맞닿아 있습니다. 아무도 검증하지 않는 서명은 장식일 뿐입니다. 자체 패키지 인덱스나 프록시를 운영한다면 (그리고 엔지니어가 몇 명 이상이라면 그래야 합니다), "예상한 출처에서 검증 가능한 출처 증명이 없는 아티팩트는 거부한다"는 정책은 모든 개발자를 위해 한 번에 중앙에서 강제할 수 있습니다.

좀 더 미묘한 교훈도 있습니다. 공격자에게는 세 명의 개발자 자격 증명이 필요했습니다. 하나는 워크플로를 리다이렉트하기 위해, 하나는 승인을 통과하기 위해, 그리고 나머지 하나는 예비용으로 쓰였습니다. 이건 단일 실수가 아니라 자격 증명 위생의 체계적 실패입니다. PyPI에서 수명이 짧고 OIDC 기반인 Trusted Publisher 토큰을 썼다면 공격 표면이 크게 줄었을 겁니다. 로그 파일에 훔칠 만한 장기 토큰이 남아 있지 않기 때문입니다. 아직도 CI 시크릿에 저장한 API 토큰으로 배포하고 있다면, 이번이 그 방식을 점검할 신호입니다.

샌드박스 설치와 쿨링오프 기간이 필요한 이유

최악의 경우를 가정해 봅시다. 악성 릴리스가 lockfile 위생 관리와 출처 검증을 모두 뚫고 들어올 수 있습니다. 언젠가는 그런 일이 생길 테니까요. 그다음에 남는 것은 격리입니다. 질문은 "나쁜 패키지를 어떻게 막을까?"에서 "나쁜 패키지가 실행되면 무엇에 닿을 수 있는가?"로 바뀝니다.

CI의 경우 답은 실제로 달성 가능합니다. 빌드는 장기 자격 증명이 없는 일회성 환경에서, 범위가 제한된 토큰과 이그레스 필터링을 갖춘 채 실행해야 합니다. LiteLLM 탈취 코드의 동작은 네트워크를 주시하면 시끄럽습니다. 낯선 도메인으로 커스텀 인증 헤더를 붙인 POST, 시간마다 반복되는 raw GitHub URL 요청, 그리고 가장 큰 단서인 Python 프로세스가 AWS 인스턴스 메타데이터 서비스인 169.254.169.254를 조회하는 것입니다. 애플리케이션 코드가 IMDS를 직접 호출해야 할 정당한 이유는 거의 없습니다. SDK가 알아서 처리하니까요. Python 프로세스가 그 엔드포인트에 접근하면 알림을 설정해 두면, 이 공격뿐 아니라 자격 증명 도둑 계열 전반을 잡을 수 있습니다. 예전에 클라우드 인증 로깅의 사각지대에 대해 글을 쓴 적이 있는데, 이것도 그중 하나이고 막기도 쉽습니다.

개발자 머신은 더 어렵고, 그렇지 않은 척하지 않겠습니다. 같은 사용자가 에디터, 브라우저, SSH 에이전트를 돌리고 임의의 pip install까지 하는 노트북은 탈취범에게 뷔페와 같습니다. 실용적인 방법은 이렇습니다. 탐색적 설치는 호스트 환경 대신 컨테이너나 devcontainer에서 하고, 악성 sitecustomize.py가 호스트의 site-packages에 들어오지 않도록 패키지 설치는 프로젝트 devcontainer 안에서 하세요. 그리고 ~/.aws/credentials를 위험 요소로 취급하세요. aws sso login이나 gcloud auth처럼 수명이 짧은 토큰을 쓰는 SSO를 쓰면 디스크에 훔칠 것이 훨씬 줄어듭니다. 어느 것도 특별하지 않습니다. 그냥 마찰일 뿐이고, 그 마찰이 바로 핵심입니다.

  • lockfile에 버전과 해시를 함께 고정하되, 업그레이드는 배경 소음이 아니라 의도적인 이벤트로 다루세요.
  • 중요하지 않은 의존성 업그레이드가 프로덕션 빌드에 반영되기 전에 48~72시간의 쿨링오프 기간을 두세요. 대부분의 악성 릴리스는 몇 시간 안에 회수됩니다.
  • 출처 증명 attestation이 있는 경우 검증하고, 장기 API 토큰보다 OIDC 기반 Trusted Publisher로 배포된 패키지를 우선 선택하세요.
  • CI 빌드는 이그레스 필터링이 적용된, 일회성이며 자격 증명이 없는 환경에서 실행하고, 인스턴스 메타데이터 엔드포인트에 접근하는 Python 프로세스가 있으면 알림을 받으세요.
  • 서드파티 코드는 개발자 머신의 컨테이너 안에서 설치하고 실행하고, 정적인 클라우드 자격 증명 파일 대신 수명이 짧은 SSO 세션을 사용하세요.
  • 이 기간에 무엇이라도 받아왔다면 IoC를 점검하세요. site-packages의 cook.pth, cook.py, 또는 방치된 sitecustomize.py, 그리고 디스크의 /tmp/.pc_once_*나 /tmp/remote_code_loader.lock을 확인하세요.

이번 공격이 보여주는 실제 위험의 위치

공격자가 굳이 하지 않은 일을 보세요. 코드 취약점을 악용하지 않았습니다. 제로데이가 필요하지도 않았습니다. 현대 소프트웨어에서 가장 약한 표적, 즉 상시 자격 증명을 가진 사람과 기계를 노렸습니다. 인터넷에 노출된 내부 배포 환경의 로그에서 가져온 것으로 보이는 GitHub CLI 토큰 세 개면, 수백만 건의 다운로드를 제공하는 릴리스 파이프라인을 장악하기에 충분했습니다. 시크릿이 로그, 에이전트, 설정 파일로 퍼져 나가고 각각이 만능키가 되는 패턴은 업계 전반에서 반복되는 것을 계속 봐 왔습니다. 무언가의 내부 인스턴스를 운영하고 그것을 인터넷에 노출한다면, 그 로그도 이제 위협 모델의 일부입니다. 이 문장을 읽고 몇 사람은 불편해져야 합니다.

속도 문제도 짚을 만합니다. LiteLLM은 빠르게 움직이는 프로젝트입니다. 커밋이 수만 개이고 릴리스가 잦으며, AI 도구 스택의 중심에 있어서 많은 팀이 자신이 의존하고 있다는 사실조차 모르는 프레임워크와 SDK를 통해 끌려 들어옵니다. 간접 의존성이 닿는 범위 때문에, 직접 의존성을 고르는 것으로는 노출 범위를 고를 수 없습니다. 이런 세계에서 SBOM은 컴플라이언스 서류가 아니라 "우리가 영향을 받았나?"라는 질문에 며칠이 아니라 몇 분 안에 답하는 수단이 됩니다. 매니페스트를 긁어모으는 대신 실제 빌드 증거에서 부품 명세서를 생성하는 도구는, requirements.txt에 언급되지 않은 의존성까지 잡아낼 겁니다.

끝없이 늘어선 창고 선반 사이에서 상자 하나를 살펴보는 랜턴을 든 사람 한 명.
현대의 의존성 트리는 창고와 같고, 대부분의 팀은 선반에 무엇이 있는지 목록을 만들어 본 적이 없습니다.

제 생각은 이렇습니다. 공급망 공격에 대한 업계의 대응은 사후에 알려진 악성 버전을 표시하는 스캐너처럼 예방 연극에 치우쳐 있었고, 이번 사건의 흐름을 바꿨을 평범하고 지루한 통제에는 상대적으로 소홀했습니다. 수명이 짧은 배포 자격 증명. 프록시 단계의 출처 검증. 쿨링오프 기간. 메타데이터 엔드포인트에 대한 이그레스 알림. 샌드박스 설치. 어느 것도 연구 과제가 아닙니다. 오늘 당장 가능한 설정 결정이고, 대부분의 팀은 그중 하나도 구현하지 않았습니다. LiteLLM 공격자는 빌드 설정의 잘못된 줄 하나와 훔친 토큰 세 개만 필요했습니다. 위의 방어책들이 있었다면 그들은 하나가 아니라 네 개의 독립된 계층을 뚫어야 했을 겁니다. 심층 방어가 실제로 뜻하는 바는 스캐너를 더 많이 두는 것이 아니라, 실패할 수 있는 독립된 경로를 더 많이 만드는 것입니다. 이는 그저 최선을 바라기보다 재앙에 저항하도록 설계된 시스템을 만드는 것과 같은 철학입니다. 재앙에 저항하는 시스템 엔지니어링과 같은 맥락이죠.

다음 침해된 릴리스는 이미 누군가가 계획하고 있습니다. 그것이 배포되는 것을 막을 수는 없습니다. 하지만 그것이 여러분의 AWS 키에 닿고, SSH 에이전트가 잠금 해제된 상태이고, 공격자가 통제하는 도메인으로 가는 네트워크 경로가 열린 채 실행될지, 아니면 자격 증명이 없고 방화벽이 있는 일회성 컨테이너에서 소멸할지는 여러분이 정할 수 있습니다. 그 결정은 사고 보고서가 올라오는 날이 아니라, 지금 CI 설정과 devcontainer 구성에서 내려야 합니다.