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.
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?
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.
The initial vendor advisory was clear, immediately pointing to the culprit: the License Servlet. Key points from the advisory:
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;
}
//...
}
bundle
parameter from the HTTP request.SessionUtilities.isLicenseRequestTokenValid
to validate the user-supplied license request token.LicenseAPI.getResponse
with the bundle
parameter.So, how does token validation (the GUID) actually work? Well...
LicenseAPI.getResponse
.So: without a valid token tied to the user session, we cannot even begin to reach the vulnerable deserialization routine.
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:
/x
(or any other invalid data) to the endpoint, like so: /goanywhere/license/Unlicensed.xhtml/x
.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);
//...
}
/license/Unlicensed.xhtml
.LicenseUtilities.requestOnlineActivation
. bundle
parameter.Now, for anyone who doesn’t live and breathe GoAnywhere MFT’s licensing process, the license request does two key things:
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...
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:
bundle
parameter carries a serialized Java object that the server decrypts during processing.bundle
parameter value is recoverable offline.com.linoma.license.gen2.BundleWorker.verify
, where the application hands us the raw input byte array derived from the bundle
.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:
SignedObject
.CommonsBeanutils1
gadget.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:
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:
my.goanywhere.com
)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.
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
/license/Unlicensed.xhtml
with a valid license request token attached./license/Unlicensed.xhtml
with no license request token.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.
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