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.
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:
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.
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.
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.
Inventory before you delete anything. Write down what you find.
bash
The third command is the list you have to finish. If a file appears in
it at the end, you are not done.
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.
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.
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.
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.
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.
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.
Delete the nulled theme or plugin. Not deactivate. Delete the
directory. The last section on this page explains why that is not
optional.
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.
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.