WordPress Security Testing Environments
Reproducible, isolated testing environments are essential for WordPress security research. This article covers Docker-based setups, WP-CLI automation, installing specific vulnerable versions, debugging configuration, and automation strategies for systematic testing.
Docker-Based WordPress Setup
Minimal docker-compose.yml
# docker-compose.yml
version: '3.8'
services:
db:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: wordpress
MYSQL_USER: wpuser
MYSQL_PASSWORD: wppassword
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 5s
timeout: 5s
retries: 10
wordpress:
image: wordpress:6.4-apache
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: wppassword
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('SAVEQUERIES', true);
define('SCRIPT_DEBUG', true);
define('FS_METHOD', 'direct');
volumes:
- wp_data:/var/www/html
- ./plugins:/var/www/html/wp-content/plugins
- ./themes:/var/www/html/wp-content/themes
- ./uploads:/var/www/html/wp-content/uploads
# WP-CLI as a one-shot service for setup automation
wpcli:
image: wordpress:cli-2.9
depends_on:
wordpress:
condition: service_started
volumes:
- wp_data:/var/www/html
user: "33:33" # www-data user
entrypoint: ["wp", "--allow-root"]
volumes:
db_data:
wp_data:
# Start the stack
docker compose up -d
# Wait for WordPress to be ready
docker compose exec wordpress wp --info --allow-root 2>/dev/null || sleep 10
# Verify services are running
docker compose ps
docker compose logs wordpress | tail -20
Extended Setup with Debug Tools
# docker-compose.debug.yml (extends base compose)
version: '3.8'
services:
wordpress:
environment:
WORDPRESS_CONFIG_EXTRA: |
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', '/var/www/html/wp-content/debug.log');
define('WP_DEBUG_DISPLAY', false);
define('SAVEQUERIES', true);
define('SCRIPT_DEBUG', true);
define('FS_METHOD', 'direct');
define('WP_DISABLE_FATAL_ERROR_HANDLER', true);
@ini_set('display_errors', 'On');
@ini_set('error_reporting', E_ALL);
phpmyadmin:
image: phpmyadmin:5.2
restart: unless-stopped
depends_on: [db]
ports:
- "8081:80"
environment:
PMA_HOST: db
PMA_USER: wpuser
PMA_PASSWORD: wppassword
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
WP-CLI for Automated Environment Setup
WP-CLI is the command-line interface for WordPress and is indispensable for security testing automation.
Full Site Installation Script
#!/bin/bash
# setup-wp-test-env.sh
# Complete WordPress setup for security testing
WP_DIR="/var/www/html"
WP_CLI="docker compose exec --user=www-data wordpress wp"
SITE_URL="http://localhost:8080"
echo "Installing WordPress core..."
$WP_CLI core install \
--url="$SITE_URL" \
--title="Security Test Site" \
--admin_user="admin" \
--admin_password="Admin_Password_123!" \
--admin_email="admin@example.com" \
--skip-email
echo "Setting permalink structure..."
$WP_CLI rewrite structure "/%postname%/" --hard
echo "Creating test users..."
# Create one of each role for testing
$WP_CLI user create subscriber subscriber@test.com \
--role=subscriber \
--user_pass=subscriber_pass \
--first_name=Test \
--last_name=Subscriber
$WP_CLI user create contributor contributor@test.com \
--role=contributor \
--user_pass=contributor_pass
$WP_CLI user create author author@test.com \
--role=author \
--user_pass=author_pass
$WP_CLI user create editor editor@test.com \
--role=editor \
--user_pass=editor_pass
echo "Disabling file editing in admin (not needed for CLI)..."
$WP_CLI config set DISALLOW_FILE_EDIT false --raw
echo "Setting up basic content..."
$WP_CLI post create \
--post_title="Test Post" \
--post_content="Test content" \
--post_status=publish
$WP_CLI post create \
--post_title="Private Post" \
--post_content="Secret content" \
--post_status=private \
--post_author=1
echo "List all users:"
$WP_CLI user list --fields=ID,user_login,roles,user_email
echo "Setup complete. Site at: $SITE_URL"
Installing Specific Plugin Versions from WordPress SVN
WordPress.org hosts all plugin versions in SVN. You can install any historical version:
# Plugin SVN URL format:
# https://plugins.svn.wordpress.org/{plugin-slug}/tags/{version}/
# Method 1: Direct SVN export
svn export https://plugins.svn.wordpress.org/contact-form-7/tags/5.7.5/ \
/var/www/html/wp-content/plugins/contact-form-7
# Method 2: Download zip from wordpress.org
wget "https://downloads.wordpress.org/plugin/contact-form-7.5.7.5.zip" \
-O /tmp/cf7.zip
unzip /tmp/cf7.zip -d /var/www/html/wp-content/plugins/
# Method 3: Via WP-CLI (latest version)
docker compose exec --user=www-data wordpress wp plugin install contact-form-7 --activate
# Method 4: Via WP-CLI (specific version)
docker compose exec --user=www-data wordpress wp plugin install contact-form-7 \
--version=5.7.5 \
--activate
# List available versions via SVN
svn list https://plugins.svn.wordpress.org/contact-form-7/tags/ 2>/dev/null | \
sort -V | tail -20
Installing Vulnerable WordPress Core Versions
# Download specific WordPress core version
wget "https://wordpress.org/wordpress-6.2.zip" -O /tmp/wp-6.2.zip
# Or install via WP-CLI
docker run --rm \
-v /tmp/wp-install:/var/www/html \
wordpress:cli \
wp core download --version=6.2 --allow-root
# Install an old vulnerable version for research
docker run --rm \
-v /tmp/wp-old:/var/www/html \
wordpress:cli \
wp core download --version=4.9.1 --allow-root
WP-CLI Security Research Commands
User and Authentication Management
WP="wp --allow-root --path=/var/www/html"
# List all users with roles
$WP user list --fields=ID,user_login,user_email,roles --format=table
# Get all user capabilities
$WP user list --format=ids | xargs -I{} sh -c \
"echo 'User {}: '; $WP user get {} --field=roles"
# Reset user password
$WP user update admin --user_pass=NewPassword123!
# Create user with specific capabilities
$WP user create testadmin testadmin@test.com --role=administrator --user_pass=pass
# List active sessions for a user
$WP user session list admin
# Destroy all sessions (force re-login)
$WP user session destroy admin --all
# Get user meta (check wp_capabilities)
$WP user meta get 1 wp_capabilities
# Update capabilities directly
$WP user meta update 1 wp_capabilities 'a:1:{s:13:"administrator";b:1;}'
Plugin and Theme Management
# List all plugins with status
$WP plugin list --format=table
# Install plugin from zip file (for local testing with modified code)
$WP plugin install /tmp/vulnerable-plugin.zip --activate
# Activate specific plugin version
$WP plugin activate vulnerable-plugin
# Get plugin version info
$WP plugin get contact-form-7 --field=version
# Deactivate all plugins (for isolation testing)
$WP plugin deactivate --all
# List plugin files
$WP plugin get contact-form-7 --format=json | python3 -m json.tool
Database Operations
# Export entire database
$WP db export /tmp/wp-backup.sql
# Import database
$WP db import /tmp/wp-backup.sql
# Run raw SQL query
$WP db query "SELECT user_login, user_pass FROM wp_users;"
# Search and replace in database (for URL changes)
$WP search-replace 'http://localhost:8080' 'http://newdomain.com' --dry-run
$WP search-replace 'http://localhost:8080' 'http://newdomain.com'
# Get specific option value
$WP option get siteurl
$WP option get auth_key
$WP option get active_plugins
# Update option
$WP option update default_role administrator # Make all new registrations admin!
$WP option update users_can_register 1 # Enable registration
# List all options (useful for understanding site configuration)
$WP option list --format=table | head -50
# Get WordPress secret keys
$WP config get auth_key
$WP config get secure_auth_key
$WP config get logged_in_key
Log Analysis
# Watch debug log in real time
docker compose exec wordpress tail -f /var/www/html/wp-content/debug.log
# Extract SQL queries from log (SAVEQUERIES must be true)
$WP eval 'global $wpdb; var_dump($wpdb->queries);'
# Check recent queries
$WP eval '
global $wpdb;
$wpdb->show_errors();
$queries = $wpdb->queries;
if ($queries) {
usort($queries, function($a,$b){ return $b[1] <=> $a[1]; });
foreach(array_slice($queries, 0, 10) as $q) {
echo round($q[1]*1000, 2) . "ms: " . substr($q[0], 0, 100) . "\n";
}
}
'
Debugging Configuration
wp-config.php Debug Settings
// Add to wp-config.php or via docker environment:
// Enable all debugging
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true ); // Log to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Don't show on frontend
define( 'SAVEQUERIES', true ); // Save all DB queries
// Script debugging (loads unminified JS/CSS)
define( 'SCRIPT_DEBUG', true );
// Direct filesystem method (no FTP prompts)
define( 'FS_METHOD', 'direct' );
// Disable fatal error handler (see actual errors)
define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true );
// Increase memory for complex operations
define( 'WP_MEMORY_LIMIT', '256M' );
// Disable cron (run manually during testing)
define( 'DISABLE_WP_CRON', true );
// Concatenate scripts off (easier JS debugging)
define( 'CONCATENATE_SCRIPTS', false );
Query Monitoring
# Enable query logging via wp-config.php then check queries
docker compose exec wordpress wp eval '
global $wpdb;
// Print all queries so far
foreach ($wpdb->queries as $q) {
printf("[%.4f ms] %s\n", $q[1]*1000, $q[0]);
}
' --allow-root
# Monitor MySQL slow query log
docker compose exec db bash -c \
"mysql -u root -prootpassword -e 'SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 0;'"
docker compose exec db tail -f /var/lib/mysql/mysql-slow.log
Testing with curl
Authentication and Session Management
TARGET="http://localhost:8080"
# Login and capture cookies
curl -s -c /tmp/wp_admin.txt \
-d 'log=admin&pwd=Admin_Password_123!&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1' \
-H 'Cookie: wordpress_test_cookie=WP+Cookie+check' \
"$TARGET/wp-login.php" -L -o /dev/null -w "HTTP %{http_code}\n"
# Verify login worked
curl -s -b /tmp/wp_admin.txt "$TARGET/wp-admin/" -o /dev/null -w "HTTP %{http_code}\n"
# Get REST API nonce for an authenticated session
NONCE=$(curl -s -b /tmp/wp_admin.txt "$TARGET/wp-admin/" | \
grep -oP '"nonce":"?\K[a-f0-9]+"?' | head -1 | tr -d '"')
echo "Admin nonce: $NONCE"
Comprehensive AJAX Testing Script
#!/bin/bash
# test-ajax-endpoints.sh
TARGET="${1:-http://localhost:8080}"
COOKIES="${2:-/tmp/wp_admin.txt}"
echo "Testing AJAX endpoints on $TARGET"
echo "Using cookies from: $COOKIES"
# Get all actions from installed plugins
PLUGIN_DIR="/var/www/html/wp-content/plugins"
ACTIONS=$(docker compose exec wordpress find /var/www/html/wp-content/plugins -name "*.php" \
-exec grep -ohP "wp_ajax_nopriv_\K[^'\"]+" {} \; 2>/dev/null | sort -u)
for action in $ACTIONS; do
RESPONSE=$(curl -s -m 5 -X POST "$TARGET/wp-admin/admin-ajax.php" \
-d "action=$action" 2>/dev/null)
HTTP_CODE=$(curl -s -m 5 -o /dev/null -w "%{http_code}" -X POST \
"$TARGET/wp-admin/admin-ajax.php" -d "action=$action" 2>/dev/null)
if [ "$RESPONSE" != "0" ] && [ "$RESPONSE" != "-1" ] && [ -n "$RESPONSE" ]; then
echo "[INTERESTING] $action (HTTP $HTTP_CODE): $(echo "$RESPONSE" | head -c 150)"
fi
done
Playwright Automation for Security Testing
Playwright is ideal for testing JavaScript-dependent XSS, DOM-based vulnerabilities, and complex multi-step flows.
Basic Setup
npm install playwright
npx playwright install chromium
Security Testing Script
// wp-security-test.js
const { chromium } = require('playwright');
const TARGET = 'http://localhost:8080';
const CREDS = { admin: 'Admin_Password_123!' };
async function loginAsAdmin(page) {
await page.goto(`${TARGET}/wp-login.php`);
await page.fill('#user_login', 'admin');
await page.fill('#user_pass', CREDS.admin);
await page.click('#wp-submit');
await page.waitForURL(`${TARGET}/wp-admin/`);
console.log('Logged in as admin');
}
async function getNonce(page, action) {
// Get nonce for a specific action from admin page
const nonce = await page.evaluate(async (action) => {
const resp = await fetch('/wp-admin/admin-ajax.php', {
method: 'POST',
body: new URLSearchParams({ action: 'get_nonce', nonce_action: action }),
});
const data = await resp.json();
return data.nonce;
}, action);
return nonce;
}
async function testStoredXSS(page) {
console.log('\nTesting stored XSS via plugin settings...');
// Inject XSS payload into plugin settings as subscriber
await page.goto(`${TARGET}/wp-login.php`);
await page.fill('#user_login', 'subscriber');
await page.fill('#user_pass', 'subscriber_pass');
await page.click('#wp-submit');
const response = await page.evaluate(async () => {
return fetch('/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'include',
body: new URLSearchParams({
action: 'save_user_bio',
bio: '<script>window.__xss_fired=true;</script>',
}),
}).then(r => r.json());
});
console.log('Save response:', response);
// Now visit as admin and check if XSS fires
await loginAsAdmin(page);
// Set up XSS detection
let xssFired = false;
page.on('dialog', async dialog => {
xssFired = true;
console.log('XSS dialog fired:', dialog.message());
await dialog.accept();
});
await page.goto(`${TARGET}/wp-admin/users.php`);
await page.waitForTimeout(1000);
const xssInDom = await page.evaluate(() => window.__xss_fired);
console.log('XSS fired:', xssFired || xssInDom);
}
async function testPrivilegeEscalation(page) {
console.log('\nTesting privilege escalation...');
// Login as subscriber
await page.goto(`${TARGET}/wp-login.php`);
await page.fill('#user_login', 'subscriber');
await page.fill('#user_pass', 'subscriber_pass');
await page.click('#wp-submit');
// Try to access admin pages
const tests = [
{ url: '/wp-admin/', expect: '/wp-admin/$', name: 'Admin Dashboard' },
{ url: '/wp-admin/options-general.php', expect: 'General Settings', name: 'Settings' },
{ url: '/wp-json/wp/v2/users', expect: 'id', name: 'User Enumeration' },
];
for (const test of tests) {
const response = await page.goto(`${TARGET}${test.url}`);
const content = await page.content();
if (response.url().includes(test.expect) || content.includes(test.expect)) {
console.log(`[FINDING] ${test.name}: Accessible to subscriber`);
} else {
console.log(`[OK] ${test.name}: Properly restricted`);
}
}
}
async function main() {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox'],
});
const page = await browser.newPage();
// Intercept all requests for logging
page.on('request', req => {
if (req.method() === 'POST') {
console.log(`POST ${req.url()}`);
}
});
try {
await testStoredXSS(page);
await testPrivilegeEscalation(page);
} finally {
await browser.close();
}
}
main().catch(console.error);
Useful WP-CLI Commands for Security Research
Capability and Permission Testing
WP="docker compose exec --user=www-data wordpress wp"
# Check what capabilities a user has
$WP user list-caps subscriber@test.com
# Test if user can perform an action
$WP eval 'var_dump(user_can(get_user_by("login","subscriber"), "edit_posts"));'
# List all available capabilities in this installation
$WP role list --format=table
# Get capabilities for a specific role
$WP role get editor --field=capabilities
# Check current active theme
$WP theme status
# Evaluate arbitrary PHP in WordPress context (powerful for testing)
$WP eval '
$users = get_users(["role" => "administrator"]);
foreach ($users as $u) {
echo "Admin: " . $u->user_login . " - " . $u->user_email . "\n";
}
'
# Run a specific function and see its output
$WP eval 'var_dump(wp_get_current_user());'
$WP eval 'var_dump(get_option("active_plugins"));'
Plugin Security Checks
# List all hooks registered by a specific plugin
$WP eval '
global $wp_filter;
foreach ($wp_filter as $hook => $callbacks) {
foreach ($callbacks as $priority => $fns) {
foreach ($fns as $fn) {
$cb = $fn["function"];
$name = is_array($cb) ? get_class($cb[0])."::".$cb[1] : (string)$cb;
if (strpos($name, "my_plugin") !== false) {
echo "$hook ($priority): $name\n";
}
}
}
}
'
# Check registered REST routes
$WP eval '
$server = rest_get_server();
foreach ($server->get_routes() as $route => $handlers) {
if (strpos($route, "/wp/v2") === false) {
echo "$route\n";
}
}
'
# Check registered AJAX actions
$WP eval '
global $wp_filter;
foreach ($wp_filter as $hook => $callbacks) {
if (strpos($hook, "wp_ajax_") === 0) {
$handlers = [];
foreach ($callbacks as $priority => $fns) {
foreach ($fns as $fn) {
$cb = $fn["function"];
$handlers[] = is_array($cb) ? get_class($cb[0])."::".$cb[1] : (string)$cb;
}
}
echo "$hook -> " . implode(", ", $handlers) . "\n";
}
}
'
Database Direct Access
# Connect to MySQL directly
docker compose exec db mysql -u wpuser -pwppassword wordpress
# Run queries via docker
docker compose exec db mysql -u wpuser -pwppassword wordpress \
-e "SELECT user_login, user_pass, user_email FROM wp_users;"
# Check all tables
docker compose exec db mysql -u wpuser -pwppassword wordpress \
-e "SHOW TABLES;"
# Export just user data
$WP db query "SELECT user_login, user_pass, meta_value as caps \
FROM wp_users u \
JOIN wp_usermeta m ON u.ID = m.user_id \
WHERE m.meta_key = 'wp_capabilities';" \
--format=table
Environment Reset Script
Quickly reset to a clean state between tests:
#!/bin/bash
# reset-test-env.sh
echo "Resetting WordPress test environment..."
docker compose down -v
docker compose up -d
echo "Waiting for database..."
until docker compose exec db mariadb-admin ping -h localhost -u root -prootpassword -s; do
sleep 2
done
echo "Waiting for WordPress..."
until curl -s http://localhost:8080/ -o /dev/null -w "%{http_code}" | grep -q "200\|302"; do
sleep 2
done
echo "Running setup..."
bash setup-wp-test-env.sh
echo "Installing target plugin..."
docker compose exec --user=www-data wordpress wp plugin install \
/tmp/target-plugin.zip --activate --allow-root
echo "Environment ready at http://localhost:8080"
echo "Admin: admin / Admin_Password_123!"
echo "phpMyAdmin: http://localhost:8081"