GHSA-mm2q-qcmx-gw4w json
RustFS: ListServiceAccount authorizes against wrong admin action, enabling cross-user enumeration and root service account takeover
published 6mo ago modified 6mo ago
- cvss
- —
- not scored
- cisa kev
- Not listed
- not in CISA's catalog
- epss
- —
- no score, EPSS covers CVEs only
- public exploits
- 0
- none known
Summary
## Summary
`ListServiceAccount` (`GET /rustfs/admin/v3/list-service-accounts?user=<other>`) authorizes cross-user requests against `UpdateServiceAccountAdminAction` instead of `ListServiceAccountsAdminAction` at `rustfs/src/admin/handlers/service_account.rs:936`. The handler accepts the **wrong** admin action and rejects the **correct** one:
- A user granted only `admin:UpdateServiceAccount` enumerates every service account in the cluster, including the root user's (HTTP 200, full metadata). - A user granted only `admin:ListServiceAccounts` — the permission name every IAM document treats as "list service accounts" — receives HTTP 403 AccessDenied on the same request.
Because service account access keys act as the identifier a `UpdateServiceAccount` holder needs to rotate a secret, and the `UpdateServiceAccount` handler at `rustfs/src/admin/handlers/service_account.rs:489` performs no ownership check on the target access key, leaking those access keys lets a delegated "service account updater" role overwrite `root-sa-1`'s secret, authenticate as the root user's service account, and create a persistent backdoor admin with `admin:*` + `s3:*`. Proven live end-to-end against `rustfs/rustfs:latest` (1.0.0-alpha.91, revision `d4ea14c2`) — the same revision is byte-identical on current `origin/main`.
## Vulnerability Details
- **Package:** `rustfs` (binary crate `rustfs`) - **Affected versions:** From `0a2411f` (the initial `service_account.rs` check-in on 2026-03-15) through current HEAD `90e584a`. The vulnerable line has never been touched. - **Fixed versions:** None - **Vulnerable file:** `rustfs/src/admin/handlers/service_account.rs` - **Vulnerable route:** `GET /rustfs/admin/v3/list-service-accounts?user=<other_user>` (`ListServiceAccount::call`) - **CWE:** CWE-863 (Incorrect Authorization), chained with CWE-620 (Unverified Password Change) to reach CWE-269 (Improper Privilege Management) - **CVSS (demonstrated chain to full admin):** `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` = **10.0 Critical**. If scored as Scope:Unchanged the vector is `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` = **8.8 High**. The list bug alone (no chain) is `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` = **6.5 Medium** and is what a maintainer would rate it if the Update ownership gap is out of scope for this report.
## Two Distinct Vulnerabilities
This report documents two bugs that chain to full RustFS administrative takeover. Each is independently fixable and independently a security issue:
**Vulnerability A — Wrong action constant in ListServiceAccount (CWE-863)** `ListServiceAccount::call` at line 936 checks `UpdateServiceAccountAdminAction` instead of `ListServiceAccountsAdminAction`. This is a copy-paste typo: the three sibling list handlers (lines 658, 799, 1095) all use the correct constant. The result is a permission inversion — the correct permission (`admin:ListServiceAccounts`) is rejected, and the wrong one (`admin:UpdateServiceAccount`) is accepted. Independently, this is a Medium-severity cross-user information disclosure.
**Vulnerability B — Missing ownership check in UpdateServiceAccount (CWE-620)** `UpdateServiceAccount::call` at lines 489-614 authorizes on possession of `admin:UpdateServiceAccount` but never verifies the target `?accessKey=` belongs to the caller or the caller's parent. Lines 522-525 contain a commented-out `get_service_account` call that would have loaded the target for such a check. This means any holder of `admin:UpdateServiceAccount` can overwrite any service account's secret in the cluster, regardless of ownership.
**Chain (A + B) — Full RustFS administrative takeover** Vulnerability A leaks every service account's access key (including the root administrator's). Vulnerability B allows overwriting any SA's secret given its access key. Together: a user with a single permission (`admin:UpdateServiceAccount`) enumerates the root user's SA access key via the wrong-action list bug, overwrites its secret via the ownership-free update handler, authenticates as the root user's service account, and creates a persistent backdoor admin with full RustFS administrative control.
**Authorization mismatch at a glance:**
Exact policies attached to each test identity (retrieved from running server via `GET /admin/v3/info-canned-policy`):
legit-list-pol -> {"Action": ["admin:ListServiceAccounts"], "Resource": ["arn:aws:s3:::*"]} list-sa-probe-pol -> {"Action": ["admin:UpdateServiceAccount"], "Resource": ["arn:aws:s3:::*"]} list-sa-restricted -> {"Action": ["admin:UpdateServiceAccount"], "Resource": ["arn:aws:s3:::probe-scope/*"]} (zero-priv-user has no attached policy)
| Identity | Attached policy | `GET /list-service-accounts?user=rustfsadmin` | Expected | |---|---|---|---| | `probe-user` | `list-sa-probe-pol` (`admin:UpdateServiceAccount`) | **200** (full SA metadata) | 403 | | `legit-list-user` | `legit-list-pol` (`admin:ListServiceAccounts`) | **403** AccessDenied | 200 | | `restricted-update-user` | `list-sa-restricted` (`admin:UpdateServiceAccount` on `probe-scope/*`) | **200** | 403 | | `zero-priv-user` | (none) | **403** | 403 | | (unauthenticated) | n/a | **403** Signature required | 403 |
### Why the correct permission gets 403
The handler at line 936 calls `is_allowed` with the action `AdminAction::UpdateServiceAccountAdminAction`. The IAM engine performs an exact string match between the action in the `is_allowed` call (`admin:UpdateServiceAccount`) and the action in the caller's attached policy:
- `legit-list-user` has policy action `admin:ListServiceAccounts`. This does **not** match `admin:UpdateServiceAccount`. `is_allowed` returns false. The handler returns 403. The user who holds the *correct* permission for listing service accounts is denied. - `probe-user` has policy action `admin:UpdateServiceAccount`. This **matches** `admin:UpdateServiceAccount`. `is_allowed` returns true. The handler returns 200. The user who holds a *different, unrelated* permission is granted access to a list endpoint. - `restricted-update-user` has the same action string but resource-scoped to `arn:aws:s3:::probe-scope/*`. Admin-action statements skip resource matching (`crates/policy/src/policy/statement.rs:132`: `&& !self.is_admin() && !self.is_sts()`), so the resource restriction is ignored and `is_allowed` still returns true.
There is no wildcard, superset, or inheritance relationship between these two action strings. They are separate enum variants (`crates/policy/src/policy/action.rs:459-462`) with distinct `strum(serialize)` values. The IAM engine is working correctly; the handler passes the wrong action to it.
Raw request/response for `legit-list-user` (the counterintuitive 403):
GET /rustfs/admin/v3/list-service-accounts?user=rustfsadmin HTTP/1.1 Authorization: AWS4-HMAC-SHA256 Credential=legit-list-user/...
HTTP/1.1 403 <?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>access denied</Message></Error>
**Why this is not "working as intended":** - `admin:UpdateServiceAccount` and `admin:ListServiceAccounts` are distinct enum variants with distinct string representations. The codebase treats them as orthogonal permissions. - Three sibling list handlers in the same file (lines 658, 799, 1095) all check `ListServiceAccountsAdminAction`. Only line 936 deviates. - CVE-2026-22042 / GHSA-vcwh-pff9-64cc is the maintainers' own precedent: `ImportIam` checking `ExportIAMAction` was rated Medium and fixed. The same class of bug applies here. - A zero-privilege user (no admin policies at all) cannot exploit either vulnerability — both handlers correctly enforce their respective `is_allowed` checks. The bug is that the list handler enforces the *wrong* action constant, not that it skips enforcement entirely.
## Root Cause — Vulnerability A (Wrong Action Constant)
`ListServiceAccount::call` is registered for `GET /rustfs/admin/v3/list-service-accounts` at `rustfs/src/admin/handlers/service_account.rs:137-141`. The cross-user branch (entered when `?user=<x>` does not match the caller) checks the wrong admin action:
// rustfs/src/admin/handlers/service_account.rs:931-953 (HEAD 90e584a, identical at d4ea14c2) let target_account = if query.user.as_ref().is_some_and(|v| v != &cred.access_key) { if !iam_store .is_allowed(&Args { account: &cred.access_key, groups: &cred.groups, action: Action::AdminAction(AdminAction::UpdateServiceAccountAdminAction), // WRONG bucket: "", conditions: &get_condition_values(...), is_owner: owner, object: "", claims: cred.claims.as_ref().unwrap_or(&HashMap::new()), deny_only: false, }) .await { return Err(s3_error!(AccessDenied, "access denied")); } query.user.unwrap_or_default() } else if cred.parent_user.is_empty() { cred.access_key } else { cred.parent_user };
The action enum definitions are cleanly distinct at `crates/policy/src/policy/action.rs:459-464`:
#[strum(serialize = "admin:CreateServiceAccount")] CreateServiceAccountAdminAction, #[strum(serialize = "admin:UpdateServiceAccount")] UpdateServiceAccountAdminAction, #[strum(serialize = "admin:RemoveServiceAccount")] RemoveServiceAccountAdminAction, #[strum(serialize = "admin:ListServiceAccounts")] ListServiceAccountsAdminAction,
Every *other* list handler in the same file authorizes on the correct constant:
rustfs/src/admin/handlers/service_account.rs:658 InfoServiceAccount::call -> ListServiceAccountsAdminAction rustfs/src/admin/handlers/service_account.rs:799 InfoAccessKey::call -> ListServiceAccountsAdminAction rustfs/src/admin/handlers/service_account.rs:1095 ListAccessKeysBulk::call -> ListServiceAccountsAdminAction
Only `ListServiceAccount::call` at line 936 deviates. This is a typo/wiring error, not a design choice.
`git blame` shows the line has been wrong since commit `0a2411f` (heihutu, 2026-03-15), the initial check-in of `service_account.rs`.
## Root Cause — Vulnerability B (Missing Ownership Check in Update)
Service account access keys are the identifier the `UpdateServiceAccount` handler accepts via the `?accessKey=` query string. Inspecting `UpdateServiceAccount::call` at `rustfs/src/admin/handlers/service_account.rs:489-614`:
let access_key = query.access_key; // line 509 ... if !iam_store.is_allowed(&Args { account: &cred.access_key, action: Action::AdminAction(AdminAction::UpdateServiceAccountAdminAction), ... }).await { return Err(s3_error!(AccessDenied, "access denied")); } // line 538-559 ... let updated_at = iam_store.update_service_account(&access_key, opts).await // line 579 .map_err(...)?;
The handler authorizes on *possession of* `admin:UpdateServiceAccount` and **never checks** that the `?accessKey=` query parameter resolves to a service account owned by the caller. Notably, lines 522-525 contain a commented-out `get_service_account` call that would have loaded the target SA for an ownership check — it was present in the initial commit and has been commented out since:
// let svc_account = iam_store.get_service_account(&access_key).await.map_err(|e| { // debug!("get service account failed, e: {:?}", e); // s3_error!(InternalError, "get service account failed") // })?;
The inner `IamSys::update_service_account` at `crates/iam/src/sys.rs:495-501` delegates to `IamCache::update_service_account` at `crates/iam/src/manager.rs:663` which loads the credentials by access-key name, verifies it is a service account, and overwrites `secret_key` — again, no ownership check:
// crates/iam/src/manager.rs:663 pub async fn update_ser…
Patch / fix
- crates.io rustfs 1.0.0-alpha.98
Is GHSA-mm2q-qcmx-gw4w in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.
Check with FencerAffected packages
| ecosystem | package | affected | fixed in |
|---|---|---|---|
| crates.io | rustfs | <1.0.0-alpha.98 |
1.0.0-alpha.98 |
Classification
- Weakness
-
- CWE-863 Incorrect Authorization
References
Sources
- GitHub Advisory GHSA-mm2q-qcmx-gw4w
Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.