Is This Bad? This Feels Bad. (Fortra GoAnywhere CVE-2025-10035)

Is This Bad? This Feels Bad. (Fortra GoAnywhere CVE-2025-10035)

File transfer used to be simple fun - fire up your favourite FTP client, log in to a glFTPd site, and you were done.

Fast forward to 2025, and the same act requires a procurement team, a web interface, and a vendor proudly waving their Secure by Design pledge.

Ever seen the glFTPd developers on the list of pledge signers? Exactly.

Welcome back to another watchTowr Labs analysis. This time, we are dissecting CVE-2025-10035, a perfect CVSS 10.0 vulnerability in Fortra’s GoAnywhere MFT.

For the uninitiated, GoAnywhere is a "secure" managed file transfer solution that automates and protects data exchange across enterprises, trading partners, and critical applications.

Not your friend's photo-sharing setup. We are talking Fortune 500 deployments, with over 20,000 instances exposed to the Internet. A playground APT groups dream about.

GoAnywhere has a history. In 2023, the cl0p ransomware gang turned CVE-2023-0669, a pre-auth command injection in the Licensing Response Servlet, into widespread compromise. That was the year of MFT exploitation trauma across multiple vendors, burned into the memory of defenders everywhere.

As always, and you'll read, we have an inner feeling (call it "instinct") that there is more to this vulnerability that we are not yet being told.

What Is CVE-2025-10035

On Thursday, September 18, Fortra published a security advisory fi-2025-012 titled: Deserialization Vulnerability in GoAnywhere MFT's License Servlet.

The title in itself is reason for alarm, with the description going further to explain how we likely got to a CVSS 10.0:

A deserialization vulnerability in the License Servlet of Fortra's GoAnywhere MFT allows an actor with a validly forged license response signature to deserialize an arbitrary actor-controlled object, possibly leading to command injection.

For those that recall the excitement of CVE-2023-0669, this description might feel.. familiar..:

GoAnywhere MFT suffers from a pre-authentication command injection vulnerability in the License Response Servlet due to deserializing an arbitrary attacker-controlled object

But watchTowr, how did this get a CVSS 10.0? The advisory clearly states meaningful hurdles for attackers to traverse:

Exploitation of this vulnerability is highly dependent upon systems being externally exposed to the Internet.

Fortra, should the advisory also note that the solution needs to be running?

In The Wild Exploitation?

As always, we must all play a game.

The above sometimes happens when a vendor updates references attached to a CVE. In this case, FI-2025-011 was deleted, and FI-2025-012 was added to replace it.

In FI-2025-012, a section was appended - an innocent "Am I Impacted?" section.

Typically (not always...), when a vulnerability receives in-the-wild exploitation, clarity to customers is provided to help inform prioritisation and remediation process expectations.

Fortra's advisory never says, “We’ve seen this exploited in-the-wild.”

What they do say is more curious: check your Admin Audit logs, look for SignedObject.getObject in exception traces, and if you see it, you were “likely affected.”

Affected, as in, vulnerable? Or affected like, the fox is already in the hen-house?

To determine if you're "affected", Fortra provides an IoC (Indicator of Compromise) for this ambiguous-state vulnerability.

As discussed above, the advisory for FI-2025-012 includes a stack trace which will appear in your logs should you be "affected" by this vulnerability:

ERROR Error parsing license response
java.lang.RuntimeException: InvocationTargetException: java.lang.reflect.InvocationTargetException
...
at java.base/java.io.ObjectInputStream.readObject(Unknown Source)
at java.base/java.security.SignedObject.getObject(Unknown Source)
at com.linoma.license.gen2.BundleWorker.verify(BundleWorker.java:319)
at com.linoma.license.gen2.BundleWorker.unbundle(BundleWorker.java:122)
at com.linoma.license.gen2.LicenseController.getResponse(LicenseController.java:441)
at com.linoma.license.gen2.LicenseAPI.getResponse(LicenseAPI.java:304)
at com.linoma.ga.ui.admin.servlet.LicenseResponseServlet.doPost(LicenseResponseServlet.java:64)

We know Fortra wouldn't be ambiguous on purpose, though, because CISA's Secure By Design pledge, which Fortra signed up to, talks about transparency around ITW exploitation:

Let’s dive in.

Part 1 - License Servlet “Authentication” Bypass

The initial vendor advisory was clear, immediately pointing to the culprit: the License Servlet. Key points from the advisory:

  • The License Servlet contains an insecure deserialization vulnerability.
  • According to the CVSS score, it’s reachable without authentication.

So, first things first: can we actually hit the servlet and trigger the deserialization routine without credentials? To answer that, we need to crack open the code.

The License Servlet lives in com.linoma.ga.ui.admin.servlet.LicenseResponseServlet and is exposed at:

/goanywhere/lic/accept/<GUID>

Let’s take a look at the entry point:

public void doPost(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException {
    String var3 = var1.getParameter("bundle"); // [1]
    String[] var4 = var1.getRequestURI().split("/"); // [2]
    String var5 = var4[var4.length - 1];
    Object var6 = null;
    if (!SessionUtilities.isLicenseRequestTokenValid(var5, var1.getSession())) { // [3]
        LOGGER.error("Unauthorized bundle from invalid session: " + var3);
        var2.sendError(400);
        var1.getSession().removeAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey());
    } else {
        try {
            var9 = LicenseAPI.getResponse(var3); // [4]
        } catch (Exception var8) {
            LOGGER.error("Error parsing license response", var8);
            var2.sendError(500);
            var1.getSession().removeAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey());
            return;
        }
    //...
 }

  • At [1] the servlet retrieves the bundle parameter from the HTTP request.
  • At [2] it extracts a string from our URL - a GUID that defines the license request token.
  • At [3] it calls SessionUtilities.isLicenseRequestTokenValid to validate the user-supplied license request token.
  • If the check at [3] passes, the servlet calls LicenseAPI.getResponse with the bundle parameter.

So, how does token validation (the GUID) actually work? Well...

  • The servlet takes the GUID token extracted at [2].
  • It compares that token to the token that was stored on the user’s session.
  • If both tokens match, the validation at [3] succeeds and execution proceeds to LicenseAPI.getResponse.
  • If the tokens do not match, the flow stops - we never reach the deserialization code.

So: without a valid token tied to the user session, we cannot even begin to reach the vulnerable deserialization routine.

OK, We Need A Token - But How?

Well, if your target GoAnywhere MFT instance has no license applied, this is trivial - you can head straight to the endpoint that starts the activation procedure, and a valid token will be applied to your session.

However, this is not a production reality where licenses are inevitably provided - and this is not as simple. Typically, in such a case, you need to be authenticated to generate a valid license request token and attach it to your session.

Our vulnerability is a perfect 10 CVSS, though, so logically there must be a way to obtain this token without any authentication.

All of our analysis led us to the /goanywhere/license/Unlicensed.xhtml endpoint, where we discovered a few important items:

  • We can bypass authentication requirements for this endpoint by appending /x (or any other invalid data) to the endpoint, like so: /goanywhere/license/Unlicensed.xhtml/x .
  • We need this endpoint to trigger an exception - for example, by providing an invalid ViewState, like this: /goanywhere/license/Unlicensed.xhtml/x?javax.faces.ViewState=x&GARequestAction=activate

Why, you ask? Because, in doing so, the application then flows to the AdminErrorHandlerServlet servlet, where all of our fun begins:

protected void doGet(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException {
    Integer var3 = (Integer)var1.getAttribute("javax.servlet.error.status_code");
    String var4 = (String)var1.getAttribute("javax.servlet.error.message");
    Class var5 = (Class)var1.getAttribute("javax.servlet.error.exception_type");
    String var6 = (String)var1.getAttribute("javax.servlet.error.request_uri");
    Throwable var7 = (Throwable)var1.getAttribute("javax.servlet.error.exception");
    String var8 = var1.getRemoteAddr();
    String var9 = var1.getParameter("GARequestAction");
    if (var3 == null && var5 == null && var7 == null) {
        var2.sendError(404);
    } else if (!this.bypassHandling(var3, var6)) {
        if (var6.startsWith(var1.getContextPath() + "/license/Unlicensed.xhtml")) { // [1]
            if (StringUtilities.isNotEmpty(var9) && var9.equalsIgnoreCase("activate")) {
                String var14 = SessionUtilities.generateLicenseRequestToken(var1.getSession()); // [2]

                try {
                        LicenseUtilities.requestOnlineActivation(var1, var2, var14); // [3]
                        return;
                    } catch (Exception var13) {
                        this.LOGGER.error(var13.getMessage(), var13);
                    }
                }

                var2.sendRedirect(var6);
                //...
}
  • At [1], the code checks whether the request URL begins with /license/Unlicensed.xhtml.
  • If it does, then at [2] the application generates a valid license-request token and attaches it to the session.
  • Finally, at [3], the token is passed to LicenseUtilities.requestOnlineActivation.
  • This method builds a redirect URL to the GoAnywhere license server, embedding the signed license request inside an HTTP GET bundle parameter.

Now, for anyone who doesn’t live and breathe GoAnywhere MFT’s licensing process, the license request does two key things:

  • It stores a serialized Java object containing the license-request token.
  • It’s encrypted with hard-coded keys (and partially compressed for good measure).

An attacker can simply send:

GET /goanywhere/license/Unlicensed.xhtml/watchTowr?javax.faces.ViewState=watchTowr&GARequestAction=activate HTTP/1.1
Host: {{Hostname}}

In response, the server redirects and returns a bundle parameter (the license request) — plus a cookie where the generated token has been attached.

HTTP/1.1 302 
...
Location: <https://my.goanywhere.com:443/lic/request?bundle=p55wfyVKXDVM_bAVZtDLOg3PglFmtEOHyjm4vYZ9l2kwhyouIP6ieq_VZ6lJbVsf5J7KHr..... snip .....

Because the encryption key is hard-coded, the bundle parameter value can be decrypted offline to recover the embedded GUID.

Using said GUID, we are then able to interact with the License Servlet without "actually" authenticating:

POST /goanywhere/lic/accept/d1a8b697-d68c-4e7d-b179-5f3b8b529e6f HTTP/1.1
Host: {{Hostname}}
Cookie: ASESSIONID=F970BB906F5F7D325BFC6E261CF87AE6;
Content-Type: application/x-www-form-urlencoded

bundle=inputhere

There we have it - the "Authentication Bypass" portion of this vulnerability. Let's move on...

Part 2 - Insecure Deserialization in License Servlet

Now that we can obtain the GUID token unauthenticated, we can reach the deserialization sink that this vulnerability ends with.

For your sake, and our sanity, we are going to skip the majority of the code and leave you with two basic facts you need to know:

  • The bundle parameter carries a serialized Java object that the server decrypts during processing.
  • Decryption uses hard-coded keys, so the bundle parameter value is recoverable offline.
  • Processing eventually reaches com.linoma.license.gen2.BundleWorker.verify, where the application hands us the raw input byte array derived from the bundle.
  • In short, an attacker-controlled serialized object reaches server-side deserialization logic.

Let's step through com.linoma.license.gen2.BundleWorker.verify:

private static byte[] verify(byte[] var0, KeyConfig var1) throws IOException, ClassNotFoundException, NoSuchAlgorithmException, InvalidKeyException, SignatureException, UnrecoverableKeyException, CertificateException, KeyStoreException {
    String var2 = "SHA1withDSA";
    if ("2".equals(var1.getVersion())) {
        var2 = "SHA512withRSA";
    }

    PublicKey var3 = getPublicKey(var1);
    Signature var4 = Signature.getInstance(var2);
    SignedObject var5 = (SignedObject)JavaSerializationUtilities.deserialize(var0, SignedObject.class, new Class[]{byte[].class}); // [1]
    if (var1.isServer()) {
        return ((SignedContainer)JavaSerializationUtilities.deserializeUntrustedSignedObject(var5, SignedContainer.class, new Class[]{byte[].class})).getData();
    } else {
        boolean var6 = var5.verify(var3, var4); // [2]
        if (!var6) {
            throw new IOException("Unable to verify signature!");
        } else {
            SignedContainer var7 = (SignedContainer)var5.getObject(); // [3]
            return var7.getData();
        }
    }
}

This part can be a little confusing, so let’s stick to the key points.

At [1] the code calls into a hardened deserialization wrapper (wrapping around ValidatingObjectInputStream.readObject from standard Apache libraries), to deserialize our data.

It also supplies extra arguments, such as SignedObject.class, which define the only types the routine will accept during deserialization.

Those additional arguments restrict what types can be deserialized. In practice, this means the routine will only accept a java.security.SignedObject or a raw byte[].

So what is a SignedObject? According to the Java documentation:

SignedObject is a class for the purpose of creating authentic runtime objects whose integrity cannot be compromised without being detected.

More specifically, a SignedObject contains another Serializable object, the (to-be-)signed object and its signature.

In simple terms, a SignedObject is just a wrapper. It stores a serialized object inside and a signature calculated over that stream with a private key alongside it.

The class also provides a few helper methods:

  • verify - uses the public key to check that the signature matches.
  • getObject - deserializes and returns the inner serialized object.

We’re reaching the end!

At [3], the code will call getObject on our deserialized SignedObject, immediately allowing an attacker to:

  • Deliver a serialized SignedObject.
  • Which internally stores a malicious serialized object, like a one based on the CommonsBeanutils1 gadget.
  • GoAnywhere will deserialize this inner object, and that’s it.

WRONG WRONG WRONG

Do you see what we missed? Look at [2].

The code checks the signature of our serialized object against a public key baked into GoAnywhere. On paper, this is sensible. Signature validation is handled by Bouncy Castle and its FIPS API.

So the final barrier to a pre-auth RCE is bypassing that signature check. But here’s the problem: we don’t know how. Really.

Either we are missing a trick, or the check is genuinely solid. We tried:

  • Using several private keys shipped in GoAnywhere to generate a valid signature. None of them matched the public key in play.
  • Reviewing the Java code responsible for the signature verification.
  • Chasing alternative code paths that might hit deserialization without needing this check.

All of this failed.

One might think: just diff the patch, you dummies.

Here’s the kicker though. The patch does harden the deserialization routine, but the signature verification logic? Completely untouched.

See for yourself:

The patch doesn’t amend the signature check at all. Instead, it only changes the deserialization flow, replacing SignedObject.getObject with a custom wrapper called deserializeUntrustedSignedObject. The idea seems to be to add another layer of “safety” around deserialization.

We’ve got a few conspiracy theories, though, all of which we have absolutely zero evidence for, and are complete conjecture. Regardless, you’re free to pick whichever one fits your mood:

  • We’re dummies. We somehow missed an obvious bypass for the signature verification routine. If that’s the case, why didn’t the patch touch it?
  • The vendor got popped. The private key leaked. Dramatic, yes, but possible. That would let attackers sign malicious objects that every GoAnywhere instance on the planet would happily accept.
  • The vendor accidentally signed evil. Imagine this:
    • When you activate your GoAnywhere product, your installation generates a serialized license request.
    • It’s sent to the vendor’s license server (my.goanywhere.com)
    • If someone slipped a malicious object inside that request and the vendor blindly signed it, attackers would now have a perfectly valid signed payload that works everywhere.

Fueling our conspiracy theories was the advisory deletion and reference update we discussed above, including a stack trace which signals a valid exploitation attempt and asks the user to check their logs:

Who does that? Well, in our opinion, typically vendors whose products are facing ITW exploitation - but we're not experts.

It’s all a mystery. We can’t see a path to exploit this without a valid private key. On paper, that should kill the bug dead.

On the other hand, this has a perfect 10 CVSS score, and the vendor has published "IoCs," which indicates that it is likely real.

And on the other hand, we recently saw a critical CVE in Sitecore that existed purely because… people were copy-pasting machine keys straight from the documentation.

At this point, nothing shocks us. CVE assignments feel less like a science and more like a game of darts in the dark.

Detection Artefact Generator

Across our client base, we used the Authentication Bypass weakness within an impact-less (but still exploitation-based) mechanism to identify unpatched and vulnerable GoAnywhere systems at scale.

Today, we're sharing this mechanism:

GET /goanywhere/license/Unlicensed.xhtml/watchTowr?javax.faces.ViewState=watchTowr&GARequestAction=activate HTTP/1.1

If the instance is unpatched, you’ll see the response include a Location header with a license request embedded in the bundle query string parameter:

If you don’t see the bundle parameter, your instance is patched. That’s because the AdminErrorHandlerServlet no longer generates a valid license request token once the fix is applied.

TL;DR

  • Unpatched: redirect to /license/Unlicensed.xhtml with a valid license request token attached.
  • Patched: redirect to /license/Unlicensed.xhtml with no license request token.

Sigh

No mystery is complete without a few unanswered questions. Despite our usual routine of reverse engineering and creative detours, we’ve ended this one with more questions than usual.

Did we miss critical lines of code that makes everything click into place? Will the first reply on social media point out the obvious and send us a working PoC embedded in a meme? Please.

Because the alternatives are less comforting.

  • Could this vulnerability already be in active use?
  • Could someone have access to a signed malicious object ready to be sprayed across the Internet or delivered with precision to a single target?
  • Could that be happening right now?

One thing is certain: no vendor assigns a CVSS 10 to a purely theoretical bug. We'd advise against leaving GoAnywhere unpatched below 7.8.4 (or Sustain Release 7.6.3).

While this mystery continues to evolve, and we're excited to see if anyone takes the baton from us, concerned operators and end users can use our Detection Artefact Generator to check for externally vulnerable instances.

The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.

By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.

Gain early access to our research, and understand your exposure, with the watchTowr Platform

REQUEST A DEMO