Every year a fresh crop of developers ships string-concatenated SQL and every year a fresh crop of CISOs acts surprised. So let’s stop acting surprised and actually dump a database, on purpose, on a box we own.
This is the Red Team How-To for people who are tired of reading about SQL injection and want to see the damn dump. We will spin up a tiny vulnerable app, confirm the bug by hand, then let sqlmap finish the job and print the table. Real terminal output, from a real run on this machine, today. The screenshots below are the actual session, not mockups.
What we are building
A one-file Flask app with a SQLite backend and a classic SQL injection in the id query parameter. The query is built with string formatting. Yes, in 2026. Yes, this still ships in production. Ask your MSP.
# vulnlab.py (lab only, do not ship)
import sqlite3
from flask import Flask, request
app = Flask(__name__)
def init():
c = sqlite3.connect('lab.db')
c.execute("DROP TABLE IF EXISTS users")
c.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, email TEXT)")
c.executemany("INSERT INTO users (username,password,email) VALUES (?,?,?)",[
('admin','S3cr3t_sloth_hash','[email protected]'),
('aturnbull','hunter2','[email protected]'),
('msp_intern','password1','[email protected]'),
])
c.commit(); c.close()
@app.route('/product')
def product():
pid = request.args.get('id','1')
conn = sqlite3.connect('lab.db')
cur = conn.cursor()
# INTENTIONALLY VULNERABLE: string-formatted SQL, lab only
q = "SELECT id, username, email FROM users WHERE id = %s" % pid
cur.execute(q)
return "".join("<p>%s</p>" % str(r) for r in cur.fetchall()) or "<p>no rows</p>"
if __name__ == '__main__':
init(); app.run(host='127.0.0.1', port=5055)
Fire it up with one line: ./venv/bin/python vulnlab.py and it listens on http://127.0.0.1:5055. That is the whole target. Nice and local. Nobody’s lawyer gets involved.
Confirm the bug by hand
Before you reach for the power tools, prove the hole exists with nothing but curl. A normal request returns one row. Then you feed it 1 OR 1=1, the single most famous five characters in hacking, and the app happily coughs up the entire table. Your input became part of the query. The database did exactly what it was told.

1 OR 1=1 turns one row into the whole table.If this looks trivial, good. Most real SQLi is this trivial. The difference between your lab and a breach is a WAF, a login page, or an MSP who swore the vendor “handles security.”
Let sqlmap finish it
sqlmap is the boring, reliable hammer. Point it at the local lab, tell it the DBMS, ask it to dump the table, and walk away. Here is the real run from this box. First the launch, the banner, and sqlmap deciding the id parameter looks tasty:

id parameter.Seconds later it has confirmed two separate injection types and fingerprinted the backend as SQLite. Note the UNION query payload: that is how an attacker rewrites your SELECT to return columns you never exposed.

And then the part that ends arguments: the full table, including the password column the original endpoint never selected, because UNION lets the attacker pick the columns.

Sixteen HTTP requests. Under two seconds. Full table, including the password column that the original SELECT never asked for. That is why “we don’t select passwords on that endpoint” is not a defense. The attacker selects whatever they want.
Who is screwed (in the real world)
Any app that builds SQL with string concat, string format, or “just this one dynamic ORDER BY.” Legacy PHP, rushed Flask apps, the internal tool your MSP wrote in 2019 and never touched again. SQLi is not dead. It just moved into the boring software nobody audits because “it’s only internal.” Internal until it is not.
How to not be the lab
- Parameterized queries. Always.
cur.execute("SELECT ... WHERE id = ?", (pid,)). Not f-strings. Not percent-formatting. Not string concat. - Least privilege on the DB user. The app account should not be able to read every table for fun. In Postgres/MySQL land, that means no SELECT on schemas it does not need.
- WAF is a seatbelt, not a brake. It will catch the noisy stuff. It will not catch a patient attacker with encoding tricks. Fix the code.
- If you have a bug bounty, SQLi still pays. If you do not, your attackers are unpaid and less polite.
What to do Monday morning
- Clone this lab. Break it. Fix it with a parameterized query. Confirm sqlmap comes back empty. That muscle memory matters more than another webinar.
- Grep your own codebases for string-built SQL: f-string executes, string concat near query strings, percent-format into SQL. You will find something. You always find something.
- Ask your vendors and MSPs whether their apps use parameterized queries. “We follow best practices” is not an answer. “Show me the query construction” is.
- Put sqlmap (or equivalent) in your authorized pentest checklist for every app with a database. If it has a query string and a database, assume it until proven otherwise.
Sources
- sqlmap project: sqlmap.org / github.com/sqlmapproject/sqlmap
- OWASP SQL Injection: owasp.org
- OWASP Query Parameterization Cheat Sheet: cheatsheetseries.owasp.org
- Lab run performed on the author’s box, 2026-10-13, against 127.0.0.1:5055 only
