[하루한줄] CVE-2025-4428 : Ivanti EPPM 에서 발생한 Spring EL Injection 취약점

URL

https://labs.watchtowr.com/expression-payloads-meet-mayhem-cve-2025-4427-and-cve-2025-4428/

https://projectdiscovery.io/blog/ivanti-remote-code-execution

https://www.wiz.io/blog/ivanti-epmm-rce-vulnerability-chain-cve-2025-4427-cve-2025-4428

Target

  • Ivanti Endpoint Manager Mobile
    • 11.12.0.4 이하
    • 12.3.0.1 이하
    • 12.4.0.1 이하
    • 12.5.0.0 이하

Explain

Ivanti EPPM 에서 Spring EL Injection 취약점이 발견되었습니다. 이 취약점은 ITW 에서 활발하게 Exploit 되던 취약점입니다.

/mifs/admin/rest/api/v2/featureusage 에서 사용되던 format 파라메터에서 취약점이 발생하여 다음과 같이 요청을 보내면 Spring EL Injection 를 일으킬 수 있었습니다.

GET /mifs/admin/rest/api/v2/featureusage?format=${7*7} HTTP/1.1
Host: 192.168.111.148
Cookie: ...

/api/v2/featureusage 를 라우팅하는 컨트롤러에서 downloadDeviceFeatureUsageReport 메서드를 확인할 수 있는데요 @Valid 어노테이션으로 DeviceFeatureUsageReportQueryRequestConstraintValidator 를 통해 입력값 검증을 하고 있습니다.

@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;
}

DeviceFeatureUsageReportQueryRequest 에서 format 이라는 변수가 정의되어 있습니다. [1]

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() { // [1]
    return this.format;
  }
  //...
}

@Valid 어노테이션을 사용하면 DeviceFeatureUsageReportQueryRequestValidator 에 구현된ConstraintValidatorisValid 가 호출되어 입력값 검증을 수행합니다.

해당 코드를 보면 format 이 json 이나 csv 가 아니면 에러를 발생시키도록 구현되어 있습니다. [1] 에서 format 값을 받고 [2] 에서 비교하며 [3] 에서 formatMessage 가 에러메시지에 매핑되죠.

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(); // [4]
    } 
    
    return isValid;
  }
}

에러메시지는 다음과 같이 되어 있으며 {0} 부분에 유저 입력값이 들어가서 포맷팅이 되는 것입니다.

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

그리고 [4] context.buildConstraintViolationWithTemplate(formatMessage) 를 호출하며 해당 포맷이 템플릿화 됩니다. 해당 부분 때문에 SSTI 가 발생하는 것입니다.

Reference

https://labs.watchtowr.com/expression-payloads-meet-mayhem-cve-2025-4427-and-cve-2025-4428/

https://projectdiscovery.io/blog/ivanti-remote-code-execution

https://www.wiz.io/blog/ivanti-epmm-rce-vulnerability-chain-cve-2025-4427-cve-2025-4428

https://xen0vas.github.io/Leveraging-the-SpEL-Injection-Vulnerability-to-get-RCE/#

https://www.hahwul.com/blog/2018/spel-injection-springboot-rce/