Challenge: SecureForm Admin · CTF: DalCTF 2026 · Category: Web · Difficulty: Hard
You’ve discovered the admin panel of a contact form application called SecureForm. Access is protected by a 4-digit PIN… but is it really secure? Once inside, the dashboard lists form submissions and offers sorting options. The developer tried to mimic WordPress sanitization, but something slipped through.
TL;DR
- The “secure” admin gate is a 4-digit PIN with no rate limiting → brute force 10,000 values, PIN is
7392. - The dashboard’s sort feature builds an
ORDER BYclause from user input → blind SQL injection. - The home-made “WordPress-style” sanitizer strips the
<character → blind extraction still works fine using only>. - Dump
information_schema, find asecretstable, read theflagcolumn.
Recon
The landing page is a slick login form asking for a 4-digit PIN:
| |
The response headers tell us what we’re dealing with:
| |
So: PHP 8.2 on Apache, session-based auth. A quick check of the usual suspects (robots.txt, config.php, index.php.bak, .swp files) turns up nothing useful — no source disclosure here.
Submitting a wrong PIN just re-renders the page with:
| |
Before reaching for brute force, I checked for the cheaper bugs first — PHP loose-comparison / type juggling is a classic on PIN checks:
| Payload | Result |
|---|---|
pin[]=1 (array) | Invalid PIN |
pin=true | Invalid PIN |
pin=0 | Invalid PIN |
pin= (empty) | Invalid PIN |
No type-juggling shortcut. But there’s a much simpler observation: a 4-digit PIN is only 10,000 possibilities, and the server happily processes 20 wrong attempts in a row without any lockout, CAPTCHA, or delay. The “secure” in SecureForm is doing a lot of heavy lifting.
Step 1 — Brute forcing the PIN
The success condition is easy to detect: a correct PIN gets a 302 Location: dashboard.php, while a wrong one re-renders the form with Invalid PIN. So I just fired all 10,000 PINs in parallel and flagged any response that didn’t contain Invalid PIN.
| |
| |
PIN = 7392. Logging in properly (following the redirect and keeping the session cookie) lands us on the dashboard.
Step 2 — The dashboard
The admin dashboard (“Contact Form Entries”) lets you add entries, clear them, and — crucially — sort the list:
| |
Two user-controlled parameters that feed directly into what is almost certainly an ORDER BY clause: orderby and order. This is the textbook spot for an ORDER BY injection — you can’t parameterize a column name in a prepared statement, so developers tend to string-concatenate it, and that’s exactly what the challenge hints at with “the developer tried to mimic WordPress sanitization.”
A single quote confirms it:
| |
The error is generic (no SQL message leaked), so this is blind. We control an expression inside ORDER BY, and subqueries are allowed.
Step 3 — Building a boolean oracle
ORDER BY injection doesn’t let you UNION SELECT directly, but you can branch on a condition with CASE and make the database error on demand. The trick: a scalar subquery that returns more than one row throws “Subquery returns more than 1 row”.
| |
- Condition TRUE → orders by
name→ normal page - Condition FALSE → subquery error →
alert-errordiv appears
That gives a clean 1-bit oracle:
| |
Gotcha #1 — you need rows in the table
The oracle was wildly inconsistent at first. The reason: with 0 or 1 rows, MySQL doesn’t reliably evaluate the per-row ORDER BY expression (with nothing to sort, the erroring branch may never get evaluated). After adding a handful of dummy entries through the legitimate “Add Entry” form, the oracle became perfectly deterministic:
| |
| |
Fingerprinting the DB through the same oracle: @@version evaluates fine while sqlite_version() errors → MySQL.
Gotcha #2 — the “WordPress sanitization”
Now the fun part the challenge teased. When I started binary-searching string lengths, the results were self-contradictory:
| |
A length can’t be both > 4 and >= 65535. Something was mangling the queries. So I probed the operators one by one:
| Expression | Expected | Oracle says |
|---|---|---|
1=1 | TRUE | TRUE |
1=2 | FALSE | FALSE |
5>4 | TRUE | TRUE |
2>=1 | TRUE | TRUE |
1!=2 | TRUE | TRUE |
5 LIKE 5 | TRUE | TRUE |
5<4 | FALSE | FALSE (but errors, not real) |
1<2 | TRUE | FALSE |
1<=2 | TRUE | FALSE |
The verdict: the < character is being filtered/stripped before the query runs. Any payload containing < becomes malformed SQL and errors out (which the oracle reads as “false”). Everything else — >, >=, =, !=, LIKE, BETWEEN — works.
This is the “WordPress sanitization” joke. WordPress has helpers like sanitize_sql_orderby(), and a developer cargo-culting that idea wrote a filter that scrubs < (presumably an over-eager attempt to block <script>/HTML) while leaving the actual ORDER BY injection wide open. Classic “sanitized the wrong thing.”
Step 4 — Blind extraction with > only
Losing < costs us nothing — binary search works perfectly fine with a single comparison operator. To find a value, keep testing value > mid:
| |
One more small detail: I passed table names as hex literals (table_name=0x...) to avoid quoting issues entirely.
Walking information_schema:
| |
A secrets table with a flag column. Read it:
| |
| |
Flag
| |
Lessons / takeaways
- A short PIN with no rate limiting is not authentication. 10,000 combinations fall in seconds. Add lockouts, exponential backoff, or just use real credentials.
ORDER BYcannot be parameterized, so it’s a perennial injection sink. The only safe pattern is to whitelist column names and the sort direction against a fixed allow-list — never concatenate user input into the clause.- Sanitize the right thing. Stripping
<does nothing against SQL injection; it only blocks a character SQLi rarely needs. A blocklist that misses>,=,BETWEEN,LIKE, and subqueries is no defense at all. - Blind ≠ safe. A generic “An error occurred” page still leaks one bit per request, and one bit per request is all you need to exfiltrate an entire database.
Defensive fix
| |