WordPress Internals

WordPress Plugin Architecture for Security Researchers

Plugin file structure, lifecycle hooks, entry points, and efficient audit methodology for finding security-relevant code

WordPress Plugin Architecture for Security Researchers

Understanding how WordPress plugins are structured and loaded is fundamental to efficient security auditing. This article covers the anatomy of a plugin, the lifecycle of execution, and systematic approaches for identifying security-relevant code quickly.

Plugin File Structure

A typical WordPress plugin follows this structure:

my-plugin/
├── my-plugin.php          # Main plugin file (required)
├── includes/
│   ├── class-main.php     # Core plugin class
│   ├── class-ajax.php     # AJAX handlers
│   ├── class-rest.php     # REST API handlers
│   ├── class-admin.php    # Admin page handlers
│   └── class-frontend.php # Frontend hooks
├── admin/
│   ├── views/             # Admin page templates
│   └── assets/
│       ├── js/admin.js
│       └── css/admin.css
├── public/
│   ├── views/             # Frontend templates
│   └── assets/
│       ├── js/public.js
│       └── css/public.css
├── languages/             # Translation files
└── vendor/                # Third-party dependencies

The main plugin file always starts with the plugin header comment:

<?php
/**
 * Plugin Name: My Plugin
 * Plugin URI:  https://example.com
 * Description: Description here
 * Version:     1.2.3
 * Author:      Author Name
 * License:     GPL-2.0+
 */

// Security check: prevent direct file access
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// Define constants
define( 'MY_PLUGIN_VERSION', '1.2.3' );
define( 'MY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

// Initialize plugin
add_action( 'plugins_loaded', 'my_plugin_init' );
function my_plugin_init() {
    require_once MY_PLUGIN_DIR . 'includes/class-main.php';
    new My_Plugin_Main();
}

WordPress Plugin Lifecycle

Understanding when code runs is critical for identifying the attack surface:

Execution Order

1. wp-config.php loads
2. wp-settings.php loads
3. mu-plugins/ loaded (in alphabetical order)
4. plugins_loaded hook fires (active plugins loaded)
5. init hook fires (after all plugins loaded)
   - AJAX handlers registered here
   - Custom post types registered here
   - Shortcodes registered here
6. wp hook fires (main WordPress query runs)
7. template_redirect fires (before template selection)
8. wp_head / wp_footer fire (inside templates)
9. shutdown hook fires (after response sent)

Key Hooks for Security Research

// plugins_loaded: Earliest reliable hook after plugins are active
add_action( 'plugins_loaded', function() {
    // Plugin classes and functions available
    // Good place to register REST routes, check for activation, etc.
});

// init: Most common hook for registrations
add_action( 'init', function() {
    // AJAX handlers, shortcodes, rewrite rules
    // Note: is_user_logged_in() is available here
});

// admin_init: Admin-only initialization
add_action( 'admin_init', function() {
    // Register settings, handle form submissions
    // Only runs on admin pages (including admin-ajax.php!)
});

// wp_loaded: After WordPress is fully loaded
add_action( 'wp_loaded', function() {
    // After functions.php, plugins all loaded
});

// rest_api_init: REST API specific
add_action( 'rest_api_init', function() {
    // Register REST routes
    register_rest_route( ... );
});

Activation, Deactivation, and Uninstall Hooks

These hooks run during plugin lifecycle events and commonly contain security issues:

// Activation: runs when plugin is first activated
register_activation_hook( __FILE__, 'my_plugin_activate' );
function my_plugin_activate() {
    // Common security issues here:
    // - Creates database tables without proper schema
    // - Sets up default options with weak values
    // - Creates admin accounts
    // - Writes files to filesystem
    
    global $wpdb;
    $wpdb->query( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}my_table (
        id int NOT NULL AUTO_INCREMENT,
        data text,
        PRIMARY KEY (id)
    )" );
    
    add_option( 'my_plugin_setup_key', md5( time() ) ); // Weak secret!
}

// Deactivation
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );

// Uninstall: runs when plugin is deleted
register_uninstall_hook( __FILE__, 'my_plugin_uninstall' );
// OR via uninstall.php file in plugin directory
# Find activation hooks (often set up backdoors or weak secrets)
grep -rn "register_activation_hook\|activation_hook" \
  /var/www/html/wp-content/plugins/ --include="*.php" -n

# Find what happens in activation
grep -rn "function.*activate\|function.*install\|function.*setup" \
  /var/www/html/wp-content/plugins/target-plugin/ --include="*.php" -n

How Plugins Register Features

AJAX Handler Registration Pattern

class My_Plugin_Ajax {
    
    public function __construct() {
        // All AJAX registrations in constructor or init
        add_action( 'wp_ajax_my_action',        [$this, 'handle_my_action'] );
        add_action( 'wp_ajax_nopriv_my_action', [$this, 'handle_my_action'] );
        add_action( 'wp_ajax_my_admin_action',  [$this, 'handle_admin_action'] );
    }
    
    public function handle_my_action() {
        check_ajax_referer( 'my_nonce_action', 'nonce' );
        // ...
        wp_die();
    }
}
# Find AJAX handlers in class-based plugins (common pattern)
grep -rn "add_action.*wp_ajax" /var/www/html/wp-content/plugins/target-plugin/ \
  --include="*.php" -n

# Find handler methods (array callback syntax)
grep -rP "add_action\s*\(\s*['\"]wp_ajax_[^'\"]+['\"],\s*\[" \
  /var/www/html/wp-content/plugins/target-plugin/ --include="*.php" -n

# Also covers string callbacks
grep -rP "add_action\s*\(\s*['\"]wp_ajax_[^'\"]+['\"],\s*'[^']+'" \
  /var/www/html/wp-content/plugins/target-plugin/ --include="*.php" -n

Shortcode Registration

add_shortcode( 'my_shortcode', 'my_shortcode_callback' );
// Or class method:
add_shortcode( 'my_shortcode', [$this, 'render_shortcode'] );

function my_shortcode_callback( $atts, $content = null ) {
    $atts = shortcode_atts([
        'id'    => 0,
        'style' => 'default',
    ], $atts, 'my_shortcode' );
    
    return '<div>' . esc_html( $content ) . '</div>';
}
# Find all shortcodes
grep -rn "add_shortcode" /var/www/html/wp-content/plugins/ --include="*.php" -n

# Find shortcodes without proper escaping
grep -rn "add_shortcode" /var/www/html/wp-content/plugins/target-plugin/ \
  --include="*.php" -A 30 | grep -v "esc_\|absint\|intval\|sanitize_" | \
  grep "echo\|return.*\$atts"

Admin Menu Registration

add_action( 'admin_menu', function() {
    add_menu_page(
        'My Plugin Settings', // Page title
        'My Plugin',          // Menu title  
        'manage_options',     // Required capability
        'my-plugin',          // Menu slug
        'my_plugin_page',     // Callback function
        'dashicons-admin-generic',
        65
    );
    
    add_submenu_page(
        'my-plugin',
        'Advanced Settings',
        'Advanced',
        'manage_options',      // Check what capability is required
        'my-plugin-advanced',
        'my_plugin_advanced_page'
    );
});
# Find admin page registrations and their required capabilities
grep -rn "add_menu_page\|add_submenu_page\|add_options_page\|add_management_page" \
  /var/www/html/wp-content/plugins/target-plugin/ --include="*.php" -n

# Find pages with weak capability requirements
grep -rn "add_menu_page\|add_submenu_page" \
  /var/www/html/wp-content/plugins/target-plugin/ --include="*.php" | \
  grep -v "manage_options\|activate_plugins\|install_plugins"

Settings API Registration

add_action( 'admin_init', function() {
    register_setting(
        'my_plugin_options',          // Option group
        'my_plugin_setting',          // Option name
        [
            'sanitize_callback' => 'sanitize_text_field',
            'type'              => 'string',
            'default'           => '',
        ]
    );
});
# Find settings registration (check sanitize_callback)
grep -rn "register_setting" /var/www/html/wp-content/plugins/target-plugin/ \
  --include="*.php" -A10 | grep -A10 "register_setting"

Efficient Audit Methodology

Step 1: Get the Lay of the Land

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

# Count files and size
find "$PLUGIN_DIR" -name "*.php" | wc -l
find "$PLUGIN_DIR" -name "*.php" -exec wc -l {} + | tail -1

# Find the main plugin file
grep -rln "Plugin Name:" "$PLUGIN_DIR" --include="*.php"

# List all PHP files by size (largest first - most code)
find "$PLUGIN_DIR" -name "*.php" -exec wc -l {} + | sort -rn | head -20

# Understand the class structure
grep -rn "^class " "$PLUGIN_DIR" --include="*.php" -n

Step 2: Map All Entry Points

# All AJAX actions (authenticated and unauthenticated)
echo "=== AJAX ACTIONS ==="
grep -rn "wp_ajax_" "$PLUGIN_DIR" --include="*.php"

# All REST routes
echo "=== REST ROUTES ==="
grep -rn "register_rest_route" "$PLUGIN_DIR" --include="*.php"

# All shortcodes
echo "=== SHORTCODES ==="
grep -rn "add_shortcode" "$PLUGIN_DIR" --include="*.php"

# All admin pages
echo "=== ADMIN PAGES ==="
grep -rn "add_menu_page\|add_submenu_page" "$PLUGIN_DIR" --include="*.php"

# All filter/action hooks that process user input
echo "=== INPUT HOOKS ==="
grep -rn "add_action.*init\|add_action.*wp_loaded\|add_action.*parse_request" \
  "$PLUGIN_DIR" --include="*.php"

# Template includes (potential LFI)
echo "=== TEMPLATE INCLUDES ==="
grep -rn "include\|require\|include_once\|require_once" "$PLUGIN_DIR" \
  --include="*.php" | grep "\$_\|\$plugin_dir\|\$path\|\$template"

Step 3: Trace User Input from Sources to Sinks

Sources (user-controlled input):

$_GET, $_POST, $_REQUEST, $_COOKIE, $_SERVER, $_FILES
get_query_var(), get_search_query()
WP_REST_Request::get_param()
get_option() // if options are user-settable
get_post_meta(), get_user_meta() // if set by users

Sinks (dangerous functions):

// Database
$wpdb->query(), $wpdb->get_results(), $wpdb->get_row()
// File system
file_put_contents(), fwrite(), include(), require(), unlink()
// Output
echo, print, printf, header()
// Execution
system(), exec(), shell_exec(), passthru(), eval()
// HTTP requests
wp_remote_get(), wp_remote_post(), curl_exec() // SSRF
# Find all database sinks with user input (no prepare)
grep -rP '\$wpdb->(query|get_results|get_row|get_var)\s*\([^;]*(GET|POST|REQUEST)' \
  "$PLUGIN_DIR" --include="*.php" -n

# Find file operation sinks
grep -rn "file_put_contents\|fwrite\|file_get_contents\|unlink\|rename\|copy" \
  "$PLUGIN_DIR" --include="*.php" -n | grep -v "//\|#"

# Find code execution sinks
grep -rn "eval\|system\|exec\|shell_exec\|passthru\|popen\|proc_open" \
  "$PLUGIN_DIR" --include="*.php" -n

# Find SSRF sinks
grep -rn "wp_remote_get\|wp_remote_post\|wp_safe_remote\|curl_exec\|file_get_contents" \
  "$PLUGIN_DIR" --include="*.php" -n

# Find output sinks without escaping
grep -rP "echo\s+\\\$[^;]*;" "$PLUGIN_DIR" --include="*.php" -n | \
  grep -v "esc_\|absint\|intval\|'[^']*'\|\"[^\"]*\""

Step 4: Check Security Controls

# Are nonce checks present?
echo "Nonce checks:"
grep -rn "check_ajax_referer\|wp_verify_nonce\|check_admin_referer" \
  "$PLUGIN_DIR" --include="*.php" -n | wc -l

# Are capability checks present?
echo "Capability checks:"
grep -rn "current_user_can\|user_can(" "$PLUGIN_DIR" --include="*.php" -n | wc -l

# ABSPATH check (prevents direct file access)
echo "Direct access prevention:"
grep -rn "defined.*ABSPATH.*exit\|WPINC.*die\|ABSPATH.*wp-load" \
  "$PLUGIN_DIR" --include="*.php" -n | wc -l

# Files WITHOUT ABSPATH check (potentially accessible directly)
for f in $(find "$PLUGIN_DIR" -name "*.php"); do
    if ! grep -q "ABSPATH\|WPINC\|wp-load" "$f" 2>/dev/null; then
        echo "No ABSPATH check: $f"
    fi
done

Step 5: Check Third-Party Dependencies

# Find vendor/composer dependencies
ls "$PLUGIN_DIR/vendor/" 2>/dev/null
cat "$PLUGIN_DIR/composer.json" 2>/dev/null

# Check for known vulnerable libraries
grep -r "\"version\"" "$PLUGIN_DIR/vendor/" 2>/dev/null | head -20

# Find JavaScript dependencies
ls "$PLUGIN_DIR/node_modules/" 2>/dev/null
cat "$PLUGIN_DIR/package.json" 2>/dev/null

Common Security Patterns to Look For

Pattern: Unserialize with User Input

// CRITICAL: Object injection via unserialize
$data = unserialize( base64_decode( $_GET['data'] ) );
$data = unserialize( get_option( 'plugin_data' ) ); // If option is user-controlled
$data = unserialize( $wpdb->get_var( $query ) );    // If data came from user
# Find unserialize calls
grep -rn "unserialize" "$PLUGIN_DIR" --include="*.php" -n

# Find maybe_unserialize (can be safe but check context)
grep -rn "maybe_unserialize" "$PLUGIN_DIR" --include="*.php" -n

Pattern: Dynamic Hooks / eval

// DANGEROUS: Dynamic hook registration from user input
$hook = $_POST['action_hook'];
add_action( $hook, 'some_function' );

// DANGEROUS: eval of user-controlled content
eval( base64_decode( get_option('plugin_license_data') ) );

Pattern: SSRF via HTTP Requests

// SSRF: Plugin fetches URL provided by user
$url = sanitize_url( $_POST['feed_url'] );
$response = wp_remote_get( $url );
// attacker can use: http://169.254.169.254/latest/meta-data/ (AWS metadata)
// or: http://localhost/wp-admin/ (internal services)
# Find SSRF candidates
grep -rn "wp_remote_get\|wp_remote_post\|wp_remote_request" \
  "$PLUGIN_DIR" --include="*.php" -B5 | grep "\$_\|get_option\|get_post_meta"

Complete Audit Command Set

Run this script against any plugin for a quick security overview:

#!/bin/bash
PLUGIN_DIR="${1:-/var/www/html/wp-content/plugins/target-plugin}"

echo "======================================"
echo "Security Audit: $PLUGIN_DIR"
echo "======================================"

echo ""
echo "--- AJAX ENTRY POINTS ---"
grep -rn "wp_ajax_nopriv_" "$PLUGIN_DIR" --include="*.php" | grep "add_action"

echo ""
echo "--- REST API ROUTES (check permission_callback) ---"
grep -rn "register_rest_route" "$PLUGIN_DIR" --include="*.php" -A3 | \
  grep -E "register_rest_route|permission_callback|__return_true"

echo ""
echo "--- RAW DATABASE QUERIES (potential SQLi) ---"
grep -rP '\$wpdb->(query|get_results|get_row|get_var)\s*\(' \
  "$PLUGIN_DIR" --include="*.php" | grep -v "prepare("

echo ""
echo "--- UNESCAPED OUTPUT (potential XSS) ---"
grep -rP "echo\s+\\\$(_(GET|POST|REQUEST)|atts|_)" \
  "$PLUGIN_DIR" --include="*.php" -n | grep -v "esc_"

echo ""
echo "--- FILE OPERATIONS (potential path traversal/upload) ---"
grep -rn "file_put_contents\|move_uploaded_file\|unlink\|rename" \
  "$PLUGIN_DIR" --include="*.php" -n

echo ""
echo "--- DANGEROUS FUNCTIONS ---"
grep -rn "eval\|unserialize\|system\|exec\|shell_exec" \
  "$PLUGIN_DIR" --include="*.php" -n

echo ""
echo "--- MISSING ABSPATH CHECKS ---"
for f in $(find "$PLUGIN_DIR" -name "*.php" -not -path "*/vendor/*"); do
    if ! head -5 "$f" | grep -q "ABSPATH\|WPINC"; then
        echo "  $f"
    fi
done

echo ""
echo "======================================"
echo "Audit complete"
echo "======================================"