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 functionsquery = "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}'" # VULNERABLEEach 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 pressuredef 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_atFROM messagesWHERE title LIKE '%project deadline%' OR content LIKE '%project deadline%'ORDER BY created_at DESCLooks 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_atFROM messagesWHERE 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 DESCThe -- 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 concatenationquery = text(f"SELECT * FROM messages WHERE title LIKE '%{keyword}%'")
# RIGHT: Parameterized queryquery = text("SELECT * FROM messages WHERE title LIKE :keyword")result = db.execute(query, {"keyword": f"%{keyword}%"})Python (Raw SQLite)
import sqlite3
# WRONGcursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
# RIGHTcursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))Java (JDBC)
// WRONGString query = "SELECT * FROM users WHERE id = " + userId;
// RIGHTPreparedStatement stmt = connection.prepareStatement( "SELECT * FROM users WHERE id = ?");stmt.setInt(1, userId);JavaScript (Node.js)
// WRONGconst query = `SELECT * FROM users WHERE id = ${userId}`;
// RIGHTconst 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 blindlyUser.query.filter(f"name = '{user_input}'").all()
# RIGHT: Use ORM properlyUser.query.filter(User.name == user_input).all()Mistake 2: Sanitization Instead of Parameters
# WRONG: Trying to escape manuallydef sanitize(input_string): return input_string.replace("'", "''")
query = f"SELECT * FROM users WHERE name = '{sanitize(user_input)}'"
# RIGHT: Let the driver handle itquery = "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 proceduresCREATE PROCEDURE search_messages(@keyword VARCHAR(100))ASBEGIN DECLARE @sql NVARCHAR(1000) SET @sql = 'SELECT * FROM messages WHERE title LIKE ''%' + @keyword + '%''' EXEC sp_executesql @sqlENDThis stored procedure is still vulnerable. Parameters must be used everywhere.
Testing Your Code
Automated Scanning
# SQLMap - automated injection testingsqlmap -u "https://your-api.com/search?q=test" --batch
# Output shows if injection is possibleManual Testing
' 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
[ ] 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 scanningLimited time │ No time constraintsFinds obvious vulns │ Finds subtle patternsSingle attack vector │ Parallel attack vectorsHuman speed discovery │ Machine speed discoveryAn 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 malicioususer_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
# Weekly automated scansqlmap -u "https://api.example.com/search?q=test" --batch --level=55. Code review for SQL patterns
# Find potential vulnerabilitiesgrep -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