Đọc báo cáo ATTT mới nhất tại đây

VCI
RED TEAM

Dissecting a Pre-Auth SQL Injection in GeoServer 3.0.0

TL;DRGeoServer lets anyone, unauthenticated, submit OGC filter expressions (CQL/ECQL or OGC XML) to its WFS/WMS services. Those expressions are compiled to SQL by the GeoTools library and executed dir ...

21-08-2026 10:33 GMT Thời gian đọc: 12 phútRED TEAM
Dissecting a Pre-Auth SQL Injection in GeoServer 3.0.0

TL;DR

GeoServer lets anyone, unauthenticated, submit OGC filter expressions (CQL/ECQL or OGC XML) to its WFS/WMS services. Those expressions are compiled to SQL by the GeoTools library and executed directly against the backing database.

The filter function jsonArrayContains(column, pointer, expected) is encoded by GeoTools' PostGIS dialect into a jsonb_path_exists(...) expression. In the branch for PostgreSQL >= 12, the third argument (expected) is concatenated straight into a single-quoted SQL string literal without any escaping, and emitted as raw SQL rather than a bound parameter. The result is a pre-authentication SQL Injection.

  • Prepared statements do NOT protect against it — the value is concatenated, not bound via a ? placeholder.
  • The function is registered as "encodable to SQL" unconditionally, so it is always pushed down to the database.
  • No authentication required: anonymous WFS/WMS read is the default.

This article focuses on the mechanism and root cause at the source-code level together with the real-world exploitation preconditions. Concrete payload construction is intentionally kept at a conceptual level — the emphasis is on "why the bug exists", not "what the attack string looks like".

1. Background: GeoServer, GeoTools and the OGC services

GeoServer is a widely deployed open-source server for publishing geospatial data (maps, vector/raster layers) via the standard OGC (Open Geospatial Consortium) services: WFS, WMS, WCS, WMTS and OGC API. It is common in government GIS systems, land/environment, agriculture and urban-planning platforms.

Under the hood, GeoServer builds on the GeoTools library. Its gt-jdbc module is responsible for translating OGC filter expressions into SQL for JDBC datastores such as PostGIS, Oracle Spatial and SQL Server. This translation layer is exactly where the bug lives.

A client submits a filter in one of two ways:

  • CQL/ECQL — a compact text form passed via the CQL_FILTER parameter, e.g. CQL_FILTER=population>1000.
  • OGC XML Filter — a verbose XML form passed via the FILTER parameter or a POST body.

Both forms support function calls, e.g. strStartsWith(...), strLength(...) — and the one we care about: jsonArrayContains(...).

The security-critical fact: on most deployments, WFS GetFeature and WMS GetMap are anonymously readable. When a layer is backed by a PostGIS store, the client-supplied filter is compiled to SQL and executed on the database. That is the path an attacker abuses.

Figure 1 — The path of a request from the (unauthenticated) Internet to SQL running in the database. The expected argument flows straight from HTTP into the SQL string.

Figure 1 — The path of a request from the (unauthenticated) Internet to SQL running in the database. The expected argument flows straight from HTTP into the SQL string.

2. What is jsonArrayContains for?

jsonArrayContains is a standard GeoTools function (gt-main) serving a legitimate need: a table column holds JSON data (e.g. an array of objects), and the user wants to filter records whose JSON array contains a given value at a given path (pointer).

Function signature (JsonArrayContainsFunction.java):

gt-main — org/geotools/filter/function/JsonArrayContainsFunction.java

public static FunctionName NAME = new FunctionNameImpl(

"jsonArrayContains",

Boolean.class,

parameter("column",  String.class),  // the JSON column

parameter("pointer", String.class),  // path, e.g. /arr/name

parameter("expected", String.class));  // <-- the value to look for (the attacked argument)

 

Being a standard function, it can be invoked directly in a CQL_FILTER — well within reach of an anonymous user. All three arguments are of type String and all are client-supplied.

3. Root-cause analysis at the source level

The whole problem lives in the FilterToSqlHelper class of the gt-jdbc-postgis module. We will walk from where the function is "allowed to go to SQL", through where it is encoded, and finally to the sink.

3.1. Registered as "encodable" — unconditionally

GeoTools uses FilterCapabilities to decide which filters can be "pushed down" to the database (instead of being evaluated in the JVM). In the PostGIS dialect this list is built in createFilterCapabilities(boolean encodeFunctions).

FilterToSqlHelper.java — jsonArrayContains is added outside the encodeFunctions flag

public static FilterCapabilities createFilterCapabilities(boolean encodeFunctions) {

...

caps.addType(JsonArrayContainsFunction.class);  // line 184 — OUTSIDE the if(encodeFunctions) block

...

if (encodeFunctions) {  // the str*/math functions live only in here

caps.addType(FilterFunction_strConcat.class);

caps.addType(FilterFunction_strStartsWith.class);

...

}

}

 

The crucial point: JsonArrayContainsFunction is added outside the if (encodeFunctions) block. While most other functions (str*, math...) are only encoded to SQL when an administrator enables the encodeFunctions option on the datastore, jsonArrayContains is always considered encodable. As a result capabilities.fullySupports(filter) always returns true and the filter is always compiled to SQL — regardless of configuration.

Figure 3 — jsonArrayContains is registered outside the encodeFunctions gate, so it is always pushed down to SQL, unlike the str* functions which must be explicitly enabled.

3.2. Encoding the function: two branches by PostgreSQL version

When the filter is pushed down, encodeJsonArrayContains builds the SQL. It has two branches:

FilterToSqlHelper.java:763 — encodeJsonArrayContains

private void encodeJsonArrayContains(Function jsonArrayContains) throws IOException {

PropertyName column = (PropertyName) getParameter(jsonArrayContains, 0, true);

Literal jsonPath  = (Literal)  getParameter(jsonArrayContains, 1, true);

Expression expected =  getParameter(jsonArrayContains, 2, true);



String[] strJsonPath = escapeJsonLiteral(jsonPath.getValue().toString()).split("/");

if (strJsonPath.length > 0) {

if (jsonPathExistsSupported) {  // PostgreSQL >= 12 (the modern default)

out.write("jsonb_path_exists(");

column.accept(delegate, null);

out.write("::jsonb, '$");

out.write(constructPath(strJsonPath));  // pointer  -> ESCAPED

out.write(" ? ");

out.write(constructEquality(strJsonPath, expected));  // expected -> NOT escaped

out.write("')");

} else {

... // the @> branch for PG < 12 — escaped, NOT affected

}

}

}

 

The jsonPathExistsSupported branch (PostgreSQL >= 12, i.e. the default on every modern deployment) is the vulnerable one. Note the asymmetry right here: pointer is run through escapeJsonLiteral(...) before being written, while expected goes to constructEquality(...).

3.3. The sink: pointer is escaped, expected is not

Compare the two helpers. The pointer handler:

pointer goes through this — safe

private static String escapeJsonLiteral(String literal) {

return EscapeSql.escapeLiteral(literal, true, true);  // ' -> ''  (prevents break-out)

}

 

But the expected handler takes the raw value and interpolates it directly, never calling escapeJsonLiteral:

FilterToSqlHelper.java:791 — constructEquality (line 803 is the sink)

private String constructEquality(String[] jsonPath, Expression expected) {

int lastIndex = jsonPath.length - 1;

Object value = ((LiteralExpressionImpl) expected).getValue();  // RAW value

if (value instanceof Integer i) return "(@.%s == %d)".formatted(jsonPath[lastIndex], i);

if (value instanceof Float f)  return "(@.%s == %f)".formatted(jsonPath[lastIndex], f);

if (value instanceof Double d)  return "(@.%s == %f)".formatted(jsonPath[lastIndex], d);

return "(@.%s == \"%s\")".formatted(jsonPath[lastIndex], value);  // <-- SINK: value NOT escaped

}

 

When expected is a string (the common case), the last branch runs: the client-controlled value is dropped straight between "...". Since the only escaper (escapeJsonLiteral) is applied only to pointer, a single quote ' in expected is not doubled and can therefore close the enclosing SQL string literal early.

Figure 2 — All three arguments are client-controlled, but only pointer is escaped. expected flows raw into the single-quoted SQL string — this is the essence of the bug.

3.4. Prepared statements do not help either

A natural reflex is: "GeoServer uses prepared statements by default, doesn't parameterization stop SQLi?". The answer is no, and the reason is in the code path.

Both dialects — PostgisFilterToSQL (plain statement) and PostgisPSFilterToSql (prepared statement) — route visit(Function) to the same helper.visitFunction(...) → encodeJsonArrayContains. And in that method the value is written via out.write(...) as raw SQL text, never becoming a ? placeholder to be bound. In other words, the value is welded into the SQL string before parameterization ever gets a chance to act.

Design lesson: prepared statements only protect values that actually pass through a placeholder. Any branch that builds SQL by string concatenation (out.write, StringBuilder, String.format...) sits outside that protection.

3.5. The generated SQL

For the call jsonArrayContains(col, '/arr/name', VALUE) on PostgreSQL >= 12, GeoTools emits:

jsonb_path_exists( col::jsonb, '$.arr ? (@.name == "VALUE")' )

 

The entire JSONPath expression sits inside one single-quoted SQL string literal '...'. VALUE is attacker-controlled and unescaped, so a single ' is enough to escape the string and have PostgreSQL interpret what follows as SQL. Also note that col::jsonb is fixed — this becomes an important precondition for exploitation (Section 4).

4. Exploitation preconditions & limits (a critical view)

What makes this bug interesting is that it is clear in theory, yet whether exploitation succeeds depends on several real-world conditions. Understanding them separates "reached SQL" from "actually exfiltrated data", and avoids both false positives and false negatives when scanning.

4.1. The layer must be on a JDBC/PostGIS store

If the layer is backed by a non-JDBC store (shapefile, GeoJSON...), GeoTools evaluates jsonArrayContains in the JVM using the Jackson library and never generates SQL. The tell-tale sign is an error like:

com.fasterxml.jackson.core.JsonParseException: Unrecognized token '...'

 

Seeing this means the layer is not exploitable via this vector. It is a common trap when scanning many layers: the function "runs", but not on the database.

4.2. The target column must be castable to ::jsonb

    Because col::jsonb is fixed in the SQL, the target column must be castable to jsonb: either a json/jsonb column, or one whose values are all NULL. A text column holding non-JSON content raises invalid input syntax for type json — which only proves SQL was reached, not that data was extracted.

Ironically, GeoTools maps a PostGIS jsonb column to xsd:string in DescribeFeatureType. So you cannot tell a json column from a text column by the schema alone — you must test it directly.

4.3. Version & plugin conditions

  • PostgreSQL >= 12: only the jsonb_path_exists branch is affected. On PG < 12, GeoTools uses the @> operator branch which escapes fully → not affected. Modern systems default to >= 12.
  • The JSON plugin must be loaded: some builds ship without it, so the function is not registered; then the CQL parser returns Function not found and the vector does not exist server-wide.
  • The backend must be PostGIS: this is the PostGIS dialect. Oracle/SQL Server have different encoders (the Oracle variant is a narrower JSON-path injection, not full SQLi).

4.4. A probing pitfall: PostgreSQL constant folding

When testing whether a column is castable to ::jsonb, using a constant condition (like 1=1) is misleading: PostgreSQL constant-folds X OR true into true and skips evaluating the cast. A text column then appears to "pass", but real extraction (where the condition is no longer constant) forces the cast to run and errors out. The correct approach is to probe with a STABLE function (not folded, e.g. current_database()) to force the database to actually evaluate the cast. This is a memorable detail, but it belongs to probing technique, so we only state the principle here.

5. Impact and real-world evidence

Because this is a single query over JDBC (stacked queries like ; DROP TABLE are usually not allowed), exploitation is mainly data read (blind / error-based), with possible escalation under the right conditions.

  • Boolean-based blind: a true condition returns rows, a false one returns none — inferring data bit by bit.
  • Time-based blind: use a delay (e.g. a sleep function) to distinguish true/false when row counts are not observable.
  • Error-based: cast a chosen value to a numeric type so PostgreSQL leaks the value verbatim in the error message — the fastest and most reliable route (one request reads one value).

The image below is real evidence (system identifiers redacted): a WFS GetFeature request makes the database throw a numeric-type error that leaks the connection's current_user value directly in the ExceptionText of the response.

5.1. Escalation when the DB account is over-privileged

If the account GeoServer uses to connect to the database is a PostgreSQL superuser, the SQL injection escalates to server-side file reads and even OS command execution (via PostgreSQL's file-read / program-execution mechanisms). This is precisely why least-privilege on the database account is one of the most important mitigations.

5.2. A signal-classification matrix (reading scan results)

In practice, the same request can return many different signals. The table below helps quickly interpret what each signal means and whether the layer is exploitable:

Figure 4 — Triage tree: each signal in the response maps to a different state of the vulnerability.

Signal in the responseMeaningExploitable?
Function not found / Could not parse CQLJSON plugin not installedNo (server-wide)
JsonParseException / Unrecognized tokenNon-JDBC store -> runs in-memoryNo (not SQL)
column index is out of rangeReached SQL; keep the placeholder in the payloadYes
invalid input syntax for type jsonReached SQL but column not jsonb-castableReach only, no exfil
invalid input syntax for type numeric: "..."Error-based leak succeededYes (data leaked)
Row-count difference / timing delayValid jsonb column on PostGISYes (blind)

 

6. Remediation and mitigation

In order of preference, from definitive to temporary:

  1. Upgrade GeoServer/GeoTools to a patched build — the definitive fix. At the source level, the patch must escape the expected value in constructEquality (e.g. reuse escapeJsonLiteral) or, better, pass the value as a bound parameter instead of concatenating.
  2. Least-privilege database account: never connect as a superuser; grant SELECT only on the required tables/layers. This blocks file-read/RCE escalation and limits the blast radius.
  3. Block at the WAF / reverse proxy: reject requests containing jsonArrayContains in the query string as a temporary defense-in-depth measure.
  4. Tighten OWS security: disable anonymous access to sensitive PostGIS layers; apply GeoServer service/data security per workspace/layer.
  5. Monitoring: watch database logs for anomalies such as sleep functions, invalid input syntax for type json, and column index is out of range.

7. Conclusion

The bug is essentially tiny — one unescaped argument in a JSON helper. But it sits on a pre-authentication attack surface, reaches the database even under prepared statements, and is registered for SQL push-down unconditionally. The "hard" and interesting part is not building the attack string, but correctly understanding the operational conditions: is the layer JDBC-backed, is the column jsonb-castable, the PostgreSQL version, the constant-folding behavior, and the in-memory-vs-SQL boundary. Those insights are what turn a theoretical finding into a reliable assessment.

On the defensive side, the takeaway is a classic one: every piece of user-controlled data must be parameterized or escaped consistently — no exceptions for "it's just a harmless JSON pointer".

May đo bảo mật theo
quy mô & nhu cầu của Tổ chức

Tìm kiếm đơn vị Bảo vệ An ninh mạng cho tổ chức của bạn?