DEV Community

EME GUG
EME GUG

Posted on

Open Weights, Hidden Payloads: How to Download and Load LLM Models Without Getting Owned

Tuần này HN có hai tin nằm sát nhau: một bên là Mistral công bố weights của Mistral Large 4, một bên là vụ Anthropic bị hack và bị lợi dụng để phát tán remote access trojan. Đọc chung hai tin thì thấy rõ một điều: kênh phân phối càng được tin tưởng thì càng đáng để kẻ tấn công nhắm vào. Dev mình bây giờ tải model vài chục GB về máy gần như mỗi tuần, chạy from_pretrained() mà không nghĩ gì. Nhưng một file model không chỉ là một đống số. Tùy định dạng, nó có thể chứa code chạy ngay lúc bạn load. Bài này mình chia sẻ quy trình mình đang dùng để tải và load open-weight model an toàn hơn, áp dụng được cho Hugging Face, Ollama hay llama.cpp.

Vì sao file model lại chạy được code?

Thủ phạm quen thuộc là pickle. Định dạng .bin, .pt, .pth cũ của PyTorch thực chất là file zip chứa dữ liệu pickle. Pickle không chỉ lưu dữ liệu mà còn lưu cả "công thức" để dựng lại object, và công thức đó có thể là "gọi os.system với tham số này".

Đây là ví dụ nhỏ để bạn tự thấy (chạy trong VM hoặc container bỏ đi):

import pickle, os

class Payload:
    def __reduce__(self):
        # Khi unpickle, Python sẽ gọi os.system("id > /tmp/pwned")
        return (os.system, ("id > /tmp/pwned",))

with open("model.pkl", "wb") as f:
    pickle.dump({"weights": [0.1, 0.2], "x": Payload()}, f)

# Phía nạn nhân: chỉ cần load là xong
pickle.load(open("model.pkl", "rb"))
print(open("/tmp/pwned").read())
Enter fullscreen mode Exit fullscreen mode

Không cần gọi hàm nào, không cần eval. Chỉ một lệnh load là đủ. Trên Hugging Face đã từng có nhiều repo bị phát hiện chứa payload kiểu này, có cái mở reverse shell, có cái đọc ~/.aws/credentials.

Ngoài pickle còn hai đường nữa hay bị bỏ qua:

  • trust_remote_code=True: transformers sẽ tải file modeling_*.py từ repo về và import. Code đó là Python thật, toàn quyền trên máy bạn.
  • Repo bị chiếm quyền: tài khoản tác giả bị hack, file trên nhánh main bị thay. Bạn tải đúng repo, đúng tên, nhưng nội dung đã khác hôm qua. Vụ Anthropic chính là kiểu này: kênh chính thức, payload giả.
flowchart TD
    A[Tải model từ Hub] --> B{Định dạng?}
    B -->|.bin / .pt / .pkl| C[Pickle: có thể chạy code khi load]
    B -->|.safetensors / .gguf| D[Chỉ là tensor + metadata]
    A --> E{trust_remote_code?}
    E -->|True| F[Import file .py từ repo]
    C --> G[RCE trên máy dev / GPU server]
    F --> G
    D --> H[An toàn hơn, vẫn cần verify hash]

Bước 1: Ưu tiên safetensors và GGUF, bật weights_only

safetensors (của Hugging Face) chỉ gồm một header JSON và một khối byte thô của tensor. Không có cơ chế dựng object, nên không có chỗ để nhét code. GGUF của llama.cpp cũng tương tự: header, metadata key-value, rồi tensor. Gần như mọi model lớn hiện nay đều có bản safetensors, cộng đồng thì convert sang GGUF rất nhanh.

Quy tắc của mình: repo nào chỉ có .bin mà không có .safetensors thì coi là đáng ngờ, nhất là với model mới ra.

Nếu buộc phải load file PyTorch cũ thì dùng weights_only=True. Từ PyTorch 2.6, đây là giá trị mặc định của torch.load, nhưng nhiều codebase cũ vẫn ghi đè thành False cho "hết lỗi". Grep thử repo của bạn xem:

# Tìm những chỗ đang tắt cơ chế bảo vệ
grep -rnE "weights_only\s*=\s*False|trust_remote_code\s*=\s*True" --include="*.py" .

# Chỉ tải safetensors + config, bỏ hẳn file pickle
hf download mistralai/SomeModel \
  --include "*.safetensors" "*.json" "tokenizer*" \
  --local-dir ./models/somemodel
Enter fullscreen mode Exit fullscreen mode

hf là CLI mới của huggingface_hub (thay cho huggingface-cli, có từ khoảng bản 0.34). Lọc bằng --include ngay từ lúc tải là cách đơn giản nhất để file .bin không bao giờ nằm trên đĩa.

Bước 2: Pin revision và verify hash

Tải theo tên nhánh main cũng giống pip install không pin version: hôm nay khác, mai khác. Mỗi commit trên Hub có một SHA, và mỗi file LFS có SHA256 công khai. Hãy pin cả hai.

# verify_model.py — pin commit, tải về, so SHA256 với lockfile
import hashlib, json, sys
from pathlib import Path
from huggingface_hub import HfApi, snapshot_download

REPO = "mistralai/SomeModel"
REVISION = "a1b2c3d4e5f6..."  # commit SHA đã review, KHÔNG dùng "main"
LOCK = Path("model.lock.json")

def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()

def build_lock():
    info = HfApi().model_info(REPO, revision=REVISION, files_metadata=True)
    lock = {s.rfilename: s.lfs.sha256 for s in info.siblings if s.lfs}
    LOCK.write_text(json.dumps(lock, indent=2))
    print(f"Đã ghi {len(lock)} hash vào {LOCK}")

def verify():
    lock = json.loads(LOCK.read_text())
    local = Path(snapshot_download(REPO, revision=REVISION,
                                   allow_patterns=["*.safetensors", "*.json"]))
    bad = [n for n, h in lock.items()
           if (local / n).exists() and sha256(local / n) != h]
    if bad:
        sys.exit(f"HASH MISMATCH: {bad}")
    print("OK: tất cả file khớp lockfile")

if __name__ == "__main__":
    build_lock() if "--lock" in sys.argv else verify()
Enter fullscreen mode Exit fullscreen mode

Commit model.lock.json vào repo giống như package-lock.json. Trong CI hay lúc build image cho GPU server, chạy python verify_model.py trước khi khởi động inference. Khi muốn nâng cấp model thì đổi REVISION, chạy --lock, review diff rồi mới merge. Với GGUF tải từ nguồn khác, ít nhất hãy so sha256sum với giá trị tác giả công bố.

Bước 3: Scan và cô lập lần load đầu tiên

Với file pickle bắt buộc phải dùng (checkpoint nội bộ cũ, model nghiên cứu chưa có bản safetensors), hãy scan trước rồi convert một lần duy nhất trong môi trường cô lập:

pip install picklescan fickling

# picklescan: liệt kê global import đáng ngờ (os.system, subprocess, builtins.exec...)
picklescan --path ./models/legacy/pytorch_model.bin

# fickling: phân tích sâu hơn, có cả decompile pickle ra pseudo-Python
fickling --check-safety ./models/legacy/pytorch_model.bin

# Convert trong container không mạng, không mount home
docker run --rm --network none \
  -v "$PWD/models/legacy:/in:ro" -v "$PWD/models/clean:/out" \
  pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime \
  python -c "import torch; from safetensors.torch import save_file; \
    sd = torch.load('/in/pytorch_model.bin', weights_only=True); \
    save_file({k: v.contiguous() for k, v in sd.items()}, '/out/model.safetensors')"
Enter fullscreen mode Exit fullscreen mode

--network none để payload có chạy cũng không gọi về được, mount :ro để nó không sửa file gốc. Sau bước này team chỉ dùng bản .safetensors.

sequenceDiagram
    participant Dev
    participant Hub as Hugging Face Hub
    participant CI
    participant GPU as Inference Server
    Dev->>Hub: model_info(revision=SHA)
    Hub-->>Dev: danh sách file + SHA256
    Dev->>CI: commit model.lock.json
    CI->>Hub: snapshot_download(revision=SHA, *.safetensors)
    CI->>CI: verify SHA256 với lockfile
    CI->>GPU: deploy image đã verify
    GPU->>GPU: load, trust_remote_code=False

Còn trust_remote_code: nếu model cần kiến trúc mới, đọc file modeling_*.py trước, pin revision, hoặc chờ vài tuần để kiến trúc đó vào transformers chính thức. Mấy model lớn thường được support upstream rất nhanh.

Kết luận

Open weights là chuyện tốt, nhưng "tải từ nguồn chính thức" không có nghĩa là an toàn. Vụ Anthropic cho thấy chính kênh chính thức có thể bị biến thành đường phát tán malware. Checklist mình áp dụng cho mọi project có load model:

  1. Chỉ dùng .safetensors hoặc .gguf, lọc bằng hf download --include để file pickle không có mặt trên đĩa.
  2. Không bao giờ dùng weights_only=False nếu chưa hiểu rõ file đó. Lên PyTorch >= 2.6 để mặc định là an toàn.
  3. Pin commit SHA và commit lockfile SHA256, verify trong CI trước khi deploy.
  4. trust_remote_code=False là mặc định, chỉ bật sau khi đã đọc code và pin revision.
  5. File pickle cũ thì scan bằng picklescan/fickling, convert trong container --network none, rồi bỏ hẳn bản gốc.
  6. Thêm lệnh grep ở trên vào pre-commit hook để chặn những chỗ tắt bảo vệ lọt vào codebase.

Mất khoảng một buổi chiều để setup, đổi lại GPU server, nơi thường có token cloud và dữ liệu khách hàng, không biến thành cửa sau chỉ vì một lần from_pretrained().

Top comments (0)