Let's talk
security

Signature scanning found nothing. File integrity found 211 malicious files

A site kept getting reinfected. We ran the usual scanners over it — the pattern-matching kind that look for eval(base64_decode(, obfuscated variable names, known shell signatures. Zero hits. Clean report. Then we compared every file in the installation against the official distribution for that version, and the comparison returned 211 files that should not have been there.

Zero and 211, on the same directory tree, on the same afternoon. That gap is the whole lesson.

Why pattern matching lost

Signature scanning asks: does this file look like malware I have seen before? It is a question about appearance, and appearance is the one thing an attacker controls completely. Anybody writing a backdoor for a widely-deployed CMS knows exactly which scanners the victim is likely to run, because those scanners are free and public. They test against them. Code that trips a regex gets rewritten until it does not.

So the arms race is real, and the defender is structurally behind in it. Every new evasion is cheap for the attacker and requires a signature update for the defender. You are always scanning for last month’s payload.

File-integrity comparison asks a completely different question: is this file the one the vendor shipped? That question has an objective answer that does not depend on what the file contains. The attacker cannot write code that is simultaneously a working backdoor and byte-identical to a file in the official release. There is no evasion available, because there is no pattern being matched.

This is why the checksum verification built into the CMS’s own command-line tooling should be the first thing you run on a suspect installation, not the last. It compares core files and plugin files against the published distribution and reports anything modified, added or missing. On a site that had returned a clean bill of health from signature scanning, it produced a list of 211 files.

What the attacker was actually doing

The interesting part is not that the files were hidden. It is how they were hidden, because every technique was aimed at a specific detection method that people trust.

Drop-ins. The CMS supports a small set of specially-named files that, if present in the content directory, are loaded on every single request. They do not need to be activated. They do not appear in the plugin list. Nothing in the administration interface will tell you one exists. One of these had been replaced with a restorer: code whose only job was to check whether the payloads were still in place and reinstall them if they were not. This is why the site kept getting reinfected after each cleanup. We were removing the symptoms and leaving the thing that reproduced them.

Self-filtering plugins. Comparing the list of active plugins in the database against the list shown in the interface produced a mismatch. One plugin was registered as active and did not appear. It had hooked the filter that builds the plugin list and removed itself from the output. An administrator looking at the plugins screen would have seen a normal, short, unremarkable list.

Backdated timestamps. The standard triage move on a compromised host is to sort by modification time and look at what changed recently. Every backdoor we found had been stamped with a date well over a year old. Sorting by mtime pushed the malicious files to the bottom of a list of thousands and put ordinary, untouched core files at the top. The check does not fail loudly when this happens. It just quietly returns the wrong answer, and you move on believing you have looked.

Location. Stray PHP files sitting in the document root alongside the legitimate entry points. Directories named with random hex strings. Executable PHP inside the uploads directory, which should never contain anything the interpreter runs.

Each of these defeats a different check. Drop-ins defeat “look at the installed extensions”. Self-filtering defeats “look at the active plugin list”. Backdating defeats “look at what changed recently”. None of them defeat “is this byte-for-byte the file that was published”.

The thing that actually keeps you honest

There is a wider point here about how we assess whether a system is in a known state.

Most operational checks are descriptive. They ask the system to describe itself, and then you trust the description. The plugin list, the modification times, the admin dashboard, the process list, the output of a scan — all of these are the system telling you about itself, using code that lives inside the same boundary the attacker has already crossed. A compromised system is under no obligation to describe itself accurately, and a competent attacker will make certain it does not.

Integrity comparison is external. The reference — the published distribution and its published hashes — lives outside the compromised boundary. That is the only reason it works. The value is not in the hashing algorithm; it is in where the reference copy sits.

Applied more generally, the same test tells you which of your checks are worth anything:

  • Container images verified against a registry digest, not against what the running container says its version is.
  • Dependency lockfiles with integrity hashes, checked at install time, not a package list read back from the installed tree.
  • Infrastructure state compared against version-controlled definitions, not against the cloud console’s current display.
  • Database schema compared against the migration history, not against whatever the current schema happens to contain.

In each case, ask where the reference lives. If it lives inside the thing you are inspecting, you are not verifying anything. You are asking a question and accepting whatever answer comes back.

The parts integrity checking will not cover

It is not a complete answer, and it is worth being blunt about the gaps.

It only covers files that came from a distribution with published checksums. Custom themes, bespoke plugins, uploaded media and configuration files have no reference to compare against. For those you need a different baseline — your own version control, a hash manifest taken when the system was known good, or simply a rule that the interpreter never executes anything in the directories where users can write.

It says nothing about injected database content. Spam URLs stored as posts, malicious redirects in options, altered administrator accounts — none of that is on disk, and none of it will show up in a file comparison.

And it cannot tell you the difference between an unauthorised modification and a legitimate one. On a system where people patch files by hand, integrity checking produces noise until you stop doing that. Which is arguably a benefit: it forces the deployment discipline that makes the check meaningful in the first place.

What I do now

Run integrity verification first, before anything else, on any host you suspect. Not after the signature scanner comes back clean — instead of treating that clean result as information. A scan with zero hits tells you the attacker was competent. It does not tell you the system is clean.

Then check the things that never appear in a list: the auto-loaded drop-in files, the active-extension records in the database compared against what the interface renders, and anything executable in a directory that only exists to hold uploads.

The general form of the rule is short enough to keep. Never let a system be the only witness to its own integrity. If your entire assessment rests on what the system reports about itself, you have not assessed it. You have interviewed it.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch