WordPress vulnerabilities

How bots and detection tools recognize WordPress, and which clues to remove

How bots and Wappalyzer recognize a WordPress site

Before trying an exploit, a bot needs to know what it is talking to. A single page of your site is often enough for it to conclude "WordPress", and a few more requests let it list your plugins and their versions. This article details the exact signals Wappalyzer and WPScan rely on, what you can cleanly remove, and what will stay visible no matter what you do.

Why identification comes before the attack

Automated attacks on WordPress rarely target "your" site. They target a specific plugin version known to be vulnerable, across thousands of sites at once. To sort those sites, a bot answers three questions in order: is it WordPress? Which core version? Which plugins, and which versions?

WPScan illustrates this logic well. By default, when it looks for plugins in aggressive mode, it does not test every existing plugin: it tries the list of plugins known to have a vulnerability in its database. A site that gives no useful answer to these questions drops out of the queue faster; a site that announces everything ends up in the right bucket.

Passive signals: what the home page reveals

A "passive" tool sends no special requests: it reads the HTML, headers and JavaScript of the page any visitor receives. That is how the Wappalyzer browser extension works.

The generator tag

By default, core hooks the wp_generator function into wp_head. It outputs <meta name="generator" content="WordPress 6.9" /> with the exact version number. The same information appears in the feeds: <generator> in RSS 2 and the version attribute in Atom. Removing the tag from the <head> without handling the feeds therefore leaves the version readable at /feed/.

The /wp-content/ and /wp-includes/ paths

Stylesheets, scripts, images: nearly every asset of a WordPress site is served from /wp-content/ (themes, plugins, media) or /wp-includes/ (core libraries). This is the most robust signal, and as a bonus it gives away plugin names: /wp-content/plugins/plugin-name/ appears in plain text in the source code.

The ver= parameter

Core scripts and styles are registered with a default version equal to the WordPress version (default_version = get_bloginfo( 'version' ) in script-loader.php). Not all of them use it (jQuery carries its own number), but a script registered without a version, such as wp-includes/js/comment-reply.min.js, is served with ?ver= followed by the core version, even when the generator tag is gone. Plugins, for their part, often pass their own version number in that same parameter.

HTTP headers

  • Link: <https://example.com/wp-json/>; rel="https://api.w.org/": sent by rest_output_link_header() on public pages to advertise the REST API. Wappalyzer recognizes it directly.
  • X-Pingback: since WordPress 4.4, it is only sent on single content (a post or page) that accepts pings, and it points to xmlrpc.php.
  • Shortlinks: wp_shortlink_header() adds a Link header of type shortlink on content.

JavaScript and CSS classes

The block editor leaves wp-block-... classes in published content. On the JavaScript side, objects such as wp.ajax, a wp_username variable or the wp-embed.min.js script are enough for Wappalyzer. WooCommerce adds its own markers (woocommerce_params, wc_add_to_cart_params).

Map of WordPress detection signals in four columns: HTML code, HTTP headers, JavaScript and active requests
The first three columns are read from an ordinary page; the last one requires targeted requests.
SignalWhere to see itReveals the version?Read by
Generator tag<head> of every pageYes, exactWappalyzer, WPScan
Feed generator/feed/, /feed/atom/Yes, exactWPScan
wp-content, wp-includes pathsSource codeNo (but plugin names)Wappalyzer, WPScan
ver= parameterScript and style URLsOftenWPScan
api.w.org Link headerHTTP responseNoWappalyzer
X-PingbackHTTP response (posts)NoWappalyzer
JavaScript variablesPage scriptsNoWappalyzer
Plugin readme.txtDedicated requestYes (Stable tag)WPScan
MD5 fingerprintsRequests for static filesYesWPScan
Main signals, based on the webappanalyzer WordPress definition and the WPScan code.

How Wappalyzer decides

Wappalyzer compares the page against a database of definitions: for each technology, a list of regular expressions to look for in the HTML, meta tags, headers, cookies, script URLs and JavaScript variables. The original repository went private in August 2023; the database lives on in open projects such as webappanalyzer (Enthec), which keeps the same format.

The "WordPress" definition in that database is short and instructive:

  • meta generator: ^WordPress(?: ([\d.]+))?, capturing the version number;
  • headers: X-Pingback ending in /xmlrpc.php, and link containing rel="https://api.w.org/";
  • scriptSrc and html: /wp-content/ or /wp-includes/ paths, wp-embed.min.js;
  • js: wp.ajax, wp.receiveEmbedMessage, wp_username;
  • implies: PHP and MySQL. The WooCommerce definition, for its part, requires WordPress (requires).

The practical consequence: a single signal is enough. Removing the generator tag does not change the verdict as long as /wp-content/ paths remain in the page. Conversely, a site that exposes the markers of other software will be filed under that other software by the same mechanism.

How WPScan goes further

WPScan is a security scanner dedicated to WordPress, used by auditors and attackers alike. Its source code is public, so we know exactly what it looks for:

  • WordPress detection: the home page and a 404 page are examined (paths, generator, HTML comments mentioning WordPress, admin-ajax.php in an inline script). In mixed or aggressive mode, it also loads wp-login.php and wp-admin/install.php.
  • Core version: RSS, Atom and RDF feed generators, readme.html (low confidence for recent versions), then fingerprints: it compares the MD5 of static files with a database of known checksums, at 100% confidence.
  • Plugins: URLs seen on the home page and the 404 page, patterns in headers, comments and JavaScript variables, then requests to /wp-content/plugins/slug/. A 200, 401, 403 or 500 response counts as present.
  • Plugin versions: reading readme.txt, either the Stable tag field (confidence 80) or the changelog section (confidence 50).
Six-step diagram of a WPScan scan, from the home page to matching against a vulnerability database
The first step is just an ordinary visit; the orange steps generate requests that show up in your access logs.

The active steps leave traces: dozens of requests for /wp-content/plugins/.../readme.txt returning 404 within a few seconds are the signature of enumeration. This is also where a defense can step in, by not answering 200 or 403 for files that have no reason to be public.

WPScan demo by Secureaks (in French): enumerating plugins and versions on a test site.

What you can reduce, and what will stay visible

WordPress lets you remove several of these announcements with simple hooks, for example in a custom plugin:

remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );   // feeds included
remove_action( 'wp_head', 'rsd_link' );
remove_action( 'wp_head', 'wp_shortlink_wp_head', 10 );
remove_action( 'template_redirect', 'wp_shortlink_header', 11 );
remove_action( 'wp_head', 'rest_output_link_wp_head', 10 );
remove_action( 'template_redirect', 'rest_output_link_header', 11 );

Note the priority: remove_action() only works if it uses the same priority as when the hook was added (11 for both headers). On the other hand, there is no need to look for wlwmanifest_link: core has not output that Windows Live Writer link since WordPress 6.3.

SignalCountermeasureEffectLimitation
Generatorthe_generator filterVersion removed from pages and feedsThemes and plugins that write their own generator
api.w.org LinkRemoving both REST hooksAPI announcement removedThe API is still reachable at /wp-json/
ver=Rewriting or removing the parameterCore version less visibleCan interfere with browser cache busting
wp-content pathsURL aliases + server rulesNo more /wp-content/ in the HTMLTricky: CSS, JS and JSON also contain paths
Plugin readme.txtServer-level block (404)Plugin version harder to readStatic file fingerprinting still possible
MD5 fingerprintsNo simple countermeasureOnly updating removes the vulnerability
Reducing the identification surface: effects and limitations.

Keep the priorities straight: hiding the version fixes no vulnerability. A bot that cannot identify your site can still try the exploit blindly, and some do. Hiding reduces noise and takes your site off sorted target lists; updates, backups and strong authentication remain the foundation.

Hide WordPress or imitate other software?

Removing the signals leaves a site "without an identity", which is a clue in itself. The other approach is to present the markers of different software. That is the principle behind CMS imitation in WP Masquerade, our WordPress security plugin:

FeatureFreePro
WordPress generator removed from pages and feedsYesYes
Authentic generator tag from other software5 profiles (Joomla!, Drupal, PrestaShop, TYPO3, MediaWiki)41 profiles
Markers recognized by Wappalyzer (headers, JavaScript variables)NoYes
RSD, Windows Live Writer and shortlink links removedYesYes
REST API and oEmbed announcements removedNoYes
Aliases for wp-content, media, wp-includes, plugins, themesNoYes
Plugin readme and translation files return 404 (Apache, LiteSpeed)YesYes
What WP Masquerade does against identification (according to the product documentation).

The limitations are documented: plugin and theme folder names remain present under their root folder, CSS and JavaScript files are not rewritten globally, and no result is guaranteed in any detector. See also technical links and file URLs.

Test your own site in ten minutes

  1. Open a private browsing window (logged out) and view the source: look for generator, wp-content, wp-includes, ver=.
  2. Open /feed/ and look for the <generator> tag.
  3. Read the headers with curl -sI https://your-site.com/: is there a Link to api.w.org?
  4. Run the Wappalyzer extension on the home page, then on a post.
  5. Try /wp-content/plugins/some-plugin/readme.txt: a 200 response gives its version to anyone.
  6. After each change, purge the page cache: an old cached copy can keep the old signals.
  7. If you run WPScan, do it only on a site you are responsible for.
The visitor's point of view (in French): simple ways to recognize a WordPress site.

Key takeaways

  • A single signal is enough for Wappalyzer: removing the generator tag is not enough while /wp-content/ remains visible.
  • The core version also leaks through feeds, the ver= parameter and file fingerprints.
  • WPScan reads the Stable tag in plugin readme.txt files: block those files if they return 200.
  • remove_action() requires the same priority as the original hook (11 for the Link headers).
  • Hiding reduces automated targeting, but only updates remove a vulnerability.

Sources

Other sources consulted: official documentation and official vendor pages.

WordPress vulnerabilities6 min

How to stop WordPress user enumeration

A bot does not need to guess your usernames: WordPress publishes several of them by default. The five sources of leaks, how to test your site…

WordPress security, without the noise

One e-mail when something matters: new attacks seen in the wild, plugin updates and practical guides. No spam, unsubscribe in one click.

Start free, go Pro when you’re ready

The free version already hides WordPress and moves the login page. Pro adds aliases, two-factor authentication and monitoring.