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())
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 filemodeling_*.pytừ 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
mainbị 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
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()
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')"
--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:
-
Chỉ dùng
.safetensorshoặc.gguf, lọc bằnghf download --includeđể file pickle không có mặt trên đĩa. -
Không bao giờ dùng
weights_only=Falsenếu chưa hiểu rõ file đó. Lên PyTorch >= 2.6 để mặc định là an toàn. - Pin commit SHA và commit lockfile SHA256, verify trong CI trước khi deploy.
-
trust_remote_code=Falselà mặc định, chỉ bật sau khi đã đọc code và pin revision. -
File pickle cũ thì scan bằng
picklescan/fickling, convert trong container--network none, rồi bỏ hẳn bản gốc. - 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)