Skip to content

Commit 70e8905

Browse files
authored
Merge pull request #31 from FujitsuResearch/develop/v1-2-1
Fix unsafe .pt deserialization in model loaders (CWE-502)
2 parents 600d2d2 + 99d08fa commit 70e8905

10 files changed

Lines changed: 307 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Change log
22

3+
## [v1.2.1] 2026-07-03
4+
5+
### Security
6+
7+
- **Unsafe deserialization hardening (CWE-502)**: `QuantizedModelLoader.load_quantized_model_pt()` (alias `onecomp.load_quantized_model_pt()`) previously called `torch.load(model.pt, weights_only=False)` unconditionally, allowing arbitrary code execution when loading a malicious `.pt` checkpoint. It now refuses to load unless the caller explicitly opts in via `allow_unsafe_deserialization=True`, and emits a strong warning when it does load. For untrusted models, use the safetensors-based `load_quantized_model()`, which does not execute code.
8+
- **Breaking change**: existing callers of `load_quantized_model_pt()` must pass `allow_unsafe_deserialization=True` for trusted `.pt` files.
9+
- **`Quantizer.load_results()` / `ResultLoader`**: same hardening applied. Loading with `weights_only=False` now requires `allow_unsafe_deserialization=True` (added as a `ResultLoader` field), and logs a warning. The safe `weights_only=True` path is unchanged.
10+
- Updated docstrings, docs, and the LoRA SFT example to document the risk and the required opt-in.
11+
312
## [v1.2.0] 2026-06-08
413

514
### Save/Load Support for JointQ, RTN, and OneBit Quantizers

docs/api/quantized_model_loader.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,20 @@ from onecomp import load_quantized_model, load_quantized_model_pt
2222
model, tokenizer = load_quantized_model("./saved_model")
2323

2424
# Load a PyTorch .pt model (post-processed, e.g. LoRA-applied)
25-
model, tokenizer = load_quantized_model_pt("./saved_model_lora")
25+
# Requires explicit opt-in: the .pt loader uses torch.load(weights_only=False),
26+
# which can execute code from a malicious file (CWE-502). Only enable this for
27+
# model.pt files from a fully trusted source.
28+
model, tokenizer = load_quantized_model_pt(
29+
"./saved_model_lora", allow_unsafe_deserialization=True
30+
)
2631
```
32+
33+
!!! warning "Unsafe deserialization (.pt loader)"
34+
`load_quantized_model_pt()` loads `model.pt` with
35+
`torch.load(..., weights_only=False)`. Because PyTorch `.pt` checkpoints use
36+
Python `pickle`, a maliciously crafted `model.pt` can execute arbitrary code
37+
during loading (CWE-502). The method refuses to load unless you pass
38+
`allow_unsafe_deserialization=True`. Only opt in for models you produced
39+
yourself or obtained from a fully trusted source. For untrusted or
40+
third-party models, prefer the safetensors-based `load_quantized_model()`,
41+
which does not execute code.

docs/user-guide/examples.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,11 +579,20 @@ LoRA-applied models use a dedicated save/load API:
579579
# Save after LoRA SFT
580580
runner.save_quantized_model_pt("./my_model_lora")
581581

582-
# Load
582+
# Load (opt-in required: the .pt loader uses torch.load(weights_only=False),
583+
# which can execute code from a malicious file. Trusted sources only.)
583584
from onecomp import load_quantized_model_pt
584-
model, tokenizer = load_quantized_model_pt("./my_model_lora")
585+
model, tokenizer = load_quantized_model_pt(
586+
"./my_model_lora", allow_unsafe_deserialization=True
587+
)
585588
```
586589

590+
!!! warning "Unsafe deserialization (.pt loader)"
591+
`load_quantized_model_pt()` loads `model.pt` via `torch.load(weights_only=False)`
592+
(Python `pickle`), so a malicious file can execute arbitrary code (CWE-502).
593+
It refuses to load unless you pass `allow_unsafe_deserialization=True`; only
594+
opt in for fully trusted models.
595+
587596
!!! note
588597
For standard quantized models (without LoRA), use `save_quantized_model()` / `load_quantized_model()` instead.
589598

docs/user-guide/post-process.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,21 @@ runner.save_quantized_model_pt("./my_model_lora")
335335
```python
336336
from onecomp import load_quantized_model_pt
337337

338-
model, tokenizer = load_quantized_model_pt("./my_model_lora")
338+
# The .pt loader uses torch.load(weights_only=False) and requires an explicit
339+
# opt-in. Only enable it for models from a fully trusted source (see warning).
340+
model, tokenizer = load_quantized_model_pt(
341+
"./my_model_lora", allow_unsafe_deserialization=True
342+
)
339343
```
340344

345+
!!! warning "Unsafe deserialization (.pt loader)"
346+
`load_quantized_model_pt()` deserializes `model.pt` with
347+
`torch.load(..., weights_only=False)`, which uses Python `pickle` and can
348+
execute arbitrary code from a malicious file (CWE-502). It refuses to load
349+
unless you pass `allow_unsafe_deserialization=True`. Only opt in for models
350+
you produced yourself or trust completely; otherwise use the safetensors
351+
`load_quantized_model()`, which does not execute code.
352+
341353
!!! info "save_quantized_model vs save_quantized_model_pt"
342354
| Method | Format | Use Case |
343355
|--------|--------|----------|

example/post_process/example_lora_sft.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,11 @@ def generate_text(model, tokenizer, prompt, device, max_new_tokens=64):
133133
print(f"Step 4: Loading model from {SAVE_DIR}")
134134
print("=" * 70)
135135

136-
loaded_model, loaded_tokenizer = load_quantized_model_pt(SAVE_DIR)
136+
# SAVE_DIR was produced by this script (trusted), so we opt in to the
137+
# pickle-based .pt loader. Never enable this for untrusted model.pt files.
138+
loaded_model, loaded_tokenizer = load_quantized_model_pt(
139+
SAVE_DIR, allow_unsafe_deserialization=True
140+
)
137141
print(f"Loaded model type : {type(loaded_model).__name__}")
138142
print(f"Loaded model device: {next(loaded_model.parameters()).device}")
139143

onecomp/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
77
"""
88

9-
__version__ = "1.2.0"
9+
__version__ = "1.2.1"

onecomp/post_process/post_process_lora_sft.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ class PostProcessLoraSFT(PostQuantizationProcess):
298298
>>> import torch
299299
>>> from onecomp import ModelConfig, PostProcessLoraSFT
300300
>>> model_config = ModelConfig(model_id="meta-llama/Llama-2-7b-hf")
301+
>>> # weights_only=False uses pickle and can execute code from a
302+
>>> # malicious file (CWE-502); only load trusted checkpoints.
301303
>>> quantized_model = torch.load(
302304
... "quantized_model.pt",
303305
... map_location="cpu",

onecomp/quantized_model_loader.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ def load_quantized_model_pt(
187187
*,
188188
device_map: str = "auto",
189189
local_files_only: bool = True,
190+
allow_unsafe_deserialization: bool = False,
190191
) -> Tuple[Any, Any]:
191192
"""Load a quantized model and tokenizer saved as a PyTorch .pt file.
192193
@@ -198,18 +199,39 @@ def load_quantized_model_pt(
198199
- ``model.pt`` (serialized with ``torch.save``)
199200
- Tokenizer files
200201
202+
.. warning::
203+
This method deserializes ``model.pt`` with
204+
``torch.load(..., weights_only=False)``. Because PyTorch ``.pt``
205+
checkpoints use Python's ``pickle``, a maliciously crafted
206+
``model.pt`` can execute arbitrary code during deserialization
207+
(CWE-502). ``weights_only=False`` is required here because the
208+
``.pt`` format preserves full custom module objects (e.g.
209+
``LoRAGPTQLinear``) that cannot be reconstructed from tensors
210+
alone. Only load ``model.pt`` files that you produced yourself
211+
or obtained from a fully trusted source. For untrusted or
212+
third-party models, prefer the safetensors-based
213+
:meth:`load_quantized_model`, which does not execute code.
214+
201215
Args:
202216
save_directory: Path to the saved model directory.
203217
device_map: Device placement (default: ``"auto"``).
204218
Set to ``""`` or ``None`` to skip device placement.
205219
local_files_only: Passed to ``AutoTokenizer.from_pretrained``.
220+
allow_unsafe_deserialization: Must be explicitly set to ``True``
221+
to acknowledge the unsafe-deserialization risk described
222+
above and permit loading. Defaults to ``False``, in which
223+
case this method raises before any code can be executed.
206224
207225
Returns:
208226
(model, tokenizer)
209227
228+
Raises:
229+
ValueError: If ``allow_unsafe_deserialization`` is not ``True``.
230+
210231
Example:
211232
>>> model, tokenizer = QuantizedModelLoader.load_quantized_model_pt(
212-
... "./quantized_model_lora"
233+
... "./quantized_model_lora",
234+
... allow_unsafe_deserialization=True, # trusted source only
213235
... )
214236
"""
215237
save_directory = os.path.abspath(save_directory)
@@ -224,6 +246,24 @@ def load_quantized_model_pt(
224246
"(safetensors format); use load_quantized_model() instead."
225247
)
226248

249+
if not allow_unsafe_deserialization:
250+
raise ValueError(
251+
f"Refusing to load '{model_path}': loading a .pt model uses "
252+
"torch.load(weights_only=False), which deserializes arbitrary "
253+
"Python objects via pickle and can execute code embedded in a "
254+
"malicious file (CWE-502). Only load model.pt files you produced "
255+
"yourself or obtained from a fully trusted source, then pass "
256+
"allow_unsafe_deserialization=True to acknowledge this risk. "
257+
"For untrusted or third-party models, use the safetensors-based "
258+
"load_quantized_model() instead."
259+
)
260+
261+
logger.warning(
262+
"Loading '%s' with torch.load(weights_only=False); arbitrary code in "
263+
"a malicious checkpoint can execute during deserialization. Ensure "
264+
"this model.pt comes from a trusted source.",
265+
model_path,
266+
)
227267
model = torch.load(model_path, map_location="cpu", weights_only=False)
228268

229269
if device_map:

onecomp/quantizer/_quantizer.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -641,22 +641,40 @@ def save_results(self, filepath):
641641
torch.save(self.results, filepath)
642642
self.logger.info("Saved quantization results to %s", filepath)
643643

644-
def load_results(self, filepath, weights_only=False):
644+
def load_results(self, filepath, *, weights_only=False, allow_unsafe_deserialization=False):
645645
"""Load the quantization results from a file into self.results.
646646
647647
Loads saved quantization results and stores them in self.results.
648648
649+
.. warning::
650+
With ``weights_only=False`` this method deserializes arbitrary
651+
Python objects via ``torch.load`` / ``pickle``, so a maliciously
652+
crafted file can execute code during deserialization (CWE-502).
653+
Only load result files you produced yourself or obtained from a
654+
fully trusted source, and set ``allow_unsafe_deserialization=True``
655+
to acknowledge the risk.
656+
649657
Args:
650658
filepath (str): The path to load the results from.
651659
weights_only (bool): If True, only load tensor weights (safer but limited).
652660
Default is False to support loading QuantizationResult objects.
661+
allow_unsafe_deserialization (bool): Required to be True when
662+
``weights_only`` is False, to acknowledge the
663+
unsafe-deserialization risk. Ignored when ``weights_only`` is
664+
True (that path is safe). Defaults to False.
653665
654666
Returns:
655667
dict: A dict mapping layer name -> QuantizationResult (same reference as self.results).
656668
669+
Raises:
670+
ValueError: If ``weights_only`` is False and
671+
``allow_unsafe_deserialization`` is not True.
672+
657673
Example:
658674
>>> quantizer = JointQ()
659-
>>> quantizer.load_results("quantization_results.pt")
675+
>>> quantizer.load_results(
676+
... "quantization_results.pt", allow_unsafe_deserialization=True
677+
... )
660678
>>> for layer_name, result in quantizer.results.items():
661679
... print(f"{layer_name}: {result.dequantized_weight.shape}")
662680
@@ -667,6 +685,22 @@ def load_results(self, filepath, weights_only=False):
667685
- Loading files saved with older versions may fail if class
668686
definitions have changed.
669687
"""
688+
if not weights_only and not allow_unsafe_deserialization:
689+
raise ValueError(
690+
f"Refusing to load '{filepath}': loading with weights_only=False "
691+
"deserializes arbitrary Python objects via pickle and can execute "
692+
"code embedded in a malicious file (CWE-502). Only load result "
693+
"files from a fully trusted source, then pass "
694+
"allow_unsafe_deserialization=True to acknowledge this risk, or "
695+
"use weights_only=True for the safe (tensors-only) path."
696+
)
697+
if not weights_only:
698+
self.logger.warning(
699+
"Loading '%s' with weights_only=False; arbitrary code in a "
700+
"malicious file can execute during deserialization. Ensure this "
701+
"file comes from a trusted source.",
702+
filepath,
703+
)
670704
self.results = torch.load(filepath, weights_only=weights_only)
671705
self.logger.info("Loaded quantization results from %s", filepath)
672706
return self.results
@@ -951,6 +985,9 @@ class ResultLoader(Quantizer):
951985
# Optional: load precomputed results at initialization time
952986
results_file: str = None
953987
weights_only: bool = False
988+
# Required to be True when weights_only is False, to acknowledge the
989+
# unsafe-deserialization risk of torch.load(weights_only=False) (CWE-502).
990+
allow_unsafe_deserialization: bool = False
954991

955992
# Ensure no layers are selected for quantization by default
956993
target_layer_types: tuple = field(default_factory=tuple)
@@ -962,7 +999,11 @@ class ResultLoader(Quantizer):
962999
def __post_init__(self):
9631000
super().__post_init__()
9641001
if self.results_file is not None:
965-
self.load_results(self.results_file, weights_only=self.weights_only)
1002+
self.load_results(
1003+
self.results_file,
1004+
weights_only=self.weights_only,
1005+
allow_unsafe_deserialization=self.allow_unsafe_deserialization,
1006+
)
9661007

9671008
def setup(self, model): # pylint: disable=unused-argument
9681009
"""Select no layers (no-op).

0 commit comments

Comments
 (0)