Working out what you have

wp-vcd puts a copy in every theme, then rebuilds what you delete

The files have names and the install pattern has barely moved since 2017, which works in your favour. Clean it in the wrong order and one page load puts the whole thing back.

Checked against a live WordPress install on .

What wp-vcd looks like from the outside

You installed a premium theme or plugin that you did not pay for. Maybe a week ago, maybe by a developer three years ago whose name nobody remembers. Since then some of these are true:

  • A scanner names files you did not create. wp-includes/wp-vcd.php, wp-includes/wp-tmp.php, or a class.theme-modules.php sitting next to a theme's style.css.
  • Visitors get pop-ups or land somewhere else. Usually only some visitors, usually only on single posts, and usually not you, because the injected code checks who is asking.
  • Your posts carry links you never wrote. Often to sites offering free downloads of paid themes, and often hidden with a CSS rule that parks the element five thousand pixels off the top of the page.
  • An administrator account exists that nobody created. In older samples it is literally named 100010010.
  • You deleted a file and it came back. This is the one that brings people here.

The campaign is called wp-vcd after the string its own code uses to mark the files it has already infected. It spreads through nulled software: paid themes and plugins with the licence check stripped out, handed around by sites that rank well for searches like "eduma theme download". Wordfence traced the download network back to a single origin domain that served identical archives to every front-end site, so the version of the theme you took made no difference to what you got.

Nothing was exploited to put this on your site. You were logged in as an administrator and you uploaded it yourself, which is why no patch fixes it and why the last step of this page matters more than the first four.

Read the code, do not run it. Nothing on this page needs you to execute a sample to identify it, and the injected code fetches and runs whatever a remote server hands back.

Confirming it is wp-vcd

Four checks. The first two settle almost every case in a couple of minutes, and none of them change a file.

1. Search for the marker string

The deployer needs to know which themes it has already done, so it writes a flag it can find again. Before injecting into a functions.php it tests whether the file already contains the literal text WP_V_CD, and skips the file if it does. That check is what makes the string reliable: every infected functions.php contains it, by design.

bash
grep -rlE --include="*.php" "WP_V_CD|div_code_name|wp_temp_setup" wp-content/ wp-includes/

Three markers in one pass. WP_V_CD is the reinfection flag. $div_code_name = "wp_vcd"; is the variable the spam injector uses to find its own output in a page. wp_temp_setup is the function that writes remote code to a temporary file, includes it and unlinks it. Wordfence published detection rules for the second and third, so they are worth searching even when the first turns up nothing.

Any hit under wp-content/themes/ or wp-includes/ is enough to stop guessing.

2. Look for the loader line at the top of files

wp-vcd does not obfuscate its entry point. It prepends a plain, readable line to the first byte of the file it wants to hook. In a theme:

php
<?php if (file_exists(dirname(__FILE__) . '/class.theme-modules.php')) include_once(dirname(__FILE__) . '/class.theme-modules.php'); ?><?php

In a plugin the filename changes to class.plugin-modules.php and nothing else does. In core, the same line appears at the top of wp-includes/post.php naming wp-vcd.php. Sucuri published this shape in December 2017, Wordfence quoted it again in November 2019, and it matches byte for byte what sits at the front of a publicly captured post.php.

The tell is the double tag. The line closes with ?> and immediately reopens with <?php, because the original file started with its own opening tag and the injector pushed it along. Legitimate code does not open, close and reopen PHP on line one. In a real infected post.php the original Core Post API docblock starts on line two, exactly where it always did.

3. Check core against the published checksums

A modified wp-includes/post.php is a core file that no longer matches its release, and WP-CLI will say so.

bash
wp core verify-checksums

Two things about the result. An added file such as wp-includes/wp-vcd.php prints as Warning: File should not exist and does not set the failure flag, so the command can print Success: WordPress installation verifies against checksums. and exit 0 while listing the malware. Read the warnings, not the exit code.

And the command ignores the whole of wp-content by an explicit rule, so every copy of the backdoor in your themes is outside its field of view. Plugins have their own command, which flags a class.plugin-modules.php as an added file:

bash
wp plugin verify-checksums --all

It skips anything with no manifest on WordPress.org, which includes every nulled plugin, so a silent pass there means nobody checked. Themes have no equivalent command at all. The whole picture of which checks cover which files is in restoring modified core, plugin and theme files.

4. Search on ctime, not on modification time

The obvious check fails here, and it fails silently. The deployer reads each functions.php modification time before it writes, then calls touch() afterwards to put the old value back. Wordfence documented this in 2019 and it does exactly what the author wanted: the file reads as untouched in a directory listing and in every "recently modified files" report.

PHP can set the modification time. It cannot set the inode change time, which the kernel updates on any write regardless. So the injection is invisible to the usual search and visible to the one nobody runs:

bash
find wp-content/themes -name "functions.php" -newermt "-30 days"
find wp-content/themes -name "functions.php" -newerct "-30 days"

On a file whose modification time was reset to 2023 and whose contents were rewritten today, the first command returns nothing and the second returns the file. Both were run here on GNU findutils 4.9.0 to check that claim before it went on this page.

Two limits. That is GNU find, the one on almost every Linux host; BSD find takes the same flag with a different time format. And any bulk operation over the tree resets ctime on everything it touches, so a restore from backup or a site copied between servers floods the result. Run it on a site that has been sitting still.

Every file it touches

wp-vcd is unusually easy to write down, because it does not generate random filenames. It uses names that look like they belong, in directories where PHP files are normal, and it has reused most of them for years.

Path What it is Era
wp-content/themes/<theme>/functions.php Carries the backdoor itself, prepended at line one. Contains WP_V_CD. One copy per theme directory on the site. Current
wp-content/themes/<theme>/class.theme-modules.php The deployer, shipped inside the nulled theme archive. Runs on activation, installs everything else, then edits itself out. Current
wp-content/plugins/<plugin>/class.plugin-modules.php The same deployer, in a nulled plugin instead of a theme. Current
wp-includes/wp-vcd.php The reduced deployer. It reinstalls the theme backdoors and cannot install further deployers. This is the file that rebuilds what you delete. Current
wp-includes/post.php A real core file with the loader line added at the top, so wp-vcd.php runs on every request. Current
wp-includes/wp-tmp.php Cache of the last code the control server sent. When all three of its addresses fail, the backdoor runs this instead. 2019 onward
wp-includes/wp-feed.php Read by the spam injection script. Wordfence's published rule for that script matches on code reading this exact path. 2017 to 2019
wp-includes/class.wp.php Downloaded from the control server. Creates the rogue administrator and answers requests to add and remove users. 2017 to 2018
ccode.php, cplugin.php, mplugin.php, .class-wp-cache.php Filenames reported by cleanup vendors after 2019, some of them presenting as a plugin that does not appear in your plugin list. No published teardown backs them, so treat them as names to search for. Reported, unanalysed

Why there is a copy in every theme

This is the part people underestimate. The deployer does not inject into your active theme. It lists wp-content/themes/, and for every directory that contains a functions.php it checks for WP_V_CD, prepends the backdoor if the flag is absent, and restores the timestamp. Four themes on disk means four copies.

Which theme is running does not enter into it. Sucuri's November 2017 writeup found the code in twentyfifteen and twentysixteen on sites that ran neither, because the folders were sitting there from the original install. An inactive theme is inert until somebody activates it, and then it is not.

Ask WordPress which theme is yours before you start, so you know which directories are supposed to exist:

bash
wp option get template
wp option get stylesheet

A child theme sets those two to different values. Both directories have a functions.php and wp-vcd treats them as two separate targets.

What the deployer does after it finishes

It removes its own installation code from the nulled theme or plugin, leaving a single line that reads <?php error_reporting(0);?>. Wordfence points out the forensic value of that residue: on a server hosting several sites, only the site where the infection started keeps a class.theme-modules.php containing that line. The others got a wp-vcd.php instead.

That matters because the deployer does not stop at your site. It walks up the directory tree, then down again, looking for other WordPress installs in the same hosting account. Each one it finds gets wp-includes/wp-vcd.php and an edited post.php. Clean one site on a shared account and leave its neighbours, and you have left a working reinstaller pointed at the same disk. That pattern, and the other reasons a cleaned site comes back, are in why a site gets reinfected after a cleanup.

What is not a file

Two pieces of wp-vcd live in your database and survive any amount of filesystem work. The 2017 samples insert an administrator with the username 100010010 and the email address te@ea.st, and create a table named datalist, carrying your usual table prefix, with columns for url, title, keywords, description and content. The spam served to your visitors is read out of that table. Neither goes away when you delete a PHP file.

Removing it in the order that works

The order is forced by one fact. wp-vcd.php runs on every page load through post.php, and its job is to put the theme backdoors back. Clean the themes first and the next request undoes your work, which is why people report deleting the same file four times.

So: kill the things that rebuild, then clean the things that get rebuilt. A file manager and a text editor are enough for all of it.

  1. Stop the site serving requests. Every page load re-runs the reinstaller, so a maintenance page or a temporarily disabled vhost turns a race into a job. If you cannot take it down, work through steps two to six in one sitting and re-check afterwards.
  2. Inventory before you delete anything. Write down what you find.
    bash
    find . -name "class.theme-modules.php" -o -name "class.plugin-modules.php"
    find wp-includes -name "wp-vcd.php" -o -name "wp-tmp.php" -o -name "wp-feed.php" -o -name "class.wp.php"
    grep -rlE --include="*.php" "WP_V_CD" wp-content/ wp-includes/
    The third command is the list you have to finish. If a file appears in it at the end, you are not done.
  3. Cut the loader out of wp-includes/post.php. Open it, delete the injected first line up to and including the first ?>, and leave the <?php that starts the real file. The clean file opens with <?php and then the Core Post API docblock. Replacing the file with the copy from your WordPress release works too and is safer if you are unsure what to cut.
  4. Delete the reinstaller and its caches. Now that nothing loads it, remove wp-includes/wp-vcd.php, wp-includes/wp-tmp.php, wp-includes/wp-feed.php and wp-includes/class.wp.php if they exist. None of these are WordPress files. Nothing in a stock install is named any of them.
  5. Clean every theme's functions.php. Every directory under wp-content/themes/, not only the active one. The injected block sits at the top of the file, before the opening comment, and it ends where the theme's own code begins. Cut from the first byte down to the last line that belongs to the injection, and leave the file starting with <?php. For a stock theme, compare against the copy from WordPress.org rather than judging by eye. For a theme with no published copy, the technique is in cleaning a file when there is no clean copy to compare against.
  6. Remove the deployer files. Delete every class.theme-modules.php and class.plugin-modules.php from step two. Then open the main file of the theme or plugin each one sat in and remove the loader line pointing at it, the same edit you made to post.php.
  7. Deal with the database. List your administrators and look for one you did not create.
    bash
    wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
    Delete anything you do not recognise, reassigning its content rather than dropping it. Then look for the spam table. Your prefix may not be wp_, so read $table_prefix from wp-config.php first.
    bash
    wp db query "SHOW TABLES LIKE '%datalist';"
    A query that matches nothing prints nothing at all, so silence there is a result and not a broken command. A datalist table with url, title, keywords, description and content columns belongs to this infection and nothing else. Drop it.
  8. Check the neighbours. If other WordPress installs share the hosting account, run step two against each of them. The deployer reached them on the day it ran, and one untouched neighbour is a working reinstaller with access to the same disk.
  9. Delete the nulled theme or plugin. Not deactivate. Delete the directory. The last section on this page explains why that is not optional.
  10. Bring the site back and re-run step two. Load the front page and the admin first, so the reinstaller gets its chance, then search again. A clean result after the site has served traffic is the only result that counts.

If the site breaks after step five, you cut too much out of a functions.php. That file holds real theme code below the injection and the boundary is easy to misjudge. Recovering from it is covered in what to do when a malware cleanup breaks the site.

Rotate the salts afterwards

The backdoor's password is the MD5 of your site's hostname and your AUTH_SALT constant, worked out at install time and written into the injected block. The operator was sent that value when your site registered itself. Replacing the salts in wp-config.php makes any copy of that password useless, and it logs everybody out, which is the point. WordPress.org serves a freshly generated block at api.wordpress.org/secret-key/1.1/salt/, which returned 200 with eight define lines when this page was written. Paste it over the old block.

The nulled theme is the fix

Everything above removes the malware. It does not remove the reason you have it. If the nulled theme or plugin is still on the server, or still in a downloads folder, or still listed in a deployment script, you are one reinstall away from doing this again.

The distribution network is the part worth understanding. Wordfence traced a set of near-identical download sites, all serving their archives from a single origin, and found that updates to the deployer were patched into every archive at once. A two-year-old post offering a two-year-old theme version handed you that week's deployer. There is no safe file to keep from any of them, and no version of the theme that predates the problem.

Those sites rank well, which is not an accident. Part of what wp-vcd does once installed is inject links back into the search results that brought you there. Sites infected today are the advertising that infects the next person, and their traffic pays for the campaign through the ad code the control server pushes out.

What to do instead, in order of how much it costs you:

  • Buy the licence. If the theme is worth building a site on, it is worth its price, and you get updates, which is the one thing no other route gives you.
  • Replace it with something from the WordPress.org directory. Every file has a published checksum, so you can prove what you have. That is also what makes future infections detectable rather than a matter of opinion.
  • Ask where your developer got it. If you paid someone to build the site, the archive came from somewhere and the invoice will not say. A theme with no licence record and no upstream is a theme nobody can update.
  • Write down what is installed and where each piece came from. A component nobody can name the source of is the one you will be arguing about the next time a scanner flags it.

Two related shapes are worth recognising while this is fresh. wp-vcd's deployer arrives as an extra file inside something you installed on purpose, and there is a whole family that goes further and ships as the plugin itself, described in the plugin that is itself the backdoor. And where wp-vcd hides in plain sight with readable code and honest-looking filenames, most injections do the opposite and pack themselves into one encoded line, which is reading eval and base64_decode in a PHP file.

One caution on the era labels in the table above. The last full published analysis of this campaign is Wordfence's whitepaper from November 2019. Everything since adds filenames without taking the code apart, so a current infection may not match the 2019 description in every detail. The loader line, the reinfection flag and the copy-in-every-theme behaviour have held from 2017 through every writeup, and those are the three the removal above is built on.

Questions

I deleted wp-vcd.php and it came back. Why?
Because something still loads it. The usual pair is wp-includes/post.php carrying a one-line include of wp-vcd.php, and a copy of the backdoor sitting in a theme's functions.php that rewrites the file on the next page load. Deleting one part of a set that rebuilds itself buys you seconds. Cut the loader out of post.php and clean every theme's functions.php in the same pass, then delete the file.
I only use one theme. Do I have to check the others?
Yes, and the ones you never activated are the ones people miss. The deployer walks wp-content/themes and injects into the functions.php of every directory that has one. Sucuri's November 2017 writeup found the code in twentyfifteen and twentysixteen on sites that ran neither. An inactive theme still sits on disk, and reactivating it later reintroduces the backdoor.
The file dates look normal. Does that rule wp-vcd out?
No. Wordfence documented that the deployer records each functions.php modification time before it writes and restores it afterwards, so the file reads as untouched. PHP cannot set the inode change time, though, so on Linux the ctime still moves to the moment of the write. Search on ctime with find -newerct rather than on modification time with find -newermt.
Does wp core verify-checksums catch this?
Partly, and the part it misses is the important one. It compares wp-admin, wp-includes and the root wp-*.php files, so a modified wp-includes/post.php shows up as mismatched and wp-vcd.php shows up as a file that should not exist. It ignores all of wp-content by an explicit rule in the command, so the copies in your themes' functions.php are invisible to it. There is no wp theme verify-checksums at all.
Is any of wp-vcd in the database?
Some of it. Early samples insert an administrator with the username 100010010 and the email te@ea.st, and create a table named datalist alongside your other tables, carrying url, title, keywords, description and content columns for the injected spam. Neither is removed by cleaning files, so check both by hand after the filesystem work.
Can I keep the nulled theme if I clean the malware out of it?
No. The copy you have on disk is one build of one download from a distribution network that patches its deployer into every archive it serves. You cannot update it, you cannot verify it against anything, and the next version you fetch from the same place carries whatever the operator shipped that week. Buy the licence or switch to something from the WordPress.org directory.

Next

Where the repeat passes go

The hard part of that procedure is not any single step. It is finding every copy, in themes you forgot you had and on sites sharing the account, and then cutting an injected block out of a file that still has to work afterwards. Segurium does both sides of that: it fingerprints every file and looks the fingerprint up before reading anything, and when it finds an injection it writes the cured file back in place rather than deleting a functions.php your theme needs.

Work the whole list rather than the file steps alone. Step seven is database work, so check the user list and the spam table once the files are clean. Step eight is the neighbouring installs on the same hosting account. And step nine deletes the nulled theme that started this, which nothing running on your server can stop you installing again.