Tools & Methods

WordPress Security Testing Environments

Docker-based WordPress setup, WP-CLI for automated configuration, installing specific plugin versions, debugging configuration, and test automation

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"