SQL Injection: How Attackers Break Your Database and How Django's ORM Stops Them

SQL Injection: How Attackers Break Your Database and How Django's ORM Stops Them

Django Security Series β€” Post 1 | Series I: Injection Attacks
OWASP A05:2025 β€” Injection | Reading time: ~19 min

πŸ§ͺ Run it yourself. This attack has a companion lab in django-security-lab: a vulnerable Django view and a secure one. Static analysers β€” Bandit and Semgrep's community Django ruleset β€” scan them in CI on every commit (both flag the vulnerable view, silent on the secure one), and a sqlmap run against the booted lab pulls a CTF flag straight out through the injection. Clone it and reproduce every step.

The Django Security Series is my ongoing project to document what I learn about application security β€” mapping the most dangerous web attacks to their concrete prevention in Python and Django. I'm a lawyer who transitioned into software development. I have been building Django applications for a decade and hold the CompTIA Network+, Security+ and CySA+ certifications. For each attack I build a small runnable lab β€” a vulnerable view and a secure one β€” and scan it with the industry-standard static analysers a security analyst actually runs, Bandit and Semgrep. All of it is published in the companion django-security-lab repository with its tests green in CI. I write these posts to solidify what I'm learning, and I publish them, along with the labs, so others on the same path can clone the repo and re-run everything themselves. Attack techniques are mapped to MITRE ATT&CK tactics and techniques, prevention guidance is anchored to the official Django security documentation, and each post draws on two practitioner books: Web Security for Developers (Malcolm McDonald) and Secure Web Application Development with Python and Django (Matthew Baker, Apress).

SQL Injection is where we start β€” and where something surprised me. I run PetiΓ§Γ£o Brasil, a civic-tech platform for legally-binding public petitions with ICP-Brasil digital signatures. The platform stores CPF numbers and digital certificate data alongside the petitions each person signed β€” and a signature on a political petition reveals a political opinion, which LGPD classifies as sensitive personal data. A single query there that leaked those fields could be an incident the platform must report to the ANPD and to the people affected, not just a bug, and that stake is what made me want to understand why Django's ORM keeps the safe path the default β€” what exactly the ORM does, and what it would take to accidentally step outside that protection. So I built the lab for this one and traced exactly where that protection holds and where a single careless line breaks it.

SQL Injection has appeared in every edition of the OWASP Top 10 since 2003. In the 2021 report, 94% of applications were tested for some form of injection β€” the most broadly assessed category in the dataset, with an average incidence rate of 3.37% and peaks reaching 19%. Two decades of breaches, frameworks, and education have not eliminated it β€” because the misunderstanding is not about ignorance of the attack, but about a false sense of security from using an ORM.

Django's ORM does protect you by default. But developers who reach outside it β€” through raw(), extra(), string formatting, or third-party integrations β€” open a gap that attackers have been reliably finding and exploiting for as long as web applications have existed. In this post, I'm going to break down how SQL Injection actually happens at the query level, why Django's built-in parameterisation beats manual escaping every time, and the exact coding habits that can silently disable this protection.


The Attack: What It Is and How It Works

SQL Injection occurs when an attacker inserts SQL syntax into an input field that a web application later embeds directly into a database query β€” without proper separation of code and data.

The root cause almost always comes down to one basic mistake: treating user input as executable code instead of raw data. The database cannot tell the difference between the developer's intended SQL and the attacker's injected payload.

Consider a search box that looks notes up by title, built like this:

SELECT id, title, body FROM notes WHERE title LIKE '%[USER INPUT]%'

The attacker searches for ' UNION SELECT id, username, password FROM auth_user --. The quote closes the LIKE literal, UNION SELECT appends the rows of a second query, and -- comments out the %' left at the end. The database executes:

SELECT id, title, body FROM notes WHERE title LIKE '%' UNION SELECT id, username, password FROM auth_user -- %'

The second query has to return as many columns as the first, of compatible types, which the attacker works out by trial or reads off the page. After that the search results carry the rows of a table the search was never meant to touch, every username and password hash, and the page renders them as if they were notes.

The same unseparated string gives way in other directions too. A login form built this way falls to admin' --, which comments out the password check, or to a tautology such as ' OR '1'='1. When the page shows no query results, blind injection asks true-or-false questions instead and reads the answer from a changed page or a delayed one (pg_sleep()), and error-based injection reads data out of the database's own error messages. Stacked queries append a whole second statement after a ;. And Django's own ORM has had its edges: CVE-2025-64459, fixed in Django 5.2.8, was SQL injection through a crafted dictionary expanded into filter(), get() or Q().


Real-World Incidents

Heartland Payment Systems (2007–2008)

Beginning in late 2007, Albert Gonzalez and two co-conspirators breached Heartland Payment Systems β€” a company processing over 100 million card transactions per month for 250,000 businesses across the United States. The attackers used SQL injection in a web form to compromise Heartland's corporate network, spent almost six months working their way into the separate payment-processing network, then planted network sniffing malware to capture card data in plaintext. By the time forensic investigators identified the breach, approximately 130 million credit and debit card numbers had been stolen β€” the largest payment card breach ever recorded at the time. The entry point was not a sophisticated zero-day β€” it was a basic SQL injection in a web form, the same vulnerability pattern that appears in introductory web development tutorials.

Heartland reported losses of nearly $130 million and reached separate settlements with Visa, American Express, and issuing banks. For the Heartland conspiracy, Gonzalez was sentenced to 20 years and one day in federal prison, concurrent with a separate 20-year sentence for the TJX intrusions. In MITRE ATT&CK terms, the initial access maps to T1190 (Exploit Public-Facing Application); post-exploitation included credential harvesting and lateral movement to reach the card processing infrastructure. Heartland was removed from Visa's list of PCI DSS Validated Service Providers, although qualified assessors had certified it PCI-compliant several times while the vulnerability was present β€” the breach exposed the gap between passing a compliance assessment and finding an exploitable SQL injection. For Django developers, the lesson is direct: one SQL injection in a web form cost Heartland multi-party settlements and its validation as a payment processor. A parameterised query is not a performance optimisation; it is a core technical safeguard.

Source: Heartland Payment Systems: Lessons Learned from a Data Breach


Django's Default Protections

Standard ORM lookups pass user-supplied values to the database as parameters, never as SQL text. This is the core protection, and it stops where SQL is written by hand, in raw SQL and extra(), which is where the patterns below live.

In Web Security for Developers (Chapter 6: Injection Attacks), Malcolm McDonald defines a parameterised query as one where the SQL code and the data values are sent to the database through completely separate channels. The query template is parsed first and the data values are bound afterwards. Because the database has already decided what is code and what is data before the values arrive, it is structurally impossible for user input to alter the query's logic, regardless of what characters it contains. McDonald contrasts this with string-building approaches (concatenation, f-strings, % formatting), where code and data share a single channel and the database cannot distinguish one from the other. He describes parameterisation not as a sanitisation technique but as an architectural separation β€” the root cause of SQL injection is removed, not patched over.

This distinction matters. Escaping you write yourself is a mitigation, and it fails in the one place you forgot it; a parameter takes the decision out of your code entirely.

When you write:

Post.objects.filter(title=user_input)

Django generates:

SELECT * FROM blog_post WHERE title = %s

…and hands user_input to the database driver as a separate parameter. Your code never builds the SQL string. What the driver does next is a detail I had wrong when this post first went out, so it is worth being precise. With psycopg β€” version 2, or version 3 under Django's default settings β€” the driver itself converts the value into a correctly quoted SQL literal and sends one statement. On PostgreSQL, the two separate channels McDonald describes reach the server only with psycopg 3 and "server_side_binding": True in the database OPTIONS. The protection holds either way, because the conversion is done by the driver, by type, for every value, and never by a string your code assembled.

The UNION payload from The Attack is treated as the literal search string it looks like β€” not as SQL logic. The attack fails structurally, regardless of what characters the input contains.


The Vulnerable Pattern and the Fix

Pattern 1 β€” Raw SQL built with an f-string

The lab's search view does what a developer under time pressure does when an ORM query feels awkward: it drops to a cursor and formats the search term into the SQL. The fix is the lab's secure view, and it is one line:

 def search(request):
     q = request.GET.get("q", "")
-    with connection.cursor() as cursor:
-        cursor.execute(
-            f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"
-        )
-        rows = cursor.fetchall()
+    results = Note.objects.filter(title__icontains=q).values_list("id", "title", "body")

This is the search from The Attack, and the lab's payload has the same shape: ' UNION SELECT id, 'x', value FROM sqli_flag -- appends the rows of the table that holds the CTF flag. Both views are in the lab in full: views_vulnerable.py and views_secure.py.

The fix looks as though it should have the same problem, since icontains is a LIKE with % on either side of the term. It doesn't. On PostgreSQL Django generates UPPER("sqli_note"."title"::text) LIKE UPPER(%s) (LIKE %s on MySQL, LIKE %s ESCAPE '\' on SQLite) and passes %q% as the parameter: the wildcards go into the value, not into the SQL. Django also escapes any % or _ the user typed, so a search for 50% looks for those three characters instead of becoming a wildcard of its own.

Pattern 2 β€” % formatting is not a placeholder

String concatenation, .format() and Note.objects.raw(f"...") are the same mistake in different syntax: each builds one finished string before the database sees it. The spelling that fools people is %, because the safe form uses the same two characters:

-cursor.execute("SELECT id, title FROM sqli_note WHERE title = '%s'" % title)
+cursor.execute("SELECT id, title FROM sqli_note WHERE title = %s", [title])

A handful of characters apart, and structurally opposite. Python's % operator fills the value in before execute() is called, so the driver receives a finished string with the attacker's quote already inside it. In the second line the %s is not Python formatting at all. It is a placeholder, and the value travels to the driver in a separate list, to be turned into a literal there. When you write SQL by hand that separation is your job, and the comma that hands [title] over on its own is all of it. (Keep the quotes off the placeholder too: Django's own documentation flags '%s' as injectable even when the value is passed in params.)

Every raw-SQL entry point takes its parameters the same way: cursor.execute(sql, [params]) for results that are not model instances, Note.objects.raw(sql, [params]) when you want model instances from SQL the ORM cannot express (a recursive CTE, for example), and RawSQL(sql, [params]) for one computed expression inside an otherwise ORM-built query. The ORM stays the default; the other three are for what it cannot say.

Pattern 3 β€” extra() with the value in the string

extra() was Django's original escape hatch for SQL fragments inside an ORM query, and older codebases are full of it. The documentation now calls it "an old API that we aim to deprecate at some point in the future" and says bugs in it are no longer fixed, so the security fix and the migration are the same edit:

-Note.objects.extra(where=[f"title = '{title}'"])
+Note.objects.filter(title=title)

If you have to touch an extra() call before you can replace it, move the value out of the string: extra(where=["title = %s"], params=[title]).

Two more controls fix no single pattern. They are there for the day one of the fixes above gets missed. The first is least privilege, which McDonald frames as defence in depth across every layer of the stack (Web Security for Developers, Chapter 6): the database account your application uses at runtime is neither a superuser nor the owner of the tables β€” a separate, privileged account creates and migrates them β€” and it holds grants only on the tables the application uses. A UNION can read only what that account can read, so a table no runtime account is granted stays out of reach of an injection that slips through.

The second is input validation, which narrows what reaches the query without replacing parameterisation. Where the input is a number it removes the attack surface outright. A <int:pk> URL converter answers 404 to anything that is not digits, so the view only ever receives a Python int; for a ?id= query parameter, int() in the view does the same job, as long as you turn its ValueError into a 404. There is no string left to inject into. A free-text search cannot be constrained that way, because it has to accept quotes and keywords. A forms.CharField(max_length=200) still hands you a stripped, bounded str from cleaned_data instead of raw request data, but the protection against injection remains the parameterised query behind it.


The Analyst's View

Everything above is developer work, but the CySA+ material frames it in a vocabulary worth borrowing β€” mostly because it changes how you argue for the work. Parameterisation is a preventive control: it doesn't detect SQL injection or reduce its impact, it removes the vulnerability class outright by making the attack structurally unreachable. Input validation is a second preventive layer, weaker on its own β€” a well-crafted payload can slip past a filter β€” but valuable because type coercion eliminates the attack surface entirely for numeric inputs rather than merely narrowing it. Least privilege is a third layer, aimed at impact rather than at the injection: it assumes the first two failed and limits what the attacker can read to the tables the application's own account may read.

That taxonomy is the practical half of "defence in depth," and it's why the three layers are worth keeping even though the first one alone is structurally sufficient. Each holds when the others are bypassed, so an analyst reviewing this code doesn't stop at "the query is parameterised." The question is what happens when a control fails β€” and a runtime account limited to the tables it needs is the difference between exposing what the application stores and exposing everything else on the database server. What the list leaves out is as telling as what's in it. A web application firewall is the control most teams reach for first, and in analyst vocabulary it mitigates rather than remediates β€” it pattern-matches payloads at the edge, which buys time and can be evaded by encoding tricks, comment splitting, or an endpoint the rules never covered. The practical consequence: when a scan reports this finding and someone answers "the WAF blocks it," the finding doesn't close. It gets a compensating control and stays open until the query is parameterised. And none of the three layers tells you an attempt happened β€” that's a detective control, and it lives in logging and monitoring rather than in the query layer.


Catching It Automatically

Testing Your Defence

One test is enough to prove the fix: send the UNION payload to the search view and check that the flag is not in the response. This is the one the lab uses:

UNION_PAYLOAD = "' UNION SELECT id, 'x', value FROM sqli_flag -- "

def test_secure_view_does_not_leak_flag(self):
    resp = self.client.get("/sql-injection/secure/", {"q": UNION_PAYLOAD})
    self.assertEqual(resp.status_code, 200)
    self.assertNotContains(resp, "FLAG{")  # payload treated as a literal search term

setUp creates one ordinary note and the flag row. The status check proves nothing on its own, since the vulnerable view answers 200 too; the assertion that matters is the missing flag. Point the same test at /sql-injection/vulnerable/ and it fails, which is what makes it a test of the control rather than of the page. In your own project, use your search URL and a row your search must never return. The lab's tests.py adds the other two halves: the exploit succeeds on the vulnerable view, and the secure view still finds real matches.

Scanning It

The unit test proves the fix against the demonstrated payload. Scanning is the other half β€” and it is the analyst's job in the CySA+ sense: not writing detection logic, but running the off-the-shelf tools and reading what they say. Two kinds fit this class. Static analysis (SAST) reads the source and flags the dangerous construct before it ships. Dynamic analysis (DAST) attacks the running app the way a real attacker would. I ran both against the committed lab, so every result below is reproducible from a clone.

Reading the source β€” Bandit and Semgrep

Both are static analysers: they read the source without running it. Bandit, from the PyCQA project, walks a Python file's syntax tree looking for known-risky constructs β€” each check has a B-number, and SQL built by string formatting is B608. It needs no configuration; you point it at a path and it reports. Semgrep is a multi-language pattern scanner: instead of matching text, it matches code by structure (an f-string inside an execute() call, regardless of variable names or spacing), and its taint-mode rules can follow a value from where it enters the program to where it is used. You run it against a ruleset; its community packs are curated, community-maintained rulesets, and p/django is the Django one. I pointed both at the two lab views:

bandit -r labs/post_01_sql_injection/
semgrep scan --config p/django labs/post_01_sql_injection/

Both catch the vulnerable view. Bandit flags the f-string as B608:

>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
   Severity: Medium   Confidence: Medium
   CWE: CWE-89 (https://cwe.mitre.org/data/definitions/89.html)
   Location: labs/post_01_sql_injection/views_vulnerable.py:26:14
25          cursor.execute(
26              f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"

Semgrep reaches the same line by a different route. Bandit's B608 is a heuristic: it flags any query assembled with an f-string, %, .format(), or +, whether or not user input ever reaches it. Semgrep's community Django rule is not a taint-mode rule, but it is narrower: its patterns fire only when a value read from request and the execute() call that receives it sit in the same function, so its message names the request as the source:

python.django.security.injection.sql.sql-injection-using-db-cursor-execute.sql-injection-db-cursor-execute
  User-controlled data from a request is passed to 'execute()'. This could lead to a SQL
  injection ... Instead, use django's QuerySets, which are built with query parameterization.
  20┆ q = request.GET.get("q", "")
  ...
  26┆ f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"

The two are complementary, not redundant. Bandit's heuristic casts the widest net β€” it will flag a built SQL string even in a helper function with no request in sight, which is exactly the code you get once a view hands user input down to a repository or service layer; the cost is the occasional false positive on constant SQL that never carries user data. Semgrep's rule is more precise β€” it stays silent unless the request read and the execute() call appear together in one function β€” but that same precision means it misses the helper Bandit would catch, because with no request in scope there is nothing for its patterns to start from. On the secure ORM view both report zero findings: no raw SQL string is built, so there is nothing to flag. The full runs are committed in the lab under scans/.

Attacking it live β€” sqlmap

Static analysis reads code and reasons about it; sqlmap is dynamic analysis β€” it never sees the source. It is an automated SQL-injection tool that fires crafted payloads at a live endpoint, watches how the responses change, and, once it finds an injectable parameter, can fingerprint the database and dump tables out of it. That makes it the closest thing to what a real attacker does, and the proof that the bug is not just theoretical.

I gave it the obvious command β€” point it at the endpoint, hand it the parameter, and let it run:

sqlmap -u 'http://127.0.0.1:8000/sql-injection/vulnerable/?q=1' \
       -p q --batch --dump -T sqli_flag

It needs no tuning. sqlmap fingerprints the back-end as PostgreSQL on its first probes, confirms q is injectable four different ways, and then walks straight into the one table this search feature has no business touching:

Parameter: q (GET)
    Type: boolean-based blind
    Title: PostgreSQL AND boolean-based blind - WHERE or HAVING clause (CAST)
    Payload: q=1' AND (SELECT (CASE WHEN (1267=1267) THEN NULL ELSE CAST((CHR(89)||CHR(69)||CHR(100)||CHR(107)) AS NUMERIC) END)) IS NULL-- iJxI

    Type: error-based
    Title: PostgreSQL AND error-based - WHERE or HAVING clause
    Payload: q=1' AND 7677=CAST((…(SELECT (CASE WHEN (7677=7677) THEN 1 ELSE 0 END))::text…) AS NUMERIC)-- XUkB

    Type: stacked queries
    Title: PostgreSQL > 8.1 stacked queries (comment)
    Payload: q=1';SELECT PG_SLEEP(5)--

    Type: time-based blind
    Title: PostgreSQL > 8.1 AND time-based blind
    Payload: q=1' AND 4539=(SELECT 4539 FROM PG_SLEEP(5))-- hnyv
back-end DBMS: PostgreSQL
Table: sqli_flag
[1 entry]
+----+--------------------------------------------------+
| id | value                                            |
+----+--------------------------------------------------+
| 1  | FLAG{sql_injection_via_raw_string_interpolation} |
+----+--------------------------------------------------+

None of the four is the UNION the lab's test sends: sqlmap found the blind, error-based and stacked routes named at the end of The Attack, and read the table through them. Point the same command at /sql-injection/secure/ and sqlmap gives up β€” "all tested parameters do not appear to be injectable" β€” because the ORM's parameterisation leaves it nothing to work with. One honest artefact worth naming: the raw-SQL view returns HTTP 500 on sqlmap's malformed probes (unbalanced quotes become database syntax errors), which sqlmap logs as it runs β€” itself a tell that the view is feeding attacker-shaped input straight to the database. Both runs β€” the successful dump and the clean secure scan β€” are committed under scans/ so you can read them without booting anything.

For this lab that is the whole detection story, and it takes no heroics: SQL injection is old, well-understood, and covered end to end by tools an analyst already has on hand β€” SAST reads the code, DAST proves the exploit. You do not need to write a custom rule to catch it here β€” you need to run the standard ones and read what each reports. A project outgrows off-the-shelf rules when its queries go through custom APIs or wrappers the rulesets do not model, or on a pattern no generic rule can see (mass assignment, IDOR, a privilege-escalation shape). That is where a hand-written Semgrep rule earns its place β€” and where later posts show several. This lab is not that case.


The habit I took away from writing this post: whenever a query is being assembled with an f-string, %, + or .format(), stop and pass the value as a parameter instead. In the lab, it is the only change between the two views that matters. Post 2 moves to the browser side: Cross-Site Scripting (XSS), why mark_safe is as dangerous as an unparameterised query, and how to render user-supplied Markdown without opening an XSS vector.

Further Reading

Next in this series β†’ Post 2: Cross-Site Scripting (XSS): Stored, Reflected and DOM-Based Attacks β€” and Why mark_safe and Unsafe Markdown Are Equally Dangerous

← Back to the series