Safeguard
Vulnerability Analysis

Django QuerySet.explain SQL injection (CVE-2022-28346)

A Django ORM flaw let unvalidated input reach EXPLAIN and annotate() SQL generation. Here's the CVE-2022-28347 impact, fix, and defense playbook.

Safeguard Research Team
Research
7 min read

Django's ORM shipped a subtle but serious flaw in its QuerySet.explain() method: the format keyword argument — and, on PostgreSQL, arbitrary options keys — were passed into the generated EXPLAIN SQL statement without being checked against an allowlist. Any application that forwards user-influenced values into .explain() (for example, a debug toolbar, an internal query-profiling endpoint, or an admin diagnostics view) can be coerced into executing attacker-controlled SQL fragments. The issue was assigned CVE-2022-28347 and patched alongside a related ORM injection issue, CVE-2022-28346, affecting annotate(), aggregate(), and extra(). Together they're a useful case study in how even a mature, security-conscious framework like Django can leave a narrow but real SQL injection window open in a rarely-audited corner of its API.

What went wrong

QuerySet.explain() is a convenience method that wraps a query in the database's EXPLAIN statement so developers can inspect its execution plan — e.g. MyModel.objects.filter(...).explain(format="json"). Internally, Django builds the EXPLAIN clause by string-interpolating the format value and, for PostgreSQL, any additional keyword arguments supplied as options (such as ANALYZE, BUFFERS, COSTS, TIMING, SUMMARY). Before the fix, Django validated that these values were "sane" in a way that was insufficient to prevent an attacker from smuggling additional SQL through a crafted format string or option name.

Because explain() returns the raw query plan as text, and because query plans can be run against arbitrary querysets, the real-world attack surface is any code path where a request parameter, feature flag, or admin-configurable setting is forwarded into .explain(format=user_input) or .explain(**user_supplied_options). That pattern shows up more often than teams expect — internal tooling that lets support staff or power users tweak how a diagnostic query is displayed is a classic (if unglamorous) source of this kind of injection.

CVE-2022-28346 is the sibling issue: QuerySet.annotate(), aggregate(), and extra() accept column aliases via **kwargs, and if an application expands a dictionary built from untrusted input into these methods (queryset.annotate(**request_data)), the alias names themselves could be used to inject SQL. Both bugs share the same root cause — trusting caller-supplied strings to end up safely inside generated SQL without an allowlist — and both were fixed in the same security release.

Affected versions and components

  • Django 2.2 before 2.2.28
  • Django 3.2 before 3.2.13
  • Django 4.0 before 4.0.4
  • The Django main development branch prior to the fix (later shipped as part of 4.1)

Any project pinning an unpatched Django release — directly, or transitively through a framework wrapper, internal platform library, or vendored dependency — is exposed if it exercises the vulnerable code paths. Because Django is frequently pulled in as a second- or third-order dependency inside internal tooling, admin panels, and data platforms, supply chain visibility matters as much as direct dependency pinning here: teams that only scan their top-level requirements.txt can miss a vulnerable Django install buried inside a plugin or shared internal package.

Severity context: CVSS, EPSS, and KEV

NVD's mechanical scoring for CVE-2022-28347 lands in the critical range (CVSS v3.1 base score around 9.8) under a network-exploitable, no-privileges-required vector, which reflects the worst-case scenario of unauthenticated SQL injection. In practice, real-world exploitability is conditional: the vulnerability is only reachable if application code actually passes attacker-influenced data into explain()'s format or options parameters. That distinction is exactly why the Django project itself described the issue in more measured terms than the raw CVSS number suggests — it's a framework-level guardrail failure, not a guaranteed remote-code path in every Django app.

EPSS scoring for this CVE has stayed low relative to headline critical-severity vulnerabilities, consistent with the fact that it requires a specific, less-common coding pattern to be exploitable rather than being wormable out of the box. As of this writing, CVE-2022-28347 does not appear on CISA's Known Exploited Vulnerabilities (KEV) catalog, meaning there's no confirmed evidence of in-the-wild exploitation at scale. That said, "not on KEV" is not the same as "safe to ignore" — plenty of quietly exploited SQL injection bugs never make a KEV list, particularly ones gated behind an internal admin surface that attackers reach only after an initial compromise.

Timeline

  • Pre-disclosure: The issue was reported privately to the Django security team through the project's responsible disclosure process, alongside the related annotate()/aggregate()/extra() injection (CVE-2022-28346).
  • April 11, 2022: The Django team issued coordinated security releases — Django 4.0.4, 3.2.13, and 2.2.28 — patching both CVE-2022-28346 and CVE-2022-28347 in the same advisory.
  • April 2022: CVE identifiers were published and the issues were added to the NVD, along with the Django project's own security advisory describing the fix and mitigation.
  • Ongoing: Downstream Linux distributions, container base images, and dependency scanners backported or flagged the fix over the following weeks, though — as with most Python ecosystem CVEs — long-tail exposure persisted in projects that don't track Django security releases closely.

Remediation steps

  1. Upgrade Django immediately to 2.2.28, 3.2.13, 4.0.4, or any later release. All subsequent Django versions include the fix by inheritance.
  2. Audit every call site of QuerySet.explain() in your codebase. Search for .explain( and trace whether format or any options kwargs originate from request parameters, query strings, feature-flag configuration, or admin-editable settings.
  3. Audit annotate(), aggregate(), and extra() calls for dictionary expansion patterns (**some_dict) where the dictionary keys could be influenced by user input — this closes the CVE-2022-28346 gap in the same pass.
  4. Add explicit allowlists as defense in depth, even post-patch. Restrict accepted format values to Django's supported set (text, json, yaml, xml) and validate any options keys against a known-safe list before they ever reach the ORM.
  5. Remove or lock down debug/diagnostic endpoints that expose query-plan tooling to end users; if these are needed, gate them behind strong authentication and internal-network restrictions rather than relying solely on the framework patch.
  6. Verify the fix across your full dependency graph, not just your primary application repo — internal libraries, admin dashboards, and data-science tooling built on Django are common places for an outdated pin to survive unnoticed.
  7. Re-run SAST/SCA scans post-upgrade to confirm the vulnerable version string is gone everywhere it was previously detected, including in container images and lockfiles that may cache an old resolution.

How Safeguard Helps

Vulnerabilities like CVE-2022-28347 are a textbook case for why raw CVSS scores can't drive prioritization on their own — the real question is whether your code ever calls .explain() or .annotate()/.extra() with attacker-influenced input, and Safeguard's reachability analysis answers exactly that by tracing data flow from request boundaries into vulnerable ORM call sites rather than flagging every Django install by version alone. Griffin AI layers on top of that analysis to explain, in plain language, which of your services actually exercise the vulnerable code path, cutting through alert noise so security and engineering teams aren't chasing false positives across dozens of repos. Safeguard's SBOM generation and ingest capabilities give you continuous visibility into every place Django appears — including nested and transitive dependencies inside internal tooling — so a vulnerable version buried three layers deep doesn't go undetected the way it often does with top-level-only scanning. And where remediation is confirmed safe, Safeguard can open auto-fix pull requests that bump Django to a patched release and flag risky .explain()/.annotate() call sites for manual review, shrinking the time between disclosure and a merged fix.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.