Plugin & Theme Enumeration
Plugins and themes are where most WordPress findings live. Two enumeration modes:
# 1. Passive - read what's loaded on the pagecurl -s http://target/ \ | grep -oE 'wp-content/(plugins|themes)/[^/]+' \ | sort -u
# 2. Active - probe a known path with response code as oraclecurl -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.txtcurl -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.
Two enumeration modes
Section titled “Two enumeration modes”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.
Passive enumeration from page source
Section titled “Passive enumeration from page source”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>/....
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_themeThree 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):
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.3For 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).
Source-line patterns
Section titled “Source-line patterns”Plugin and theme files leave fingerprints other than asset URLs:
# Plugin-registered shortcodes - sometimes printed in page outputcurl -s http://target/ | grep -oE '\[[a-z_-]+ [^]]*\]' | sort -u
# Theme-specific class names - often "<theme>-..." in the bodycurl -s http://target/ | grep -oE 'class="[^"]*ben[^"]*"' | sort -u
# Inline JS variables initialized by pluginscurl -s http://target/ | grep -oE 'var [a-zA-Z_]+ = {' | sort -uThese 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.
Active enumeration via path probing
Section titled “Active enumeration via path probing”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.
curl -sI http://blog.inlanefreight.com/wp-content/plugins/mail-masta/ | head -1Three meaningful response patterns:
| Response | Meaning |
|---|---|
HTTP/1.1 200 OK | Plugin exists; directory listing is enabled (or there’s an index.html/index.php serving content) |
HTTP/1.1 301 Moved Permanently | Plugin exists; you forgot the trailing slash, server redirected to add one |
HTTP/1.1 403 Forbidden | Plugin exists; directory listing is disabled (Apache’s Options -Indexes or per-directory .htaccess) |
HTTP/1.1 404 Not Found | Plugin 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:
# Installed plugin$ curl -sI http://target/wp-content/plugins/mail-masta/ | head -1HTTP/1.1 301 Moved Permanently
# Non-existent plugin$ curl -sI http://target/wp-content/plugins/doesnotexist/ | head -1HTTP/1.1 404 Not FoundThe response-code differential is the oracle. Build a wordlist of common plugin names and brute-force:
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)";; esacdone < /usr/share/seclists/Discovery/Web-Content/CMS/wordpress-plugins.fuzz.txtThe SecLists wordpress-plugins.fuzz.txt and wp-plugins.fuzz.txt cover ~20k common plugin names.
Same approach for themes:
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)";; esacdone < wordpress-themes.fuzz.txtPlugin and theme versions
Section titled “Plugin and theme versions”The most reliable version source is the extension’s own readme.txt. Every plugin in the WordPress.org plugin repository ships with one:
curl -s http://target/wp-content/plugins/mail-masta/readme.txt | head -20=== Mail Masta ===Contributors: ronmasonDonate link: http://...Tags: email, newsletter, mailing-listRequires at least: 3.0.1Tested up to: 4.5.2Stable tag: 1.0License: 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 coreTested 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:
curl -s http://target/wp-content/themes/ben_theme/style.css | head -15/*Theme Name: Ben ThemeTheme URI: https://example.com/ben_theme/Author: ...Description: ...Version: 1.4.2License: GNU General Public License v2 or later*/Version: line in the CSS comment is the version.
When readme.txt is missing
Section titled “When readme.txt is missing”Some plugins don’t ship a readme.txt. Fallbacks:
- The main plugin PHP file’s docblock contains
Version:- same idea - A
package.jsonorcomposer.jsonif 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
# Read the main plugin file's headercurl -s http://target/wp-content/plugins/mail-masta/mail-masta.php | head -30The main plugin file is typically named the same as the directory (mail-masta/mail-masta.php), or sometimes <plugin>.php or plugin.php.
Directory indexing - the bonus oracle
Section titled “Directory indexing - the bonus oracle”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.
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.2KThe 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.
The deactivation gap
Section titled “The deactivation gap”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 callableWordPress’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 automation
Section titled “WPScan automation”wpscan automates all of the above:
# Vulnerable plugins via passive detectionwpscan --url http://target --enumerate vp
# All plugins (passive + active wordlist brute)wpscan --url http://target --enumerate ap
# All themeswpscan --url http://target --enumerate at
# Combined: all plugins, all themes, users, config backups, db backupswpscan --url http://target --enumerate ap,at,u,cb,dbeThe 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.
Cross-references for what to do next
Section titled “Cross-references for what to do next”| Once you have | Next page |
|---|---|
| Plugin list with versions | Vulnerable plugins - match versions to known CVEs |
| Directory listing of a plugin | Browse for entry-point PHP files; many are unauthenticated handlers |
readme.txt showing very old plugin version | Search WPScan database for that plugin |
| Multiple plugins listed | Prioritize by attack surface - plugins handling file uploads, AJAX endpoints, and email subscriptions historically have the highest CVE density |
| Theme name | Theme CVEs are rarer than plugin CVEs, but Theme Editor (post-auth) gives RCE on any theme - see Admin to RCE |
Quick reference
Section titled “Quick reference”| Task | Command |
|---|---|
| Passive: extract plugin/theme paths from HTML | curl -s URL | grep -oE '/wp-content/(plugins|themes)/[^/"]+' | sort -u |
| Passive: with version query strings | curl -s URL | grep -oE '/wp-content/[^"]+?ver=[^"&]+' | sort -u |
| Active: probe a specific plugin | curl -sI http://target/wp-content/plugins/PLUGIN/ | head -1 |
| Active: brute force from wordlist | bash loop above with wordpress-plugins.fuzz.txt |
| Plugin version from readme | curl -s http://target/wp-content/plugins/PLUGIN/readme.txt | grep -iE 'stable tag|^version' |
| Theme version from style.css | curl -s http://target/wp-content/themes/THEME/style.css | head -15 |
| Directory listing test | curl -s http://target/wp-content/plugins/PLUGIN/ | html2text |
| WPScan - vulnerable plugins only | wpscan --url URL --enumerate vp |
| WPScan - all plugins (deep) | wpscan --url URL --enumerate ap |
| WPScan - everything | wpscan --url URL --enumerate ap,at,u,cb,dbe |