Ringkasan Utama
Hasil scan pickle Hugging Face yang bersih bukan bukti sebuah model aman. Paper ShadowPickle (Juli 2026) lolos dari sepuluh scanner dan empat model hub, dengan varian Overwritten rata-rata 63 persen lolos dari lima scanner open-source. Muat hanya safetensors, pin commit, dan perlakukan setiap checkpoint pickle sebagai kode.
Kebiasaan yang nyaman biasanya begini: buka halaman model, tidak melihat tanda merah di samping pytorch_model.bin, lalu menganggap file itu sudah dicek. Dokumentasi Hugging Face sendiri menyebut daftar import itu hanya best-effort dan tidak 100 persen kebal salah. Pada Juli 2026 sebuah paper memberi angka seberapa jauh celahnya.
Post ini membaca paper tersebut, ShadowPickle karya Dhruv Pradhan, Sarang Nambiar dan Ezekiel Soremekun (arXiv 2607.17503), dan membandingkannya dengan dokumentasi Hugging Face, Python, PyTorch dan safetensors. Sudut pandangnya defender: apa yang diukur dan apa artinya bagi kebijakan loading. Tidak ada payload dan tidak ada resep membuat file yang lolos scanner. Setiap angka di bawah dikutip dari sumber yang saya baca, dan bila saya sedang bernalar sendiri, saya menyebutnya.
Apa yang diukur ShadowPickle, dan arti 63 persen
Paper menguji tiga varian serangan terhadap sepuluh scanner: lima open-source (PickleScan, ModelScan, Fickling, Weights-only Unpickler milik PyTorch, dan ModelTracer) serta lima scanner closed-source yang tampil lewat Hugging Face. Tabel berikut menunjukkan berapa dari 1.000 model hasil injeksi yang terdeteksi tiap scanner open-source per varian, dengan versi tool dari lampiran paper: PickleScan 0.0.32, ModelScan 0.8.7, Fickling 0.1.5, torch 2.9.1 dan ModelTracer 0.0.1.
| Scanner | PyPI Injected | External Modules | Overwritten |
|---|---|---|---|
| PickleScan | 0 dari 1.000 | 0 dari 1.000 | 0 dari 1.000 |
| ModelScan | 0 dari 1.000 | 0 dari 1.000 | 0 dari 1.000 |
| Fickling | 1.000 dari 1.000 | 1.000 dari 1.000 | 1.000 dari 1.000 |
| Weights-only (torch) | 1.000 dari 1.000 | 1.000 dari 1.000 | 39 dari 1.000 |
| ModelTracer | 907 dari 1.000 | 953 dari 1.000 | 821 dari 1.000 |
Angka utamanya bisa dicek dari tabel itu. Paper melaporkan rata-rata detection rate 0,372 untuk varian Overwritten, dan satu dikurangi 0,372 adalah evasion rate 63 persen di abstrak. Hitungan yang sama memberi 0,5814 dan 0,5906 untuk dua varian lain, sekitar 42 dan 41 persen lolos. Jadi 63 persen adalah rata-rata dari lima scanner open-source, bukan peluang sebuah model di Hub berbahaya. Rata-rata itu juga menyembunyikan hasil yang timpang: dua scanner tidak mendeteksi apa pun, dan satu mendeteksi semuanya. Deteksi sempurna Fickling pun tidak seindah kelihatannya, karena paper melaporkan F1 score-nya 0,6791 sebelum patch penulis, artinya menangkap semuanya datang bersama false positive.
Mengapa scan Hub yang bersih lebih lemah dari kelihatannya
Dokumentasi Hub sendiri menjelaskan dua scan pada file yang di-push, yaitu ClamAV dan pickle import scan, dan menyatakan terus terang bahwa ClamAV tidak mencakup exploit pickle. Import scan mengekstrak daftar import dengan pickletools.genops, yang membaca file tanpa mengeksekusinya, lalu menyorot yang mencurigakan. Halaman itu ditutup dengan disclaimer bahwa ini tidak 100 persen kebal salah, pengguna harus memeriksa sendiri, dan daftar import aman maupun tidak aman dikelola secara best-effort.
Itu deskripsi jujur untuk filter berbasis nama, dan filter berbasis nama adalah desain yang disasar ShadowPickle. Di sisi Hub, paper melaporkan varian Overwritten lolos dari semua scanner Hugging Face yang diuji (HF_JFrog, HF_ProtectAI, HF_ClamAV, HF_VirusTotal dan HF_PickleScan), sementara varian PyPI Injected dan External Modules hanya terdeteksi HF_PickleScan. Penulis mengunggah contoh representatif ke Hugging Face dan OpenCSG, bukan ribuan, jadi hasil closed-source bertumpu pada sampel kecil.
Total ada empat hub yang diuji: Hugging Face, ModelScope, OpenCSG dan GitHub. Paper melaporkan ModelScope dan GitHub tidak punya scanner khusus pre-trained model, dan satu scanner OpenCSG sama sekali tidak memindai repositori yang diunggah. Cakupan antar hub tidak merata, jadi kebijakan yang bergantung pada hub yang sudah memeriksa berarti bergantung pada hub mana yang Anda pakai.
Cara kerja kelas serangan ini, sebatas kebutuhan defender
Pickle adalah stack machine. Dokumentasi Hugging Face menjelaskan bahwa GLOBAL dan STACK_GLOBAL menyuruh unpickler mengimpor sesuatu dan REDUCE memanggilnya, dan instruksi-instruksi itulah yang berbahaya. Ketiga serangan di paper memakai external module import milik Pickle VM untuk menjalankan kode saat deserialization. Bedanya ada pada apa yang diandalkan penyerang, dan perbedaan itu bagian yang berguna bagi defender.
| Varian | Yang diandalkan penyerang | Yang harus dilakukan korban | Hasil di Hub menurut paper |
|---|---|---|---|
| Overwritten | Module yang menggantikan module di allowlist scanner, seperti collections.OrderedDict, didistribusikan lewat PyPI atau bersama model | Menginstal module itu, sering lewat requirements.txt. Weights-only loading tidak perlu dimatikan | Lolos dari semua scanner Hugging Face yang diuji |
| PyPI Injected | Library PyPI pihak ketiga yang mendukung eksekusi kode | Menginstal library itu dan mematikan Weights-only loading | Hanya terdeteksi HF_PickleScan |
| External Modules | Helper module yang dikirim bersama model | Mengunduh helper itu dan mematikan Weights-only loading | Hanya terdeteksi HF_PickleScan |
Pelajarannya bukan trik untuk ditiru. Scanner yang membuat allowlist berdasarkan nama menilai namanya, sedangkan interpreter memuat apa pun yang diselesaikan nama itu di environment korban. Bila environment sudah diubah, nama di allowlist tidak lagi berarti seperti asumsi allowlist-nya. Karena itu penulis menyarankan memeriksa apakah module standard library di allowlist ternyata di-resolve ke lokasi non-standar seperti site-packages.
Batas 63 persen: keterbatasan dari paper itu sendiri
Baca lagi kolom korban. Setiap varian membutuhkan environment korban untuk ikut bekerja sama: paket tambahan terinstal, atau file helper terunduh, dan pada dua dari tiga varian Weights-only loading dimatikan. Paper mencatat ini sebagai keterbatasan, bersama tiga lainnya: scanner closed-source hanya diuji dengan beberapa unggahan representatif, hasil dynamic scanning bergantung pada execution environment, dan jumlah pada dataset pembanding tidak konsisten antar bagian (91, 64 dan 85 model berbahaya muncul untuk MalHug).
Benchmark-nya sendiri layak diketahui. PickleBench menyuntikkan serangan ke model yang jinak: basis jinaknya adalah 3.000 model text-generation paling banyak di-like di Hugging Face, dengan 3.000 model hasil injeksi pada set berbahaya, 1.000 per varian. Penulis melaporkan benchmark ini hingga 25,6 persen lebih menantang daripada tiga benchmark yang sudah ada. Soal disclosure, mereka menyatakan metode dan usulan pertahanan akan dibagikan ke Hugging Face, PyTorch dan Trail of Bits, serangan PyPI hanya tayang 24 jam dan terutama di Test PyPI, payload dibatasi pada enumerasi dasar atau reverse shell ke alamat lokal, dan model berbahaya tidak dirilis ke publik. Teks yang saya baca tidak memuat tanggapan vendor maupun tanggalnya.
Salah baca yang mahal adalah menganggap 63 persen sebagai porsi model Hub yang berbahaya, atau hasil scan bersih sebagai vonis. Angka itu mengukur scanner yang kecolongan pada model benchmark yang disuntikkan penulis. Ia tidak bilang berapa banyak file jahat yang ada di dunia nyata, dan saya tidak mereproduksi apa pun. Pakai untuk menghapus anggapan bahwa scanning menggantikan kebijakan loading, bukan untuk mengukur besar ancaman.
Satu catatan lagi, dan ini soal argumen saya sendiri. Rekomendasi paper adalah scanner yang lebih baik, pemeriksaan environment, dan pemindaian file requirements. Paper tidak menyebut safetensors. Kebijakan safetensors-first di bawah adalah inferensi saya: format itu menyimpan tensor, bukan kode, dan README safetensors mendefinisikan kolom Safe sebagai apakah file yang diunduh sembarangan bisa dipakai tanpa menjalankan kode arbitrer, dengan pickle ditandai tidak aman. Itu posisi yang beralasan, bukan hasil yang diuji paper.
Kebijakan loading safetensors-first yang bisa ditegakkan
Kebijakan harus fail closed dan berjalan sebelum apa pun diunduh. Gate di bawah hanya membaca daftar file dari satu commit yang di-pin lewat client huggingface_hub dan menolak repositori yang tidak punya file .safetensors. Ia tidak mengeksekusi apa pun dari repositori.
# gate_model_repo.py: decide BEFORE any weight file is downloaded or loaded.
# Reads only the file listing of one pinned commit, so nothing is executed.
from huggingface_hub import HfApi
PICKLE_SUFFIXES = (".bin", ".pt", ".pth", ".pkl", ".pickle", ".ckpt")
def gate(repo_id: str, commit_sha: str) -> None:
info = HfApi().model_info(repo_id, revision=commit_sha)
names = [s.rfilename for s in info.siblings]
has_safetensors = any(n.endswith(".safetensors") for n in names)
pickles = [n for n in names if n.endswith(PICKLE_SUFFIXES)]
if not has_safetensors:
raise SystemExit(f"{repo_id}@{commit_sha[:8]}: no .safetensors file, refusing")
if pickles:
# Not fatal by itself, but a repo shipping both formats is where
# a loader silently choosing the wrong one gets expensive.
print(f"warning: {repo_id} also ships {pickles}")
gate("example-org/example-model", "0123456789abcdef0123456789abcdef01234567")Loader lalu menyatakan kebijakannya, bukan mewarisi default. Dokumentasi Transformers menyebut use_safetensors default-nya None dan, bila tidak ditentukan dan safetensors tidak terinstal, diatur menjadi False. Itulah kegagalan diam-diam: image container yang lebih ramping mengubah format file yang Anda terima. Memberi use_safetensors=True, mem-pin revision ke commit hash, dan membiarkan trust_remote_code mati mengubah pilihan itu menjadi kode yang bisa dilihat reviewer.
# load_model.py: the loader states the policy instead of trusting defaults.
from transformers import AutoModel
# Wrong: use_safetensors defaults to None. Per the Transformers docs, if it is
# not specified and the safetensors package is not installed, it is set to False,
# so a missing dependency changes which file format you are willing to read.
model = AutoModel.from_pretrained("example-org/example-model")
# Right: pinned commit, safetensors demanded, repo code refused, and the
# restricted unpickler left ON (weights_only=True is already the default
# in from_pretrained; stating it makes a later edit show up in review).
model = AutoModel.from_pretrained(
"example-org/example-model",
revision="0123456789abcdef0123456789abcdef01234567",
use_safetensors=True,
trust_remote_code=False,
weights_only=True,
)
# Pin the behaviour in a test. This repo ships only a pickle checkpoint, so the
# call above must fail on your Transformers version, not quietly fall back.
# The docs I read do not state the failure mode, so verify it yourself.Bila pickle tidak bisa dihindari
Sebagian checkpoint hanya ada dalam bentuk pickle. Untuk itu, susun beberapa lapis pertahanan dan jujur soal apa yang dicakup tiap lapis. torch.load mendokumentasikan weights_only=True sebagai default, membatasi unpickler pada tensor, tipe primitif, dictionary dan apa pun yang didaftarkan lewat add_safe_globals, dan tetap memperingatkan agar tidak memuat data dari sumber yang tidak tepercaya. Dokumentasi Python menyatakan pickle tidak aman dan menunjukkan pembatasan globals dengan meng-override find_class. Pemeriksaan environment di bawah adalah sketsa saya atas rekomendasi penulis, yang mereka laporkan menaikkan F1 score Weights-only dari 0,8007 ke 0,9911 dan Fickling dari 0,6791 ke 0,7165.
# env_tamper_check.py: a defender-side sketch of the paper's recommendation.
# The authors suggest checking whether allowlisted standard-library modules
# (their example is collections.OrderedDict) resolve to a non-standard place
# such as site-packages. This is my reading of that idea, not their patch.
import sys
import sysconfig
from pathlib import Path
STDLIB = Path(sysconfig.get_paths()["stdlib"]).resolve()
def shadowed_stdlib_modules() -> list[str]:
bad = []
for name in sys.stdlib_module_names: # Python 3.10 and later
mod = sys.modules.get(name)
path = getattr(mod, "__file__", None) # built-in modules have none
if path and STDLIB not in Path(path).resolve().parents:
bad.append(f"{name} -> {path}")
return bad
if __name__ == "__main__":
problems = shadowed_stdlib_modules()
if problems:
raise SystemExit("stdlib module resolved outside stdlib:\n" + "\n".join(problems))
print("stdlib modules resolve inside", STDLIB)
# Run it in the SAME interpreter and virtualenv that will call torch.load,
# after "import torch", because it only sees modules already in sys.modules.Pencatat import di bawah berguna untuk triase dan menyesatkan jika dijadikan vonis. Ia mencetak apa yang diminta sebuah file untuk diimpor tanpa mengeksekusinya, ide yang sama dengan scan Hub, dan ia punya batas yang sama yang diungkap paper: daftar nama yang tampak normal tidak memberi tahu apa yang di-resolve nama-nama itu di proses yang akan memuat file. Ia juga heuristik yang bisa melewatkan nama yang di-memoize, jadi curigai hasil yang kosong.
# triage_imports.py: list what a pickle asks to import WITHOUT executing it.
# This is the same idea as the Hub's import scan (pickletools.genops), and it
# has the same limit: it shows names, and cannot tell you what those names
# resolve to in the environment that will load the file.
import pickletools
import sys
def imports(path: str):
with open(path, "rb") as f:
recent_strings = []
for op, arg, _pos in pickletools.genops(f):
if op.name in ("SHORT_BINUNICODE", "BINUNICODE", "UNICODE"):
recent_strings.append(arg)
elif op.name == "GLOBAL":
yield arg.replace(" ", ".")
elif op.name == "STACK_GLOBAL" and len(recent_strings) >= 2:
yield ".".join(recent_strings[-2:])
for name in sorted(set(imports(sys.argv[1]))):
print(name)
# Expect torch._utils._rebuild_tensor_v2 and collections.OrderedDict in a normal
# checkpoint. An unexpected name is a reason to stop. An expected-looking list is
# NOT a reason to load: that is exactly the gap ShadowPickle measures.Muat pickle yang tidak tepercaya hanya di container sekali pakai tanpa jaringan, dibangun dari lockfile ke virtual environment baru. Itu pilihan saya, bukan rekomendasi paper, tetapi ia mengikuti threat model: varian yang membutuhkan module tertanam tidak bisa jalan saat Anda mengendalikan setiap module di environment dan proses tidak bisa menghubungi luar.
Yang tidak diselesaikan safetensors
Pindah ke safetensors menghapus satu kelas risiko, yaitu memuat kode dari file weight, dan membiarkan sisa supply chain tetap di tempatnya. Celah yang tidak akan saya tutupi:
- Kode repositori. Pengaturan seperti trust_remote_code menjalankan Python yang dikirim di repositori apa pun format weight-nya, dan itu risiko terpisah dari pickle.
- Environment. Varian Overwritten datang lewat sebuah paket. Paper menyarankan hub memindai file requirements.txt atau uv.lock dengan vulnerability scanner, dan hal yang sama berlaku untuk image Anda sendiri.
- Nilai tensor. README safetensors mencatat nilai tensor tidak diperiksa, dan nilai NaN serta infinity bisa ada di file, jadi file yang aman belum tentu model yang benar.
- Provenance. Dokumentasi Hugging Face menyatakan signed commit menjamin asal file, bukan bahwa file itu aman. Asal dan keamanan adalah dua pertanyaan berbeda, dan commit hash yang di-pin hanya menjawab yang pertama.
Deskripsi format safetensors juga menenangkan pada satu hal yang lebih sempit: header adalah string JSON yang didahului ukuran 8 byte little-endian, header dibatasi 100MB, dan byte buffer tidak boleh punya lubang, yang menurut README mencegah file polyglot. Itu permukaan yang kecil dan bisa diaudit, dan itulah argumen sebenarnya dibanding aliran instruksi serbaguna.
Yang dikerjakan hari Senin
Tidak ada yang membutuhkan produk baru. Yang dibutuhkan adalah lima keputusan yang ditulis ke dalam kode dan review:
- Cari di codebase torch.load, pickle.load dan from_pretrained, lalu tetapkan pemilik untuk setiap pemanggilan yang bisa membaca checkpoint pickle.
- Jadikan safetensors default: use_safetensors=True, commit hash yang di-pin, trust_remote_code tetap mati, dan script gate di CI sebelum unduhan apa pun.
- Pastikan paket safetensors terinstal di setiap image yang memuat model, agar dependency yang hilang tidak diam-diam mengubah format yang diterima.
- Untuk checkpoint pickle yang terpaksa dipertahankan, muat hanya di container sekali pakai tanpa jaringan, dan jalankan pemeriksaan environment di interpreter yang sama terlebih dulu.
- Berhenti membaca scan bersih sebagai persetujuan. Catat hasil scanner sebagai satu input, di samping commit yang di-pin dan format file, dalam checklist intake model Anda.
# Monday audit: which of our code paths can still read a pickle checkpoint?
grep -rnE "torch\.load\(|pickle\.load|from_pretrained\(" --include="*.py" .
# Anything that passes weights_only=False deserves a named owner and a reason.
grep -rn "weights_only=False" --include="*.py" .
# Is the safetensors package present in every image that loads models?
pip show safetensors || echo "MISSING: use_safetensors=None may fall back to pickle"Scanner menjawab apakah sebuah file mirip pickle berbahaya yang sudah dikenal. Kebijakan loading menjawab apakah proses Anda akan pernah mengeksekusi apa yang dikatakan file itu. ShadowPickle adalah pengukuran bahwa pertanyaan pertama punya celah, dan solusinya adalah berhenti bergantung padanya. Baca weight dari format yang tidak bisa membawa kode, pin apa yang Anda baca, dan jadikan scan sebagai tripwire, bukan gerbang.
Sumber dan bacaan lanjutan
- arXiv: ShadowPickle, Evading Machine Learning Model Scanners via Stealthy Pickle Deserialization Attacks (2607.17503)
- Dokumentasi Hugging Face Hub: Pickle Scanning
- Dokumentasi Python: pickle, serialisasi objek Python (peringatan keamanan, pembatasan globals)
- Dokumentasi PyTorch: torch.load dan parameter weights_only
- GitHub: README safetensors (format, tabel perbandingan, batas)
- Dokumentasi Hugging Face Transformers: PreTrainedModel.from_pretrained (use_safetensors, weights_only)

