Yesterday I opened SigmaHQ/sigma#6032, a small upstream contribution for CVE-2024-36401 — the unauthenticated RCE in GeoServer that ships through any OGC-compliant request path. The PR description has the shape every SigmaHQ PR has: a one-paragraph summary, a changelog line, a passing CI badge. It does not have space for the part that took me the longest to get right, which is why the rule looks the way it does and not three other ways it almost did. This post is that explanation.

The short version is that CVE-2024-36401 is one of those bugs where the obvious detection — cs-uri-query contains "Runtime.getRuntime" — looks completely sensible on paper, fires reliably against every public PoC, and is also wrong. It is wrong in a quiet, embarrassing way: it will alert on benign GeoServer traffic from one of the hundred WFS clients in the wild that happen to reference a Java class name in a propertyName expression. The correct rule is a three-stage AND whose existence I had to argue myself into, against my own first instinct.

The bug, in one paragraph

GeoServer exposes a family of OGC standard APIs — WFS (Web Feature Service), WMS (Web Map Service), WPS (Web Processing Service), WCS (Web Coverage Service). Several of those APIs accept parameters whose value is meant to identify a feature property: WFS GetPropertyValue uses propertyName, filter encodings use valueReference, sort expressions use sortBy. Under the hood, GeoServer evaluates those property identifiers through the Apache Commons JXPathContext engine, which is the same family of evaluator used by libraries that do allow Java expression evaluation. The 36401 advisory (GHSA-6jj6-gm7p-fcvv) made the punchline explicit: any string the attacker can put in those parameters is evaluated as a JXPath expression, and JXPath cheerfully resolves java.lang.Runtime.getRuntime().exec(...) as if it were a property path.

The vulhub reproduction container is one docker compose up and one curl request. The exploit reduces to:

GET /geoserver/ows?service=WFS&version=2.0.0&request=GetPropertyValue
    &typeNames=sf:archsites
    &valueReference=exec(java.lang.Runtime.getRuntime(),'id')

That is the entire attack surface. Three layers (the endpoint, the OGC parameter, the payload) and the request is one HTTP GET away from RCE.

Failure mode 1 — the keyword rule

If you stop reading the advisory at Runtime.getRuntime and write the obvious Sigma rule:

selection:
    cs-uri-query|contains:
        - 'Runtime.getRuntime'
        - 'ProcessBuilder'
        - 'exec(java.lang'
condition: selection

…then every CI/CD scanner that probes GeoServer with a Nuclei template will trip it, and every legitimate WFS client that happens to query feature data containing the string Runtime will also trip it. The second failure is the dangerous one. A SOC will tune the rule by adding “and not from this IP range,” then “and not these user agents,” then “only on /geoserver/ paths,” and after three weeks the rule will be a maintenance burden that nobody trusts and nobody removes. I have seen this happen — not for GeoServer, but for half a dozen other “obvious” CVE rules in production rule sets. The keyword-only shape is the default failure mode for CVE detection content, and avoiding it is the entire reason any of this work is interesting.

Failure mode 2 — the endpoint-only rule

The opposite mistake is also tempting. Bundle every /geoserver/ request into a single “suspicious GeoServer” rule and let the analyst triage. That rule will alert on every routine WFS query in your network, which means it will alert hundreds of times an hour in any environment that actually uses GeoServer for what GeoServer is for. The endpoint is a necessary part of the signal but it is not the signal.

The three-stage shape

The correct decomposition reads the request the way the vulnerable code path reads it. The vulnerable code path in GeoServer does three things in order:

Stage 1 — Route the request to one of the OGC service handlers (/geoserver/ows, /geoserver/wfs, /geoserver/wms, etc.).

Stage 2 — Parse out a parameter that the OGC spec says is a property identifier. The three I have seen in public exploits are propertyName, valueReference, and sortBy. There is at least one more (filter expressions reaching the same evaluator through CQL) but the three above cover every public PoC at the time of writing.

Stage 3 — Hand the parameter value to JXPath, which evaluates it as a property path and as a Java expression if the path happens to dereference a class.

A clean Sigma rule mirrors that three-stage shape directly:

detection:
    selection_endpoint:
        cs-uri-stem|contains: '/geoserver/'
    selection_ogc_param:
        cs-uri-query|contains:
            - 'propertyName='
            - 'valueReference='
            - 'sortBy='
    selection_payload:
        cs-uri-query|contains:
            - 'exec(java.lang'
            - 'Runtime.getRuntime'
            - 'getRuntime()'
            - 'ProcessBuilder'
            - 'java.lang.Runtime'
    condition: all of selection_*

condition: all of selection_* is the load-bearing line. Each of the three selections individually would be too noisy. Together they describe the exact request shape that reaches the vulnerable evaluator. A WFS client that sends propertyName=name is fine. A WFS client that happens to include the literal string Runtime in a feature name is fine. A WFS client that hits /geoserver/ows with propertyName= and a Java expression payload is not fine, and there is no plausible benign workflow that looks like that.

Why propertyName= and not just propertyName

A small but real detail. The Sigma |contains modifier is byte-level, not token-level. If the rule said propertyName without the trailing =, it would also fire on every legitimate JSON or XML body that references the string propertyName (which OGC’s own descriptor documents do, in normal operation). The = byte is what locks the match to a URI query-string parameter actually being assigned. That kind of detail is the entire reason I prefer reproducing the bug to reading the advisory: an advisory tells you the symptom, but only the wire format tells you which byte you are actually looking for.

What I left out, and why

I did not include filter= in the OGC parameter list. CQL filter syntax can reach the same JXPath evaluator through nested function calls, and there is at least one published PoC that uses that path. I left it out because:

  1. The public exploits I could reliably reproduce against vulhub all used the three direct parameters above.
  2. Adding filter= significantly widens the false-positive surface (legitimate CQL filters are common in OGC traffic), and the marginal coverage gain is small.
  3. SigmaHQ’s status: experimental is the right home for the rule until I have validated the CQL-through-filter path in a lab and have a clean signal for it.

The rule will be revised, ideally upward through test and eventually stable status, as variants are validated. That is the entire point of the status field, and a reason I think the SigmaHQ schema is genuinely well-designed: it accommodates an honest progression from “I have one good PoC” to “I have validated this against years of telemetry.”

The piece you should steal

If you only take one thing from this post into your own detection work, take this:

When the vulnerable code path is layered — a routing decision, then a parsing decision, then a sink — your detection rule should be layered the same way, with each layer expressed as a separate selection_* and the layers ANDed together. Each layer is permissive on its own; the layers in combination are precise.

The opposite of this — a flat list of keywords ORed together — is how detection content turns into noise that the SOC eventually ignores. CVE-2024-36401 is a tidy example because all three layers happen inside one HTTP request, but the shape generalizes: anywhere a vulnerability needs a specific entry point and a specific parameter and a specific payload, the rule should require all three.

The companion Suricata signature and the Splunk / Sentinel / Elastic hunting queries for the same CVE follow the same three-stage logic, because the logic is about the bug and not about the tool. I will write those up in their own post if there is interest.

Reference

The upstream PR with the cleaned-up rule is SigmaHQ/sigma#6032. The locally-tracked equivalents — Sigma, Suricata, and a Splunk hunting query — live under rules/geoserver_2024_36401/ in the sigma-detection-rules repo. The reproduction lab is at labs/geoserver_2024_36401/ under ctf-notes.