Cover Image

WPProbe: A Pragmatic Approach to Detecting WordPress Plugins

Introduction

WPProbe is a tool I built to solve a simple problem: detecting installed WordPress plugins in a reliable and quiet way. In this post, I explain how the tool came to life, its strengths, its limits, and why this REST-based idea is still not widely used, even though it works well.

Why WPProbe?

Honestly, I still don’t get why WordPress shows all REST endpoints by default when debug is on. Even if some bug bounty programs reward people for finding plugin vulnerabilities, there seems to be very little awareness of the attack surface exposed by this REST feature, which is on by default.

I’m not saying I’m a security expert, there are clearly people way more skilled than me, but I think this default setting is risky. Especially when you know that WordPress runs around 47% of all websites (based on recent numbers). That kind of popularity should come with stronger default protections.

And one quick reminder: if you get an idea, don’t throw it away too fast. WPProbe is proof of that. I had the idea almost one year before I built it, but I forgot about it. Then one day I remembered and just built it, fast and simple. Sometimes, old ideas are worth finishing.

Let’s be real: WPProbe isn’t magic. I was just bored, like often, and decided to work on something random. This time, it turned out to be useful. People liked it, maybe because it’s simple and solves a real problem.

I had no clue if it would even work. I try lots of things that don’t lead anywhere. But even failed projects teach me something. This time, it worked.

While working on WordPress modules for Metasploit, I saw something strange: a default WordPress install (like the official Docker image) shows all REST endpoints at ?rest_route=/. That’s on by default and lets anyone see every route from WordPress core and plugins.

This is great for passive fingerprinting, but very few tools use it. At the time, I wanted to help LeakIX scan WordPress sites without overloading servers. Tools like wpfinger work, but they’re too aggressive, sending too many requests. I wanted something calmer but still efficient.

Even WPScan, as far as I know, doesn’t use this method directly (I didn’t test their API). But it’s strange, because many recent critical CVEs can be used through REST endpoints.

Building the Database

V1: The Original Single-Container Approach (2025)

The first version was simple: one Docker container, processing plugins one by one.

flowchart LR
    subgraph V1["๐Ÿณ Single Container"]
        WP[WordPress + MariaDB]
    end
    
    SVN[plugins.svn.wordpress.org] -->|100k plugins| WP
    WP -->|sequential| JSONL[(routes.jsonl)]

The process:

  1. Fetch plugin list from WordPress SVN
  2. Install plugin via WP-CLI
  3. Run PHP script to extract REST routes
  4. Delete plugin, repeat

The PHP script (V1 - no baseline):

<?php
define('WP_USE_THEMES', false);
require_once 'wp-load.php';

global $wp_rest_server;
$wp_rest_server = new WP_REST_Server();
do_action('rest_api_init');

$blacklisted_routes = ["/", "/wp/v2", "/wp/v2/posts"];
$routes = $wp_rest_server->get_routes();
$filtered_routes = [];

foreach ($routes as $route => $details) {
    if (!in_array($route, $blacklisted_routes)) {
        $filtered_routes[] = addslashes($route);
    }
}

echo json_encode(array_values($filtered_routes), JSON_PRETTY_PRINT);
?>

Problems with V1:

  • โฑ๏ธ 2 weeks to scan everything
  • ๐Ÿ’ฅ When the container crashed, we had to manually restart
  • ๐Ÿ” No way to detect corruption (bad plugin leaves traces โ†’ pollutes next scans)
  • ๐ŸŒ Sequential = painfully slow

Technical Background

I’m not a Go developer. I have little experience with the language. So yes, the WPProbe code is probably not perfect, but it works pretty well. The main goal was to make something that just works.

At first, WPProbe was a quick Python PoC. But it was way too slow. I also wanted to learn Go and make something that could be used by LeakIX in production. I didn’t want something noisy like wpfinger. No judgment here, I actually admire the work LeakIX has done, but in this specific case, it felt too risky for wide use.

I’m not a sysadmin either. The first version used one single Docker instance for everything. The full scan took almost two weeks (because of bad plugins, container crashes, slow DB startup, etc.). This has since been replaced by a much faster V2 system (see below).

How WPProbe Works (and What It Can’t Do)

WPProbe has 3 steps:

  1. It fetches all REST endpoints via ?rest_route=/ (or wp-json alternative)
  2. It matches those to known plugin signatures from a local database
  3. If possible, it tries to read readme.txt, README.txt to find the version, then checks for known CVEs via Wordfence

When I started, WPProbe could detect about 900 plugins. After the V1 lab, it supported ~3,000. With V2, it now has 3,400+ plugins (and growing). Note that WordPress.org keeps adding new plugins over time, so part of this increase comes from new plugins released between 2025 and 2026.

All the official plugins from WordPress.org are supported. But non-official ones are hard to handle automatically. If you know a trick to find and scan those, I’d love to hear it. Only ~5% of plugins expose REST routes, but that’s still a solid database for passive detection.

Plugins may have slightly different endpoints depending on the version. So WPProbe uses a confidence score, based on how many routes match, unless we get the version, then it’s accurate.

Some plugins use the same routes, which can give false positives. That’s normal. I didn’t want to remove all shared routes, since some are valid. So I left them as-is. That said, for bigger plugins like Jetpack or WooCommerce, I cleaned some of the noise using bash scripts.

Vulnerability Info (Wordfence Matching)

Thanks to the free Wordfence API, WPProbe can say if a plugin version has known vulnerabilities.

To get the version, WPProbe tries to fetch the readme file with different cases: readme.txt, Readme.txt, README.txt (up to 3 requests per plugin). It then parses the Stable tag: or Version: field.

Total requests in stealthy mode:

  • 1 request: HTML page (discover plugins from <script> and <link> tags)
  • 2 requests: REST API (/?rest_route=/ and /wp-json in parallel)
  • Up to 3 requests per detected plugin: readme files for version detection

So for a site with 5 detected plugins, that’s only ~18 requests max. Compare that to bruteforce scanners that send thousands.

References & Ideas

The idea came from building WordPress modules for Metasploit. It started while I was working on a module, actually several, where I used this handy technique to check which endpoints were available. It helped me reproduce PoCs locally before writing the module itself. But I also heard it mentioned in Critical Thinking Podcast - Episode 55 by a Wordpress Security Researcher. That pushed me to work on it.

I also took some ideas from wpfinger , made by the LeakIX team. For the local Wordfence vulnerability database, I actually reused that idea, big shout-out to Gregory Boddin, the CTO of LeakIX, who developed it. It was a smart move that I adapted into WPProbe.

V2: Parallel Workers with Auto-Recovery (2026)

The V1 approach worked but was too slow and fragile. So I rebuilt everything from scratch: 50 parallel workers, automatic crash recovery, and a baseline/diff system to ensure clean data.

flowchart LR
    subgraph V1["โŒ V1: Single Container"]
        OLD[1 WordPress]
    end
    
    subgraph V2["โœ… V2: Worker Pool"]
        W1[WP 1]
        W2[WP 2]
        WN[WP N...]
    end
    
    V1 -->|"2 weeks ๐ŸŒ"| R1[3k plugins]
    V2 -->|"2-3 days ๐Ÿš€"| R2[3.4k+ plugins]

Architecture

Instead of one container doing everything sequentially, I now run up to 50 parallel WordPress workers managed by a Python orchestrator. Each worker is an isolated WordPress instance that can scan plugins independently.

flowchart TB
    subgraph Orchestrator["๐ŸŽฏ Python Orchestrator"]
        M[main.py]
        TP[ThreadPoolExecutor]
        SC[PluginScanner]
    end

    subgraph Workers["๐Ÿณ Docker Worker Pool"]
        subgraph DB["๐Ÿ—„๏ธ Shared MariaDB"]
            MDB[(MariaDB)]
        end
        
        W1[WordPress:8001]
        W2[WordPress:8002]
        WN[WordPress:800N...]
    end

    subgraph Storage["๐Ÿ’พ Storage"]
        JSONL[scanned_plugins.jsonl]
        STATE[scan_state.json]
    end

    M --> TP
    TP --> SC
    SC -->|installs plugin| Workers
    W1 & W2 & WN --> MDB
    SC --> JSONL
    SC --> STATE

Baseline/Diff Extraction

The old single-container method worked, but it had issues: we couldn’t detect when the environment was corrupted, and when it happened, we had to manually restart the only container we had. This was slow and painful.

The new system uses baseline/diff extraction. The idea is simple: how do we know which routes belong to a plugin? We capture all routes before installing it, then capture again after, and keep only the difference.

sequenceDiagram
    participant S as ๐Ÿ” Scanner
    participant W as ๐Ÿณ Worker
    participant PHP as ๐Ÿ“„ extract_routes.php

    Note over S,PHP: 1๏ธโƒฃ BASELINE - Before plugin install
    S->>W: php extract_routes.php
    W->>PHP: Load WordPress core
    PHP-->>S: ["wp/v2", "wp/v2/posts", ...] (42 routes)
    Note right of S: Store as baseline

    Note over S,PHP: 2๏ธโƒฃ INSTALL - Add the plugin
    S->>W: cp plugin + wp plugin activate
    W-->>S: Plugin active โœ“

    Note over S,PHP: 3๏ธโƒฃ DIFF - Extract NEW routes only
    S->>W: php extract_routes.php {baseline_b64}
    W->>PHP: Load WP + plugin routes
    PHP->>PHP: array_diff(current, baseline)
    PHP-->>S: ["plugin/v1", "plugin/v1/endpoint"] (2 NEW routes)

    Note over S,PHP: 4๏ธโƒฃ CLEANUP
    S->>W: wp plugin uninstall --deactivate

This ensures we only capture routes actually added by the plugin, not WordPress core routes or leftovers from previous scans.

Worker Corruption Handling

PHP fatal errors from buggy plugins can corrupt a worker’s state. The new system detects this (exit code 255) and handles it gracefully:

flowchart TD
    A[๐Ÿ” Plugin Scan] --> B{Exit Code?}
    B -->|0| C[โœ… Success]
    B -->|255| D{wp eval check}
    D -->|OK| E[Plugin crashed
Worker OK] D -->|FAIL| F[Worker corrupted] F --> G[๐Ÿงน Cleanup] G --> H{Fixed?} H -->|No| I[โŒ Remove worker] H -->|Yes| J[๐Ÿ”„ Retry] E --> J I --> K{Workers < 5?} K -->|Yes| L[๐Ÿ”„ Reinit cluster] K -->|No| J

Results: V1 vs V2

Metric V1 (2025) V2 (2026)
Scan time ~2 weeks ~2-3 days
Workers 1 50 (dynamic)
Plugins detected ~3,000 3,400+
Routes collected ~25,000 30,000+
Crash recovery Manual restart Automatic
Corruption detection None wp eval health check

The scanner dynamically calculates workers based on available RAM and can reinitialize the entire cluster if too many workers die. Running stable with 49 workers on 32GB RAM.

Final Words

WPProbe is a small but useful tool for scanning WordPress in a quiet and smart way.

I plan to keep improving it:

  • Update the plugin signature list when needed

I’m not sure if I’ll keep working on it completely on my own. Help is always welcome, whether it’s sharing ideas or contributing code. The project is under the MIT license, but please don’t abuse that. Even a small mention or credit means a lot. Between open source fans, it’s important to give credit where it’s due.

Source code: https://github.com/Chocapikk/WPProbe