Skip to content
winfunc
Disclosure record · ZenML

Workload API tokens can be exchanged for persistent unscoped user tokens

The generic-token branch discarded workload claims before the parent token expired

ZenMLDisclosedSource record

CVSS 3.1 base score

High
7.4/ 10

Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Record

Project
ZenML
Severity
High7.4
CVE
None listed
Disclosed
Trace
3 steps

Summary

Source

build_runner_environment()

src/zenml/zen_server/pipeline_execution/utils.py:1089

Sink

api_token()

src/zenml/zen_server/routers/auth_endpoints.py:549

Code running inside a ZenML pipeline workload could exchange its run-bound token for a generic user token that remained valid after the workload ended, preserving the issuing principal's control-plane RBAC authority for up to the generic-token lifetime.

Root cause. The api_token() generic branch returned before the confinement logic used for workload-token issuance. It called generate_access_token() with only user_id and expires_in, silently dropping schedule_id, pipeline_run_id, and deployment_id claims from the authenticated parent.

Remediation evidence. ZenML opened PR #5152 five days after the disclosure and merged it on 28 August 2026. The PR is an exact remediation: it prevents a scoped workload credential from being exchanged for an unscoped token and adds targeted tests.

Severity

Attack vectorAV
Network
Attack complexityAC
Low
Privileges requiredPR
Low
User interactionUI
Required
ScopeS
Unchanged
ConfidentialityC
High
IntegrityI
High
AvailabilityA
None

Metric values as published in the disclosure vector. Meters show how far each value raises exposure.

Source-to-sink trace

  1. Source · attacker-controlledsrc/zenml/zen_server/pipeline_execution/utils.py:1089

    build_runner_environment()

  2. Step 01src/zenml/zen_server/pipeline_execution/utils.py:1089

    ZenML injects a signed API token carrying a run, schedule, or deployment binding into the workload environment.

    python
    environment["ZENML_STORE_API_TOKEN"] = generate_access_token(
      user_id=user.id, pipeline_run_id=run.id
    ).access_token
    
  3. Step 02src/zenml/zen_server/routers/auth_endpoints.py:549

    During the live run, malicious workload code asks the generic-token branch for the maximum allowed lifetime.

    python
    GET /api/v1/api_token?token_type=generic&expires_in=604800
    Authorization: Bearer <workload-token>
    
  4. Step 03src/zenml/zen_server/auth.py:1093

    The vulnerable branch issued a new token with only the parent user ID, removing every workload-liveness claim.

    python
    return generate_access_token(
      user_id=token.user_id,
      expires_in=expires_in,
    ).access_token
    
  5. Sinksrc/zenml/zen_server/routers/auth_endpoints.py:549

    api_token()

Impact

Reported impact

The workload escapes its intended authorization lifetime and continues authenticating as the issuing user or service account after termination. Accessible data and mutations depend on that principal's existing RBAC grants; the flaw does not add permissions beyond them.

Attack surface

ZenML servers that inject API tokens into pipeline workloads and expose the authenticated API-token exchange endpoint.

Preconditions

Attacker-controlled code or a compromised dependency must execute in a live credential-bearing workload. No ZenML signing key, user password, or pre-stolen external token is needed.

Attack path

  1. 1

    Read the server-injected workload token during an active pipeline run.

  2. 2

    Call the generic API-token endpoint with that bearer token.

  3. 3

    Retain the returned unscoped child JWT.

  4. 4

    Wait for the run to finish and the original workload token to fail liveness checks.

  5. 5

    Continue calling the control plane with the child token under the issuing principal's RBAC grants.

Proof of concept

Reproduction5 stages
  1. 01

    Environment setup

    Run an isolated affected ZenML server and execute a test pipeline step that receives ZENML_STORE_API_TOKEN.

  2. 02

    Target configuration

    Use normal authentication and default generic-token lifetime limits. The parent token should carry a real pipeline_run_id.

  3. 03

    Exploit delivery

    During the run, request a generic token with the injected credential. End the run, verify the parent is rejected, then call a harmless authorized read endpoint with the child token.

  4. 04

    Expected response

    On the vulnerable build, the child request remains authenticated after the parent workload credential has expired through run-liveness enforcement.

  5. 05

    Outcome

    PR #5152 rejects the scope-stripping exchange, so a workload-bound parent cannot mint an unscoped generic child token.

Remediation

Guidance

Upgrade to a ZenML release containing PR #5152. Reject generic-token issuance from any workload-scoped parent and preserve every security-relevant binding when a delegated exchange is intentionally supported.

Before and after−2+3
if token_type == APITokenType.GENERIC:    return generate_access_token(user_id=token.user_id, expires_in=expires_in)if token_type == APITokenType.GENERIC:    if token.schedule_id or token.pipeline_run_id or token.deployment_id:        raise AuthorizationException("Scoped tokens cannot mint generic tokens")
View original source record

Check the upstream record for the project's current remediation status.

Your codebase

Investigate the paths that matter in your codebase.

Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.