How To

Install and Use WPScan WordPress Security Scanner on Linux

One command no longer gives everyone the same WordPress security scanner. WPScan moved to a 4.x line in 2026 that needs Ruby 3.3, so gem install wpscan on Ubuntu 24.04, whose packaged Ruby is 3.2.3, quietly installs the last 3.x release instead and still reports success. Ubuntu 26.04, Rocky Linux 10 and Kali get the current scanner, and the official Docker image skips the Ruby question entirely.

Original content from computingforgeeks.com - post 112418

This guide shows how to install WPScan on each of those platforms and add the WPScan API token that turns a scan into vulnerability data. It then runs the scans worth running: plugin, user and config backup enumeration, then JSON and SARIF reports with exit codes you can gate a pipeline on. Each distro ran as a clean container image on one lab host, scanning a WordPress 7.1 test site in the same pod. On Ubuntu 26.04 the gem install pulled 19 gems in 18.5 seconds (WPScan 4.1.0, tested October 2026). Scan only sites you own or have written permission to test.

Which install method gets you WPScan 4.x

Packaged Ruby decides the outcome, not the distro family. Every row below comes from the same gem install wpscan (or package install) run on a fresh image, with the result read from wpscan --version afterwards:

PlatformRuby shippedWhat you getInstall time
Ubuntu 26.04 LTS3.3.8WPScan 4.1.0, 19 gems18.5 s
Ubuntu 24.04 LTS3.2.3WPScan 3.8.28, 23 gems (silent fallback)not timed
Rocky Linux 10.23.3.12WPScan 4.1.0, 25 gems36.6 s
Kali rolling3.3.8apt package 4.1.0-0kali112.2 s
Docker image4.0.3 (inside the image)WPScan 4.1.0, 180 MB imagepull only

Ubuntu 24.04 is the row to watch. RubyGems picks the newest WPScan whose Ruby requirement the host meets, which on Ruby 3.2 is 3.8.28 from February 2025, and it never mentions that 4.x exists. You lose the 4.x features covered later (--wp-auth, SARIF output, the XDG config paths), so on 24.04 we would run the Docker image instead. Rocky took twice as long as Ubuntu 26.04 because it pulled six gems that Ubuntu’s ruby-full already provides as packages, among them racc, rexml and prism, and built native extensions for four of them: yajl-ruby, racc, websocket-driver and prism.

Prerequisites

WPScan is a client, so it needs very little from the scanning host. The target is what matters, and the permission to scan it:

  • Scanner host: Ubuntu 26.04, Rocky Linux 10, Kali, or any Linux box with Docker. Ubuntu 24.04 works only through Docker or a newer Ruby if you want the 4.x line.
  • Ruby 3.3 or newer with development headers and a C compiler, because some dependencies, yajl-ruby among them, build native extensions during the gem install.
  • A WordPress site you are authorized to test. Scanning someone else’s site without permission is illegal in most jurisdictions.
  • A free WPScan API token from wpscan.com/register. Without it the scanner still runs, but prints no vulnerability data.

Step 1: Install WPScan on Ubuntu 26.04

Ubuntu 26.04 ships Ruby 3.3.8, which meets the 4.x requirement, so the RubyGems path works as documented. The build tools come first because some of the dependencies compile C code during the install:

sudo apt update
sudo apt install -y ruby-full build-essential libcurl4-openssl-dev zlib1g-dev

Confirm the Ruby version before installing anything, since it decides which WPScan you end up with:

ruby -v

Anything at 3.3 or above is fine. The lab host reported:

ruby 3.3.8 (2025-04-09 revision b200bad6cd) [x86_64-linux-gnu]

Now install the gem. With sudo, the wpscan binary lands in /usr/local/bin and the gems under /var/lib/gems/3.3.0:

sudo gem install wpscan

Check which version actually arrived, because on an older Ruby the same command succeeds with a different release:

wpscan --version --no-banner

A 4.x number here means the install picked the current line:

Current Version: 4.1.0

On the lab host, the whole sequence, including the first database update covered in Step 6, looked like this:

Install WPScan with gem on Ubuntu 26.04, Ruby 3.3.8 and WPScan 4.1.0 version check

If you would rather keep Ruby off the host altogether, skip to the Docker method in Step 5. It runs the same 4.1.0 release with its own Ruby inside the image.

Step 2: Ubuntu 24.04 and the silent 3.x fallback

On Ubuntu 24.04 the same two commands succeed and give you an older scanner. The packaged Ruby is 3.2.3, WPScan 4.0.0 and later declare required_ruby_version >= 3.3, and RubyGems resolves to the newest release the host can run. Nothing in the install output says a newer version was skipped:

WPScan gem install on Ubuntu 24.04 with Ruby 3.2.3 installs 3.8.28 and rejects 4.1.0

You only see the fallback when you pin the version, which is also how you confirm the cause on your own host. Anyone who followed an older guide on 24.04 and never checked wpscan --version is running 3.8.28 right now.

Error: “wpscan-4.1.0 requires Ruby version >= 3.3”

Pinning the release on a Ruby 3.2 host fails straight away, before any gem is downloaded:

sudo gem install wpscan -v 4.1.0 #https://github.com/wpscanteam/wpscan/releases

RubyGems names both versions in the error, and that line is the proof you need before picking a fix:

ERROR:  Error installing wpscan:
	wpscan-4.1.0 requires Ruby version >= 3.3. The current ruby version is 3.2.3.

There are two ways out. The Docker image in Step 5 is the shorter one, because it carries its own Ruby and leaves the system packages alone. The other is a per-user Ruby 3.3 or newer from rbenv, which our Ruby on Ubuntu guide covers, followed by the same gem install wpscan without sudo. If 3.8.28 is already installed system-wide, remove it with sudo gem uninstall wpscan first so two wpscan binaries do not compete on the PATH.

Step 3: Install WPScan on Rocky Linux 10

Rocky Linux 10.2 ships Ruby 3.3.12 in AppStream, so no extra repository is needed. The package names follow the RHEL convention, with ruby-devel and libcurl-devel in place of the Debian -dev packages:

sudo dnf install -y ruby ruby-devel gcc make libcurl-devel

Install the gem. The --no-document flag skips the ri and RDoc generation, which only adds time on a server:

sudo gem install wpscan --no-document

The last lines of the install show the gem count and the version that was resolved:

Successfully installed activesupport-8.1.4
Successfully installed wpscan-4.1.0
25 gems installed

Here too the binary goes to /usr/local/bin/wpscan. On a stock Rocky 10 install that directory is on the default PATH for regular users, so wpscan --version works without a full path. AlmaLinux 10 carries the same AppStream Ruby and package names; we ran this step on Rocky only.

Step 4: Install WPScan on Kali Linux

Kali packages WPScan in its own repository, and the WPScan maintainers recommend the package manager on pentesting distributions. Mixing gem update wpscan into an apt-managed install leaves two copies on the box, so stay with apt. Check what the repository offers first:

apt-cache policy wpscan

Kali rolling already carried the current release when we checked, so there is no reason to reach for RubyGems on Kali:

wpscan:
  Installed: (none)
  Candidate: 4.1.0-0kali1
  Version table:
     4.1.0-0kali1 500
        500 http://http.kali.org/kali kali-rolling/non-free amd64 Packages

Install it on a box that does not have it yet:

sudo apt update
sudo apt install -y wpscan

On a Kali box that already has WPScan, the package moves forward with the rest of the system, which is how the WPScan maintainers say Kali users should update it:

sudo apt update
sudo apt full-upgrade -y

The package install took 12.2 seconds and pulled Ruby 3.3.8 as a dependency. A Kali box that has not been upgraded in a while still runs the WPScan it shipped with. That is true of its Nmap and the other bundled scanners, so run wpscan --version after the upgrade and expect a 4.x number.

Step 5: Run WPScan from the Docker image

The official wpscanteam/wpscan image is the cleanest install on any host with a container runtime, including Ubuntu 24.04. It takes about 180 MB on disk, bundles Ruby 4.0.3, and its entrypoint is the wpscan binary, so arguments go straight after the image name. You need Docker on the host first; our guides cover Docker CE on Ubuntu and Docker on Rocky or AlmaLinux.

docker pull wpscanteam/wpscan

Vulnerability metadata is baked into the image at build time, and that copy ages. Ours reported a database date three months older than the day we pulled it:

docker run --rm wpscanteam/wpscan --version --no-banner

Read the date line. An old date means the outdated-version checks compare your site against stale data:

Current Version: 4.1.0
Last DB Update: 2026-07-23

With --rm, any update a run downloads is thrown away when the container exits. Mount a named volume over the image’s cache directory so updates persist between runs:

docker run --rm -v wpscan-db:/wpscan/.cache/wpscan/db wpscanteam/wpscan --update --no-banner

Run the same --version check with the volume mounted and it reports the current date:

Current Version: 4.1.0
Last DB Update: 2026-10-24

Most scan commands in the rest of this guide work in the container by replacing wpscan with docker run --rm -e WPSCAN_API_TOKEN -v wpscan-db:/wpscan/.cache/wpscan/db wpscanteam/wpscan. Three things differ from a host install:

  • Report files: -o report.json writes inside the container, and --rm deletes it on exit. Drop -o and redirect stdout instead, as shown below.
  • Config file: a ~/.config/wpscan/scan.yml on the host is not visible in the container, so pass the token with -e WPSCAN_API_TOKEN, which copies the exported value from Step 6.
  • Rootless Podman: the image runs as a wpscan user with UID 1000. On our rootless Podman host, which has no subordinate UID range, it failed with crun: cannot setresgid to `1000`: Invalid argument until we added --user 0 -e HOME=/wpscan. Docker, and Podman with a normal /etc/subuid entry, do not need that.

A JSON report from the container therefore looks like this, with the file landing in the current directory on the host. Run it after the exports in Step 6, since it uses both variables:

docker run --rm -e WPSCAN_API_TOKEN -v wpscan-db:/wpscan/.cache/wpscan/db wpscanteam/wpscan --url "https://${SITE_DOMAIN}/" --no-banner -e cb -f json > report.json

We ran that form with Podman (plus the two rootless flags) against the lab site, and it wrote a 6 KB JSON file listing the exposed wp-config.php.bak, with exit code 5.

Step 6: Update the database and add the WPScan API token

WPScan keeps two kinds of data. The local database holds plugin and theme metadata and fingerprints, and it is free; the vulnerability records come from the WPScan API and need a token. Register at wpscan.com/register first and copy the token from your profile page, then set the target and the token once for this shell session, since both repeat in every command from here on:

export SITE_DOMAIN="blog.example.com"
export WPSCAN_API_TOKEN="your-token-here"

Replace both values with your own. No token yet? Skip the second line, or run unset WPSCAN_API_TOKEN, because a placeholder left in the variable makes every scan abort with the 401 error covered below. Then confirm the values are set; a new SSH session loses them, so re-run the exports when you reconnect:

echo "Target: https://${SITE_DOMAIN}/"
echo "Token set: ${WPSCAN_API_TOKEN:+yes}"

Update the local database next. New 4.x installs keep it in ~/.cache/wpscan/db, following the XDG layout, while hosts upgraded from 3.x keep using ~/.wpscan/db:

wpscan --update --no-banner

That update took 2.6 seconds on the lab host and left 25 MB in the cache directory, most of it metadata.json (17 MB, covering 135,471 plugins and 33,604 themes). WPScan offers an update when the copy is more than five days old, but only in an interactive terminal with the default cli output; cron jobs and containers never prompt and silently scan with the old data, so a scheduled scan should run --update first.

Three ways WPScan reads the API token

The free plan allows 25 API requests a day. A scan spends one request on the WordPress version, one per plugin and one per theme it detects, so a site with 20 plugins and 2 themes uses 23 of the 25. WPScan accepts the token from any of these, and the command-line flag wins over the environment variable when both are set:

  • Environment variable: WPSCAN_API_TOKEN, which you already exported above. Best for cron jobs and containers, because the token never appears in the process list.
  • Command-line flag: --api-token. Handy for a one-off test, but the token is visible to anyone who can run ps on the host while the scan runs.
  • Config file: ~/.config/wpscan/scan.yml on new 4.x installs. The old ~/.wpscan/scan.yml is still read, so a file copied from a 3.x guide keeps working.

For a permanent setup on a workstation, the config file is the tidiest. Create the directory, then open the file:

mkdir -p ~/.config/wpscan
vim ~/.config/wpscan/scan.yml

Add the token under the cli_options key. Any other long option can sit here too, in snake case:

cli_options:
  api_token: your-token-here

The file holds a credential, so make it readable by you alone:

chmod 600 ~/.config/wpscan/scan.yml

From now on a plain wpscan --url picks the token up without any flag. If you also keep WPSCAN_API_TOKEN exported, make sure both hold the same value, or you will chase the error below.

Error: “Scan Aborted: Unable to connect to the WPScan API … (status: 401)”

A wrong or revoked token does not degrade to a token-less scan. After a few requests to confirm the target is up and runs WordPress, WPScan checks the token against the API status endpoint and aborts with exit code 4 if the API refuses it, before any enumeration starts. We fed a dummy 43-character token through each of the three methods above and got the identical error every time, which also proves each method is actually read:

Scan Aborted: Unable to connect to the WPScan API: HTTP Error: https://wpscan.com/api/v4/status?version=4.1.0 (status: 401). Please check https://status.wpscan.com/ for service status.

Despite the wording, a 401 is not an outage. Copy the token again from your profile, check that no stray quote or space came with it, and look for a forgotten token in scan.yml or the environment that is overriding the one you meant to use. Our lab had no live token, so the vulnerability output itself is not shown in this guide; every other result below is from the real scans.

Step 7: Run a first WPScan scan and read the output

A plain scan with no enumeration flags checks the server headers, a set of interesting files, the WordPress version and the active theme. In 4.x it no longer enumerates plugins by default, which is the biggest behaviour change for anyone reusing a 3.x command:

wpscan --url "https://${SITE_DOMAIN}/"

Against the lab site, with the banner suppressed and no token, it finished in 3 seconds and 32 requests:

[+] URL: http://wp.lab.test/ [127.0.0.1]
[+] Started: Sat Oct 24 09:43:46 2026
[+] Command Line: wpscan --url http://wp.lab.test/ --no-banner --clear-cache
[+] Hostname: bench01

Interesting Finding(s):

[+] Headers
 | Interesting Entries:
 |  - Host: wp.lab.test
 |  - X-Powered-By: PHP/8.3.28
 | Found By: Headers (Passive Detection)
 | Confidence: 100%

[+] XML-RPC seems to be enabled: http://wp.lab.test/xmlrpc.php
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 100%
 | References:
 |  - http://codex.wordpress.org/XML-RPC_Pingback_API
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner/
 |  - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos/
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login/
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access/

[+] WordPress readme found: http://wp.lab.test/readme.html
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 100%

[+] The external WP-Cron seems to be enabled: http://wp.lab.test/wp-cron.php
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 60%
 | References:
 |  - https://www.iplocation.net/defend-wordpress-from-ddos
 |  - https://github.com/wpscanteam/wpscan/issues/1299

[+] WordPress version 7.1.3 identified (Latest, released on 2026-10-06).
 | Found By: Rss Generator (Passive Detection)
 |  - http://wp.lab.test/?feed=rss2, <generator>https://wordpress.org/?v=7.1.3</generator>
 | Confirmed By: Rss Generator (Passive Detection)
 |  - http://wp.lab.test/?feed=comments-rss2, <generator>https://wordpress.org/?v=7.1.3</generator>

[+] WordPress theme in use: twentytwentyfive
 | Location: http://wp.lab.test/wp-content/themes/twentytwentyfive/
 | Last Updated: 2026-05-20 12:00am GMT (5 months ago, per WordPress.org)
 | Active Installs: 1,000,000 (per WordPress.org)
 | Readme: http://wp.lab.test/wp-content/themes/twentytwentyfive/readme.txt
 | [!] The version is out of date, the latest version is 1.5
 | Style URL: http://wp.lab.test/wp-content/themes/twentytwentyfive/style.css?ver=1.3
 | Style Name: Twenty Twenty-Five
 | Style URI: https://wordpress.org/themes/twentytwentyfive/
 | Description: Twenty Twenty-Five emphasizes simplicity and adaptability. It offers flexible design options, suppor...
 | Author: the WordPress team
 | Author URI: https://wordpress.org
 |
 | Found By: Css Style In Homepage (Passive Detection)
 |
 | Version: 1.3 (80% confidence)
 | Found By: Style (Passive Detection)
 |  - http://wp.lab.test/wp-content/themes/twentytwentyfive/style.css?ver=1.3, Match: 'Version: 1.3'

[!] No WPScan API Token given, as a result vulnerability data has not been output.
[!] You can get a free API token with 25 daily requests by registering at https://wpscan.com/register
[+] Finished: Sat Oct 24 09:43:49 2026
[+] Requests Done: 32
[+] Cached Requests: 5
[+] Most response codes received: 200: 17, 404: 14, 302: 1
[+] Data Sent: 8.556 KB
[+] Data Received: 164.514 KB
[+] Memory used: 205.453 MB
[+] Elapsed time: 00:00:03

Read it top to bottom by the markers. Lines starting [+] are findings, [!] are the ones WPScan considers a problem, and the confidence figure tells you how much to trust a detection. The WordPress version came from the RSS generator tag in two feeds, which means the site advertises its exact version to anyone. The theme version sits at 80% because it rests on a single passive source, the style.css header. Only the theme is a real problem in this run, at 1.3 against a current 1.5. Two more lines matter even when nothing is wrong: Hostname: bench01 is the scanner’s own hostname, which 4.x now records for audit trails, and the memory line (205 MB here) is WPScan’s own footprint, useful when you size a CI runner.

A fixed site still shows the old findings

WPScan caches HTTP responses between runs. We changed how the lab server answered missing paths and rescanned within a minute, and the second scan reported Requests Done: 2 with Cached Requests: 38, repeating two findings that no longer existed. After you fix something on the target, add --clear-cache to the next scan so it fetches every page again:

wpscan --url "https://${SITE_DOMAIN}/" --clear-cache

The cache lives in ~/.cache/wpscan/cache on a 4.x install unless $TMPDIR is set, so deleting that directory has the same effect.

Step 8: Enumerate plugins without 135,000 requests

Plugins are where most WordPress vulnerabilities live, and the enumeration flag you pick decides whether a scan takes seconds or hours. In 4.x the plugin detection mode inherits the global --detection-mode, which defaults to mixed, so -e ap on its own probes the directory of every plugin slug in the database. We measured each approach against the same lab site, which had two plugins installed but not active:

CommandRequestsTimePlugins found
-e ap (mixed, default)about 10,400 when stopped (access log count)stopped after 10 minnot finished
-e ap --plugins-detection passive325 s0
-e p (popular list, mixed)1,54172 s2
-e ap --plugins-list ... --plugins-detection aggressive466 s2
--wp-auth with an Application Password634 s4, plus every theme

The lab target was the PHP built-in web server, which managed roughly 17 requests a second (about 10,400 requests in the 10 minutes before we stopped the full run, counted from its access log), so the absolute times will be shorter against Apache or Nginx; the request counts are what carry over. Passive mode found nothing because it only reads plugin paths that appear in the page HTML, and inactive plugins print nothing. The full -e ap brute force would have needed roughly 135,000 requests, which is slow on any server and looks like an attack to a WAF or an intrusion detection system that does not know you are coming.

Pick by what you know about the site:

  • You have admin access: use --wp-auth. It reads the authoritative plugin and theme list from the REST API, with exact versions, in a few dozen requests.
  • You know which plugins should be there: pass them with --plugins-list and --plugins-detection aggressive, as shown below.
  • You know nothing about the site: start with -e p, the popular list (1,498 slugs in the database we pulled), and reserve full -e ap for a scheduled window you have agreed with the site owner.

The targeted form looks like this. Swap the comma-separated slugs for the plugin folder names you expect on your site:

wpscan --url "https://${SITE_DOMAIN}/" -e ap --plugins-list contact-form-7,akismet,classic-editor --plugins-detection aggressive

WPScan confirmed the two installed plugins and skipped classic-editor, which was not on the server:

[+] Enumerating Most Popular Plugins (via Aggressive Methods)

[+] contact-form-7
 | Location: http://wp.lab.test/wp-content/plugins/contact-form-7/
 | Last Updated: 2026-10-21 8:43am GMT (3 days ago, per WordPress.org)
 | Active Installs: 10,000,000 (per WordPress.org)
 | Readme: http://wp.lab.test/wp-content/plugins/contact-form-7/readme.txt
 | [!] The version is out of date, the latest version is 6.2.3
 |
 | Found By: Known Locations (Aggressive Detection)
 |  - http://wp.lab.test/wp-content/plugins/contact-form-7/, status: 403
 |
 | Version: 5.9 (80% confidence)
 | Found By: Readme - Stable Tag (Aggressive Detection)
 |  - http://wp.lab.test/wp-content/plugins/contact-form-7/readme.txt

[+] akismet
 | Location: http://wp.lab.test/wp-content/plugins/akismet/
 | Last Updated: 2026-08-18 11:42pm GMT (2 months ago, per WordPress.org)
 | Active Installs: 5,000,000 (per WordPress.org)
 | Readme: http://wp.lab.test/wp-content/plugins/akismet/readme.txt
 | [!] The version is out of date, the latest version is 5.7.2
 |
 | Found By: Known Locations (Aggressive Detection)
 |  - http://wp.lab.test/wp-content/plugins/akismet/, status: 200
 |
 | Version: 5.5 (100% confidence)
 | Found By: Readme - Stable Tag (Aggressive Detection)
 |  - http://wp.lab.test/wp-content/plugins/akismet/readme.txt
 | Confirmed By: Readme - ChangeLog Section (Aggressive Detection)
 |  - http://wp.lab.test/wp-content/plugins/akismet/readme.txt
[i] 2 plugin(s) Identified.

Contact Form 7’s status: 403 is still a hit: the folder exists but directory listing is off, which is exactly what a hardened server returns. Its version is 80% because it rests on the readme’s stable tag alone, while Akismet’s is 100% because the changelog section agrees. Both plugins are a major version or more behind, and with a token WPScan would list the known vulnerabilities for each under these blocks.

Authenticated inventory with –wp-auth

New in 4.x, --wp-auth logs in to the REST API with an Application Password and reads /wp-json/wp/v2/plugins and /themes directly. Create one for the admin user under Users, then Profile, then Application Passwords; a normal account password is rejected over Basic Auth. WordPress only offers Application Passwords over HTTPS unless the site is flagged as a local environment, so this works on production sites with a certificate:

wpscan --url "https://${SITE_DOMAIN}/" --wp-auth "admin:xxxx xxxx xxxx xxxx xxxx xxxx" -e ap,at

In the excerpt below, the source of each finding changes; the ... lines mark where the other plugin blocks were cut. WPScan also replaced the password with [REDACTED] in its own Command Line output line, so reports are safe to share:

[+] Fetching authoritative inventory via WP REST API (/wp-json/wp/v2/)

[i] Plugin(s) Identified:

...

[+] contact-form-7
 | Location: http://wp.lab.test/wp-content/plugins/contact-form-7/
 | Last Updated: 2026-10-21 8:43am GMT (3 days ago, per WordPress.org)
 | Active Installs: 10,000,000 (per WordPress.org)
 | Readme: http://wp.lab.test/wp-content/plugins/contact-form-7/readme.txt
 | [!] The version is out of date, the latest version is 6.2.3
 |
 | Found By: WP REST API (Authenticated)
 |  - http://wp.lab.test/wp-json/wp/v2/plugins
 |
 | Version: 5.9 (100% confidence)
 | Found By: WP REST API (Authenticated)

...
[i] --wp-auth provided; ignoring plugin/theme enumeration option(s): all_plugins, all_themes (authoritative inventory already retrieved via the WP REST API).

Contact Form 7 moved from 80% to 100% confidence, and the full run also listed Hello Dolly and the SQLite integration plugin (the lab site’s database layer), plus the two inactive default themes, none of which the unauthenticated scans saw. This is the mode we would schedule for sites we administer: 63 requests, no brute force, and a complete list. Use a dedicated Application Password per scanner so you can revoke it without touching anything else.

Step 9: Find exposed config backups and usernames

Two enumerations catch the findings that turn into incidents fastest. cb requests a list of common backup names for wp-config.php, and u collects usernames from the RSS feed, the REST API and author ID brute forcing. We left a copy of the config file named wp-config.php.bak on the lab site, a mistake editors and deploy scripts make often:

wpscan --url "https://${SITE_DOMAIN}/" -e u,cb

Both enumerations together cost 194 requests and 9 seconds. Almost all of those were config backup probes; a -e cb run on its own took 173, so user enumeration added only about 20 author ID checks:

WPScan config backup and user enumeration output finding wp-config.php.bak

The [!] on wp-config.php.bak is the most serious line in this guide. A .bak file is not executed by PHP, so the web server hands it over as plain text, database password and authentication salts included. Delete the file, then rotate the database password and the salts, because you cannot know who downloaded it before you found it. The username finding is lower risk on its own, but it gives an attacker half of a login. Pair strong passwords with rate limiting, such as Fail2ban in front of wp-login.php, since password brute forcing with Hydra is the obvious next step for anyone holding that name.

Step 10: Save JSON and SARIF reports and use exit codes

For anything that runs unattended, write a machine-readable report and act on the exit code. WPScan 4.x writes json, streaming jsonl, sarif and plain cli output with -f, and -o sends it to a file:

wpscan --url "https://${SITE_DOMAIN}/" --no-banner -e ap,cb --plugins-list contact-form-7,akismet --plugins-detection aggressive -f json -o report.json
echo "exit code: $?"

At 8.6 KB the JSON report is too long to read by eye after every run, so a short jq filter pulls out the fields worth alerting on:

jq "{wp: .version.number, wp_status: .version.status, theme: .main_theme.slug, theme_version: .main_theme.version.number, plugins: (.plugins | to_entries | map({(.key): {version: .value.version.number, outdated: .value.outdated, latest: .value.latest_version}}) | add), config_backups: (.config_backups | keys)}" report.json

Outdated components and the exposed backup each show up as a field a script can test:

{
  "wp": "7.1.3",
  "wp_status": "latest",
  "theme": "twentytwentyfive",
  "theme_version": "1.3",
  "plugins": {
    "akismet": {
      "version": "5.5",
      "outdated": true,
      "latest": "5.7.2"
    },
    "contact-form-7": {
      "version": "5.9",
      "outdated": true,
      "latest": "6.2.3"
    }
  },
  "config_backups": [
    "http://wp.lab.test/wp-config.php.bak"
  ]
}

The exit code from that run was 5, even with no API token. These are the codes defined in the 4.1.0 source (lib/wpscan/exit_code.rb):

Exit codeNameMeaning
0OKScan finished, nothing vulnerable found
1CLI_OPTION_ERRORInvalid or conflicting command-line option
2INTERRUPTEDInterrupted, for example with Ctrl+C
3EXCEPTIONUnhandled error inside WPScan
4ERRORScan did not finish, such as the 401 token abort
5VULNERABLEAt least one vulnerable item found

Against the lab site, the default scan returned 0 and the -e cb scan returned 5, so the exposed config backup alone is enough to fail a pipeline, while outdated plugins without a token are not. Interesting findings such as the readme or XML-RPC never change the code. Treat 5 as “stop and look” and 4 as “the scan itself broke”, because a cron job that only checks for non-zero will page you for an expired token as if the site were compromised.

SARIF output for code scanning dashboards

SARIF is the format GitHub code scanning and most CI security dashboards import. Generate it the same way:

wpscan --url "https://${SITE_DOMAIN}/" --no-banner -e cb -f sarif -o report.sarif

Check what actually made it into the file before you trust it in a dashboard. One jq line prints each result with its rule, level and URL:

jq -c ".runs[0].results[] | {ruleId, level, uri: .locations[0].physicalLocation.artifactLocation.uri}" report.sarif

Four results came back, all at note level:

{"ruleId":"wpscan.interesting-finding.headers","level":"note","uri":"http://wp.lab.test/"}
{"ruleId":"wpscan.interesting-finding.xmlrpc","level":"note","uri":"http://wp.lab.test/xmlrpc.php"}
{"ruleId":"wpscan.interesting-finding.readme","level":"note","uri":"http://wp.lab.test/readme.html"}
{"ruleId":"wpscan.interesting-finding.wp_cron","level":"note","uri":"http://wp.lab.test/wp-cron.php"}

Missing from that list is the wp-config.php.bak finding, although the same run exited with code 5 because of it. In 4.1.0, SARIF carries interesting findings (and, with a token, vulnerabilities), but not config backups. If a dashboard is your only view, an exposed credentials file stays invisible there, so gate the job on the exit code and keep the JSON report next to the SARIF file.

What the lab scans flagged, and the fix for each

Every finding from the scans above maps to a change on the WordPress host. Ordered by how quickly each one can hurt you:

FindingRiskFix
wp-config.php.bak readableDatabase password and salts exposedDelete it, rotate the DB password and salts, block *.bak and similar extensions at the web server
Outdated plugins (5.9 and 5.5 against 6.2.3 and 5.7.2)Known vulnerabilities, listed once a token is setUpdate, or remove plugins you do not use; inactive plugins are still reachable on disk
Outdated theme (1.3 against 1.5)Same as pluginsUpdate, and delete unused default themes
Username from the RSS feedHalf of a login handed outStrong passwords, two-factor login, rate limiting on wp-login.php
XML-RPC enabledCredential stuffing in bulk through system.multicallBlock xmlrpc.php at the web server if no app or plugin needs it
readme.html and the RSS generator tagExact WordPress version advertisedDelete readme.html after each core update; low priority once core is current
External WP-CronAnyone can trigger cron runsBlock public access to wp-cron.php at the web server, set DISABLE_WP_CRON, and run due events from a system cron job with wp cron event run --due-now

Alert on the top two rows from a scheduled scan; the rest are hardening you do once. When a WordPress core vulnerability is announced, such as the recent file inclusion fix in CVE-2026-87902, a WPScan run with a token is the quickest way to confirm which of your sites still report the old core version.

If you are building a new site to practise on, the WordPress on Ubuntu 26.04 guide gives you a target with Nginx and TLS that is closer to production than our lab pod, and the WPScan release notes list the remaining 4.x changes, including the opt-in enterprise database added in 4.1.0.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Best Pentesting Hardware for Kali Linux in 2026 Security Best Pentesting Hardware for Kali Linux in 2026 APT CVE-2026-109518: Check and Patch Debian and Ubuntu Debian APT CVE-2026-109518: Check and Patch Debian and Ubuntu How To Secure BIND DNS Server With DNSSec Keys Security How To Secure BIND DNS Server With DNSSec Keys

Leave a Comment

Press ESC to close