Allow 1-2 minutes after spawning for the services to start and then connect to the SIEM. WordPress web server logs ingested into Splunk.
One external host scanned a WordPress site with WPScan, then switched to the login form and brute-forced it until a request finally returned a redirect, which marks a successful login. With valid credentials the attacker opened the built-in theme editor and modified a PHP file in the active default theme, turning it into a web shell. Minutes later the same address began sending POST requests to that file a few seconds apart, the rhythm of an operator typing commands, using an ordinary desktop browser user-agent rather than a scanner string. Everything is visible in a single access_combined source in Splunk.
index=main, sourcetype=access_combined). Fields used: clientip, useragent, method, uri_path, uri, statusTimestamp note: Splunk shows
_timeas ingested. Answers use theYYYY-MM-DD hh:mm:ssform straight fromstrftimeor the Time column, with no timezone conversion.
Start with a census of every client and user-agent, ordered by first appearance. One address carries a WPScan v3.8.25 user-agent, which is the scanner’s default header. That is the enumeration source.
index=main sourcetype=access_combined | stats count min(_time) as first by clientip, useragent | sort first
Pivot on the Q1 address and group by user-agent. The scanner announces itself in the useragent field, with name and version.
index=main clientip=203.101.190.116 | stats count min(_time) as f max(_time) as l by useragent | eval f=strftime(f,"%F %T"), l=strftime(l,"%F %T")
The same grouped output gives the first and last time per user-agent. The first request carrying the WPScan header from Q3 is the start of the scan.
index=main wpscan | head 3 | table _time clientip useragent
Gotcha: The same IP also made an earlier, benign-looking request with a normal Firefox user-agent (03:09:59). That is a manual browse, not scanner traffic. Reconnaissance starts at the first WPScan request.
Filter the attacker to POSTs against wp-login.php and group by status. A failed WordPress login re-renders the form with a 200, while a successful one answers with a 302 redirect. The burst starts at 03:50:33 and the first 302 arrives at 03:50:40, so the brute force lasted the difference between the two.
index=main clientip=203.101.190.116 method=POST uri_path="*wp-login.php" | stats count min(_time) as f max(_time) as l by status | eval f=strftime(f,"%F %T"), l=strftime(l,"%F %T")
To see the transition request by request, list the login traffic in time order.
index=main clientip=203.101.190.116 "wp-login" | sort _time | table _time method status uri_path bytes
After the login succeeds, look for the admin theme editor. Requests to theme-editor from the attacker address show the edit, and the POSTs that follow target files under one theme directory.
index=main uri="*theme-editor*" clientip=203.101.190.116 | sort _time | table _time method uri status
From Q5 the theme is known. Within its directory the attacker’s POSTs after the edit all land on one file under patterns/. A theme pattern template that normally gets only GETs, now receiving POSTs, is the web shell.
index=main uri_path="*hidden-404.php*" | sort _time | table _time clientip method status bytes
Sort the requests to the Q6 file by time. A GET at 04:03:46 comes first, which is the attacker loading the file to confirm it responds. The first POST, at 04:03:51, is the first command sent to the shell, and later POSTs follow a few seconds apart.
index=main uri_path="*hidden-404.php*" | sort _time | table _time clientip method status bytes
Gotcha: The earlier GET is not a command. The question asks for the first POST in the pattern.
Restrict the attacker’s traffic to browser-style user-agents and POST requests, and read the useragent on the web shell hits. It is a desktop Firefox string, so the operator drove the shell from a browser, not the scanner.
index=main clientip=203.101.190.116 useragent="Mozilla*" method=POST | sort _time | table _time method uri status useragent
Tip: The format hint is the full header, not just the browser name and version. Copy the whole string from the
useragentfield.
| Phase | Action |
|---|---|
| Reconnaissance | Manual browse, then a WPScan v3.8.25 sweep from one external IP |
| Credential Access | Burst of POSTs to wp-login.php ends in a 302 about seven seconds later |
| Persistence | Theme editor used to modify a PHP file in the twentytwentyfour theme |
| Execution | POSTs to hidden-404.php a few seconds apart, from a Firefox user-agent |
| Timestamp | Event |
|---|---|
| 2024-08-28 03:09:59 | First request from the attacker IP, a normal Firefox browse |
| 2024-08-28 03:15:23 | First request with the WPScan user-agent |
| 2024-08-28 03:50:33 | Login brute force begins against wp-login.php |
| 2024-08-28 03:50:40 | First 302 on wp-login.php, valid credentials obtained |
| 2024-08-28 04:03:46 | First GET to hidden-404.php |
| 2024-08-28 04:03:51 | First POST to hidden-404.php, first command run |
| Type | Value |
|---|---|
| IP | 203.101.190[.]116 |
| User-agent (scanner) | WPScan v3.8.25 |
| User-agent (web shell) | Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0 |
| File (web shell) | patterns/hidden-404.php in the twentytwentyfour theme |
| Technique | ID | Description |
|---|---|---|
| Active Scanning: Vulnerability Scanning | T1595.002 | WPScan enumeration of the WordPress site |
| Brute Force: Password Guessing | T1110.001 | Rapid POSTs to wp-login.php until a 302 |
| Valid Accounts | T1078 | Authenticated session used to reach the theme editor |
| Server Software Component: Web Shell | T1505.003 | Theme PHP file edited into a shell and driven with POSTs |
A login brute force that succeeds in seconds means there was no throttling. A run of failed wp-login.php POSTs ended in a valid login about seven seconds after it started. Rate limiting, a lockout plugin or MFA on administrator accounts would have broken the chain here. Alert on a burst of 200s from one IP followed by a 302.
The built-in theme editor turns one stolen admin login into code execution. The attacker never uploaded a file; they edited an existing theme file through theme-editor. Setting DISALLOW_FILE_EDIT in wp-config.php removes that path, and any request to theme-editor from an external address is worth an alert.
Scanner strings are free detection, and the attacker dropped them as soon as it mattered. The WPScan header was visible from the first request and could have been blocked at the edge. The shell phase used an ordinary Firefox string, so detection there has to rely on behaviour: POSTs to a theme template that is normally only fetched with GET, repeating every few seconds.
Watch for file-integrity changes in theme directories. A template under patterns/ started answering POSTs minutes after the editor request. File integrity monitoring on wp-content/themes would flag the modification at the moment of the edit.
The attacker went from a scanner banner to a working web shell in under an hour, using only a guessable login, the stock theme editor and a browser. The decisive detail is the pattern, not the content: failed logins into a redirect, an editor request, then rhythmic POSTs to a file that should never take them.