Skip to content

Security: persistence.load unpickles artifacts before envelope validation #697

Description

@3em0

Summary

pyod.utils.persistence.load() performs joblib.load(path) before checking whether the loaded object is a PyOD persistence envelope. Because joblib/pickle deserialization can execute Python reducers during loading, a crafted artifact can execute code before _is_envelope(), schema validation, dependency-version checks, or strict=True rejection logic run.

This is not a default network-exposed issue in PyOD itself. The impact applies when an application loads model artifacts that are attacker-controlled or not fully trusted, such as user-uploaded models, third-party artifacts, shared model directories, CI artifacts, or external model registries.

Affected component

  • pyod/utils/persistence.py
  • Function: load(path, strict=False, return_metadata=False)
  • Observed version in this checkout: 3.5.2

Relevant code path

The current order is:

  1. load() calls joblib.load(path).
  2. The returned object is passed to _handle_loaded_object().
  3. _handle_loaded_object() then checks _is_envelope(obj).
  4. Schema/version/strict-mode checks occur only after the object has already been unpickled.

In other words, the envelope shape is not a pre-deserialization security boundary.

Reproduction overview

A local proof of concept used a top-level dict with the expected envelope keys:

  • _pyod_persistence_version
  • model

The model value was an object whose pickle reducer wrote a harmless marker file during deserialization. Loading the artifact with load(path, strict=True) produced this result:

pre_dump_is_envelope: True
marker_after_dump: False
load_result: 0
marker_after_load: True
marker_content: executed-before-validation

This confirms that reducer execution happens during joblib.load() and before strict-mode or envelope validation can reject anything.

Expected behavior

If the envelope is intended to provide a validation boundary, metadata should be inspectable before deserializing the model payload, or the API should make the trusted-only boundary explicit for all callers.

Actual behavior

load() unpickles the entire artifact first. A malicious artifact can execute code with the privileges of the Python process before _is_envelope() and related validation logic run.

Impact

If an attacker can cause a victim application to call pyod.utils.persistence.load() on an attacker-controlled artifact, this can lead to arbitrary code execution under the loading process account. Consequences may include credential theft, data exfiltration, model/result tampering, persistence, lateral movement, or denial of service, depending on the deployment environment.

The severity is contextual: it is high for ML platforms, automated evaluation systems, CI/CD pipelines, or services that import external model artifacts; it is lower when users only load artifacts produced by their own trusted training pipeline.

Notes

The documentation already warns that pickle/joblib can execute arbitrary Python code and that the persistence wrapper does not sandbox unpickling. This report is specifically about the validation order in load(): strict=True and envelope validation do not mitigate malicious pickle execution because they run after deserialization.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions