# OpenMRS Module Upload Vulnerable to Path Traversal (Zip Slip)

Zip Slip in OpenMRS WebModuleUtil.startModule() lets an authenticated admin upload a crafted .omod whose traversal entries write a JSP into the webapp root, achieving RCE.

- Payout: Research
- Severity: critical
- Program: OpenMRS
- Found by: Arron-bit
- Disclosed: 2026-05-04
- CVE: CVE-2026-40076
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-78fc-9688-w8xw
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-78fc-9688-w8xw
- Tags: area:web domain:web impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:java technique:incomplete-fix technique:source-review vuln:access-control vuln:file-upload vuln:path-traversal vuln:rce

**Target:** REST module upload endpoint POST /openmrs/ws/rest/v1/module and WebModuleUtil.startModule() extraction
**Root cause:** WebModuleUtil.startModule() only rejects entry names starting with '..' via Paths.get(name).startsWith("..") and concatenates the substring after 'web/module/' into the output path without normalize()/startsWith() boundary validation, so embedded '../' sequences escape WEB-INF/view/module/.
**Steps:**
1. Craft a .omod (ZIP/JAR) with an entry named `web/module/../../../../<target_filename>` (e.g. a JSP).
2. Upload it to `POST /openmrs/ws/rest/v1/module` with valid admin credentials via Basic Auth.
3. Server loads the module; WebModuleUtil.startModule() extracts entries under `web/module/`; the check `Paths.get(name).startsWith("..")` passes since the name starts with `web`.
4. After `name.substring(11)`, `foo/../../../../evil.jsp` is appended to realPath + `/WEB-INF/view/module/<moduleId>/` unnormalized, writing outside the intended dir (e.g. into `/usr/local/tomcat/webapps/openmrs/`).
5. Request the written JSP via the browser to trigger server-side execution.
**How found:** Code review of WebModuleUtil.startModule() revealed the check only blocks entries beginning with '..' and lacks normalize()/startsWith() validation, while sibling extractors ModuleUtil.expandJar() and TestInstallUtil.addZippedTestModules() already use the correct normalize().startsWith() pattern — an incomplete-fix oversight. Also noticed module.allow_web_admin is only enforced in the Legacy UI controller, not the REST endpoint.
**Exploitation:** Authenticated admin uploads a malicious .omod to the REST module endpoint; automatic extraction during module start writes an attacker-controlled JSP into the Tomcat webapp root; browsing the JSP yields RCE. The module.allow_web_admin restriction is bypassed because only the Legacy UI controller checks it, not the REST endpoint.
**Escalation:** Arbitrary file write escalated to RCE by targeting the web application root with a JSP; also bypasses the module.allow_web_admin administrative restriction via the unchecked REST entry point.
**Insight:** When a codebase has a known-safe extraction helper, grep for every other archive-extraction site and diff their validation — the one missing normalize().startsWith() is the bug. Also check whether security toggles (like allow_web_admin) are enforced at every entry point, not just the UI controller; alternate API routes often skip them.

## Proof of concept (copied exactly from the original report)

**Malicious .omod ZIP entry with traversal payload**

```
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/../../../../<target_filename>`. Upload this file to `POST /openmrs/ws/rest/v1/module` with valid admin credentials via Basic Auth.
```

**Vulnerable extraction loop in WebModuleUtil.startModule**

```
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);
    }
}
```

