Skip to content

Why SQL Injection Still Works in 2026: The McKinsey AI Breach Lesson

Problem

I saw the headline and did a double-take: McKinsey, one of the world’s most prestigious consulting firms, had 46.5 million internal messages stolen by an AI agent.

The attack vector? SQL injection. In 2026.

A Reddit commenter captured my exact reaction:

“Not sure however how an SQL injection is still possible these days.”

I’ve been a developer for years. I know about parameterized queries. I know about ORMs. I know SQL injection is “solved.” So how did a company with dedicated security teams and Fortune 500 clients fall victim to the oldest trick in the book?

The answer changed how I think about security.

What I Found

The breach report laid out the attack path:

“The agent started by discovering exposed API documentation… From there, it found a SQL injection vulnerability in the search functionality and used it to extract data from the production database directly.”

The damage:

  • 46.5 million internal messages
  • 728,000 files with client data
  • 57,000 user accounts
  • 95 system-level control prompts

All from a single search function.

Why This Is Still Happening

I’ve written vulnerable code. I’ve seen teammates do it. Here’s why SQL injection refuses to die:

1. ORM False Security

ORMs promise safety, but they don’t prevent you from writing raw queries:

# Developer thinks: "I use an ORM, I'm safe"
from sqlalchemy import text
# But then writes this:
query = text(f"SELECT * FROM messages WHERE title LIKE '%{user_input}%'")

The ORM didn’t save you. You bypassed it.

2. Search Functions Are Complex

This is exactly where McKinsey got hit. Search UIs with multiple filters tempt developers into dynamic query building:

# Common pattern in search functions
query = "SELECT * FROM messages WHERE 1=1"
if title_filter:
query += f" AND title LIKE '%{title_filter}%'" # VULNERABLE
if date_filter:
query += f" AND created_at > '{date_filter}'" # VULNERABLE
if author_filter:
query += f" AND author = '{author_filter}'" # VULNERABLE

Each concatenation is an injection point. Multiply this across dozens of search functions in an enterprise codebase, and you’ve got a minefield.

3. Legacy Code Never Dies

McKinsey has been around for decades. Their codebase likely contains:

Legacy Code Timeline
─────────────────────────────────────────────────────────►
1990s 2000s 2010s 2020s
│ │ │ │
▼ ▼ ▼ ▼
Raw SQL Raw SQL ORM Added Modern Dev
(vulnerable) (vulnerable) (but still (still using
raw queries) old code)

That search function could have been written 15 years ago. It’s still running.

4. “Internal Tool” Blind Spot

Teams assume internal tools are safe behind authentication. But:

  • The McKinsey AI agent found exposed API documentation
  • Internal doesn’t mean isolated
  • A single compromised account exposes everything

5. Speed Over Security Culture

“Move fast” often means skipping input validation:

# What ships under deadline pressure
def search_messages(query):
# TODO: Add input validation
# TODO: Use parameterized queries
# For now, just ship it
return db.execute(f"SELECT * FROM messages WHERE content LIKE '%{query}%'")

The TODO never gets done. The vulnerability ships.

The Attack: How It Actually Works

Let me show you what the AI agent likely exploited:

The Vulnerable Code

# McKinsey's search function (hypothetical but typical)
@app.route('/api/search')
def search_messages():
keyword = request.args.get('q', '')
query = f"""
SELECT id, title, content, author_id, created_at
FROM messages
WHERE title LIKE '%{keyword}%'
OR content LIKE '%{keyword}%'
ORDER BY created_at DESC
"""
return execute_query(query)

The Normal Request

GET /api/search?q=project+deadline
SQL Executed:
SELECT id, title, content, author_id, created_at
FROM messages
WHERE title LIKE '%project deadline%'
OR content LIKE '%project deadline%'
ORDER BY created_at DESC

Looks harmless. But watch what happens with malicious input.

The Injection Attack

GET /api/search?q=' UNION SELECT id, content, NULL, author_id, created_at FROM messages--
SQL Executed:
SELECT id, title, content, author_id, created_at
FROM messages
WHERE title LIKE '%' UNION SELECT id, content, NULL, author_id, created_at FROM messages--%'
OR content LIKE '%' UNION SELECT id, content, NULL, author_id, created_at FROM messages--%'
ORDER BY created_at DESC

The -- comments out the rest. The UNION injects a second query. The attacker now has all message content.

Why AI Agents Are Dangerous Attackers

Traditional attackers probe manually. AI agents:

┌─────────────────────────────────────────────────────────────┐
│ AI Agent Attack Flow │
│ │
│ ┌──────────────┐ │
│ │ Find API │ │
│ │ Documentation│ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Map All │─────►│ Identify │ │
│ │ Endpoints │ │ Search Funcs │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Extract All │◄─────│ Try Injection│ │
│ │ Data │ │ Payloads │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 46.5M msgs │ │
│ │ 728K files │ │
│ │ 57K accounts │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

An AI agent can test hundreds of injection variations in seconds, identify the working payload, and systematically extract data—all without human intervention.

The Fix: Parameterized Queries

The solution isn’t new. It’s been around for decades:

Python (SQLAlchemy)

from sqlalchemy import text
# WRONG: String concatenation
query = text(f"SELECT * FROM messages WHERE title LIKE '%{keyword}%'")
# RIGHT: Parameterized query
query = text("SELECT * FROM messages WHERE title LIKE :keyword")
result = db.execute(query, {"keyword": f"%{keyword}%"})

Python (Raw SQLite)

import sqlite3
# WRONG
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
# RIGHT
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))

Java (JDBC)

// WRONG
String query = "SELECT * FROM users WHERE id = " + userId;
// RIGHT
PreparedStatement stmt = connection.prepareStatement(
"SELECT * FROM users WHERE id = ?"
);
stmt.setInt(1, userId);

JavaScript (Node.js)

// WRONG
const query = `SELECT * FROM users WHERE id = ${userId}`;
// RIGHT
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId]);

The pattern is always the same: use placeholders, pass parameters separately.

The Search Function Fix

Here’s how to fix the vulnerable search pattern:

from sqlalchemy import text, or_
def search_messages(title_filter=None, date_filter=None, author_filter=None):
# Build base query
base_query = "SELECT * FROM messages WHERE 1=1"
conditions = []
params = {}
# Add conditions safely
if title_filter:
conditions.append("title LIKE :title")
params["title"] = f"%{title_filter}%"
if date_filter:
conditions.append("created_at > :date")
params["date"] = date_filter
if author_filter:
conditions.append("author = :author")
params["author"] = author_filter
# Combine safely
if conditions:
base_query += " AND " + " AND ".join(conditions)
# Execute with parameters
return db.execute(text(base_query), params)

No concatenation. No injection risk.

Common Mistakes I’ve Made

Mistake 1: “My ORM Handles This”

# WRONG: Trusting ORM blindly
User.query.filter(f"name = '{user_input}'").all()
# RIGHT: Use ORM properly
User.query.filter(User.name == user_input).all()

Mistake 2: Sanitization Instead of Parameters

# WRONG: Trying to escape manually
def sanitize(input_string):
return input_string.replace("'", "''")
query = f"SELECT * FROM users WHERE name = '{sanitize(user_input)}'"
# RIGHT: Let the driver handle it
query = "SELECT * FROM users WHERE name = ?"

Sanitization is error-prone. Database drivers handle escaping correctly.

Mistake 3: “It’s Just an Internal Tool”

Every internal tool eventually becomes external. McKinsey’s API documentation was exposed. Your “internal” tool might be too.

Mistake 4: Stored Procedures Are Enough

-- WRONG: Dynamic SQL inside stored procedures
CREATE PROCEDURE search_messages(@keyword VARCHAR(100))
AS
BEGIN
DECLARE @sql NVARCHAR(1000)
SET @sql = 'SELECT * FROM messages WHERE title LIKE ''%' + @keyword + '%'''
EXEC sp_executesql @sql
END

This stored procedure is still vulnerable. Parameters must be used everywhere.

Testing Your Code

Automated Scanning

Terminal window
# SQLMap - automated injection testing
sqlmap -u "https://your-api.com/search?q=test" --batch
# Output shows if injection is possible

Manual Testing

SQL Injection Test Payloads
' OR '1'='1
' OR '1'='1' --
' UNION SELECT NULL--
' UNION SELECT NULL, NULL--
' UNION SELECT NULL, NULL, NULL--

If any of these cause errors or unexpected behavior, you have a vulnerability.

Code Review Checklist

SQL Injection Prevention Checklist
[ ] All SQL queries use parameterized statements
[ ] No string concatenation in query building
[ ] ORM methods used correctly (not raw queries)
[ ] Stored procedures don't use dynamic SQL
[ ] Search functions build queries safely
[ ] Input validation present (but not relied upon alone)

Why This Matters More Now

AI agents change the threat landscape:

Traditional Attack vs AI Attack
───────────────────────────────────────────────────────────────
Traditional Attacker │ AI Agent
───────────────────────────── │ ─────────────────────────────
Manual probing │ Automated scanning
Limited time │ No time constraints
Finds obvious vulns │ Finds subtle patterns
Single attack vector │ Parallel attack vectors
Human speed discovery │ Machine speed discovery

An AI agent found McKinsey’s API documentation, identified the search function vulnerability, and extracted 46.5 million messages—all autonomously.

My Security Protocol Now

After analyzing this breach, I follow this process:

1. Assume all input is hostile

# Every user input is potentially malicious
user_input = request.args.get('q', '')
# Never trust it. Always parameterize.

2. Use parameterized queries everywhere

No exceptions. No “just this once.” No shortcuts.

3. Audit search functions specifically

Search is where developers cut corners. I review all search code with extra scrutiny.

4. Test with SQLMap regularly

Terminal window
# Weekly automated scan
sqlmap -u "https://api.example.com/search?q=test" --batch --level=5

5. Code review for SQL patterns

Terminal window
# Find potential vulnerabilities
grep -r "execute.*f\"" --include="*.py" .
grep -r "f\".*SELECT" --include="*.py" .
grep -r "text(.*f\"" --include="*.py" .

Summary

SQL injection killed 46.5 million messages at McKinsey in 2026. The vulnerability was in a search function—the exact place developers are tempted to build dynamic queries.

The fix hasn’t changed in 25 years: parameterized queries. Always. Everywhere. No exceptions.

The only thing that’s changed is the attacker. AI agents can now find and exploit these vulnerabilities autonomously, at machine speed, across thousands of endpoints in minutes.

As one Reddit commenter put it:

“Not sure however how an SQL injection is still possible these days.”

It’s possible because developers still write concatenation. Because legacy code never gets rewritten. Because “internal tools” get exposed. Because deadlines push security to “later.”

The McKinsey breach is a reminder: the basics matter. Parameterized queries. Input validation. Security-first development.

The solution is simple. The discipline to apply it everywhere is what’s hard.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments