CVE-2026-115373: a certificate locked to one Incus project could read another project's running commands and their secrets
Incus lets an administrator issue a client certificate that only works inside one project. A client holding one could still read any running operation in any other project by asking for it by its ID: the command being executed, its environment with whatever passwords were passed in it, and the secrets for its websockets. Asking to cancel the same operation was refused. Fixed in 6.0.7, 7.0.2 and 7.5.0.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Everything below ran on my own machine: Incus 7.4.0 and 6.0.6, installed from the project’s own package repository inside disposable containers, with test projects, a test instance and test certificates, all destroyed after the run. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed releases and the CVE identifier were public.
--- attacker GET /1.0/operations/7a8fca8a-ff78-49a1-af51-fd73cf56aee1 -> 200
--- attacker GET .../wait?timeout=1 -> 200
--- attacker DELETE /1.0/operations/7a8fca8a-ff78-49a1-af51-fd73cf56aee1 -> 403
{"error_code":403,"error":"User does not have permission for project \"victim\""}
Those three requests came from one client certificate, within the same second, against one URL. The certificate belongs to a tenant I called attacker. The operation belongs to a project called victim. Incus knew the certificate had no business in that project: it said so in the error body when the certificate tried to cancel the operation. It had already answered the first request with the operation itself.
Incus runs containers and virtual machines and is built to be shared. An administrator splits a server into projects, and when they enrol a client certificate they can mark it restricted and list the projects it may touch. A restricted certificate for attacker sees attacker’s instances, images and networks, and nothing else. That is the line I was testing, and almost everywhere it held. The certificate got a 403 for victim’s instance. It got an empty list when it asked for operations in attacker, in victim and in all projects at once. A client with no certificate got a 403 on the same operation URL. The one request that crossed the line was a plain GET for a single operation by its ID.
What a running operation carries
An operation is Incus’s record of anything that takes time: an instance starting, an image downloading, a command running inside a container. While it runs, it sits in memory on the server with everything the client sent to start it. For incus exec, that is the whole request.
To make that visible, I started a command in victim as the administrator, the way deployment tooling does, with a password in the environment:
incus exec victim-box --project victim --env DB_PASSWORD=s3cr3t-victim-value -- sleep 600
This is what the attacker certificate got back from the GET, after the DELETE had been refused:
{"instances":["/1.0/instances/victim-box?project=victim"]}
{"DB_PASSWORD":"s3cr3t-victim-value","HOME":"/root","LANG":"C.UTF-8","USER":"root","PATH":"..."}
{"0":"c5c793028efb9e0691423dfcd2a36e6dc18031b1408211ed8aa80308a7aef2a1",
"1":"e557d78cd9a22790759e6997f1fdcdb9a5f22c41d811e588a3d96ea9021fffd7",
"2":"edf04a0ae7d90ed45e31b322d9be311dfaef2683e5be6676bc07bab34c0caf34",
"control":"afa5bb2ed82f416c563d6f8881a4f5a01c3d66ad14ca3b5ce3f7c90e50c4d85b"}
The first line names an instance in a project the certificate cannot list. The second is the command’s complete environment, which is where database passwords, API tokens and cloud keys go when a script runs something inside a container. The rest of the body, not shown, has the command line itself, with anything passed as an argument. The last object is the set of secrets for the operation’s websockets: one each for standard input, output and error, and one for the control channel. I did not try to use those; the report lists them as disclosed and stops there.
The more valuable operation is one that has nothing to do with containers. When an administrator runs incus config trust add to enrol a new client, Incus creates an operation that stays open until the client uses the token, and the operation’s metadata holds the token. From the restricted attacker certificate I read one of those, put the token back together, and enrolled a fresh certificate with it. The new certificate was not restricted. It read victim’s instance with a 200 and listed every project on the server.
I kept that chain out of the score. It needs an enrolment to be pending at the moment of the read, and that is the administrator’s timing, not something the attacker can arrange. It shows what the read is worth when the timing is there. The CVE record mentions it: its description lists “trust enrolment tokens” among what an attacker can retrieve.
The ID has to come from somewhere
The attacker needs the operation’s ID, and Incus does not give it out. IDs are random version 4 UUIDs (uuid.New() in internal/server/operations/operations.go:138), 122 random bits, and every listing the restricted certificate could reach came back empty. Guessing is not an option.
The ID has to come from outside Incus. Where it comes from depends on how the server is run:
- an administrator’s shell history or a CI job log that prints it;
- the
Locationheader of an asynchronous API response, passing through a proxy, a load balancer or a tracing system that records headers; incus monitoroutput pasted into a ticket or a chat;- the time before a project membership was revoked;
- a co-tenant on a shared automation account.
That is the reason for the high attack complexity in the score, and it is the same condition the Incus maintainers accepted in CVE-2026-81501: there the attacker needed an image fingerprint that the API also did not hand out. Once the ID is known, nothing else is needed. No one on the victim’s side has to click anything, and the read works for as long as the operation is running.
Three handlers, one contract
The cause fits in one file. cmd/incusd/operations.go in 7.4.0 registers these three actions, on two endpoints, with the same access handler:
// /1.0/operations/{id}
Delete: APIEndpointAction{Handler: operationDelete, AccessHandler: allowAuthenticated},
Get: APIEndpointAction{Handler: operationGet, AccessHandler: allowAuthenticated},
// /1.0/operations
Get: APIEndpointAction{Handler: operationsGet, AccessHandler: allowAuthenticated},
allowAuthenticated only confirms that the client is trusted. Its comment in cmd/incusd/daemon.go says what is supposed to happen next:
// allowAuthenticated is an AccessHandler which allows only authenticated requests. This should be used in conjunction
// with further access control within the handler (e.g. to filter resources the user is able to view/edit).
Two of the three handlers do that. operationDelete takes the operation’s project and its required permission, builds an authorization object for each resource the operation touches, and asks the authorizer about each one. That check produced the 403 above. operationsGet, the listing, gets a permission checker for can_view_operations and filters every operation through it, which is why the lists came back empty.
operationGet looked the ID up in a map of every operation on the server, keyed only by ID, and rendered what it found:
op, err := operations.OperationGetInternal(id)
if err == nil {
_, body, err = op.Render()
if err != nil {
return response.SmartError(err)
}
return response.SyncResponse(true, body)
}
It did not look at the operation’s project or its permission, and it did not read ?project= from the request. Because the map holds every project’s operations, the ID alone picked the object.
This was never a regression. git log -S traces the checks to one commit, 613f937dc, “lxd: Update authorization for operations”, imported from LXD, which Incus was forked from. It touches only that file. One hunk gives operationDelete its check, four rework operationsGet, and operationGet, defined a little above both in the same file, is not in the diff. Authorization was added to the listing and to cancelling, and reading a single operation was left as it was. Every Incus release from 0.2 onward shipped that way.
Two patches where I sent one
I suggested the obvious fix: give operationGet the can_view_operations check on the operation’s project, the same question the listing already asks. I also flagged operationWaitGet, the /wait endpoint in the transcript at the top, because it returns the same rendered operation when it completes. I had not tested a fix against it and said so.
Stéphane Graber, the Incus project lead, wrote the fix on the day of the report and posted it on the advisory nine days later. It went further than my suggestion. 86c2a125f adds operationCheckViewAccess. It lets a caller see an operation if they have can_view_operations on its project, or if they hold the permission the operation itself requires, so a client scoped to a single instance can still follow its own operations. The same commit applies it to /wait, keeps the old secret-based path there for untrusted remote servers that were handed an operation’s secret, and moves operationDelete onto the shared helper. Its message ends: “This addresses GHSA-mfwv-x733-9446 (CVE pending)”.
The second patch, b56274117, covers something my report did not. Enrolment-token and cluster-join-token operations live in the default project. With the first fix alone, any client allowed to view operations in the default project could still read the tokens in them, and those tokens are the escalation described above. The patch hides those two operation types from the single-operation read, the wait and the listing, unless the caller may create certificates or edit the server. That closes the route to an unrestricted certificate for any client that could not already create certificates itself, restricted or not.
Both commits are in 7.5.0, 7.0.2 and 6.0.7. If you run Incus with restricted certificates or project-scoped access, those are the versions to be on. If you are on an older one, operations started with secrets in their environment or arguments are readable by any trusted client that learns their ID.
Advisory GHSA-mfwv-x733-9446 · CVE CVE-2026-115373 · CVSS 6.3 Medium (v3.1), AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:N/A:N, on the advisory and the record; 7.0 High (v4.0) on the record · CWE CWE-862, CWE-863 · Fix 86c2a125f, b56274117 · Affected < 6.0.7, >= 6.1.0, < 7.0.2, >= 7.1.0, < 7.5.0 · Fixed 6.0.7, 7.0.2, 7.5.0
Reported 12 September · accepted the same day, with a CVE requested from GitHub · fix committed 12 September and posted on the advisory 21 September · 7.5.0 tagged 24 September, with 7.5.1 the same evening · advisory published 25 September, crediting me as reporter · 6.0.7 and 7.0.2 tagged 16 October · CVE reserved and record published by VulnCheck 22 October, crediting me as reporter and Stéphane Graber as finder. The GitHub request was still unanswered and the advisory’s CVE field still empty at the time of writing.
Eurico Nicacio — @h3llh0und