DEV Community

EME GUG
EME GUG

Posted on

Your pip install Just Ran Someone Else's Code: A Practical Defense Guide Against PyPI Supply Chain Attacks

Tuần này trên Hacker News có một tin khiến nhiều team Python mất ngủ: một bản LiteLLM bị compromise được đẩy lên PyPI, kèm credential stealer và backdoor. LiteLLM không phải package vô danh. Nó nằm trong dependency tree của rất nhiều dự án AI, từ side project đến hệ thống production. Mình đã làm DevOps và backend Python gần 10 năm và phải nói thật: phần lớn team ở Việt Nam mà mình từng làm cùng vẫn đang pip install -r requirements.txt với các dòng kiểu litellm>=1.40. Tức là chỉ cần một bản mới bị nhiễm được publish, lần build tiếp theo của bạn sẽ tự động kéo nó về và chạy nó với quyền của CI runner, nơi có sẵn AWS key, PyPI token và GitHub token.

Bài này không bàn lý thuyết. Mình chia sẻ những gì team mình đã áp dụng thật: lock dependency có hash, thêm cooldown cho release mới, quét file .pth độc hại, và cài package trong sandbox. Làm hết mất khoảng một buổi chiều.

Một cuộc tấn công supply chain trên PyPI diễn ra như thế nào

Điều nhiều người không để ý: cài một package Python là đã chạy code của người khác. Có ba điểm mà code đó có thể chạy:

  1. Lúc build sdist: setup.py chạy ngay khi pip build từ source.
  2. File .pth trong site-packages: mỗi dòng bắt đầu bằng import trong file .pth sẽ được Python thực thi mỗi lần interpreter khởi động, kể cả khi bạn không hề import package đó. Kiểu tấn công này đã xuất hiện trong nhiều vụ thật, vì nó âm thầm và tồn tại lâu.
  3. Lúc import: code độc nằm trong __init__.py.
flowchart LR
    A[Attacker lấy được PyPI token của maintainer] --> B[Publish version mới bị nhiễm]
    B --> C[CI chạy pip install với version range]
    C --> D[File .pth hoặc setup.py được thực thi]
    D --> E[Đọc env vars, ~/.aws, ~/.ssh, kubeconfig]
    E --> F[Gửi dữ liệu ra server của attacker]
    D --> G[Cài backdoor để duy trì truy cập]

Vấn đề nằm ở bước C. Nếu dependency của bạn là một version range, bạn đang tin tưởng mọi version trong tương lai của package đó, và cả mọi transitive dependency của nó.

Lớp 1: Lock mọi thứ, kèm hash

Pin version thôi là chưa đủ. Attacker không ghi đè được version đã publish trên PyPI, nhưng bạn vẫn nên verify bằng hash để chắc chắn file tải về đúng là file đã được review, kể cả khi đi qua mirror hoặc proxy nội bộ.

Nếu đang dùng uv (mình dùng 0.9.x), lockfile mặc định đã có hash. Thêm vào đó, uv có option exclude-newer rất đáng giá: nó bỏ qua mọi release mới hơn một mốc thời gian. Phần lớn package bị nhiễm bị phát hiện và gỡ (yank) trong vòng vài giờ đến vài ngày, nên chỉ cần một khoảng cooldown 7 ngày là bạn đã tránh được đa số trường hợp.

# pyproject.toml
# [tool.uv]
# exclude-newer = '2026-10-15T00:00:00Z'   # chỉ resolve các release cũ hơn mốc này

# Tạo lockfile (có sẵn sha256 cho từng wheel)
uv lock

# Trên CI: fail ngay nếu lockfile lệch với pyproject.toml
uv sync --locked

# Team nào vẫn dùng pip thì export ra requirements có hash
uv export --format requirements-txt > requirements.txt
pip install --require-hashes --only-binary=:all: -r requirements.txt

# Nếu dùng pip-tools
pip-compile --generate-hashes requirements.in
Enter fullscreen mode Exit fullscreen mode

Flag --only-binary=:all: buộc pip chỉ cài wheel, không build sdist, nên setup.py không có cơ hội chạy. Có vài package cũ không ship wheel; khi đó bạn sẽ thấy lỗi ngay và có thể xử lý riêng từng trường hợp, thay vì để nó chạy âm thầm.

Mốc exclude-newer nên được tự động đẩy lên hàng tuần bằng một cron job tạo PR. Như vậy việc nâng version luôn đi qua code review, không tự xảy ra lúc build.

Lớp 2: Quét file .pth và dấu hiệu bất thường

Lock chỉ chặn được những gì xảy ra sau này. Còn những gì đã nằm trong máy dev hoặc Docker image thì sao? Đây là script mình chạy định kỳ trên máy dev và trong pipeline build image:

#!/usr/bin/env python3
# scan_pth.py - tìm file .pth có dòng import đáng ngờ
import re
import site
import sys
from pathlib import Path

SUSPICIOUS = re.compile(
    r'(base64|b64decode|exec\(|eval\(|subprocess|urllib|socket|'
    r'requests\.|os\.environ|__import__|compile\()'
)
# Các .pth hợp lệ phổ biến, tự bổ sung theo môi trường của bạn
ALLOWLIST = {'distutils-precedence.pth', '_virtualenv.pth'}

dirs = list(site.getsitepackages()) + [site.getusersitepackages()]
found = 0
for d in dirs:
    p = Path(d)
    if not p.exists():
        continue
    for pth in p.glob('*.pth'):
        if pth.name in ALLOWLIST:
            continue
        lines = pth.read_text(errors='ignore').splitlines()
        for i, line in enumerate(lines, 1):
            if line.lstrip().startswith('import') and (
                SUSPICIOUS.search(line) or len(line) > 300
            ):
                print(f'[!] {pth}:{i}: {line[:150]}')
                found += 1

sys.exit(1 if found else 0)
Enter fullscreen mode Exit fullscreen mode

Chạy với python3 -I scan_pth.py (flag -I để không load code từ thư mục hiện tại). Exit code 1 nghĩa là build fail. Dòng .pth dài bất thường hoặc chứa chuỗi base64 gần như luôn là dấu hiệu xấu, vì .pth hợp lệ thường chỉ là một đường dẫn hoặc một lệnh import ngắn.

Ngoài ra nên thêm pip-audit (2.9.x) vào CI để đối chiếu với database lỗ hổng của PyPA và OSV:

pip-audit -r requirements.txt --require-hashes --strict
Enter fullscreen mode Exit fullscreen mode

Lớp 3: Cài đặt trong sandbox, tách khỏi secrets

Nguyên tắc quan trọng nhất: bước cài dependency không được thấy secrets. Trên GitHub Actions hay GitLab CI, rất nhiều team inject AWS_ACCESS_KEY_ID ở cấp job, nghĩa là mọi step, kể cả pip install, đều đọc được key.

sequenceDiagram
    participant CI as CI Runner
    participant DL as Download stage (có network)
    participant SB as Sandbox (network none)
    participant DP as Deploy stage (có secrets)
    CI->>DL: pip download wheels + verify hash
    DL->>SB: mount thư mục wheels read-only
    SB->>SB: install + test, không network, không secrets
    SB->>DP: build artifact đã kiểm tra
    DP->>DP: inject secrets chỉ ở bước này

Thực tế làm bằng Docker:

# Stage 1: chỉ tải, không cài, không có secrets
pip download --only-binary=:all: --require-hashes \
  -r requirements.txt -d ./wheels

# Stage 2: cài và test trong container không có network
docker run --rm --network none \
  -v "$PWD/wheels:/wheels:ro" -v "$PWD:/app:ro" \
  python:3.12-slim sh -c '
    pip install --no-index --find-links /wheels -r /app/requirements.txt &&
    python -I /app/scan_pth.py &&
    python -m pytest /app/tests -q -p no:cacheprovider
  '
Enter fullscreen mode Exit fullscreen mode

Với --network none, kể cả khi có code độc chạy được thì nó cũng không gửi được gì ra ngoài. Secrets chỉ được inject ở job deploy, dùng OIDC (ví dụ aws-actions/configure-aws-credentials với role ngắn hạn) thay vì key dài hạn lưu trong repo settings.

Còn trên máy dev, đây là chỗ hay bị bỏ qua nhất: đừng để ~/.aws/credentials dạng plaintext. Dùng aws sso login hoặc aws-vault để credential có thời hạn ngắn.

Nếu bạn đã lỡ cài bản bị nhiễm

Đừng chỉ pip uninstall rồi coi như xong. Backdoor có thể đã cài persistence ở nơi khác. Thứ tự xử lý của mình:

  1. Kiểm tra version trong mọi môi trường: pip show litellm, đồng thời grep lockfile của tất cả các repo.
  2. Rotate toàn bộ credential mà máy đó hoặc CI runner đó từng truy cập: cloud keys, API keys của LLM provider, GitHub/PyPI tokens, database password.
  3. Kiểm tra persistence: crontab, ~/.config/systemd/user/, ~/.bashrc, và các file .pth lạ.
  4. Xem audit log của cloud provider (CloudTrail, GCP Audit Logs) trong khoảng thời gian bị ảnh hưởng.
  5. Rebuild máy hoặc image từ đầu, không "dọn dẹp" tại chỗ.

Kết luận

Các vụ supply chain attack trên PyPI sẽ còn tiếp diễn, và các package trong hệ AI/LLM là mục tiêu béo bở vì môi trường chạy chúng luôn có sẵn API key giá trị cao. Danh sách việc cần làm ngay tuần này:

  • Hôm nay: chuyển sang uv lock hoặc pip-compile --generate-hashes, bật --require-hashes và --only-binary=:all: trên CI.
  • Tuần này: thêm exclude-newer làm cooldown 7 ngày, kèm cron tạo PR để nâng mốc định kỳ.
  • Tuần này: đưa scan_pth.py và pip-audit vào pipeline, fail build nếu phát hiện vấn đề.
  • Tháng này: tách stage cài dependency ra khỏi secrets, chạy test với --network none, chuyển CI sang OIDC.
  • Luôn luôn: khi có tin package bị compromise, coi mọi credential trên máy bị ảnh hưởng là đã lộ và rotate ngay.

Không có biện pháp nào chặn được 100%, nhưng mỗi lớp phòng thủ sẽ thu hẹp đáng kể thiệt hại khi sự cố xảy ra. Đầu tư một buổi chiều bây giờ vẫn rẻ hơn nhiều so với một đêm rotate credential cho cả công ty.

Top comments (0)