Skip to content

[Security] Multiple CRITICAL authorization vulnerabilities (unauthenticated plan activation, SUDOERS JWT revocation bypass, VPN credential exposure) #2074

Description

@Galaxync

Marzban — Multiple Critical Authorization Vulnerabilities (Unauthenticated Plan Activation, SUDOERS Token Bypass)

This report documents multiple security vulnerabilities in the master branch of Marzban, verified against the current source. The two most severe are a CRITICAL unauthenticated plan-activation endpoint that grants free VPN data/extension without admin authorization, and a CRITICAL SUDOERS JWT revocation bypass that makes superuser tokens effectively un-revocable.


Finding 1 — [CRITICAL] Authentication Bypass — Unauthenticated Plan Activation

Location: app/routers/user.py:272-275 (active_next_plan)

The active_next_plan endpoint performs a state-changing operation — it activates a user's next plan, resets their traffic to zero, changes their data_limit and expire, and re-enables them in xray — but it lacks the admin: Admin = Depends(Admin.get_current) dependency that every other mutation endpoint in the same router requires.

Compare the dependency lists in user.py:

Endpoint Line Has Admin.get_current?
reset_user_data_usage 160-164 ✅ Yes
modify_user 144-145 ✅ Yes
revoke_user_subscription 184-185 ✅ Yes
remove_user 341 ✅ Yes
active_next_plan 272-275 No — only get_validated_user

get_validated_user only loads the user from the path parameter; it does not authenticate the caller. The 403 response is declared in the responses dict but never actually produced because no authorization check runs.

Impact: An unauthenticated attacker can call POST /api/user/{username}/active-next for any user who has a next_plan configured, prematurely consuming that plan and granting the user a fresh data allowance and expiration without admin authorization — bypassing the business rule that plan changes require admin authorization.

Steps:

# attacker learns/guesses any username that has a next_plan configured:
curl -X POST "http://<host>/api/user/<username>/active-next"
# No Authorization header. Server executes crud.reset_user_by_next:
#   used_traffic = 0, applies next_plan data_limit/expire, status=active, pushes to xray.

Fix: Add admin: Admin = Depends(Admin.get_current) to active_next_plan, matching every other mutation endpoint.


Finding 2 — [CRITICAL] SUDOERS JWT Token Revocation Bypass

Location: app/models/admin.py:40-55 (Admin.get_admin)

get_admin has an early-return path for SUDOERS users that skips the password_reset_at validation entirely:

def get_admin(cls, token: str, db: Session):
    payload = get_admin_payload(token)
    ...
    if payload['username'] in SUDOERS and payload['is_sudo'] is True:   # line 45
        return cls(username=payload['username'], is_sudo=True)          # line 46  ← early return
    dbadmin = crud.get_admin(db, payload['username'])                   # line 48
    ...
    if dbadmin.password_reset_at:                                        # line 52
        ...
        if dbadmin.password_reset_at > payload.get("created_at"):       # line 55  ← never reached for SUDOERS
            ...

For regular admins, a password change updates password_reset_at, invalidating any JWT whose created_at predates the reset (lines 52-55). SUDOERS users never reach this check because the function returns at line 45-46 with an object derived solely from the JWT, without querying the database. Additionally, is_sudo for SUDOERS is trusted directly from the JWT rather than re-verified against the DB record.

Combined with create_admin_token omitting exp when JWT_ACCESS_TOKEN_EXPIRE_MINUTES <= 0, a SUDOER token can be permanently valid and un-revocable. The only other revocation lever — rotating the JWT secret via get_secret_key() — is also defeated because it is cached permanently via lru_cache.

Impact: A compromised SUDOER JWT (obtained via logs, network sniffing, social engineering) remains valid even after the SUDOER changes their password. The attacker retains full sudo access (create/delete admins, modify core config) indefinitely.

Steps:

  1. Attacker obtains a SUDOER's valid JWT (e.g. username admin listed in config.SUDOERS).
  2. SUDOER detects the compromise and changes their password → password_reset_at is set.
  3. Attacker continues using the old JWT on any sudo-level endpoint (POST /api/admin, DELETE /api/admin/{username}, PUT /api/core/config).
  4. get_admin hits the SUDOERS early-return at line 45-46, never checks password_reset_at → old token still validates as full sudo.

Fix: Move the password_reset_at check before the SUDOERS early return (or remove the early return and query the DB for SUDOERS too), and re-verify is_sudo against the database record.


Finding 3 — [HIGH] Unauthenticated User Data Exposure (VPN credential theft)

Location: app/routers/user.py:73 (get_user)

get_user returns the full UserResponse — including proxy configuration (VMess/VLESS/Trojan credentials), subscription URLs, data limits, expiration, and notes — without any admin authentication dependency. Compare with modify_user and remove_user which operate on the same {username} path and both require Admin.get_current. get_validated_user only validates that the username exists; it does not verify the caller's identity.

Impact: Any unauthenticated person retrieves all proxy credentials for any known username, effectively stealing VPN service.

Steps:

curl "http://<host>/api/user/<username>"
# Response contains full UserResponse: UUIDs, passwords, encryption settings, subscription link.

Fix: Add Admin.get_current (or an owner/self check) to get_user.


Finding 4 — [HIGH] State Machine Bypass — Disabled Users Silently Reactivated on Data Reset

Location: app/db/crud.pyreset_user_data_usage

The expression (UserStatus.expired or UserStatus.disabled) is a Python boolean OR, not a tuple. Since UserStatus.expired is truthy, the OR evaluates to just UserStatus.expired, so the check becomes dbuser.status not in (UserStatus.expired,) — the disabled exclusion is never applied. When a disabled user's data usage is reset, their status silently changes from disabled to active.

Impact: A disabled user (policy violation, non-payment, security) is silently re-enabled whenever their data usage is reset, restoring proxy access despite the disable. The audit log shows "data usage reset" rather than "user activated", obscuring the reactivation.

Fix: Use a list/tuple: dbuser.status not in [UserStatus.expired, UserStatus.disabled].


Summary table

# Finding Severity Location
1 Unauthenticated plan activation CRITICAL app/routers/user.py:272-275
2 SUDOERS JWT revocation bypass CRITICAL app/models/admin.py:40-55
3 Unauthenticated user data exposure HIGH app/routers/user.py:73
4 Disabled users reactivated on data reset HIGH app/db/crud.py:reset_user_data_usage

All findings were verified against the master branch source at the time of reporting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions