I mention on my About page that I have two self-hosted websites, a portfolio site and a site about trains and train travel.

I originally got into WordPress development in 2012 when I worked at a magazine that had its WordPress site was hacked and ruined. I started developing WordPress sites back then, and that was my real introduction to “tech,” and even secure software development. In 2020 I moved my two WordPress sites running on EC2 VMs from Bitnami for WordPress Amazon Machine Images to a single EC2 running CentOS 7, Nginx, and MariaDB. This was my first time building my websites from the (virtualized) ground up. That CentOS 7 LEMP (Linux, Nginx, MariaDB, PHP) stack worked pretty well, and it was a good introduction for me, from hardening a minimal CentOS installation to enhancing web application security through Nginx configuration files. In early 2026 I migrated to two Alma Linux VMs with similar configurations, but each WordPress site on its own Alma Linux VM.

I have not updated the content on either website for at least five years. As my professional interests have focused on information security, and I shifted to creating writing samples that showcase my potential, I have focused on projects like this, though Massolit-Media.com does have some older technical writing examples. Instead, my sole interaction with these websites is through managing the EC2 servers . And other than some doubts about whether the migration was worth it - including performance issues and higher AWS bills - I have not interacted with the sites much in 2026.

Background on CVE-2026-63030

In mid-July 2026, I started reading about “wp2shell,” or CVE-2026-63030, a critical remote-code execution vulnerability for WordPress Core. This vulnerability allows a threat actor to exploit the WordPress REST API batch process at /wp-json/batch/v1.

Initial Discovery: New Admin Accounts

I heard about this vuln a lot, but did not get around to checking my two sites until the following weeks. I logged on to wp-admin in both sites late one night and decided to check the Audit Log feature of the Wordfence plugin (free version) that I have running on each website. I took a look at each and saw… I saw that new users had been created for each website. And I knew I did not create them.

I went to the Users page on the Admin panels and, for each site, I saw several admin-level users had been created with serious-looking names. The one I wrote down was wordpress_ba1a7da27b7a@wordpress.com, but others were something like wordpressSVC@wpengine.com.

I immediately deleted the new malicious accounts and rotated my WordPress salts. Then I went to bed. And the next morning realized that I should have gathered more evidence, or at least some screenshots. Because this became an opportunity for some tech-writing content.

*NOTE I restored the websites from a known good backup, that is not the problem here.

Log Investigation

Like I said, I immediately deleted the accounts. I should have done a better job collecting evidence. Now all I have are some screenshots of Wordfence’s Audit Logs for each site. Massolit-media.com showed a single account created on July 19, then two more on July 20. On Highball8.com, four admin users were added on July 19, then a fifth with an additional “Auth Cookie Set” message in the Wordfence event, and then an account installed and activated a plugin, then a user was deleted.

Download Logs from WordPress Servers and Import Them Into Splunk

I support enterprise detective controls through logging and monitoring in my work life. And sometimes I study for a Splunk Power User certification, so I have a trial installation of Splunk on my laptop. So I tried to pull the Nginx logs off of my AWS EC2 virtual machines and import them into Splunk.

After downloading the Nginx access and error log files to my MacBookPro, Claude.ai generated a shell script to preprocess them for me. It used gzip to unarchive all my log files ending in .gz, and combined all of them for me. Then I manually imported them into Splunk. I had some issues with parsing the logs or assigning the right Splunk source type for the nginx error logs, so I downloaded and installed the Splunk Add-on for NGINX. Then I repeated the importation of the logs and started searching, importing everything into a new index called wp-nginx.

When I was done with the import, I had 45,480 total events across the two websites from between 0000 ET on July 12, 2026 and 0915 ET on July 22, 2026.

Begin Splunk Investigation

I started by investingating the endpoints discussed in some of the coverage of the vulnerability. But there were 112 events with /wp-json/batch/v1 in the uri and uri_path fields. I looked at these events for a while and noticed some potentially malicious user agents. But I did not see anything that I could understand. Even when I found the three events where the useragent was defined as wp2shell (against highball8 only), I could not understand anything in those log messages.

112 events with /wp-json/batch/v1 in the URI

index=”wp-nginx” “/wp-json/batch/v1” | stats count by useragent | sort - count

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 12 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 3 wp2shell 3 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/999.0.0.0 Safari/537.36 2 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 2 Mozilla/5.0 1

85.11.167.118 - - [20/Jul/2026:05:37:39 -0400] “POST /wp-json/batch/v1 HTTP/1.1” 403 5 “-“ “wp2shell” 196.251.121.187 - - [19/Jul/2026:20:48:39 -0400] “POST /wp-json/batch/v1 HTTP/1.1” 207 27 “-“ “wp2shell” 209.99.187.122 - - [19/Jul/2026:13:49:07 -0400] “POST /wp-json/batch/v1 HTTP/1.1” 207 27 “-“ “wp2shell”

New Approach: Matching Wordfence Events

For a while I could not find anything. Then woke up the next day and tried a new approach. Going back to my screenshots of the Wordfence Audit Log logs and wrote down the events and their timestamps in sequential order:

  • Massolit-Media.com

    • **Role Added to User User Created:** July 19, 2026 7:55:31 pm
    • **Role Added to User User Created:** July 20, 2026 12:46:05 am
    • **Role Added to User User Created:** July 20, 2026 11:37:43 am
  • Highball8.com

    • **Role Added to User User Created:** July 19, 2026 2:23:27 am
    • **Role Added to User User Created:** July 19, 2026 1:49:39 pm
    • **Role Added to User User Created:** July 19, 2026 4:54:14 pm
    • **Role Added to User User Created:** July 19, 2026 8:49:16 pm
    • **Auth Cookie Set Role Added to User User Created:** July 19, 2026 9:53:35 pm
    • Plugin Installed: July 19, 2026 9:53:42 pm
    • Plugin Activated: July 19, 2026 9:53:44 pm
    • User Deleted: July 19, 2026 9:53:47 pm

For each of these events, I set up a Splunk query that was just index="wp-nginx" host=massolit, then set the Date & Time Range to match around those few minutes, then saved the query as a report to make it easier to pivot across them. For example, looking at that first user created on Massolit-Media.com: index="wp-nginx" host=massolit returned 28 events in the one minute between 7:55:00.000 PM and 7:56:00.000 PM on July 19, 2026. IMAGE - REMEMBER, THIS IS AN ERROR LOG

And right away, one event stands out for its length:

WHERE 1=1  AND wp_hoft6837_posts.post_author NOT IN (1) AND 1=0 UNION ALL SELECT 0,1,0x323032302d30312d30312030303a30303a3030,0x323032302d30312d30312030303a30303a3030,0x5b656d6265642077696474683d2235303022206865696768743d22373530225d68747470733a2f2f7777772e6d6173736f6c69742d6d656469612e636f6d2f746563686e6963616c2d77726974696e672f6c6f63616c2d776f726470726573732d646576656c6f706d656e742d656e7669726f6e6d656e742d776974682d646f636b65722f23653535643837633830613738315b2f656d6265645d,0x74726967676572,'',0x7075626c697368,0x636c6f736564,0x636c6f736564,'',0x74726967676572,'','',0x323032302d30312d30312030303a30303a3030,0x323032302d30312d30312030303a30303a3030,'',0,'',0,0x706f7374,'',0 UNION ALL SELECT 6338,2,0x323032302d30312d30312030303a30303a3030,0...; PHP message: PHP Warning:  Undefined array key 4 in /usr/share/nginx/html/massolit-media/wp-includes/rest-api/class-wp-rest-server.php on line 1836; PHP message: PHP Warning:  Trying to access array offset on null in /usr/share/nginx/html/massolit-media/wp-includes/rest-api/class-wp-rest-server.php on line 1848; PHP message: PHP Warning:  Undefined array key 2 in /usr/share/nginx/html/massolit-media/wp-includes/rest-api/class-wp-rest-server.php on line 1836" while reading response header from upstream, client: 136.112.200.207, server: massolit-media.com, request: "POST /wp-json/batch/v1 HTTP/1.1", upstream: "fastcgi://unix:/run/php-fpm/www.sock:", host: "massolit-media.com"
  • Right away there’s an indication of an SQL injection attack that anybody who has studied for the CompTIA Security+ should recognize: WHERE 1=1.
  • wp_hoft6837_posts.post_author: I recognize this is a WordPress database table name which I have customized.

    • When I started learning WordPress in 2012, it was said that you could improve your security by changing the default wp_ prefix. I’ve been doing it ever since, even if the efficacy of this “security through obscurity” (or through obfuscation) technique has changed. So somehow whatever this thing was had already enumerated my database table prefix.
  • I also recognized several comma-separated hexcodes in this event. I started to decipher them using CyberChef.io, but since there were a lot of them.

I asked claude what thihs event meant, and it called “a beautifully documented example of the complete wp2shell chain in a single payload.” I asked Claude to explain it to me so I could better understand it and communicate the highlights here. Claude broke the log message into three parts.

“Vulnerability Entry Clause” (the WHERE 1=1)

``WHERE 1=1 AND wp_hoft6837_posts.post_author NOT IN (1) AND 1=0`

This first part establishes the “injection point,” like a path to the database where the threat actor can inject their malicious database query WHERE 1=1 AND: Begins the process (need more info) wp_hoft6837_posts.post_author: It enumerated my WordPress database table prefix to select the posts table (normally wp_posts). The wp_hoft6837_posts.post_author represents the posts_author column of the wp_posts table. T AND 1=0: By setting 1=0, this kind of cancels out the first part of the query so we only get the rest of it…

Creating a False Row in the Posts table with UNION ALL SELECT

After Part 1, the injected query seeks to create a false table within the wp_hoft6837_posts/wp_posts table. The hex values are abbreviated with ellipses, but you can refer back to the code block at the beginning of this secition if you like.

# wp_posts column Value in payload Decoded Meaning
1 ID 0 Placeholder. Not a real row, so the ID is irrelevant to the attacker.
2 post_author 1 Attributes the forged post to user ID 1 (the original admin account).
3 post_date 0x3230...3030 2020-01-01 00:00:00 Backdated so the row doesn’t stand out as newly created.
4 post_date_gmt 0x3230...3030 2020-01-01 00:00:00 Same backdate, GMT column.
5 post_content 0x5b656d...65645d [embed width="500" height="750"]https://www.massolit-media.com/technical-writing/local-wordpress-development-environment-with-docker/#e55d87c80a781[/embed] The payload. An oEmbed shortcode pointing at a real article on your own site, with a unique fragment token appended.
6 post_title 0x74726967676572 trigger Names the row for what it does — it exists to trigger oEmbed processing.
7 post_excerpt '' empty Not needed.
8 post_status 0x7075626c697368 publish Must be published for WordPress to process it in normal query paths.
9 comment_status 0x636c6f736564 closed Avoids generating comment-related side effects.
10 ping_status 0x636c6f736564 closed Same — suppresses pingback activity.
11 post_password '' empty No password, so it isn’t gated.
12 post_name 0x74726967676572 trigger The slug. Matches the title.
13 to_ping '' empty Not needed.
14 pinged '' empty Not needed.
15 post_modified 0x3230...3030 2020-01-01 00:00:00 Backdated to match creation date.
16 post_modified_gmt 0x3230...3030 2020-01-01 00:00:00 Same.
17 post_content_filtered '' empty Left blank so WordPress processes post_content fresh.
18 post_parent 0 No parent post.
19 guid '' empty Not needed for the chain.
20 menu_order 0 Default.
21 post_type 0x706f7374 post Must be a standard post to flow through normal query handling.
22 post_mime_type '' empty Only relevant for attachments.
23 comment_count 0 Default.

whois suggests google cloud platform

The WHERE clause — the vulnerability entry point:

sql WHERE 1=1 AND wp_hoft6837posts.post_author NOT IN (1) AND 1=0 post_author NOT IN (1) is the actual injection point — this maps directly to the author__not_in REST parameter from CVE-2026-60137 AND 1=0 is critical — it nullifies the legitimate query entirely, ensuring only the injected UNION rows are returned, no real posts The attacker already knows your table prefix hoft6837 — they enumerated it beforehand, likely via an earlier probe

First UNION ALL SELECT — forging a fake wp_posts row:

Mapping each value to the wp_posts column structure:

ID: 0 (fake, doesn’t matter) post_author: 1 (impersonating admin user ID 1) post_date: 2020-01-01 (backdated to look old/legitimate) post_date_gmt: 2020-01-01 post_content: [embed width=”500” height=”750”] https://www.massolit-media.com/technical-writing/ local-wordpress-development-environment-with-docker/ #e55d87c80a781[/embed] post_title: trigger post_status: publish comment_status: closed ping_status: closed post_name: trigger post_modified: 2020-01-01 post_type: post

The key to this section of the message is the oEmbed shortcode, which Claude explained points to one of my own posts on Massolit-Media.com, then appending the threat actor’s own identifier, #e55d87c80a781 on the end. By making this look like normal WordPress activity, when “WordPress processes this embed, the oEmbed handler executes in an elevated context, which is how the privilege escalation to admin happens.”

A Second Forged Table Row

UNION ALL SELECT 6338,2,0x3230...,0... represents another attempt to forge a row in the wp_posts table.

  • 6338 may or may not be a post ID from the ID column of the table
  • 2 here is for the post_author column, and this ID does correspond with a real user in my WordPress database.
  • 0x3230 is an for the post_date column, and they already used this value, which decodes to 2020-01-01 00:00:00.

Second UNION ALL SELECT — 6338,2,…:

This is significant:

6338 is a real post ID on your massolit-media site — the attacker is referencing an existing post to anchor the injected row in a legitimate context 2 is a user ID — user ID 2 on massolit-media is worth checking: SELECT ID, user_login, user_email FROM wp_hoft6837_users WHERE ID = 2; — this may be a second user the attacker was targeting or impersonating

The PHP warnings — confirmation the auth bypass worked:

Undefined array key 4 … line 1836 Trying to access array offset on null … line 1848 Undefined array key 2 … line 1836

These are not errors that stopped execution — they’re the array desync from CVE-2026-63030 working as intended. The batch endpoint processed a malformed nested request structure, PHP complained about the malformed array but continued executing, and the unauthenticated request was treated as authenticated. The warnings are logged because the exploit succeeded, not because it failed.

One thing to act on from this analysis: check who user ID 2 is in the massolit-media database, and verify that account wasn’t modified or used as a pivot point even if no webshell was dropped there.


Back to top

Last built: Thursday, September 17, 2026 at 14:25 UTC
This site uses Just the Docs, a documentation theme for Jekyll.