AppWT.Manuals
Field Report · Incident Response

Incident TRIGGER

A WordPress administrator account was created in a single second. No file was written to disk. The firewall that should have caught it was already installed, correctly configured, and completely blind.

27 July 2026 REST API SQL Injection 33 sites remediated Detected in 4 minutes

Contents

  1. What happened, in one paragraph
  2. Timeline
  3. The attack chain
  4. Why the firewall missed it
  5. Why file scanning could never find it
  6. How it was detected
  7. What we changed
  8. Check your own site
  9. Follow-up: five hours later
  10. Lessons worth keeping
  11. Indicators of compromise

What happened, in one paragraph

On 27 July 2026 at 06:22 EDT, an attacker created a full administrator account on a client WordPress site by sending crafted requests to the site's REST API. The parameter that normally excludes an author from a post listing was passed into the database query without being forced to an integer, which let the attacker append their own SQL. Nothing was uploaded. No plugin was installed. No file on the server changed. Four minutes later our daily database scan noticed an account that should not exist, and the incident was contained, root-caused and closed across all 33 WordPress installations the same morning.

Why it is called TRIGGER

The injected SQL manufactured a fake post row, and the attacker gave that row the title trigger. The name is taken straight from the payload rather than invented, so anyone reading the raw logs will recognise it immediately.

Timeline

Time (UTC)Event
10:22:10Sustained requests begin against class-wp-rest-server.php, roughly one every two to three seconds.
10:22:25A database syntax error is logged. In the same second, administrator wp2_6aaccb is created.
10:23:09An address in Indonesia triggers the security plugin's own maintenance routine, 44 seconds after the account appeared.
10:26:55The daily scan runs, finds one rogue administrator, and fires an alert to a phone and a flagged mailbox.
~10:35Evidence captured, account removed, all 33 installations checked, both mitigations deployed, attacker addresses blocked.

The attack chain

The database error log is the whole story. This is the query that failed, lightly trimmed:

SELECT SQL_CALC_FOUND_ROWS wp_posts.*
FROM wp_posts
WHERE 1=1
  AND wp_posts.post_author NOT IN (1)
  AND 1=0 UNION ALL SELECT 0,1,0x323032302d...,'trigger','publish','closed'
  OR wp_posts.post_status = 'private')))
LIMIT 0, 500

Read it left to right. post_author NOT IN (1) is WordPress excluding an author from a listing, which is a normal thing for a REST request to ask for. Everything after it is the attacker's. AND 1=0 makes the site's real results vanish, and UNION ALL SELECT substitutes a row of their own choosing.

Decoding the long hexadecimal literal in that row gives the payload:

[embed width="500" height="750"]https://<site>/cookie-policy/#6aaccb1[/embed]

That is an oEmbed shortcode. When WordPress renders a post containing one, the server fetches the target URL itself. The fragment #6aaccb1 is the attacker's own marker, and it matches the account they created in the same second, wp2_6aaccb. The synthetic post was the vehicle; the account was the goal.

The important nuance: WordPress core was not the flaw

Core validates this parameter properly. Ask core for a non-integer and it refuses:

GET /wp-json/wp/v2/posts?author_exclude[]=1)

400  {"code":"rest_invalid_param",
      "message":"Invalid parameter(s): author_exclude",
      "details":{"author_exclude[0] is not of type integer."}}

The vulnerable route belonged to a plugin that accepted a similar parameter and handed it to the query without the same type check. Every plugin that registers a REST route inherits the responsibility core already discharges.

Why the firewall missed it

This is the part worth the reader's time, because the failure was not negligence. The site had a SQL injection firewall installed at the Apache level, and its rules were correct. Among them:

RewriteCond %{QUERY_STRING}   ...union\s+(?:all\s+)?select...
RewriteCond %{HTTP_REFERER}   ...union\s+(?:all\s+)?select...
RewriteCond %{HTTP_USER_AGENT} ...union\s+(?:all\s+)?select...

The pattern that describes this exact attack was present and would have matched the payload character for character. It never got the chance.

A firewall can only block what it can read

mod_rewrite can inspect the query string, the referer and the user-agent. It cannot read a POST body. REST API calls carry their payload in the body. The rule and the attack never occupied the same room.

The one component we had that could read a request body was scoped to a single contact-form plugin. So the REST path had no body inspection at any layer, on any site.

The general lesson is not "our rules were wrong." It is that a control has a coverage surface as well as a ruleset, and the two are audited separately. Before trusting any firewall, enumerate the channels it can actually see, then ask whether the attack surface you care about travels on one of them. A pattern list is reassuring and easy to review. A coverage gap is silent.

Why file scanning could never find it

A search for everything modified on that server in the previous 24 hours returned page caches and log files, and nothing else. No shell, no altered plugin, no injected theme file.

The attack lived entirely in a request and a database. This is the counterpart to the more familiar WordPress compromise where the answer is always a file: here there is no file to find, no signature to write, and no checksum to fail. A scanner that only walks the filesystem will report a compromised site as clean, truthfully and uselessly.

The database is a first-class scan target

The check that caught this was not clever. It reads the users table and asks whether any account looks manufactured. That single query found in four minutes what a full filesystem scan would never have found at all.

How it was detected

A scheduled job runs every morning across every WordPress installation and flags accounts whose email address ends in .invalid or whose username matches known automated-creation prefixes. It found exactly one:

ROGUE ADMINS: 1
    wp2_6aaccb   wp2_6aaccb@wp2shell.invalid   2026-07-27 10:22:25

Two details made the alert immediately actionable rather than merely alarming. It named the affected site, and it carried the account's creation timestamp, which is what let us find the matching second in a 32 MB error log instead of reading the whole thing.

What we changed

Two layers, deployed to all 33 installations the same morning.

1. A request guard that can read the body

A must-use plugin hooks rest_pre_dispatch, which runs before any REST route executes. It walks the query parameters, the POST body, the JSON body and the URL parameters, and does two things:

2. The Apache ruleset everywhere

Eight installations had no firewall block at all. They have it now. Coverage of both layers is 33 of 33.

The rule that keeps a guard trustworthy

A blocker is only finished when it has been tested in both directions. Ours was verified against three attacks and four legitimate requests, two of which were deliberate traps:

RequestExpectedResult
author_exclude[]=1) … UNION ALL SELECT …block403
search=x' OR SLEEP(5)--block403
long hexadecimal literalblock403
?per_page=1allow200
author_exclude[]=1 (valid)allow200
search=union pacific railroadallow200
search=select the best contractorallow200

The last two matter most. A rule that blocks the words "union" and "select" will block a railway company and a page of advice, and a security control that breaks ordinary content gets switched off within a week. Long-form fields such as post content and descriptions are exempt from the keyword scan for the same reason: an editor is allowed to write an article about SQL.

Check your own site

Three checks, in the order that finds an answer fastest.

Look for manufactured accounts

SELECT ID, user_login, user_email, user_registered
FROM wp_users
WHERE user_email LIKE '%.invalid'
   OR user_login REGEXP '^(wp2_|wpsvc_|w2s_|Nx_|Bunk_)';

Look for the injection in your error log

grep -a "UNION ALL SELECT" error_log | head
grep -a "class-wp-rest-server.php" error_log | tail -50

A database syntax error and a new account sharing a timestamp is the signature.

Confirm your parameters are typed

curl -s -o /dev/null -w "%{http_code}\n" \
  "https://example.com/wp-json/wp/v2/posts?per_page=1&author_exclude[]=1)"

400 is correct. 200 or 500 means the parameter reached the query without being validated, and the route deserves a closer look.

Follow-up: five hours later

The guard went live at roughly 06:30. By 07:30 the same morning it had blocked a distributed campaign against four different client sites from four different addresses, using a technique the original attack did not.

Time (UTC)SiteSource
10:39:28Site A173.255.238.23
10:43:46Site B46.151.182.188
10:58:23Site C194.59.31.107
11:30:43Site B144.172.117.164

The evasion: request nesting

Every one of these arrived at the WordPress Batch API, /batch/v1, and the payload was not a top-level parameter. It sat here:

requests.1.body.requests.1.path

That is a batch request carrying another batch request inside it, with the SQL in the inner path. An inspector that reads the parameters it was handed and stops there sees a perfectly ordinary batch call. Ours caught it only because the scan function recurses through nested arrays rather than checking the first level.

If you write request filtering of any kind, walk the whole structure. Attackers reach for nesting precisely because so many filters do not.

And a false positive of our own

The same logs showed 182 blocks in a single second against one site’s /elementor/v1/globals/typography/* routes. That was not an attack. It was a person opening the page editor, which fetches every global style in parallel.

The cause was ours: the guard type-enforced any parameter named id as an integer, and Elementor’s global IDs are strings such as primary and e8d3680. A generic key name is a poor place to enforce a type. The specific WordPress query parameters keep their integer check, because their contract genuinely requires one; id does not and was removed.

The shape of the data was the tell: 182 hits, one second, one address, one route family. Attacks rarely look that tidy. A person clicking does.

Lessons worth keeping

Indicators of compromise

TypeValue
Accountwp2_6aaccb@wp2shell.invalid
Account familieswp2_*   wpsvc_*   *@*.invalid
Payload markerpost title trigger with an [embed] pointing at the site's own cookie-policy URL plus a hex fragment
Log signaturedatabase syntax error near ')))' in the same second as a new account
Request signaturerepeated class-wp-rest-server.php warnings every 2–3 seconds

A closing note on the brute force

Alongside the injection, the same actor was attempting logins for usernames such as wpsvc_0afe06c6e53d and wp2_2df2ab from seven separate addresses. None of those accounts existed on any of our 33 installations. They are accounts created on other people's sites, being sprayed at ours in the hope of a match. If you find one of those usernames in your own database, it was created there, and you have the same problem this manual describes.

Written up the same day, from the actual logs, by the team that responded to it. If you run WordPress and want the two checks in section eight run against your own estate, write to sales@appwt.com.