0

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.
    1. 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.
    1. 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:
                        
                        ```bash
                        # 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
                              ```
                              
                              Nếu dùng project kiểu `pyproject.toml` + `uv.lock` thì set trong config:
                              
                              ```toml
                              [tool.uv]
                              exclude-newer = "2026-10-15T00:00:00Z"
                              ```
                              
                              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:
                              
                              ```bash
                              pip install pip-audit
                              pip-audit -r requirements.txt --strict
                              ```
                              
                              ## 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.
                              
                              ```python
                              #!/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()
                                                                                                                                                                                              ```
                                                                                                                                                                                              
                                                                                                                                                                                              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.
                                                                                                                                                                                              
                                                                                                                                                                                              ```mermaid
                                                                                                                                                                                              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.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.