Vulnerability research

OpenBao: how restoring a backup can launch a program outside the plugin directory

OpenBao checked plugin paths at registration, but a snapshot could replace the stored plugin records directly. Loading those records could then start a process outside the configured directory. CVE-2026-104090 requires privileged restore access; a separate chain can obtain that access under specific deployment conditions.

Warm-paper illustration: a restored plugin catalog enters a server and points to a program outside the configured directory.
In this article

Someone responsible for restoring backups can usually return a service to an earlier state. This OpenBao vulnerability gave that authority another consequence: restoring a prepared snapshot could make the server launch a program outside its plugin directory. For a service entrusted with credentials and certificate issuance, that extends the operation well beyond restoring data.

OpenBao's advisory identifies the issue as CVE-2026-104090 and lists versions before 2.6.3 as affected. Releases 2.6.3 and 2.7.0 first fixed it on September 23. By our October 2 check, the latest release was 2.7.1; deployments staying on the 2.6 branch also had the 2.6.4 security update available. Older deployments using Raft storage with external plugins enabled deserve early attention.

This vulnerability on its own requires privileged snapshot-restore access. On September 28, ControlPlane described a combined path in which three other flaws could bring someone initially without a token to the restore endpoint under a particular identity configuration. Understanding the final step first makes the larger problem easier to follow: if OpenBao already checked the plugin directory, why did that check stop protecting it after a restore?

1 The backup contains a record of what to run

OpenBao can delegate tasks such as authentication and database integration to external plugins. An administrator configures a plugin_directory on the server and registers a filename, arguments, environment variables and SHA-256 digest. Once registration succeeds, these values live in the plugin catalog. When OpenBao needs the plugin, it retrieves the record, creates a child process and communicates with it. The catalog is stored data; the plugin directory is a folder on disk.

Ordinary registration does enforce a boundary. In 2.6.2, Set() and setInternal() reject commands containing parent-directory references, join the configured directory to the filename, resolve symbolic links and check the real file's parent directory. For an ordinary external plugin, that parent must equal plugin_directory. An attempt to register a command pointing elsewhere encounters these checks.

Snapshot restoration takes a different route. Raft is one of OpenBao's clustered storage backends, and its snapshot holds the storage state, including the plugin catalog. handleStorageRaftSnapshotWrite() accepts the snapshot. With snapshot-force, it skips the check that validates the snapshot using the current seal mechanism, allowing storage from a different set of keys. The endpoint still requires authorization: force relaxes snapshot compatibility checking. The restore flow clears in-memory state and replaces storage. With different keys, the service must still be unsealed using the snapshot's key material, with seal migration where required, before loading continues. The restore callback returns an error when it cannot obtain usable keys.

A plugin record can therefore enter the server with the replacement storage without passing through registration. The attacker controls the prepared snapshot and its unseal material; when the server loads that state, it sees the supplied records. Where those records were originally registered, and what was allowed there, cannot establish what this server should run.

The old PluginCatalog.get() omitted that decision. It retrieved JSON from encrypted storage, decoded a PluginRunner, checked the plugin type and joined the command path using filepath.Join. Joining cleans up path components; it supplies no guarantee that the result stays inside the configured directory. The restriction enforced during registration was consequently absent after restoration.

2 A failed handshake can follow a running process

Two more checks seem promising: the file's hash and the plugin communication protocol. Following their order shows exactly what each can establish.

run_config.go passes the stored command and arguments to exec.Command, adds the stored environment variables and gives the recorded SHA-256 to go-plugin. The plugin client subsequently calls Client(), entering the startup flow. A path appearing in JSON does not immediately execute a program; these intervening steps still apply.

The go-plugin v1.8.0 dependency used by OpenBao 2.6.2 first checks the target file against the supplied digest in Client.Start(). That expected digest comes from the restored catalog too. An attacker can supply the path and its matching digest together, so the target executable must already exist on the server and its SHA-256 must be known or guessed correctly. The checksum check runs as designed, but cannot establish whether the program has permission to serve as a plugin.

After verification, go-plugin starts the child process at line 735. Only then does it wait for plugin handshake information on standard output. An ordinary system program may never speak that protocol, and loading may ultimately fail. Its entry-point code has already had an opportunity to run. A plugin connection error in the logs can occur after command execution.

This is why a read-only container image still needs the fix. The target can be an executable already in the image, with no new file written to the plugin directory. Consequences depend on the OpenBao process identity, container restrictions, file permissions and supplied arguments. The public material supports execution in the service environment; it does not establish a host escape.

3 The fix checks the real path when reading the record

The repair commit is small. The registration-time path check becomes checkCommandDirectory(), called by both registration and retrieval. In 2.6.3, get() checks the path against this server's configuration before returning the record, returning an error on failure. Records introduced through a snapshot must pass the same check.

A snapshot replaces plugin records. Before the fix, retrieval joins the path and proceeds toward startup. After the fix, it resolves the real path and rejects a directory mismatch.
Figure 1: the added check runs when the plugin record is read. Both versions retain file-hash verification, omitted here; startup still depends on conditions including an existing file and a matching hash.

The check uses the location after resolving symbolic links. The new helper calls EvalSymlinks, then obtains the absolute parent directory. An ordinary plugin's parent must exactly equal the configured directory: arbitrary deeper subdirectories are not automatically allowed. OCI-distributed plugins follow a separate cache rule. Their parent must exactly match the cache location built from plugin type, name and the first eight hexadecimal characters of the hash.

These excerpts show the changed operation in the old and fixed get() implementations. The fixed version also returns immediately when err != nil.

// 2.6.2
entry.Command = filepath.Join(c.directory, entry.Command)

// 2.6.3
entry.Command, err = c.checkCommandDirectory(entry.Name, pluginType, entry.Command, entry.Sha256, entry.Oci)

A restore rehearsal therefore needs to reach actual plugin reloading. Successful snapshot import and a healthy endpoint can miss a record that is read later. In an isolated copy, check that a legitimate plugin starts, a hash mismatch is rejected, and a path or symbolic link resolving outside the directory is rejected. Observe actual loading on every node. This article traces the source and patch without executing a complete product exploit; these regression checks are recommendations derived from the repair location.

4 How a restricted identity reaches the restore endpoint

Restore access is powerful already, so administrators may ask why this flaw warrants particular concern. ControlPlane's scenario connects the source of that authority. The deployment authenticates workloads with certificates and places applications in a restricted namespace. A provisioner can manage some certificate roles but cannot modify the administrator role. The environment also has an administrator allowed to change role policies, and a snapshot-restore policy in the root namespace.

The first error concerns certificate issuance. ACME normally challenges an applicant to establish control of a domain before issuing a certificate. OpenBao's old implementation checked DNS and IP identifiers but could include additional, unvalidated names such as URIs from the certificate request. SPIFFE uses URIs to identify workloads. When certificate authentication trusts those names, the extra URI acquires an authorization meaning. The patch inspects the SAN extension, accepts the DNS/IP types that the order can validate and rejects other types.

Starting the chain there additionally requires ACME certificates with the ClientAuth usage enabled, which the default configuration does not allow. The applicant must complete a domain challenge, know the provisioner's identity and satisfy the deployment's ACME access requirements. With those conditions met, the certificate first obtains a token for a restricted role.

The next error gives a role name two interpretations. An ACL policy can broadly allow role management while explicitly denying admin; certificate-role handling then lowercases the name. A spelling that looks different to the rule can consequently refer to the same role in the backend. The restricted role can change the administrator certificate role's required URI to one satisfied by its certificate, then authenticate again as that administrator role. The fix canonicalizes the request path before authorization. Operators still need to check existing policy paths and use their canonical forms; the patch does not rewrite every policy string.

Even with administrator privileges inside the namespace, the root namespace's restore policy remains a separate requirement. The old policy cache constructed its key with path.Join(ns.UUID, name), whose path cleaning also interpreted parent components in the name. The repair uses a struct with separate namespace and name fields, avoiding path operations on the pair. The official advisory specifies important limits: the target policy must be in the memory cache both when the token is created and when it is used. Referencing another namespace's root policy also does not directly confer global root authority.

The combined scenario uses the existing snapshot-restore policy. Using the administrator role, the attacker adds a reference to that cross-namespace policy in token_policies and authenticates again to obtain its restore authority. Only then does the restore-and-plugin sequence examined here begin. Those deployment conditions determine whether the chain connects. The snapshot vulnerability alone still requires high privileges, and non-Raft backends are outside this snapshot-restore path.

5 Exercise the restored service after upgrading

The current upgrade choice is 2.7.1, or 2.6.4 for deployments remaining on 2.6. Both include the original repair and another set of security fixes released on October 1; 2.6.3 and 2.7.0 identify the historical repair boundary. Read the official upgrade guidance and retain a trusted data backup before changing versions. If an upgrade fails, keep affected access restricted and recover with a fixed release compatible with the data. OpenBao does not promise backward-compatible storage: swapping back the binary alone may fail to recover the service, while returning to a vulnerable release reopens this execution path.

Where upgrading must wait and external plugins are unnecessary, removing plugin_directory and restarting and validating the deployment can contain this path. The source checks only built-in plugins when no directory is configured. Legitimate external plugins will stop working, potentially interrupting authentication or credential services. Restricting snapshot-restore permissions reduces who can reach the endpoint; requiring ACME EAB tightens the earlier certificate-issuance step. These controls act at different points, and existing high-privilege tokens still require attention according to their authority.

The restore test environment also needs external network isolation. OpenBao's upgrade guide specifically warns that dynamic credentials in a test copy can expire and cause the copy to revoke real cloud or database credentials. After restoring a trusted snapshot in isolation, supplement the plugin checks above with a normal login and credential read/write operation. A plugin rejected by the new path check needs its actual location and symbolic links examined, its layout corrected to an allowed location and its business operation tested again.

If an unauthorized restore occurred, the investigation also covers restored plugin records, child processes and secrets accessible to that process. Preserve restore requests, unseal and restart records, then correlate them with process telemetry. A handshake error alone cannot exclude earlier execution. The CISA KEV snapshot retrieved on October 2 contained no entry for this identifier, and the public advisory named no confirmed victims.

The most useful lesson in this patch, in my view, concerns the authority carried by recovery. A snapshot can bring back a decision about which program to execute. Checking it only when first registered lets an earlier machine approve execution on the current one. OpenBao now makes that decision at loading time, against the current configuration. Reviews of backup, migration and import features should follow restored records just as far: all the way to the operation that consumes them.

6Evidence and sources

6.1Sources and material

  1. OpenBao: CVE-2026-104090 snapshot-restore execution advisoryhttps://github.com/openbao/openbao/security/advisories/GHSA-j6wc-jpvg-xfxq
  2. ControlPlane: the four-flaw scenario and disclosure historyhttps://control-plane.io/posts/unauthed-to-rce-in-vault-and-openbao/
  3. OpenBao: pinned plugin-path validation repairhttps://github.com/openbao/openbao/commit/6477e66e7d568cb2b8dccbd86700e670a4f3b033
  4. go-plugin v1.8.0: checksum, process startup and handshakehttps://github.com/hashicorp/go-plugin/blob/155dcddc94873a285e14b7fa24b2f6ab6139668e/client.go
  5. OpenBao 2.7.1 release noteshttps://github.com/openbao/openbao/releases/tag/v2.7.1
  6. OpenBao 2.6.4 release noteshttps://github.com/openbao/openbao/releases/tag/v2.6.4
  7. OpenBao upgrade and data-rollback guidancehttps://openbao.org/docs/guides/upgrade/
  8. CISA Known Exploited Vulnerabilities Cataloghttps://www.cisa.gov/known-exploited-vulnerabilities-catalog