Skip to content

Plugin & Theme Enumeration

Plugins and themes are where most WordPress findings live. Two enumeration modes:

# 1. Passive - read what's loaded on the page
curl -s http://target/ \
| grep -oE 'wp-content/(plugins|themes)/[^/]+' \
| sort -u
# 2. Active - probe a known path with response code as oracle
curl -sI http://target/wp-content/plugins/<plugin>/ \
| head -1
# 200/301 = installed; 403 = installed (Apache forbids listing); 404 = absent
# 3. Version from the plugin's own readme.txt
curl -s http://target/wp-content/plugins/<plugin>/readme.txt \
| grep -iE 'stable tag|^version'

Success indicator: a list of <plugin>:<version> and <theme>:<version> pairs covering both currently-loaded extensions and deactivated-but-still-on-disk ones. Each pair is a candidate for the vulnerable plugins page.

Passive catches everything currently active (i.e., loading CSS/JS or registered with WordPress to enqueue assets). It’s stealthy - you just download the homepage and read the source - but it misses deactivated plugins and themes whose files remain on disk.

Active confirms a plugin or theme exists on disk regardless of activation state. It’s noisier - every probe is a request to a specific path - but it catches the deactivated-but-vulnerable case, which is a recurring real-world finding.

Use both. Passive first to enumerate the obvious set; active to expand coverage and to find the long tail.

When WordPress loads a plugin or theme, it typically enqueues that extension’s CSS or JS files, which appear as <link> or <script> tags in the HTML. Path: /wp-content/plugins/<plugin>/... or /wp-content/themes/<theme>/....

Terminal window
curl -s http://blog.inlanefreight.com/ \
| grep -oE '/wp-content/(plugins|themes)/[^/"]+' \
| sort -u
/wp-content/plugins/mail-masta
/wp-content/plugins/wp-google-places-review-slider
/wp-content/themes/ben_theme

Three extensions visible without an active probe.

For more depth, look at the full asset URLs - they often include the plugin’s own version in the ?ver= query string (not the WP core version, but the plugin’s):

Terminal window
curl -s http://blog.inlanefreight.com/ \
| grep -oE '/wp-content/(plugins|themes)/[^"]+?ver=[^"\&]+' \
| sort -u
/wp-content/plugins/mail-masta/lib/subscriber.js?ver=5.3.3
/wp-content/plugins/wp-google-places-review-slider/public/css/wprev-public_combine.css?ver=6.1
/wp-content/themes/ben_theme/style.css?ver=5.3.3

For Mail Masta the ver matches the WP core version (5.3.3) - meaning the plugin doesn’t override the cache-buster, so this isn’t the plugin’s own version. For wp-google-places-review-slider, ver=6.1 looks like the plugin’s actual version (it’s not a WP core version that exists).

This distinction matters: when the ?ver= is the WP core version, you need a different source for the plugin version (the plugin’s own readme.txt, covered below).

Plugin and theme files leave fingerprints other than asset URLs:

Terminal window
# Plugin-registered shortcodes - sometimes printed in page output
curl -s http://target/ | grep -oE '\[[a-z_-]+ [^]]*\]' | sort -u
# Theme-specific class names - often "<theme>-..." in the body
curl -s http://target/ | grep -oE 'class="[^"]*ben[^"]*"' | sort -u
# Inline JS variables initialized by plugins
curl -s http://target/ | grep -oE 'var [a-zA-Z_]+ = {' | sort -u

These signals catch plugins that don’t enqueue obvious assets (e.g., a security plugin running silently). They’re less reliable than asset paths but cheap.

When a plugin exists on disk, requesting its directory or a known file inside it returns a non-404 response - even if the plugin is deactivated.

Terminal window
curl -sI http://blog.inlanefreight.com/wp-content/plugins/mail-masta/ | head -1

Three meaningful response patterns:

ResponseMeaning
HTTP/1.1 200 OKPlugin exists; directory listing is enabled (or there’s an index.html/index.php serving content)
HTTP/1.1 301 Moved PermanentlyPlugin exists; you forgot the trailing slash, server redirected to add one
HTTP/1.1 403 ForbiddenPlugin exists; directory listing is disabled (Apache’s Options -Indexes or per-directory .htaccess)
HTTP/1.1 404 Not FoundPlugin not installed

Any non-404 is a confirmation. Some installs serve a blank index.php placeholder (WordPress includes one in wp-content/plugins/ to thwart casual directory listing), which returns 200 but with no content - same conclusion: the parent directory exists, plugin’s there.

Compare:

Terminal window
# Installed plugin
$ curl -sI http://target/wp-content/plugins/mail-masta/ | head -1
HTTP/1.1 301 Moved Permanently
# Non-existent plugin
$ curl -sI http://target/wp-content/plugins/doesnotexist/ | head -1
HTTP/1.1 404 Not Found

The response-code differential is the oracle. Build a wordlist of common plugin names and brute-force:

Terminal window
while read plugin; do
code=$(curl -s -o /dev/null -w "%{http_code}" \
"http://target/wp-content/plugins/$plugin/")
case $code in
200|301|403) echo "$plugin (code $code)";;
esac
done < /usr/share/seclists/Discovery/Web-Content/CMS/wordpress-plugins.fuzz.txt

The SecLists wordpress-plugins.fuzz.txt and wp-plugins.fuzz.txt cover ~20k common plugin names.

Same approach for themes:

Terminal window
while read theme; do
code=$(curl -s -o /dev/null -w "%{http_code}" \
"http://target/wp-content/themes/$theme/")
case $code in
200|301|403) echo "$theme (code $code)";;
esac
done < wordpress-themes.fuzz.txt

The most reliable version source is the extension’s own readme.txt. Every plugin in the WordPress.org plugin repository ships with one:

Terminal window
curl -s http://target/wp-content/plugins/mail-masta/readme.txt | head -20
=== Mail Masta ===
Contributors: ronmason
Donate link: http://...
Tags: email, newsletter, mailing-list
Requires at least: 3.0.1
Tested up to: 4.5.2
Stable tag: 1.0
License: GPLv2 or later
== Description ==
Mail Masta is an essential email subscription plugin for ...

Key fields:

  • Stable tag - the canonical version (1.0 here)
  • Requires at least - minimum WP core
  • Tested up to - last WP core the plugin author tested

Stable tag is the field to read. If the plugin author bumped Stable tag for a security fix but the target still runs an older copy, this gets you the running version (because you’re reading the file on the target, not from the plugin repo).

For themes, the version lives in style.css’s header:

Terminal window
curl -s http://target/wp-content/themes/ben_theme/style.css | head -15
/*
Theme Name: Ben Theme
Theme URI: https://example.com/ben_theme/
Author: ...
Description: ...
Version: 1.4.2
License: GNU General Public License v2 or later
*/

Version: line in the CSS comment is the version.

Some plugins don’t ship a readme.txt. Fallbacks:

  • The main plugin PHP file’s docblock contains Version: - same idea
  • A package.json or composer.json if the plugin uses build tooling
  • The plugin’s directory layout itself - naming conventions like assets/dist/<hash>/ betray Webpack-based plugins from a specific era
Terminal window
# Read the main plugin file's header
curl -s http://target/wp-content/plugins/mail-masta/mail-masta.php | head -30

The main plugin file is typically named the same as the directory (mail-masta/mail-masta.php), or sometimes <plugin>.php or plugin.php.

A misconfigured Apache (Options +Indexes or absent .htaccess) returns full directory listings for any URL without an index file. WordPress includes a blank index.php in wp-content/plugins/ to defeat this at the parent level - but plugin directories often don’t include their own placeholder.

Terminal window
curl -s http://target/wp-content/plugins/mail-masta/ | html2text
****** Index of /wp-content/plugins/mail-masta ******
[ICO] Name Last_modified Size Description
==============================================================
[PARENTDIR] Parent_Directory -
[DIR] amazon_api/ 2020-05-13 18:01 -
[DIR] inc/ 2020-05-13 18:01 -
[DIR] lib/ 2020-05-13 18:01 -
[ ] plugin-interface.php 2020-05-13 18:01 88K
[TXT] readme.txt 2020-05-13 18:01 2.2K

The listing reveals:

  • Every subdirectory (inputs for further enumeration - inc/, lib/, amazon_api/)
  • Every file (some of which are entry points you can hit directly - see vulnerable plugins)
  • File modification times (suggests when the plugin was installed or last updated)

For the Mail Masta LFI specifically, the path inc/campaign/count_of_send.php?pl=/etc/passwd is reached by navigating from the listing into inc/campaign/count_of_send.php. The directory index is what made this discoverable on real installs in 2016 when the CVE was disclosed.

A frequent operator finding: a vulnerable plugin shows “Deactivated” in the admin dashboard, but its files remain on disk and remain accessible.

[admin dashboard]
Mail Masta - Deactivated
[filesystem]
/var/www/html/wp-content/plugins/mail-masta/ ← still here
[from the outside]
http://target/wp-content/plugins/mail-masta/inc/campaign/count_of_send.php?pl=...
← still vulnerable, still callable

WordPress’s plugin “activation” is purely a database flag (wp_options.active_plugins). It controls whether WordPress loads the plugin into the request lifecycle. It does not protect against direct HTTP requests to the plugin’s PHP files. If a plugin’s vulnerable script can be reached directly (which most can - the WordPress URL rewriter passes through requests for .php files in wp-content/), deactivation does nothing.

This is why active path probing matters even after passive enumeration looks complete. You’ll miss deactivated plugins from page source; you won’t miss them from a wordlist sweep.

The remediation, from the defender’s perspective, is delete unused plugins, not just deactivate them. The operator-relevant insight: every plugin directory on disk is in-scope.

wpscan automates all of the above:

Terminal window
# Vulnerable plugins via passive detection
wpscan --url http://target --enumerate vp
# All plugins (passive + active wordlist brute)
wpscan --url http://target --enumerate ap
# All themes
wpscan --url http://target --enumerate at
# Combined: all plugins, all themes, users, config backups, db backups
wpscan --url http://target --enumerate ap,at,u,cb,dbe

The vp mode misses deactivated plugins (since it only checks the homepage and a few well-known paths). The ap mode walks a built-in wordlist of ~28k plugins, catching the deactivated case. ap takes longer (minutes to an hour) and produces more requests, but is the thorough sweep.

See WPScan reference for the full option matrix.

Once you haveNext page
Plugin list with versionsVulnerable plugins - match versions to known CVEs
Directory listing of a pluginBrowse for entry-point PHP files; many are unauthenticated handlers
readme.txt showing very old plugin versionSearch WPScan database for that plugin
Multiple plugins listedPrioritize by attack surface - plugins handling file uploads, AJAX endpoints, and email subscriptions historically have the highest CVE density
Theme nameTheme CVEs are rarer than plugin CVEs, but Theme Editor (post-auth) gives RCE on any theme - see Admin to RCE
TaskCommand
Passive: extract plugin/theme paths from HTMLcurl -s URL | grep -oE '/wp-content/(plugins|themes)/[^/"]+' | sort -u
Passive: with version query stringscurl -s URL | grep -oE '/wp-content/[^"]+?ver=[^"&]+' | sort -u
Active: probe a specific plugincurl -sI http://target/wp-content/plugins/PLUGIN/ | head -1
Active: brute force from wordlistbash loop above with wordpress-plugins.fuzz.txt
Plugin version from readmecurl -s http://target/wp-content/plugins/PLUGIN/readme.txt | grep -iE 'stable tag|^version'
Theme version from style.csscurl -s http://target/wp-content/themes/THEME/style.css | head -15
Directory listing testcurl -s http://target/wp-content/plugins/PLUGIN/ | html2text
WPScan - vulnerable plugins onlywpscan --url URL --enumerate vp
WPScan - all plugins (deep)wpscan --url URL --enumerate ap
WPScan - everythingwpscan --url URL --enumerate ap,at,u,cb,dbe
Defenses D3-IDA