Research/cve-2026-59358
N-dayCVE-2026-59358HighPublic

CVE-2026-59358: Cloud Foundry UAA user token as client_credentials Bearer

Cloud Foundry UAA v79.6.0 accepts a public PKCE user access token as Bearer client authentication on grant_type=client_credentials. The leftover mints a client-only token with that client's authorities. Labbed clients.write creating a new OAuth client. Independent lab of the published CVE.

Name
CVE-2026-59358: Cloud Foundry UAA user token as client_credentials Bearer
Type
N-day analysis
CVE
CVE-2026-59358
CVE Risk
high
Disclosure Status
public
Vendor
Cloud Foundry UAA
Affected
Cloud Foundry UAA v3.7.0 through v79.6.0; cf-deployment through v60.4.0. Patched in UAA v79.7.0 and cf-deployment v60.5.0. Dual-grant public client required (AT:P).
Published
07 Oct 2026
Updated
07 Oct 2026
Tags
n-day, cloudfoundry, uaa, oauth, cwe-287, privilege-leftover, remote

A user JWT is not a client secret

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-59358 (loopback client). The lab stack is lab/: docker-compose.yml, uaa.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is CVE-2026-59358 (NVD) in Cloud Foundry UAA v79.6.0. CWE-287. 7.6 High (CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N). Remote privilege leftover. Authenticated with the attacker's own user access token. Patched in v79.7.0. CNA is VMware by Broadcom. Cloud Foundry Foundation published the advisory on 5 Oct 2026. Credit: Minseong Kim (mak3bread). This pack is an independent loopback lab, not a first-finder writeup.

UAA is the OAuth 2 / OIDC identity box for Cloud Foundry. Apps, the cf CLI, and a pile of platform clients all go through /oauth/token. Client credentials grants are supposed to prove you are the client: a secret, a JWT client assertion, mTLS, something that is not "I have a user session from that same client_id." Through v79.6.0 the token endpoint accepted any valid access token whose client_id matched. A public authorization_code + PKCE user token qualifies. It carries uaa.user, a user_id, and client_auth_method=none. That token cannot create OAuth clients. Replay it as Authorization: Bearer on grant_type=client_credentials and UAA hands back a client-only token with the client's authorities. In the lab those authorities include clients.write. The next POST creates labwit-CVE-2026-59358-WITNESS.

OAuth spent twenty years trying to keep those two tokens in different drawers. UAA put them in the same one and checked the label.

Why this CVE

The request was one CVE, labbed, then published if the lab was SUCCESS. Sibling CVE-2026-59357 (self-UAA OIDC JWT injection) landed the same day. Different sink. Different post, if it ever gets one.

UAA is a good lab because the official image exists, the default profile is HSQLDB, and the advisory already names the oracle: user token 403 on POST /oauth/clients, leftover token 201. You do not have to invent a witness. You have to make the token endpoint tell the truth on loopback.

I wanted Critical or High. Local is in-scope. This one is Remote. Class is privilege leftover, not RCE. A minted clients.write token can create clients with attacker-chosen authorities. That is platform identity, not a shell on the JVM. I am not going to inflate it.

Pinning v79.6.0

Last affected. The advisory is unusually polite about the range: UAA v3.7.0 through v79.6.0 inclusive, cf-deployment through v60.4.0. Fixed in v79.7.0 / cf-deployment v60.5.0. Pinning the patched tag is a good way to spend an evening proving that patches work.

Docker Hub cfidentity/uaa:v79.6.0 is the binary. Lab digest sha256:b50d1bf8298572136ea7724228877e1611b0926f7b6ee11e25ff4e75b92003ee, linux/amd64. Apple Silicon runs it under qemu. That is fine. UAA does not care which endianness you used to type a Bearer header.

Context path is /uaa. Issuer, login URL, and redirect all have to agree on host and port. The lab binds 127.0.0.1:18258:8080. Everything in uaa.yml says http://127.0.0.1:18258/uaa. Mixing localhost and 127.0.0.1 is how you get a JWT that will not come back, or a redirect that UAA refuses to honor. I know this because I did it.

The skip list is short and it still matters

Cloud Foundry has been publishing UAA bugs all year. Before you "discover" a leftover, you write down what is already public:

Password grant, refresh, client_credentials with a real secret, and authorization_code with Basic client auth are intended. Unauthenticated /info and /login are intended. Empty-secret public clients with allowpublic: true are intended for PKCE. clients.write on a confidential client that never sees a browser is intended. The leftover is the overlap: one client_id that is both a public user-facing app and a client_credentials client, plus a token endpoint that cannot tell a user JWT from a client credential.

AT:P in the 4.0 vector is that overlap. It is not default. It is also not rare in the wild once someone has copied a "login plus service account" YAML from a wiki page written in 2018.

How the two tokens are supposed to differ

A user access token from authorization_code + PKCE says: this human logged in through this public client, with this user_id, these user scopes (openid, uaa.user), and client_auth_method=none because the client has no secret. Resource servers that speak "user" look at user_id and scopes. The clients API looks at clients.write. The user token does not have it. POST /oauth/clients with that Bearer is 403 insufficient_scope. That part of UAA is doing its job.

A client_credentials token says: this client authenticated as itself. No user_id. grant_type=client_credentials. Authorities come from the client's authorities list, not from the user's groups. If you put clients.write there, the token can mint new OAuth clients. That is also intended, for a client that proved it is the client.

The proof is supposed to be a secret, a client JWT, or a configured client authentication method. "A user token whose cid matches" is none of those. UAA's client_credentials handling did not verify the Bearer was a client credential. It verified the access token was valid and the client_id matched the request. A PKCE user token for labpub matches labpub.

That is the leftover. The user token is still a user token. The token endpoint treats it as a client secret and then issues a different token that is actually privileged.

Building the lab

Official image, HSQLDB profile, one bind-mount for uaa.yml. No Cloud Controller, no BOSH, no Gorouter. Compose project cve-2026-59358. Loopback only.

Stock UAA comes with a login client that is public, allows authorization_code, and even lists client_credentials. Its authorities are scim.write and clients.read. That is a useful negative: you can mint a leftover token and still fail the 201 because you cannot write clients. I wanted the advisory's clients.write sentence on the last line, so I added labpub:

YAML
labpub:
  id: labpub
  secret: ""
  allowpublic: "true"
  authorized-grant-types: authorization_code,client_credentials,refresh_token
  scope: openid,uaa.user
  authorities: clients.write,clients.read
  redirect-uri: http://127.0.0.1:18258/cb
  autoapprove: "true"

Empty secret, public, PKCE. Redirect is a path UAA will 302 to and that nothing listens on. The PoC reads code= off the Location header. User is the sample account every UAA tutorial has used since forever: marissa / koala. JWT signing key and SAML key are lab-local. They are not production material.

The PoC is stdlib: cookie jar, CSRF from X-Uaa-Csrf, PKCE S256, no-redirect opener so 302s stay visible. Oracle is JWT claims plus HTTP status plus the created client id. Witness string CVE-2026-59358-WITNESS. No payloads. No reverse shell. A 201 on /oauth/clients is the whole point.

Flow:

  1. Wait for GET /uaa/info 200.
  2. GET /oauth/authorize with PKCE challenge. Follow 302 to /login. POST username and password. Follow 302 to http://127.0.0.1:18258/cb?code=....
  3. Exchange the code plus verifier at POST /oauth/token. 200. Decode the JWT: user_id present, client_id=labpub, client_auth_method=none, scopes openid,uaa.user.
  4. Negative: POST /oauth/clients with that Bearer. 403 insufficient_scope. If this is 201, the lab is contaminated and the run is FAIL.
  5. Attack: POST /oauth/token with grant_type=client_credentials&client_id=labpub and Authorization: Bearer <user token>. No Basic. No secret. 200.
  6. Decode the minted JWT: grant_type=client_credentials, authorities=clients.read,clients.write, user_id missing.
  7. POST /oauth/clients with the minted Bearer, client_id=labwit-CVE-2026-59358-WITNESS. 201.
  8. GET that client. 200.

Last run:

Plain text
ioc user-jwt user_id=cd88d3a0-c519-4076-a8a7-f442ed39ce29 client_id=labpub cid=labpub client_auth_method=none grant_type=authorization_code scope=openid,uaa.user
ioc negative-user-create-http=403
ioc minted-jwt grant_type=client_credentials client_id=labpub cid=labpub authorities=clients.read,clients.write user_id=<missing>
ioc create-http=201 id=labwit-CVE-2026-59358-WITNESS
SUCCESS CVE-2026-59358 grant=client_credentials clients.write create-http=201 id=labwit-CVE-2026-59358-WITNESS CVE-2026-59358-WITNESS

The user_id on the first JWT and the missing user_id on the second JWT are the whole story. Same client_id. Different kind of token. The endpoint did not notice.

Replay:

Plain text
git clone https://github.com/abraxas/CVE-2026-59358
cd CVE-2026-59358
python3 CVE-2026-59358-Abraxas-Labs.py

Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

Wrong turns, in the order I made them

Pinning v79.7.0. The patched tag is what Docker Hub will happily give you if you ask for "latest UAA." Latest is the fix. Last affected is v79.6.0. A 401 on the attack POST against 79.7.0 is the product working. It is also a wasted compose.

Hitting /oauth/token without /uaa. The image serves the app at SERVER_SERVLET_CONTEXT_PATH=/uaa. /oauth/token on the host port is 404. /uaa/oauth/token is the endpoint. Spring will not infer the context path from your optimism.

Mixing localhost and 127.0.0.1. Issuer http://localhost:18258/uaa, redirect http://127.0.0.1:18258/cb, browser-equivalent requests to 127.0.0.1. UAA compares those strings with the patience of a linter. Pick one host and use it in issuer, login URL, authorize URL, redirect, and the PoC BASE. The lab uses 127.0.0.1 everywhere. Loopback binds do not listen on the IPv6 localhost anyway.

Relying on the stock login client. It is public. It has authorization_code and client_credentials. It looks like the bug. Its authorities are scim.write and clients.read. You can prove the leftover mint and then fail the 201. I wanted clients.write on the SUCCESS line, so labpub exists. The advisory's impact sentence is about that client's authorities. Use a client that has the ones you claim.

Password grant instead of PKCE. grant_type=password with a public client is a different path, often disabled, and not what the advisory described. The leftover is a user token from authorization_code + PKCE replayed as client auth. Password grant also produces a user token, but then you are labbing a different sentence. PKCE S256, code_challenge_method=S256, empty secret, allowpublic: true. That is the public user-facing flow.

Sending Basic client_id:secret and the user Bearer. That is legitimate client authentication plus a leftover token you no longer needed. The bug is the Bearer in place of the secret. The attack POST is grant_type=client_credentials and client_id=labpub in the body, Authorization: Bearer <user JWT>, nothing in the Basic header. If you add Basic, you have not shown the leftover. You have shown that labpub can do client_credentials when you authenticate as labpub.

Calling the leftover RCE. Creating an OAuth client is not a shell. UAA's process UID does not start executing attacker bytecode. Privilege leftover is the class. Remote is the reach. The attacker is a normal user of a dual-grant public client.

Skipping the 403. If the user token already has clients.write, the 201 does not prove reuse. It proves the user was already a client admin. The negative control is load-bearing. 403, then 200, then 201. In that order.

A reverse shell. Theatre. The oracle is a JSON client id containing the witness string.

What an attacker can do

Have an account on UAA. Use a public OAuth client that also lists client_credentials on the same client_id. Complete the normal login. Take the access token you were issued as a user.

POST /uaa/oauth/token with grant_type=client_credentials and that token as Bearer. UAA returns a client-only JWT. Scopes and authorities are the client's, not yours. If the client was given clients.write, POST /uaa/oauth/clients creates a new client with whatever authorities you put in the JSON. You never possessed the original client's secret. The original client may not even have one.

From there the story follows the new client: more client_credentials tokens, more grants, whatever that platform uses OAuth clients for. Cloud Foundry uses them for a lot. Practical impact scales with the authorities on the dual-grant client. A client whose authorities are uaa.none still mints a leftover token. It just cannot write clients with it. The leftover is the mint. clients.write is how the lab makes the mint visible.

Unauthenticated callers do not get this. A user token for a client that does not list client_credentials should fail the grant. A confidential client with a secret and no public user flow is a different configuration. The advisory's interim mitigation is exactly that split: do not put both grants on one client_id, and keep administrative authorities on dedicated non-public clients.

This is not unauthenticated RCE. It is not a default-install hole on a fresh cf-deployment that never defined labpub. It is a High leftover on a configuration Cloud Foundry already told people to stop using, sitting behind a token endpoint that trusted the wrong kind of Bearer for a very long version range.

The fix

Upgrade UAA to v79.7.0 or newer, or cf-deployment to v60.5.0. The patched token endpoint should refuse a user access token as client authentication on client_credentials.

Until you can upgrade, audit clients. No public user-facing grant plus client_credentials on the same id. clients.write, clients.admin, scim.write, uaa.admin belong on dedicated confidential clients.

Re-run the proof of concept against the patched build: the user Bearer on client_credentials must stay non-200. The 403 on the user token can stay 403. That was never the bug.

I am not going to print a curl you can paste at someone else's /uaa/oauth/token. The missing client-credential check is the useful part. If you own the UAA, run the pack against loopback.

References