Invasion Investigation · Part 5
The tempo flips here
Part 4 was fast because the scene might have been live. You took the copy, you found out whether anyone was still working, and you touched as little as possible.
Now it slows down, and it can — because you’re not working on the site any more, you’re working on the copy. Nothing can be lost. Nobody can see you. There is no clock. This is the part that can take a month if it needs to.
Two rules carry over, and both are operational rather than decorative.
Work from the copy, never the original. You have a snapshot. There is no reason to open a live file again.
And read timestamps from the archive index, not the extracted files. Unzipping rewrites modification dates to the moment you unpacked. The single most valuable evidence in a compromise is destroyed by the act of looking at it wrongly.
You also arrive with a list from Part 3: exact paths, exact times, the addresses that touched them. Start there. Everything else in this post is what you do once that list runs out.
Part one: what you’re up against
Read this before you start looking, not after. Every technique below exists specifically to survive someone doing what you’re about to do.
They lie about time
Modification dates can be set to anything. A file written last February can claim 2019.
From the case file. A file in this compromise was backdated to 27 June 2021 — and that date was not random. Ninety legitimate pages and several hundred assets across that hosting account carry exactly 27 June 2021, because that’s when the original site was published. They chose the account’s own most common historical timestamp so the file would blend into a sorted list.
Modification time is easy to forge. Change time is not — it updates when the file’s record itself changes and ordinary tools can’t set it to an arbitrary past date. A file claiming 2021 in one and 2026 in the other has just confessed.
They lie about identity
Filenames are chosen to survive a glance. wp-admin.php. robots.php. cleaner.php. db-fix-sqlite.php. Fake plugin headers describing something plausible — Plugin Name: SEO Manage sitting on top of a file that does nothing of the sort.
wp-admin.php is the one to remember, because you can check it without knowing anything: WordPress has no such file. wp-admin is a directory.
They lie about type
From the case file. One file with a
.phpextension contained no PHP at all — it was an ordinary HTML page, and it cost the investigation time as a suspected form handler. A file named.ht.sqlitewas a database, named to look like an Apache config so that Apache’s own default rules would refuse to serve it. Three files namedemoji1.png,metaicons.jpgandwp-themesall.gifin a hidden folder were all zero bytes — a PNG with no image in it.
Check what’s inside, not what it’s called. Every real image announces itself in its first few bytes. A .png with no PNG header is not a PNG.
They hide where nobody looks
Dot-prefixed files and folders, invisible in most file managers by default. Directories named to look like system infrastructure. PHP in image folders.
From the case file. The earliest evidence in this seventeen-month compromise was twelve PHP files written across two static websites — ten of them into
assets/images/autogen/, an image directory. A scanner emptied them five weeks later without anyone asking how they got there.
They hide from the platform, not the filesystem
This is the category that defeats most cleanups.
From the case file. The intruders installed a complete WordPress inside a static HTML site and ran it on SQLite, so no database ever appeared in the hosting control panel. They repointed a must-use plugin — a category that loads automatically, can’t be disabled from the dashboard, and never appears in the plugins list. And they left the original 2022
index.htmluntouched, so the site looked completely normal to anyone visiting it for five months.
Never investigate from the dashboard. The dashboard is a rendering of what WordPress chooses to show you, and what it shows can be changed.
They defeat signature scanners
Randomised class names per build, layered obfuscation, payloads stored as data rather than code. Which is why a clean scan means very little: the file is built fresh per victim, so there’s no signature to match.
Comparison finds what signatures can’t. More on that shortly.
They suppress the record
@error_reporting(0) near the top of a file is a script asking not to be noticed when it fails. Self-deleting payloads run once and vanish. This is the entire argument for the fast capture in Part 4 — some of this evidence has a lifespan measured in seconds.
They sabotage your cleanup
From the case file. The uninstaller for their own component was rewritten to check a path that cannot exist, so deactivation ran, reported success, and removed nothing. Beside their modified loader they left a
.bakof the original — which makes the change look reversible and innocent to anyone who finds it.
Verify that removal actually happened. Then check again a week later.
And they build in redundancy
From the case file. Five administrator accounts, three of them created a week or two apart with names escalating from obviously foreign to indistinguishable from a default install. A spare WordPress staged elsewhere in the account. A payload replicated into ten hidden directories. Two different file managers.
The first thing you find is not the last thing there. If you find one rogue administrator, you have not found them all.
So: suspect yourself before you suspect an attacker
The most convincing findings in this case were not the intruder.
From the case file. Six separate discoveries turned out to be the owner’s own software or his hosting company. A security plugin’s traffic that read as a textbook backdoor. Provisioning files from the host. A legitimate plugin misjudged by three orders of magnitude — 983 bytes mistaken for 983 KB. And a countermeasure the owner had installed himself, producing a daily pattern that looked like proof the cleanup was working.
Inventory your own stack first. What you installed, what your host installs by default, what your plugins do. Ruling out the innocent by search costs minutes and saves days.
Part two: the filesystem
Sort, don’t search
After you’ve checked the paths from Part 3, sort the entire filesystem by modification date and read it from the top.
Searching confirms what you already suspect. Sorting shows you what you didn’t. Everything that follows becomes visible in a sorted list and invisible in a search.
The three timestamps
Most tools show you one. There are three, and they say different things.
Modified — when the contents last changed. Easiest to forge.
Accessed — when it was last read.
From the case file. The hacked configuration file in this compromise was found because it was the only file on the server with an abnormal access date. Not its name, not its contents — the fact that something had read it when nothing should have.
Changed — when the file’s record last changed. The one that catches backdating, because ordinary tools can’t set it backwards.
Clusters, and what their size means
Attackers write in bursts. A group of files sharing a timestamp is one event, not many findings — and the size of the group tells you what kind of event.
A handful in one minute is a drop.
From the case file. Twelve PHP files with random eight-letter names, written across two different websites, inside a single minute. One event. One finding.
Thousands sharing one timestamp is an unpack, not authorship.
From the case file. A complete WordPress installed into a static site carried one identical timestamp across every file. Nobody wrote that. It was extracted from an archive.
A whole site on one date is usually a restore or migration — and usually yours. Rule it in before it eats a day.
Cadence and gaps
Speed tells you whether a person or a script was working.
From the case file. A redeployment read entirely from write times: core rewritten in fourteen seconds (1,148 files at three timestamps), a
robots.txtdeclaring twenty sitemaps eight minutes later, two file-manager plugins — 1,414 files — in four seconds, and an administrator account fourteen seconds after that.Twenty and a half minutes end to end, of which under twenty seconds is work. The gaps are as diagnostic as the bursts — a queue waiting on something, not a person deciding what to do next.
And silence is an event too. Eleven months of nothing between the first drop and the return isn’t absence of activity — it’s the shape of a credential sitting unused until somebody came back for it.
Always check your own history first. Your deploys, your updates, your host’s maintenance windows. A 3am cluster on a site nobody touches at 3am means something. The same cluster on the Tuesday you updated a theme means nothing.
Out of place
PHP where PHP has no business being — uploads, images, assets, thumbnails, a static site that never had PHP in it. This is a stronger signal than any filename, because attackers choose names carefully and choose locations out of convenience.
Extension against contents. Permissions — a world-writable config file is not a configuration anyone chooses. Size against expectation.
What the folder itself tells you
A directory modified after everything inside it means something was added or removed since — a deletion you can detect without the deleted file.
And what’s absent.
From the case file. A second WordPress found in the account had 3,351 files, exactly two carrying current dates, and an empty uploads folder. An installation that has never received a single upload has never been used — which is how the investigation established it was a spare being staged rather than the thing doing the damage. No log said that. The absence did.
The hot spots
You walk every folder. But some of them deserve to be walked slowly, and they fall into four groups — which is more useful than a list to memorise, because the reasoning transfers to any site.
1. Places that should never contain code.
/wp-content/uploads/. Image, asset and thumbnail folders. cgi-bin/. Any static site with no application on it. A .php file in any of these needs no judgement call — there is no innocent reason for it to exist. These are the highest-confidence finds available, which is exactly why the 2024 drop in this case went into assets/images/autogen/.
2. Places that execute automatically.
/wp-content/mu-plugins/ — loads on every request, can’t be disabled from the dashboard, never appears on the plugins page. And /wp-content/ itself, where the drop-ins live: db.php, object-cache.php, advanced-cache.php, maintenance.php. Anything here runs before your site does. Both were used in this compromise.
3. Places that should match a clean copy exactly.
/wp-includes/, /wp-admin/, and the core files in your web root. You don’t have to judge these — you compare them against the same WordPress version downloaded fresh. Any file that exists and shouldn’t is a finding by definition, which is the only way to catch something like wp-admin.php that follows every naming convention perfectly.
4. Places nobody has opened in years.
Inactive plugins and themes. Dot-prefixed directories of every kind. Legacy or migrated site folders. Backup and staging directories. Above the web root, including the home directory. And cgi-bin, which on most modern hosting exists, is empty, and is never examined by anyone.
From the case file. Hidden directories under
cgi-binon three separate sites. A payload replicated into ten dot-prefixed folders. Amu-plugins-OFFdirectory. And a hidden folder at the account root, above every individual website, holding the largest tool in the kit.
The pattern across all four: you’re not looking for something that seems wrong. You’re looking for anything present that has no reason to be — and each group gives you a different way to know that without needing to interpret the file itself.
Every folder. Every domain.
Including the static ones. Including the sites that look untouched.
From the case file. In this account the site that looked most normal for five months was the one they were living in.
Sites in one hosting account share a filesystem. A site is not clean because its files are clean if the account around it isn’t.
Part two and a half: watch what happens when you touch something
Everything so far has been passive. This one isn’t, and it’s the only technique in this series that deliberately provokes a reaction — so it comes with conditions.
If removing something causes files to reappear, you have found a trigger.
That’s not a setback. It’s the single most informative event available to you, because the redeployment shows you where they hide things.
From the case file. In this compromise a payload replicated itself into ten hidden directories —
.ssh,.putty,.trash,.cpanel,.htpasswdsamong them — and the owner watched it happen. Ten locations, several of which nobody would think to check, revealed in one event because something noticed a file was missing.Deleting unused themes produced a reaction too. What looked like the operators being annoying was them telling him what they were monitoring.
How to run it properly
Done casually this is just being noticed. Done properly it’s a controlled experiment.
1. Capture a complete file listing first — every path, with dates and sizes, across every domain. This is your before-state and the whole technique depends on it.
2. Change exactly one thing. Rename one file. Remove one theme. One variable, or you won’t know what the reaction was to.
3. Note the time, to the second.
4. Wait, then capture the listing again.
5. Diff the two.
Everything that appears in the second listing and not the first is theirs. Not “suspicious” — theirs, demonstrated, by an action you controlled. And the paths are what you’re really buying: a list of directories they consider safe, which is a list you could not have produced by searching.
Then check the access log around that timestamp. Something initiated the redeployment, and it either came from outside — giving you an address — or it fired from something already on the server, giving you a scheduled task or a hook to find.
The conditions
Only after you’ve taken the full capture. You’re deliberately changing a live scene; the copy has to exist first.
Only once. You’re spending your invisibility, and Part 4 explains what that costs. Choose the thing you touch carefully and get the maximum out of it.
And be ready for the reaction to be bigger than you expected. In this case the response to sustained deletion was escalation — they went after the owner’s own access. Provoking something you haven’t finished mapping is how a quiet investigation becomes a fight.
Which is the honest summary: this technique produces excellent intelligence and it ends the peaceful phase. Use it when you’re nearly done and one question remains, not when you’re getting started.
Part three: inside WordPress
The second threshold
Logging in writes. Sessions, last-login times, transients, plugin activity, sometimes update checks that pull files.
Snapshot again before you authenticate. Now anything that differs between the two captures is you — which is what lets future-you subtract yourself from the evidence.
And carry this warning in: the dashboard is a rendering. A plugin can hide itself from the plugins list. A user can be hidden from the users list. Cross-check against the database rather than believing the screen.
Users, and permissions in both directions
Every account, every role, registration dates, email addresses. Sort by date and look for clusters.
Most guides stop at accounts added. The other half matters more: access removed. A password that stops working on your own site is not a glitch — it’s a permissions event, and it means somebody decided you were a problem.
Note them. Don’t delete them yet — the registration date is evidence, and deleting the account deletes the date. Removal comes after the door is closed, or you’re just provoking a tool that recreates them in one query.
Plugins and themes
Active and inactive — inactive code is still on disk and still reachable. Compare against what you actually chose to install.
Anything that manages files deserves particular attention.
From the case file. This site acquired
wp-file-managerandfileorganizerone minute apart. A file manager plugin is remote file access with a friendly interface.
Then check every installed theme, not just the active one. An inactive theme is a folder full of PHP nobody has opened in three years, and it survives every “I reinstalled the theme” cleanup.
The things that never appear on the plugins page
Tools → Site Health is worth naming specifically, because it lists must-use plugins and drop-ins — two categories the plugins screen doesn’t show at all.
Must-use plugins load automatically and can’t be disabled from the dashboard. Drop-ins — db.php, object-cache.php, advanced-cache.php, maintenance.php — are a legitimate WordPress feature that almost nobody uses, which makes them close to ideal cover.
From the case file. Both were used here. A must-use plugin was repointed to the intruders’ own code, and their
db.phpdrop-in was protected by the sabotaged uninstaller described earlier.
Settings that hand over control
Site Address and WordPress Address. Administrative email — change this and you own every password reset forever, and almost nobody checks it. Membership and New User Default Role — set that dropdown to Administrator and every future registration is a backdoor.
Application passwords
The persistence mechanism most owners have never heard of. They survive a password change, they survive two-factor authentication, they’re issued per user and they sit at the bottom of a profile page. Check every account.
Scheduled events
Rogue scheduled tasks recreate deleted files on a timer, and they’re invisible in the standard interface. Worth checking the reverse too — if nothing is running at all, something is blocking it.
Content, and one useful mismatch
Posts and pages, including the states that never appear on your site: drafts, pending, private, scheduled, and trash. Check authorship and dates — spam is frequently backdated so it lands deep in the archive rather than at the top of the blog.
Then compare against Part 1:
- Thousands of URLs indexed, normal post count → the pages are being generated, not stored. You’re looking for code and rewrite rules, not database rows.
- Spam URLs indexed, matching spam posts → ordinary content injection. The database is the target.
- Nothing indexed, spam posts present → you caught it early.
From the case file. The posts table here was normal. Thousands of spam pages existed anyway, generated on request and served from somewhere else entirely. A clean Posts screen is not evidence of a clean site.
Part four: the three files that define the site
These don’t change what your site shows. They change what it is — which is why altering them produces no visible symptom at all.
wp-config.php
DB_HOST first. On nearly all shared hosting it should say localhost. A remote address there means your site is rendering from a database you don’t own.
From the case file. One site in this account had exactly that — its configuration repointed to a database on rented infrastructure elsewhere.
Then WP_HOME and WP_SITEURL — hardcoded is normal; values built from the incoming request turn one installation into something that serves any domain pointed at it. Then everything after the stop editing line, any include or require pulling in another file, the security keys, and the permissions.
And look for extra copies: wp-config.php.bak, .old, .save, or one sitting above the web root. Each is a plaintext database credential, and backup files are how credentials leak with nobody being hacked at all.
robots.txt
WordPress serves a virtual one when no file exists — so a physical robots.txt appearing is itself worth noting.
Check for declared sitemaps you didn’t create.
From the case file. The
robots.txton the compromised domain declared twenty sitemaps, none of them the owner’s. That one file did more to get thousands of spam pages discovered than every link on the site combined.
And the unsettling part: the robots.txt you see may not be the one Google sees. Rewrite rules can route those requests to a generator. Compare what’s on disk against what Search Console reports.
sitemap.xml
Compare against what you actually generate. Cross-check Search Console’s Sitemaps report too — though note that a sitemap declared in robots.txt never has to be submitted, so a clean report there proves nothing. In this case the twenty hostile sitemaps were declared on the server and the Search Console list stayed empty.
What you have now, and what you don’t
You should be holding: a list of files that don’t belong, with locations and dates. A timeline of deployment events. An inventory of accounts, plugins, drop-ins and settings that were changed. And a set of things you can’t explain.
What you don’t have yet is what any of it means — how they got in, how long they were there, which findings are proven and which are only supported. Facts aren’t conclusions, and the difference is the next post.
What you can do yourself, and what you can’t
Honestly: most of this needs server access and a tolerance for detail. That’s the truthful line and there’s no point pretending otherwise.
What is yours is the WordPress half — users, plugins, themes, settings, application passwords, scheduled events, Site Health. All of it is in the dashboard, all of it is readable, and every one of those checks has found a compromise for somebody.
And if you’re handing this to someone else, you now know what to ask for: did you compare against a clean copy, did you check must-use plugins and drop-ins, and did you look at every domain in the account or just this one? Those three questions will tell you very quickly whether you’re getting an investigation or a tidy-up.
Back: How to Preserve Evidence After a Website Hack
There is a difference between looking at a site and investigating one. Walking it the way an investigator would is a defined piece of work with a defined end.
Start a Recovery ReviewPaid assessment · scope agreed up front
Next: Analysis of the Site → — turning findings into conclusions, labelling what’s proven against what’s inferred, and why every false positive in this case was more convincing than the truth.
Hub: Invasion Investigation Blog Series
ProVAE builds and recovers websites in Douglas, Georgia, serving South Georgia.
