← Back to all reports

Cross-Tenant KMS Decryption Oracle Recovers Any Customer's Production Secrets

Reported May 20, 2026
Severity Critical
Platform Web API
Vulnerability Class Broken Access Control / Cross-Tenant Crypto (CWE-639)
Target Type Digital Adoption / Enterprise SaaS
Impact Any tenant recovers any other tenant's production credentials

The Risk

Any paying customer of the platform could recover any other customer's live production passwords and integration keys. A normal customer account, with no admin privileges, could submit a scrambled secret belonging to a completely unrelated customer and the platform handed back the original readable value. The break is invisible to the vendor's own audit logs, because the platform itself does the unscrambling on the attacker's behalf. One careless backup, log dump, or screenshot from any customer of the platform would become a full credential breach across the entire customer base.

The Vulnerability

The platform's identity service exposed two endpoints intended for internal service-to-service use to every authenticated tenant:

  • POST /identity/kms/encrypt
  • POST /identity/kms/decrypt

Both endpoints proxy directly to AWS KMS under a single global customer master key (CMK) with no EncryptionContext binding. Every other endpoint in the same service correctly returns 403 Forbidden resource to a regular tenant Bearer token (verified across /me, /accounts, /users, /idp, /sso, /tenants). Only the encrypt and decrypt pair is missing that role guard.

Two structural facts made the break trivial. First, ciphertext produced by tenant A always began with the identical 50-character prefix that encodes the CMK identifier, proving the CMK is shared across all tenants. Second, AWS KMS refuses Decrypt calls when the original Encrypt bound an EncryptionContext unless the caller supplies a matching one. The platform's decrypt call succeeded with no context in the body, so the original encrypt never bound one. The cryptographic boundary between tenants did not exist; only the missing application-layer role check stood between an attacker and every other customer's secrets.

The Attack

Two separate browser profiles, one per tenant, were used so that cookies and storage stayed independent and each window held a distinct tenant's Bearer token.

Step 1: tenant A mints a real production secret

Logged in as tenant A in window 1, created a real platform-issued API Key via the admin console. The platform mints a real clientSecret shown only once in the post-create modal. This is a live production credential persisted exactly like any other customer secret.

Step 2: tenant A encrypts the secret via the public endpoint

Pulled tenant A's Bearer token out of the OIDC storage and submitted the freshly issued clientSecret to the encrypt endpoint:

const r = await fetch('/identity/kms/encrypt', {
  method: 'POST',
  headers: {'Authorization': 'Bearer ' + TOKEN_A, 'Content-Type': 'application/json'},
  body: JSON.stringify({plainText: REAL_SECRET})
});
const j = await r.json();
console.log('CIPHER_A =', j.cipherText);

Response: 201 with the ciphertext bytes. Copy ciphertext to clipboard.

Step 3: unrelated tenant B decrypts it

In window 2, logged in as tenant B (a completely separate customer with no relationship to tenant A). Pulled tenant B's Bearer token and posted the captured ciphertext to the decrypt endpoint:

const r = await fetch('/identity/kms/decrypt', {
  method: 'POST',
  headers: {'Authorization': 'Bearer ' + TOKEN_B, 'Content-Type': 'application/json'},
  body: JSON.stringify({cipherText: CIPHER})
});
console.log(await r.json());

Response: 201. The plainText field contained tenant A's real production clientSecret byte-for-byte, recovered using a Bearer token issued for a completely different tenant.

Step 4: convert the recovered secret into a working session

Exchanged the recovered clientSecret for a real Bearer token using the client_credentials grant against the platform's identity provider. The decoded token claims showed tenant A's account ID, subject, and account-user ID, not tenant B's. The token was then used against the production API to list tenant A's full user roster, including names and email addresses, with no further authorization steps. The cross-tenant boundary was completely gone.

Threat model

The clipboard hop between two browser windows on a single laptop is the test harness, not the attack vector. Realistic ciphertext sources require no additional vulnerability:

  • Insiders with storage access (operators, contractors, database admins, backup operators) see encrypted blobs every day. With this oracle live, they can decrypt every customer.
  • Any backup, snapshot, error tracker, log dump, or public commit that leaks a ciphertext field becomes a plaintext breach of every customer.
  • Every future low-severity information-disclosure finding that leaks a ciphertext promotes from P4 ("opaque blob leaked") to P1 ("plaintext secret of every customer").

The Impact

Any authenticated tenant could recover any other tenant's:

  • API Key client secrets (verified end-to-end with a real live credential)
  • Identity provider integration secrets
  • OAuth client secrets
  • Integration bearer tokens
  • SAML signing keys
  • Any other value the platform encrypts under the shared master key

The recovered credential was a working production token: the identity provider accepted it, returned a Bearer signed as tenant A's service principal, and the production API served tenant A's user data to a session driven entirely from tenant B's browser. Audit attribution at the cloud key-management layer logged every decryption as the platform's own service principal, so the breach was invisible to the vendor's own audit log. The encrypt direction was symmetric: an attacker could mint arbitrary ciphertext under the platform's production master key, planting attacker-controlled blobs in the vendor's cloud audit trail.

Remediation

  • Apply the same application-layer role guard that already returns 403 Forbidden resource on /me, /accounts, /users, /idp, /sso, and every other sibling endpoint to POST /kms/encrypt and POST /kms/decrypt. External tenant tokens should not reach either endpoint.
  • Bind every Encrypt call to a per-tenant EncryptionContext (for example {"tenantId":"<id>"}) and require a matching context on Decrypt, so cross-tenant decryption fails at the cloud crypto layer even if the role guard regresses.
  • Long-term: stop fronting AWS KMS as a public proxy at the API gateway. Internal services that need encryption should call the cloud key-management API directly via internal IAM roles, never through a tenant-facing path.
  • Rotate every secret that was ever encrypted under the shared CMK. Treat the entire ciphertext corpus as if it had been exfiltrated.