GHSA-78FC-9688-W8XW
Vulnerability from github – Published: 2026-05-04 17:39 – Updated: 2026-05-08 19:25Affected Versions
version ≤ 2.7.8 (latest version at time of disclosure)
https://github.com/openmrs/openmrs-core
Impact
The endpoint POST /openmrs/ws/rest/v1/module is vulnerable to a path traversal (Zip Slip) attack. An authenticated attacker can upload a crafted .omod archive containing ZIP entries with directory traversal sequences. Upon automatic extraction by the server, the incomplete path validation in WebModuleUtil.startModule() fails to prevent entries such as web/module/../../../../malicious.jsp from being written outside the intended module directory. If the traversal target falls within the web application root (e.g., /usr/local/tomcat/webapps/openmrs/), the attacker achieves arbitrary file write and subsequent Remote Code Execution.
Notably, other extraction methods in the same codebase (ModuleUtil.expandJar(), TestInstallUtil.addZippedTestModules()) are properly protected with normalize().startsWith() checks — this vulnerability is an oversight where the same fix was not applied.
Furthermore, the module.allow_web_admin runtime property, which is intended to restrict administrators from managing modules via the web interface, only gates the Legacy UI controller entry point. The REST API endpoint POST /openmrs/ws/rest/v1/module does not check this property, allowing this restriction to be fully bypassed.
Steps to Reproduce
- Construct a malicious
.omodfile (which is a ZIP/JAR archive) containing a ZIP entry with a path traversal payload in its entry name, such asweb/module/../../../../<target_filename>. Upload this file toPOST /openmrs/ws/rest/v1/modulewith valid admin credentials via Basic Auth.
- The server parses and loads the module. During
WebModuleUtil.startModule(), entries underweb/module/are automatically extracted. The existing checkPaths.get(name).startsWith("..")only blocks entries beginning with.., so an entry starting withweb/module/passes the check. The../sequences in the remaining path cause the file to be written outside the intendedWEB-INF/view/module/directory — for example, into the web application root at/usr/local/tomcat/webapps/openmrs/.
- The traversed file is now accessible under the web application root. If the written file is a JSP script, accessing it via the browser triggers server-side execution, achieving RCE.
Root Cause Analysis
The vulnerability exists in WebModuleUtil.startModule() (web/src/main/java/org/openmrs/module/web/WebModuleUtil.java).
Vulnerable code:
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String name = entry.getName();
// ❌ Incomplete check — only blocks entries starting with ".."
if (Paths.get(name).startsWith("..")) {
throw new UnsupportedOperationException("...");
}
if (name.startsWith("web/module/")) {
String filepath = name.substring(11);
StringBuilder absPath = new StringBuilder(realPath + "/WEB-INF");
absPath.append("/view/module/");
absPath.append(mod.getModuleIdAsPath()).append("/").append(filepath);
// ❌ No normalize() or startsWith() boundary check before writing
File outFile = new File(absPath.toString().replace("/", File.separator));
outStream = new FileOutputStream(outFile, false);
inStream = jarFile.getInputStream(entry);
OpenmrsUtil.copyFile(inStream, outStream);
}
}
Why the check fails: For an entry named web/module/foo/../../../../evil.jsp, Paths.get(name) starts with web, not .., so the check passes. After name.substring(11), the filepath foo/../../../../evil.jsp is concatenated directly into the output path without normalization, resulting in a write outside the intended directory.
Correctly protected code in the same codebase:
ModuleUtil.expandJar():
// ✅ Correct — uses normalize().startsWith()
if (!parent.toPath().normalize().startsWith(docBase)) {
throw new UnsupportedOperationException("...");
}
TestInstallUtil.addZippedTestModules():
// ✅ Correct — uses normalize().startsWith()
if (!zipEntryFile.toPath().normalize().startsWith(moduleRepository.toPath().normalize())) {
throw new IOException("Bad zip entry");
}
The fix pattern is already known and applied elsewhere in the codebase. WebModuleUtil.startModule() is an oversight.
Bypass of module.allow_web_admin
The module.allow_web_admin property only restricts module operations at the Legacy UI layer (ModuleListController). The REST API endpoint does not consult this property:
Legacy UI: POST /admin/modules/moduleList.form → allowAdmin() check → [BLOCKED]
REST API: POST /ws/rest/v1/module → No allowAdmin() check → [ALLOWED]
↓
ModuleFactory.loadModule()
↓
WebModuleUtil.startModule() ← Zip Slip here, no allowAdmin check
↓
FileOutputStream.write() ← Arbitrary file write
Remediation
Add normalize().startsWith() boundary validation before writing, consistent with the existing pattern in ModuleUtil.expandJar():
File outFile = new File(absPath.toString().replace("/", File.separator));
// ✅ Add this check
if (!outFile.toPath().normalize().startsWith(
Paths.get(realPath, "WEB-INF").normalize())) {
throw new UnsupportedOperationException(
"Zip entry '" + name + "' would be written outside the allowed directory.");
}
Additionally, enforce the module.allow_web_admin restriction consistently across all module upload entry points, including the REST API.
{
"affected": [
{
"package": {
"ecosystem": "Maven",
"name": "org.openmrs.web:openmrs-web"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "2.7.8"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Maven",
"name": "org.openmrs.web:openmrs-web"
},
"ranges": [
{
"events": [
{
"introduced": "2.8.0"
},
{
"last_affected": "2.8.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40076"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-04T17:39:31Z",
"nvd_published_at": "2026-05-06T20:16:31Z",
"severity": "CRITICAL"
},
"details": "## Affected Versions\n\nversion \u2264 2.7.8 (latest version at time of disclosure)\n\nhttps://github.com/openmrs/openmrs-core\n\n## Impact\n\nThe endpoint `POST /openmrs/ws/rest/v1/module` is vulnerable to a path traversal (Zip Slip) attack. An authenticated attacker can upload a crafted `.omod` archive containing ZIP entries with directory traversal sequences. Upon automatic extraction by the server, the incomplete path validation in `WebModuleUtil.startModule()` fails to prevent entries such as `web/module/../../../../malicious.jsp` from being written outside the intended module directory. If the traversal target falls within the web application root (e.g., `/usr/local/tomcat/webapps/openmrs/`), the attacker achieves arbitrary file write and subsequent Remote Code Execution.\n\nNotably, other extraction methods in the same codebase (`ModuleUtil.expandJar()`, `TestInstallUtil.addZippedTestModules()`) are properly protected with `normalize().startsWith()` checks \u2014 this vulnerability is an oversight where the same fix was not applied.\n\nFurthermore, the `module.allow_web_admin` runtime property, which is intended to restrict administrators from managing modules via the web interface, only gates the Legacy UI controller entry point. The REST API endpoint `POST /openmrs/ws/rest/v1/module` does not check this property, allowing this restriction to be fully bypassed.\n\n## Steps to Reproduce\n\n1. Construct a malicious `.omod` file (which is a ZIP/JAR archive) containing a ZIP entry with a path traversal payload in its entry name, such as `web/module/../../../../\u003ctarget_filename\u003e`. Upload this file to `POST /openmrs/ws/rest/v1/module` with valid admin credentials via Basic Auth.\n\n\u003cimg width=\"1986\" height=\"1102\" alt=\"image\" src=\"https://github.com/user-attachments/assets/647f15de-7e8c-40b9-aba9-d4db5d2e0b52\" /\u003e\n\n\u003cimg width=\"2048\" height=\"1078\" alt=\"image\" src=\"https://github.com/user-attachments/assets/301412a0-e3b0-4afb-91c2-e9739de3080d\" /\u003e\n\n\n2. The server parses and loads the module. During `WebModuleUtil.startModule()`, entries under `web/module/` are automatically extracted. The existing check `Paths.get(name).startsWith(\"..\")` only blocks entries beginning with `..`, so an entry starting with `web/module/` passes the check. The `../` sequences in the remaining path cause the file to be written outside the intended `WEB-INF/view/module/` directory \u2014 for example, into the web application root at `/usr/local/tomcat/webapps/openmrs/`.\n\n\u003cimg width=\"1439\" height=\"141\" alt=\"image\" src=\"https://github.com/user-attachments/assets/4bda3b1e-a80e-42ed-af2b-a1da53e8db03\" /\u003e\n\n3. The traversed file is now accessible under the web application root. If the written file is a JSP script, accessing it via the browser triggers server-side execution, achieving RCE.\n\n\u003cimg width=\"1482\" height=\"300\" alt=\"image\" src=\"https://github.com/user-attachments/assets/61936002-78cd-4203-80f0-f0a8702b216c\" /\u003e\n\n## Root Cause Analysis\n\nThe vulnerability exists in `WebModuleUtil.startModule()` (`web/src/main/java/org/openmrs/module/web/WebModuleUtil.java`).\n\n### Vulnerable code:\n\n```java\nEnumeration\u003cJarEntry\u003e entries = jarFile.entries();\nwhile (entries.hasMoreElements()) {\n JarEntry entry = entries.nextElement();\n String name = entry.getName();\n\n // \u274c Incomplete check \u2014 only blocks entries starting with \"..\"\n if (Paths.get(name).startsWith(\"..\")) {\n throw new UnsupportedOperationException(\"...\");\n }\n\n if (name.startsWith(\"web/module/\")) {\n String filepath = name.substring(11);\n StringBuilder absPath = new StringBuilder(realPath + \"/WEB-INF\");\n absPath.append(\"/view/module/\");\n absPath.append(mod.getModuleIdAsPath()).append(\"/\").append(filepath);\n\n // \u274c No normalize() or startsWith() boundary check before writing\n File outFile = new File(absPath.toString().replace(\"/\", File.separator));\n outStream = new FileOutputStream(outFile, false);\n inStream = jarFile.getInputStream(entry);\n OpenmrsUtil.copyFile(inStream, outStream);\n }\n}\n```\n\n**Why the check fails:** For an entry named `web/module/foo/../../../../evil.jsp`, `Paths.get(name)` starts with `web`, not `..`, so the check passes. After `name.substring(11)`, the filepath `foo/../../../../evil.jsp` is concatenated directly into the output path without normalization, resulting in a write outside the intended directory.\n\n### Correctly protected code in the same codebase:\n\n**`ModuleUtil.expandJar()`:**\n\n```java\n// \u2705 Correct \u2014 uses normalize().startsWith()\nif (!parent.toPath().normalize().startsWith(docBase)) {\n throw new UnsupportedOperationException(\"...\");\n}\n```\n\n**`TestInstallUtil.addZippedTestModules()`:**\n\n```java\n// \u2705 Correct \u2014 uses normalize().startsWith()\nif (!zipEntryFile.toPath().normalize().startsWith(moduleRepository.toPath().normalize())) {\n throw new IOException(\"Bad zip entry\");\n}\n```\n\nThe fix pattern is already known and applied elsewhere in the codebase. `WebModuleUtil.startModule()` is an oversight.\n\n### Bypass of `module.allow_web_admin`\n\nThe `module.allow_web_admin` property only restricts module operations at the Legacy UI layer (`ModuleListController`). The REST API endpoint does not consult this property:\n\n```\nLegacy UI: POST /admin/modules/moduleList.form \u2192 allowAdmin() check \u2192 [BLOCKED]\nREST API: POST /ws/rest/v1/module \u2192 No allowAdmin() check \u2192 [ALLOWED]\n \u2193\n ModuleFactory.loadModule()\n \u2193\n WebModuleUtil.startModule() \u2190 Zip Slip here, no allowAdmin check\n \u2193\n FileOutputStream.write() \u2190 Arbitrary file write\n```\n\n## Remediation\n\nAdd `normalize().startsWith()` boundary validation before writing, consistent with the existing pattern in `ModuleUtil.expandJar()`:\n\n```java\nFile outFile = new File(absPath.toString().replace(\"/\", File.separator));\n\n// \u2705 Add this check\nif (!outFile.toPath().normalize().startsWith(\n Paths.get(realPath, \"WEB-INF\").normalize())) {\n throw new UnsupportedOperationException(\n \"Zip entry \u0027\" + name + \"\u0027 would be written outside the allowed directory.\");\n}\n```\n\nAdditionally, enforce the `module.allow_web_admin` restriction consistently across all module upload entry points, including the REST API.",
"id": "GHSA-78fc-9688-w8xw",
"modified": "2026-05-08T19:25:59Z",
"published": "2026-05-04T17:39:31Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/openmrs/openmrs-core/security/advisories/GHSA-78fc-9688-w8xw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40076"
},
{
"type": "PACKAGE",
"url": "https://github.com/openmrs/openmrs-core"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/",
"type": "CVSS_V4"
}
],
"summary": "OpenMRS Module Upload Vulnerable to Path Traversal (Zip Slip)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.