DEV Community

EME GUG
EME GUG

Posted on

Your pip install Is a Remote Code Execution: Practical Defenses Against Compromised PyPI Packages

Tuần này HN lại nóng với tin một bản LiteLLM trên PyPI bị chèn mã độc: cài vào là nó đánh cắp credential và để lại backdoor. Đây không phải lần đầu, cũng chưa phải lần cuối. Nhiều team Việt Nam mình vẫn quen viết pip install -U litellm trong Dockerfile hoặc để requirements.txt không pin version. Làm vậy là đang cho bất kỳ ai chiếm được tài khoản của maintainer quyền chạy code trên laptop, CI runner và production server của mình. Bài này không bàn lý thuyết. Mình chia sẻ những gì team mình đang áp dụng để giảm rủi ro, kèm lệnh và script dùng được ngay.

Vì sao pip install nguy hiểm hơn bạn nghĩ

Nhiều người nghĩ package độc chỉ gây hại khi mình import nó. Thực tế có ít nhất ba cách để code chạy sớm hơn thế nhiều:

  1. setup.py của source distribution (sdist): chạy ngay lúc build, trước khi bạn kịp đọc code.
  2. File .pth trong site-packages: dòng nào trong .pth bắt đầu bằng import sẽ được Python thực thi mỗi khi interpreter khởi động, kể cả khi bạn chỉ chạy python -c "print(1)". Đây là kỹ thuật persistence mà các package độc gần đây rất hay dùng.
  3. Transitive dependency: bạn không cài package độc, nhưng một thư viện bạn tin dùng lại kéo version mới của nó về.

Khi đã chạy được, việc đầu tiên mã độc làm thường là quét biến môi trường (OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN), đọc ~/.aws/credentials, ~/.ssh/, ~/.kube/config, rồi gửi ra ngoài qua HTTPS. CI runner là mục tiêu béo bở nhất vì ở đó có sẵn secret deploy.

flowchart LR
    A[Attacker chiếm token maintainer] --> B[Upload version mới lên PyPI]
    B --> C[CI chạy pip install -U]
    C --> D[setup.py / .pth thực thi]
    D --> E[Đọc env vars, ~/.aws, ~/.ssh]
    E --> F[Exfiltrate qua HTTPS]
    D --> G[Cài backdoor .pth chạy mỗi lần python khởi động]

Điểm mấu chốt: các version độc thường bị phát hiện và gỡ khỏi PyPI trong vài giờ đến vài ngày. Phần lớn nạn nhân là những người cài đúng trong khoảng thời gian đó. Vì vậy hai lớp phòng thủ hiệu quả nhất là pin chặt và trì hoãn việc nhận version mới.

Lớp 1: Pin version kèm hash, và thêm "cooldown"

Pin litellm==1.x.y thôi thì chưa đủ. Nếu attacker upload lại một file wheel khác (hoặc bạn bị trỏ sang mirror độc), version vẫn khớp. Phải pin luôn hash của từng artifact. Với uv (mình dùng bản 0.9.x), việc này rất nhanh:

# requirements.in chỉ ghi dependency trực tiếp
echo "litellm>=1.50" > requirements.in
echo "fastapi" >> requirements.in

# Cooldown: chỉ chấp nhận package publish trước 7 ngày
# macOS: date -u -v-7d ; Linux: date -u -d '7 days ago'
CUTOFF=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)

uv pip compile requirements.in \
  --generate-hashes \
  --exclude-newer "$CUTOFF" \
  -o requirements.txt

# Trong CI / Dockerfile: bắt buộc hash, không tự resolve thêm gì
pip install --require-hashes --no-deps -r requirements.txt

# Chặn sdist để không có setup.py nào được chạy
pip install --only-binary=:all: --require-hashes -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Nếu dùng project kiểu pyproject.toml + uv.lock thì set trong config:

[tool.uv]
exclude-newer = "2026-10-15T00:00:00Z"
Enter fullscreen mode Exit fullscreen mode

Vì sao cooldown 7 ngày lại hiệu quả? Vì cộng đồng (Socket, Phylum, các researcher trên HN) thường phát hiện package độc trong vòng 24–72 giờ. Bạn chịu chậm hơn vài ngày, đổi lại gần như không bao giờ dính "zero-day release". Khi có security patch gấp thì override thủ công cho đúng package đó, có review.

Thêm pip-audit vào CI để bắt các version đã có advisory:

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

Lớp 2: Tự quét .pth và hành vi lạ trong môi trường đã cài

Pin chặt chỉ bảo vệ từ nay về sau. Còn những máy đã lỡ cài thì sao? Script dưới đây quét mọi file .pth trong site-packages và đánh dấu các dòng import có dấu hiệu đáng ngờ. Bình thường chỉ vài package hợp lệ dùng .pth có import (ví dụ distutils-precedence.pth của setuptools, hoặc file của editable install), nên danh sách sẽ ngắn và dễ review.

#!/usr/bin/env python3
# scan_pth.py - tìm code tự chạy khi Python khởi động
import pathlib
import re
import site

SUSPICIOUS = re.compile(
    r"(base64|b64decode|exec|eval|subprocess|urllib|socket|requests|os\.system|marshal)"
)

def site_dirs():
    dirs = list(site.getsitepackages())
    user = site.getusersitepackages()
    if user:
        dirs.append(user)
    return [pathlib.Path(d) for d in dirs if pathlib.Path(d).exists()]

def scan():
    found = 0
    for d in site_dirs():
        for pth in d.glob("*.pth"):
            text = pth.read_text(errors="ignore")
            for no, line in enumerate(text.splitlines(), 1):
                if not line.startswith(("import ", "import\t")):
                    continue
                found += 1
                flag = "[!!]" if SUSPICIOUS.search(line) or len(line) > 300 else "[ok]"
                print(f"{flag} {pth}:{no}: {line[:150]}")
    print(f"\nTổng cộng {found} dòng import trong .pth")

if __name__ == "__main__":
    scan()
Enter fullscreen mode Exit fullscreen mode

Chạy với python3 -I scan_pth.py (cờ -I để không tự load module từ thư mục hiện tại). Dòng nào dài bất thường, chứa chuỗi base64 hoặc gọi exec thì coi như máy đã bị compromise. Mình chạy script này cho từng virtualenv, base image Docker và cả Python hệ thống trên runner self-hosted.

Lớp 3: Giới hạn thiệt hại khi lỡ dính

Giả định xấu nhất là kiểu gì cũng sẽ có lúc bạn cài phải package độc. Câu hỏi là nó lấy được bao nhiêu.

flowchart TD
    A[Job CI] --> B{Bước install dependency}
    B -->|Không có secret| C[Build và test]
    C --> D{Bước deploy}
    D -->|OIDC token ngắn hạn| E[Cloud]
    B -.->|Egress bị chặn| F[Internet]

Vài nguyên tắc team mình áp dụng:

  • Tách bước install ra khỏi bước có secret. Trong GitHub Actions, chỉ inject env secret vào step deploy, không đặt ở cấp job. Bước pip install không cần thấy AWS_SECRET_ACCESS_KEY.
  • Dùng OIDC thay cho long-lived key. GitHub Actions, GitLab CI đều hỗ trợ đổi OIDC token lấy credential AWS/GCP chỉ sống 15–60 phút. Có bị lộ thì cũng nhanh hết hạn.
  • Chặn egress lúc build. Build Docker với --network=none sau khi đã tải wheel về bằng pip download, hoặc dùng proxy nội bộ (devpi, Artifactory) chỉ cho phép host PyPI.
  • Dev local: đừng để API key production trong ~/.zshrc. Dùng direnv theo từng project, và key dev phải tách khỏi key prod.
  • Nếu bạn là maintainer: chuyển sang PyPI Trusted Publishing (publish từ CI qua OIDC, không còn API token tĩnh), bật 2FA bằng hardware key.

Khi phát hiện đã cài version độc, thứ tự xử lý: (1) cách ly máy/runner, (2) rotate toàn bộ credential mà môi trường đó từng chạm tới, kể cả những thứ bạn nghĩ là không liên quan, (3) xóa sạch venv và build lại từ lockfile đã kiểm tra, (4) xem log egress để biết dữ liệu đã đi đâu. Gỡ package thôi là không đủ, vì backdoor .pth vẫn còn đó.

Kết luận

Supply chain attack trên PyPI giờ đã thành chuyện thường, không còn là chuyện hiếm. Những việc có thể làm ngay trong tuần này:

  1. Thay pip install -U bằng lockfile có hash: uv pip compile --generate-hashes + pip install --require-hashes.
  2. Thêm cooldown 3–7 ngày bằng --exclude-newer. Đây là cách rẻ nhất để né phần lớn các đợt release độc.
  3. Chạy pip-audit trong CI và fail build khi có advisory.
  4. Quét .pth trên mọi môi trường hiện có bằng script ở trên.
  5. Không để secret xuất hiện ở bước install. Chuyển sang OIDC và credential ngắn hạn.
  6. Viết sẵn runbook rotate credential để khi có sự cố thì làm theo trong 30 phút, khỏi phải ngồi nghĩ trong 3 ngày.

Không có lớp nào chặn được 100%, nhưng chồng đủ các lớp này lên nhau thì lần tới HN báo một package bị compromise, bạn chỉ cần kiểm tra lockfile là xong.

Top comments (0)