When the cleanup went wrong

There is no clean copy of this file. Cut the injection out by hand.

Custom theme code, a plugin built for one site, and wp-config.php have no pristine version anywhere. Every guide that says replace it from the original is talking about a different file. Here is the manual route, and the checks that tell you the file still does what it did before.

Checked against a live WordPress install on .

Why no scanner can repair this file

Look for the Repair button once, then stop looking. In Wordfence 9.0.0 that control is gated on the file matching a WordPress.org release, so for a file it cannot match it never renders at all. Sucuri Security 2.7.4 restores WordPress core and nothing else; plugin files, theme files and wp-config.php sit outside its integrity check. Malcure Malware Shield 19.9.8 refuses to delete wp-config.php. The same gate runs through all of them: a file gets a repair path when it can be recognised as WordPress core, a WordPress.org plugin, or a WordPress.org theme. Your code matches none of the three.

Command-line checks stop at the same wall. There is no theme checksum subcommand in WP-CLI, and WordPress.org publishes no theme checksum API to back one.

bash
$ wp theme verify-checksums
Error: 'verify-checksums' is not a registered subcommand of 'theme'.

The plugin command exists and walks straight past anything it cannot resolve upstream. It reports success while doing so, so the exit code says nothing useful about a plugin you wrote.

bash
$ wp plugin verify-checksums --all
Warning: Could not retrieve the checksums for version 1.0.0 of plugin acme-tools, skipping.
Success: Verified 0 of 1 plugins (1 skipped).

A child theme has no upstream even in principle. One was measured for this cluster and it held a single file, style.css, with a Template: header pointing at its parent; every other template resolved out of the parent's directory. Reinstalling the parent from WordPress.org never touches the child.

If the flagged file is core, a WordPress.org plugin or a WordPress.org theme, close this page and use restore modified core, plugin and theme files instead. Fetching the published copy is faster and safer than any edit you can make by hand. The rest of this page is for the files that route cannot reach.

Copy the file before you touch it

You get one shot at this file. Take the copy first, and make the edits somewhere the site is not reading.

  1. Copy the file off the server. Your own machine is the right place. If you have to keep the copy on the server, keep it outside the web root, in your home directory rather than anywhere under the WordPress folder.
  2. Record what you started with. Run sha256sum against the file before you change a byte and keep the output with your notes. When someone asks in a week which version you edited, that hash is the answer.
  3. Do not leave a copy inside the site. A file named functions.php.bak in the theme folder is a file your server may hand to anyone who guesses the name, and for wp-config.php that means your database password. Scanners treat those leftovers as findings in their own right: Wordfence 9.0.0 collects abandoned .suspected files and raises an issue for each one your server serves over HTTP.
  4. Edit the copy. Put it back only after it passes the checks in the last section of this page.

Use an editor that does not reformat on save. A reformat turns a four-line removal into a diff of the whole file, and the diff is the main thing telling you whether you cut what you meant to cut.

Where the injection sits

Injected code goes into one of four places, and each one has its own tell. The blocks below are skeletons. The payload is replaced by a comment in every one of them, so what you are reading is the shape and the position, never working code. Yours will look denser and longer than these, and it will sit in exactly one of these four spots.

1. At the top, above the file's own comment

php
<?php
/* SKELETON. Payload removed. The real thing is usually one long line. */
if ( ! function_exists( 'wp_theme_cache_init' ) ) {
    /* decoder and payload removed from this example */
}

/**
 * Acme theme functions.        <- your file starts here
 */

This is the most common shape because it is the cheapest to write: open the file, seek to zero, write. It runs on every request that loads the file, before anything of yours. The invented function name usually borrows WordPress vocabulary so it reads as native at a glance. Check line one through to your file's own opening comment or its first function.

2. At the bottom, after your last line

php
add_action( 'after_setup_theme', 'acme_setup' );
/* your file used to end here */


/* SKELETON. Payload removed. Appended block, often after blank lines. */
function wp_admin_session_check() {
    /* payload removed from this example */
}
wp_admin_session_check();

Scroll to the end. Whatever sits after your last statement is the candidate, and a run of blank lines above it is a common giveaway, because the writer appended rather than inserted. An appended block that defines a function has to call it too, so look for the call as well as the definition.

3. Inside a function that was already there

php
function acme_get_header_scripts() {
    /* SKELETON. Your body was here. The injection replaced it. */
    /* payload removed from this example */
}

The hardest of the four to see. The file length looks normal, nothing sits at either end, and the function is still called from all the places it was called from before. Nothing about the file's structure changed, so only behaviour gives it away. Compare the function against your version control, a staging copy, or another site running the same code. If the scanner named this file and the top and bottom are clean, this is where to look next.

4. One added line that pulls in a second file

php
require_once __DIR__ . '/inc/.cache-config.php';

One line, in a file full of lines that look like it. The payload lives in the file it names, and a filename that starts with a dot keeps that file out of a plain ls, so use ls -la on the directory. Both halves have to go. Cutting the line leaves the second file sitting there for the next thing that finds it, and deleting the second file while leaving the line gives you a fatal error on the next request.

Finding it faster

Three of the four shapes announce themselves by line length, because a script wrote them and a script does not press return. This prints every line over 500 characters with its number and its length:

bash
$ awk 'length($0) > 500 {print FILENAME ":" FNR ": " length($0)}' functions.php
functions.php:2: 608

A long line is a lead, not a verdict. A minified asset is long on every line, from the first to the last, because the whole file was generated in one piece. An injection is one long line in a file where everything else is a reasonable length. Telling a real detection from a false flag goes through that distinction and what to do when you still cannot call it.

Read it, then cut it out

Spend two minutes reading before you delete. You are about to destroy the only record of what was done to the site, and what the block reaches for tells you what else to check.

  • It fetches a remote address and runs the response. Whoever wrote it can change the payload without touching your site again. Treat every writable file as suspect, not this one alone.
  • It writes a file. Note the path it writes to. Removing this block does nothing about the file it already created, and that file is the one that survives your cleanup.
  • It reads a request parameter, a cookie or a header and acts on it. That is a door, and it was reachable by anyone who knew the file existed. Your server's access log for that path is where you find out whether anyone used it.
  • It redirects on a condition. Visitors and search engines saw something you never did. Check whether the site got flagged while it was running, and check it from a search result rather than by typing the address.

Read the code. Do not run it to find out what it does, and do not paste it into an online decoder that executes it for you.

Where the boundaries are

An injection is a guest in your file. It brings its own braces, defines its own names, and touches none of your variables. That self-containment is what makes it removable at all.

Start at its first character and end at its own closing brace or semicolon. If it defined a function and then called it, the call goes with the definition. If you cannot see where the block ends, count braces from its opening line: when the count returns to zero you are at the last character to remove. Everything your own code needs was there before the block arrived, so nothing you keep should refer to anything you cut.

The tag traps

Four ways a deletion that looks right breaks the file. The syntax check catches two of them and stays silent about the other two, which is why the last section has more than one step in it.

  1. You cut the file's opening tag. A block glued to the top of the file starts on the same line as <?php often enough that selecting the block takes the tag with it. The file then has no PHP in it, so PHP prints it. I checked this: a file with the tags removed passed the syntax check cleanly and printed its whole contents as text.
  2. You left a second opening tag behind. Some blocks close PHP and reopen it. Cut the middle and leave the reopening tag and the parser stops at it.
    bash
    Parse error: syntax error, unexpected token "<", expecting end of file in functions.php on line 3
    Errors parsing functions.php
  3. You took one brace too many. The message names two line numbers and the first one is the useful one. It points at the brace that never closed. The second is the end of the file, which is where PHP gave up.
    bash
    Parse error: Unclosed '{' on line 2 in functions.php on line 4
    Errors parsing functions.php
  4. You left a fragment after the closing tag. Anything after ?> is output rather than code, so the syntax check has no opinion about it and PHP prints it to the browser. Same for a blank line above the opening tag. Look at the first and last bytes of the file with your own eyes.

wp-config.php, specifically

This file has the fewest tools pointed at it of anything in your installation. It appears in no checksum set. Neither WordPress release archive contains it: the full 7.0.4 zip holds 4,401 files and the no-content zip 3,901, and what both ship is wp-config-sample.php. WP-CLI's core checksum command excludes it by name in its filter. So nothing will tell you the file was modified, and nothing can put it back. Several scanners special-case it for the same reason and refuse to remove it at all.

Find it before you edit it, because it does not have to be in the docroot. On one install checked for this page it sat a level above.

bash
$ wp config path
/var/www/html/wp-config.php

What belongs in the file

  • The database constants. DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, and usually DB_CHARSET and DB_COLLATE.
  • The eight keys and salts. AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY and the four matching _SALT constants. Long random strings, and nothing else.
  • The table prefix. One line setting $table_prefix.
  • Debug constants, if you set them. WP_DEBUG, WP_DEBUG_LOG, WP_DEBUG_DISPLAY.
  • The definition of ABSPATH, then the require of wp-settings.php. Those are the last two things in the stock file.
  • Constants your host or a plugin asked for. WP_HOME and WP_SITEURL are the usual ones.

The stock file marks its own boundary. It carries a comment saying to add custom values between that line and the stop editing line, then a line reading that this is all and you should stop editing, and below that only the ABSPATH definition and the require. Anything of yours belongs above the stop editing line. Anything you find below it was put there by someone, which is a reason to read it rather than proof it is hostile: hosts append constants there too.

The other rule is about verbs. This file defines values. Nothing in it should fetch a remote address, decode a string into code, open a socket, write a file, or include something from a directory you do not recognise. A line doing any of that does not belong regardless of how official its name looks.

Listing it without printing your credentials

WP-CLI can show you every name in the file with none of the values, which makes it safe to look at over a shared screen and fast to scan for something that should not be there.

bash
$ wp config list --fields=name,type
name              type
table_prefix      variable
DB_NAME           constant
DB_USER           constant
DB_PASSWORD       constant
DB_HOST           constant
AUTH_KEY          constant
SECURE_AUTH_KEY   constant
WP_DEBUG          constant
wp-config-ddev.php  includes

Trimmed. A real install lists all eight keys and salts and whatever else it carries.

The row of type includes is the one to read twice. It names a file that wp-config.php pulls in, which is the fourth shape from the section above showing up in the one file where it is easiest to miss. A hosting stack legitimately puts a file there. A path you cannot account for is worth opening.

When the file is clean, treat the credentials as spent. Whoever edited it read your database password and your keys. Change the database password with your host, update DB_PASSWORD to match, and replace all eight keys and salts with fresh random strings.

Prove the file still works

A file with no upstream copy cannot be verified by comparison with the publisher, so you verify it by parts: it parses, it differs from the original only where you cut, and it still does its job.

  1. Syntax check it. php -l reads the file and parses it without running any of it.
    bash
    $ php -l functions.php
    No syntax errors detected in functions.php
    Anything else is a parse error naming the file and a line. Fix it before the file goes anywhere near the site.
  2. Diff it against the copy you took. Read every line of the output, not the count. The only lines that should appear are the ones you meant to remove.
    bash
    $ diff -u /home/you/functions.php.orig wp-content/themes/acme/functions.php
    --- /home/you/functions.php.orig	2026-08-18 16:39:36.295351885 +0200
    +++ wp-content/themes/acme/functions.php	2026-08-18 16:39:36.297351906 +0200
    @@ -1,6 +1,4 @@
     <?php
    -/* the block you cut shows up here, prefixed with a minus */
    -/* and nothing else should */
     /**
      * Acme theme functions.
      */
    A line you did not intend means your editor reformatted the file. Start again from the copy.
  3. Diff it against your own history. If the theme or the plugin is in version control, the comparison against your last known good commit is the strongest check available for this file, because it compares your code against your code. A staging copy of the same site is the next best thing.
  4. Exercise the feature. A parse is not a behaviour. Load the page that uses the function you edited, then load the admin, then submit whatever form the file was responsible for. A file that parses and returns the wrong thing looks fine from the command line.
  5. Go after what the block left behind. If it wrote a file, that file is still there. If it pulled in a second file, that one is still there too. Removing the caller is half the job, and the half that gets remembered.

The syntax check is narrower than it looks. On a test file with a stray fragment sitting after the closing tag it reported no syntax errors, and running the file printed the fragment. Loading the page in a browser is the step that catches that class of mistake.

Do not expect the re-scan to confirm your work the way it would for a core file. The commands that clear core have no equivalent here, and a checksum command that skips your plugin still exits successfully. Silence from those tools means nobody checked.

When there is nothing to salvage

Some files have no injection to cut out, because the file is the injection. Three signs, any one of which is enough:

  • It is a PHP file in your uploads directory. Uploads hold media. Code that arrived there arrived by a route you did not intend.
  • The whole file is one dense block with no readable code around it. There is no host file to preserve, because there was never a host file. Compare that against a legitimate packed library, which ships inside a plugin or theme directory and appears in that component's published archive.
  • Nothing references it. Search the theme and the plugin for the filename. If nothing includes it, nothing requires it, and you did not write it, it is not part of your site.

For those, the file goes. Write the path down before you remove it, because the same path coming back is how you learn the way in is still open.

The harder case is a file where you can see the injection and you cannot see where your own code ends. Stop cutting at that point. Guessing at the boundary leaves you with a file that parses, runs, and is subtly wrong, which costs more to find later than the hour you saved. Three routes, in the order they are worth trying:

  1. Your version control. Check out the file from the last commit you trust and compare it against what is on the server.
  2. A staging or development copy. Confirm its date first. A staging site cloned after the injection has the injection.
  3. Your host's backup, restored beside the site rather than over it. Restore into a separate directory so you compare files instead of overwriting a site you have already partly cleaned.

Pick the backup by date rather than by the file's timestamp. The modification time is set by whatever wrote the file, so it can say anything. If none of the three routes exists, restoring the whole file from before the injection beats a half-confident edit, and rebuilding the one function you lose is a smaller job than debugging a file you cut by guesswork.

Questions

Can I replace an infected wp-config.php with wp-config-sample.php?
No. The sample file carries placeholder text where your database name, user, password and host belong, and it carries no keys or salts. Copy it over your file and the site stops connecting to its database. What the sample is good for is structure: open it beside your file and use it to see which lines in yours are stock and which were added. Neither WordPress release archive contains a wp-config.php at all, only wp-config-sample.php, so the sample is the closest thing to a reference copy that exists.
How do I check a custom theme file against a clean copy?
There is no clean copy to check against, and there is no command that pretends otherwise. WP-CLI has no theme checksum subcommand: `wp theme verify-checksums` answers that verify-checksums is not a registered subcommand of theme. WordPress.org publishes no theme checksum API either. Your comparison points are your own version control, a staging copy of the same site, or another site running the same code. For a child theme there is nothing upstream even in principle: a child theme is your files plus a Template header, and reinstalling the parent never touches it.
Does wp plugin verify-checksums work on a plugin I wrote myself?
It runs and it tells you nothing. A plugin with no WordPress.org manifest is skipped, and the command still reports success and exits 0. On a single custom plugin the output reads: Success: Verified 0 of 1 plugins (1 skipped), with a 404 warning above it for the checksum file it could not fetch. Read the warnings rather than the exit code, and treat a skipped plugin as unchecked rather than clean.
The file passes php -l but the page shows stray characters at the top.
You left a fragment after the closing PHP tag. Anything after ?> is output, not code, so the syntax check has no opinion about it and PHP prints it. I checked this on a test file: php -l reported no syntax errors and running the file printed the leftover characters. The same applies to a blank line above the opening tag. Open the file, look at the very first and very last bytes, and remove anything outside the tags that was not there before.
Should I just delete the whole file instead of editing it?
It depends on what the file is. A PHP file that consists of nothing but the injection has nothing worth keeping, so removing it costs you nothing. A theme functions.php or a plugin's main file is a different matter, because the site loads it on every request. Deleting a required plugin file gives a fatal error and a critical error page. Emptying the same file gives no error at all, the page still renders, and the feature just stops working, which is harder to notice and harder to trace.

Next

What a cleanup should do to a file like this

This file is the case that shaped how Segurium cleans. An injection in a legitimate file gets the injected code removed and the rest of the file written back in place, so your theme keeps its functions and your plugin keeps loading. A file that is nothing but malware becomes zero bytes at the same path instead of disappearing, because a deleted file that something still includes takes the site down while an empty one does not. When no clean version of a file can be produced, the cleanup stops and leaves the file exactly as it found it. The original goes into an encrypted backup on your own server before any of that happens.