Introduction

Must-Have Security is a WordPress security plugin built on one observation: being a WordPress administrator and being allowed to modify executable code are two different capabilities, and nothing in stock WordPress keeps them apart.

Where a traditional plugin scans your disk for malware it already recognises, Must-Have Security watches the one thing every persistent attack has in common — the moment a payload becomes a file — and refuses it. It runs before WordPress even loads, judges every filesystem write by its content rather than a signature, and lends out the right to write PHP deliberately, scoped and timed, to a strongly-authenticated administrator. For what was on disk before you installed it, a malware scanner verifies core against the official checksums and reads everything else as code — proving files harmless rather than matching them against a list.

What sets it apart

  • Runs before WordPress — an engine loaded from auto_prepend_file, the only mechanism that runs on every PHP request without a daemon, a server module or root.
  • No signatures — a deterministic policy decides, on the bytes. A brand-new webshell with no signature anywhere still has to become a file, and that is the part that is watched.
  • A scanner that proves, not guesses — core by checksum, everything else by a token-level classifier that follows request input through the code; under 1 % of files ever need an AI opinion, and the scanner only ever flags — it never deletes on its own.
  • Injection-resistant trust — the trusted-administrator registry and every credential live outside the database, so an SQL injection cannot promote itself to code execution.
  • It cannot lock you out — it starts in monitor mode, fails toward letting people in, and the loader is engineered so a broken engine can never brick the site.

Requirements

  • WordPress 5.9 or higher, PHP 7.4 or higher (8.x recommended)
  • The ability to set auto_prepend_file via .user.ini or .htaccess (almost all shared and managed hosting) — otherwise the engine boots after WordPress as a fallback
  • Multisite is out of scope and untested

Getting Started

Installation & Licensing

  1. Download the plugin ZIP from your account at musthaveplugins.com
  2. In WordPress, go to Plugins > Add New > Upload Plugin
  3. Select the ZIP, click Install Now, then Activate
  4. Open the Security menu — the dashboard walks you through installing the loader and enrolling your first factor

On first activation, the administrator who is logged in becomes the first trusted administrator. There is no ceremony that can create trust out of nothing, so the dashboard says exactly that and pushes you to enrol a real factor (a passkey or authenticator app) immediately.

Updates are delivered from musthaveplugins.com through the normal WordPress Plugins screen. Protection keeps running even if your subscription lapses; an active subscription is required for updates, support and the malware scanner, which runs on the service.

The Loader & First Boot

For the engine to run before WordPress, a small loader has to be installed. The plugin picks the method from the PHP SAPI and installs it for you:

  • .user.ini on CGI / FastCGI / PHP-FPM, and on LiteSpeed when lsphp is started with LSPHP_ENABLE_USER_INI=on
  • .htaccess on mod_php or LiteSpeed Enterprise (OpenLiteSpeed ignores php_value)
  • an mu-plugin late-boot fallback where neither can run — the engine starts after wp-config.php, covers every write WordPress makes, but not a PHP file requested directly by name; the dashboard says so and the direct-request layer stays off

On LiteSpeed the plugin checks both facts from inside the running PHP — whether lsphp was started with LSPHP_ENABLE_USER_INI=on and which edition is serving — and offers only a method that will actually take effect; the method list names what is missing.

A freshly written .user.ini is not live immediately — PHP caches the per-directory lookup for user_ini.cache_ttl seconds (300 by default), per worker. The dashboard probes three times and reports one of three states: live everywhere, live on some workers, or not yet live. "Partial" is not success, so it keeps polling until the rollout settles or a PHP-FPM reload finishes it.

Monitor vs Enforce

The plugin ships in enforce mode: a denied write fails for real from the first request. If you would rather watch first, switch File writes to monitor on the Protection tab — every write is still evaluated and recorded with its full payload, nothing is blocked, and you see exactly what enforcement would do on your real site. Turning protection down is a factor-gated change once a factor is enrolled.

ModeWhat happens
enforceDenied operations fail for real (default)
monitorEverything evaluated and logged with payload; nothing blocked
CLIConfigured separately, defaults to monitor so WP-CLI deploys keep working

Deactivation & Uninstall

Deactivating removes the loader (a two-stage retirement for .user.ini — see below; .htaccess and the mu-plugin retire at once) and unschedules the plugin's cron events. An auto_prepend_file pointing at a plugin WordPress no longer loads would be a warning at best and a site-wide fatal once the folder is gone, so deactivation always cleans it up.

Uninstalling keeps your settings and the state directory by default. Only when "Delete all data on uninstall" is ticked is everything removed — the state directory, its pointer file, the options and the scheduled events — and even then the inert loader stub is neutralised before the ini directive is stripped, so the site never 500s on the way out. Retire the loader first and nothing at all is left behind.

How It Works

The Idea in One Sentence

Nothing in stock WordPress separates "is an administrator" from "may write executable code". The whole design follows from putting that separation back:

ordinary PHP request                        executable write = DENY
WordPress administrator                      executable write = DENY
trusted administrator                        executable write = DENY
trusted admin + strong auth + scope + TTL    executable write = ALLOW, within scope
exact whitelist footprint                    executable write = ALLOW, per rule
everything else                              BLOCK + LOG + NOTIFY

The Prepend

auto_prepend_file is the only mechanism that runs on every PHP request on a shared host without a daemon, a webserver module or root. That matters more than it sounds: a dropped shell.php is requested directly, and WordPress never runs for it. A protection that only starts inside WordPress cannot see that request at all.

The boot order in a healthy install:

.user.ini → state-dir/prepend.php → runtime/bootstrap.php → WordPress → plugin

The runtime is WP-free: nothing in it calls a WordPress function, reads a WordPress constant, or touches the database. It gets its configuration from a compiled config.php — a plain PHP array the plugin writes whenever your settings change.

The Policy & Verdicts

The policy produces one of three verdicts for a path. It is deterministic by design — no network, no database, no AI, no heuristics that drift:

VerdictMeaning
ignoreOutside our remit — session files, PHP's upload temp, /tmp, the journal and quarantine
protectedDenied by a name, extension or path rule
inspectThe name looks harmless; watch what actually goes in

Rules are applied in order: the engine's own control files → protected; anything outside every configured root → ignore; an exempt glob → ignore; a protected configuration filename → protected; any dotted segment being a protected extension → protected (so shell.php.jpg is caught); a protected glob → protected; otherwise → inspect.

Paths are normalised first — scheme stripped, ../ collapsed, and the parent resolved through realpath so a symlinked directory can't be used to reach a protected tree under an innocent path. The basename is deliberately not resolved: it usually doesn't exist yet, which is the whole point of a create-time check.

Content Detection

An extension proves nothing — image.jpg, payload.txt and cache.dat all run once something includes them. So an inspect target is judged on its bytes, in two stages, because this runs on every byte of every upload:

  • Stage 1 — a single strpos($chunk, '<?'), memchr-speed. No open tag, and we're done.
  • Stage 2 — for each tag, a bounded window (128 bytes before, 8 KB after) is searched for a dangerous construct: eval, base64_decode, shell_exec, $_POST, php://input, include, and so on.

Requiring both keeps false positives survivable: plugin readmes and cached HTML legitimately contain <?php; a .txt containing <?php next to eval($_POST[…]) is not a documentation example. Writes are scanned with a 512-byte overlap between chunks, so a payload split across two fwrite() calls is still one token. A stricter any_tag mode is available.

The Wrapper

The engine registers a file:// stream wrapper (deliberately without STREAM_IS_URL, which would take the site down under allow_url_include=0). It covers stream_open, stream_write, stream_truncate, rename, unlink, mkdir, rmdir, touch/chmod and the directory handlers. Two decisions are worth knowing:

Deferred truncate. 'w' truncates at open — before a single byte of the replacement has been seen. If we then blocked the write, the site would be left with an empty file, which is worse than the attack. Inspected targets are opened with 'c' instead, and the truncate is applied only on the first write that clears the policy. A blocked write leaves the original file exactly as it was.

Exception-safe suspension. Delegating to the real filesystem means temporarily restoring the built-in wrapper. Every entry point restores its depth in a finally, so a stray warning inside a wrapper method can never silently switch the engine off for the rest of the request.

The Journal

An append-only NDJSON log, one file per day, in the randomly-named state directory. Each event records what was attempted, from which request, by whom, and — for blocked writes — the proposed content, its hash, the existing file's hash and size, and the caller chain. The payload is what makes an event evidence rather than a notification; it is also a live exploit payload, which is why the state directory is randomly named and web-denied.

The writer never waits: a lock is tried briefly and the line is appended without one rather than held up. A blocking lock on the request path would be a hang, not a wait — a torn line is skipped by the reader; a wait is a dead site.

Trusted Administrators

The administrator role lives in the database. An SQL injection anywhere on the site can insert a new administrator, rewrite an existing one's password hash, or flip a capability — each producing a session WordPress itself considers entirely legitimate. Any design that treats "is an administrator" as the gate for writing PHP has, in effect, made SQL injection equivalent to remote code execution.

The Registry

state-dir/trusted.php lives outside the database and is protected by the engine as one of its own control files, so a compromised request cannot write itself in.

WordPress admin  = YES
Trusted admin    = NO
Executable write = NO

Being in the registry is not permission to write code — it is permission to ask for permission. The first entry is whoever installed the plugin; after that the registry closes, and adding anyone else needs an existing trusted admin who has just proved a factor that is also not in the database. Removing the last one is refused, or nobody could add one back.

The factor protects exactly three things, and only those: making a trusted administrator, turning the file-write protection down (mode, content rules, the protected lists, unattended writes, the loader, the maintenance window, the fatal self-disable switch), and writing a file (whitelist, allow-once, restoring a quarantined file). Firewall lists, login hardening and notification settings are ordinary saves. And every path — multi-step included — that could take a non-trusted account to a trusted one needs a trusted administrator and a fresh factor: changing your own login factor first proves the one you have; resetting another user's factor is trusted-only, with proof.

Factors

FactorWhere the secret livesStrength
WebAuthn passkey / security keyPublic key in trusted.phpStrong — private key never leaves the authenticator
TOTP authenticator appSecret in trusted.phpStrong against SQL injection
PasswordHash in the WordPress databaseFallback, labelled as such everywhere

Password is offered only until a real factor is enrolled — because losing a phone shouldn't brick the site — and is never presented as equivalent to the other two. Enrolling a real factor switches the password off for that account: otherwise a passkey would add nothing against the very threat the registry exists for.

Reusing a Must-Have Tweaks Factor

If the account already has a passkey or authenticator set up for Must-Have Tweaks' login / 2FA, the dashboard offers to reuse it rather than enrolling a second credential. An import is not a copy. Tweaks stores those credentials in usermeta — in the database, the thing this design routes around — so the factor has to be used, right now, against the key found in usermeta, before anything is written into the registry. Afterwards, verification reads only our own copy.

Re-authentication: Every Time, Single Use

Proving a factor does not open a window. Every permission costs a factor. A window is exactly what a stored XSS in an administrator session rides on: it doesn't need to steal a passkey, it needs the passkey to have been used recently. Without one, a script has nothing to ride on — the authenticator is in a pocket, and the request that needs it waits for a tap that never comes.

The browser proves the factor and retries the one request that triggered the prompt, so the proof survives exactly that hop: it is single-use, consumed by the request it was made for, and expires in two minutes even unused. Installing four plugins is four taps. That is the price, and it is the point.

Write Permissions

Scoped Permissions

Under enforcement, a write permission is:

  • scoped — absolute path globs, built from the site's real constants, so a moved wp-content never ends up with a permission that silently covers nothing;
  • timed — minutes, capped by a setting;
  • presented — the request has to carry the permission's token in an HttpOnly, SameSite=Strict cookie and be logged in as the account it was issued to.

That last property is what makes the stored-XSS case work. The administrator is on the WooCommerce Orders screen; a stored XSS tries to write wp-content/themes/example/functions.php. The request comes from a real administrator session — and is refused, because no permission covering wp-content/themes/** was open.

Presets: plugins, themes, core, translations, mu-plugins. One live permission per account; issuing a new one replaces the old, because stacking windows is how five minutes quietly becomes an afternoon.

monitor                          -> plugin write goes through
enforce, no permission           -> plugins BLOCKED, uploads BLOCKED
enforce, plugins permission open  -> plugins WROTE, themes BLOCKED
back to monitor                  -> goes through

Allow Once & Replay

Allow once issues a one-shot authorisation bound to the path and the sha256 of the exact bytes you reviewed, then performs an ordinary write the engine evaluates again on the way through — it is not a privileged back door. The one-shot is consumed on use; one that survived its own use would leave a window open exactly as long as an attacker needs. Replaying needs the complete content, which is why blocked writes are quarantined under their own hash — a write refused at open has no content, so the UI doesn't offer a replay for those, and says why.

The Maintenance Window

A blunt fallback for a big operation: a timed window (a file with an expiry timestamp, read lazily only when the engine is about to deny something) during which protected writes are allowed. Prefer scoped permissions; use the window only when you genuinely need to.

The Whitelist

Legitimate software writes protected files with no administrator involved: cache plugins drop .htaccess, half the ecosystem writes empty index.php guards, Must-Have Backup installs a db.php drop-in.

How a Rule Works

A whitelist rule permits content, never a location:

  1. the content is normalised — PHP is tokenised and stripped of comments and whitespace; config files lose comment and blank lines;
  2. the rule stores the sha256 of that normalised form;
  3. optionally the rule is narrowed to a path, a directory or a basename.

So every spelling of "Silence is golden" collapses to one fingerprint, while the same file with one statement appended lands somewhere else entirely and is covered by nothing:

exact whitelisted content         -> WROTE
different comment, same code       -> WROTE   (comments carry no behaviour)
whitelisted content plus payload   -> BLOCKED
unrelated php file                 -> BLOCKED

Built-in Rules

Built-in rules cover empty directory guards named index.php and nothing else. Everything site-specific is created deliberately, from an event someone actually looked at in the file log.

Refused at Open

A target no rule could possibly cover is refused before a single byte is read — faster, and the correct default. Only when a rule or a one-shot could apply is the write buffered in memory and judged when complete. Deciding on the first chunk would approve a file that begins as a whitelisted guard and ends with eval() — there is a self-test vector for exactly that.

Firewall

The Request Classifier

The classifier says what a request looks like — a label, never a decision. It may never influence a write decision: a file is judged on what it is, not on what the request around it resembled. Wiring the two together would turn every false negative in the classifier into a hole in the policy, and every false positive into a site that can't update itself.

It never strikes a logged-in request to admin, admin-ajax, REST or upload endpoints — those authenticate on their own, and an editor saving a code tutorial looks exactly like an attack. The scan covers a bounded budget spread across every parameter, so a payload can't hide by being the 400th field.

Direct Requests

A dropped shell is a PHP file somebody then asks for by name. The direct-request layer decides which PHP files may be requested directly at all — WordPress's own entry points, plus what you approved. wp-admin/ is not a wildcard: only the files WordPress actually ships there (the official checksum list for your version and locale, refreshed after every core update) may be requested by name, so a dropped wp-admin/evil.php is refused like any other; if that list cannot be fetched, wp-admin stays open rather than locking you out. In watch mode everything seen is logged, and what's seen more than once becomes a suggestion (with what it belongs to). In refuse mode it's a 403 before WordPress loads. It runs from the prepend only, and the screen says so where it can't. The request path is decoded and collapsed before matching, so /xml%72pc.php can't walk past a rule for /xmlrpc.php.

Crawlers & Bots

Rules for bad bots and for AI crawlers that train on the site. Behind a proxy, REMOTE_ADDR is the proxy, so ip_header names the header carrying the real client — empty by default, because a header nobody upstream sets is whatever the visitor typed.

Country Database

For a site with no CDN country header, the plugin can fetch DB-IP's free country list into the state directory and refresh it monthly. It is opt-in, verified as a real MaxMind-format file before it replaces the one in place, and read by the engine only when a country rule exists to match. The firewall log carries the country, and "block this country" comes pre-filled. DB-IP attribution is a licence term.

Uploads & Monitors

The Upload Gate

WordPress moves every media upload with move_uploaded_file(), which renames at the C level and never enters the wrapper. The upload gate closes that at wp_handle_upload_prefilter: the temp file is judged first — by name (every dotted segment) and bytes, through the same policy every other write gets — before WordPress moves it. A JPEG with a PHP payload inside is refused for the same reason it would be at fwrite(); shell.php.jpg is refused on its name. Under monitor it records and lets it through, like everything else.

Scheduled Jobs

Cron is allowed to write, and that is a deliberate hole: it has no session and can hold no permission, so refusing it would stop automatic updates for good — which is how sites get compromised. The Scheduled jobs screen surfaces new, unclaimed and gone cron events so you can see what's scheduled. Unattended work (on by default) is what lets it write; every such write is recorded as "unattended" so it is never mistaken for a permission somebody held. Turn it off for a site where cron is disabled or driven by the system scheduler — that is a factor-gated change in the other direction. "Unattended" is decided by the running script being wp-cron.php (and the constants WordPress sets itself), never by a query parameter anyone can append.

Database Writes

The engine watches the few database writes that hand over an account — a new user, a role change, the SQL that gives an existing account administrator capabilities. Both are written down; neither is blocked. The database is the soft part: an injection reaches it, and everything WordPress trusts lives there, which is why the trust registry deliberately does not.

Malware Scanner

The write firewall stops a payload from becoming a file. The scanner is the other half: it walks what is already on disk and judges files the engine never saw being written — a webshell that arrived over FTP, a modified core file, a plugin that shipped with a backdoor, an uploaded SVG carrying a script. It is a detective layer: it may act on what it finds only in reversible ways, and it never touches the write policy.

The scan sends file content off-site, so it is off until an administrator turns it on and acknowledges that. Everything else is designed to send as little as possible: a file is sent only when nothing cheaper could decide it; the exclusion list is never read, let alone sent (wp-config.php, .env, .htpasswd, keys, logs, SQL dumps and backups by default, plus whatever you add); content travels gzipped over HTTPS; the state directory and the engine's own control files are never scanned. The scanner runs on the Must-Have Plugins service and needs the site connected with an active subscription; without one it refuses to start and offers a Connect link rather than producing an error per file.

Two Passes

Scan now runs two passes in one run:

  1. Basic — everything that can execute by name or change what executes: every PHP-ish extension in any dotted segment (shell.php.jpg counts), .htaccess, .user.ini, php.ini, web.config, and under uploads/ also markup files, files with no extension and files with a doubled extension. On a 500,000-file shop this is about 33,000 files.
  2. Deep — every other file: images, fonts, media, archives, scripts, text. These can only hurt through an include() or a handler rule (PHP hidden inside a JPEG), so they are worth a periodic pass rather than a daily one. The bar starts again; findings accumulate.

Automatic scan (daily / weekly / monthly) runs the basic pass only; Automatic full scan (weekly / monthly) runs both. On a very large site the listing alone takes tens of seconds — the bar says "Preparing — listing the files…" until the walk is done.

The Funnel

Cheapest stage first, so the paid step sees as little as possible:

StageWhereDecides
1. Local pre-filteryour server, no networkNo <?php (and no <?= in a text file), not a PHP extension, not an exec config, no active markup in a file a browser renders as markup, JavaScript not in its skimmer shape → inert, never sent
2. Reputationservice, hash onlyKnown good/bad by sha256 — content never sent; one verdict per unique content for the whole network
3. Core verificationservice, hash onlywp-admin/, wp-includes/ and the root core files against the official WordPress checksums for your version and locale: authentic → good; unknown or modified → needs review
4. Classifierservice, on contentSee below — decides almost everything that reaches it
5. AIserviceOnly what the classifier could not decide

The Classifier

The classifier works on PHP tokens — never on raw text, so a backtick or $x( inside a string literal or a comment is not a signal — and follows taint inside each function: request input ($_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES, the visitor-controlled $_SERVER keys, php://input, headers) and stored input (options, meta, database reads) → the variables assigned from them → the parameters of functions called with them → the arguments of dangerous calls. Every file lands in one tier:

TierMeaningVerdict
1 plainNo input, no mechanismgood, no AI
2 cleanInputs present, but no backdoor mechanism is reachable from themgood, no AI
3 rule-badA mechanism fed by the request directly or through decoders — eval($_POST[…]), eval(base64_decode($_REQUEST[…])), $_POST['a']($_POST['b']), include $_GET[…], a name assembled from chr() / reversed / glued literals and called with request data, remote code into eval, an unverified wp_set_auth_cookie(<literal id>), active markup in an uploaded SVG/HTML, known shell fingerprintsbad — the AI only names the family; its "clean" cannot overturn this
4 unprovenA mechanism whose input cannot be traced: an executor on stored data, a file write or an outbound request carrying request data, an account hand-over without a verification in the same function, unserialize of request data, obfuscation feeding a call, PHP in a non-PHP file under uploads, an exec-changing .htaccess directiveAI

"Mechanism" means executors (eval, system, exec, shell_exec, passthru, proc_open, backticks; assert and create_function only on PHP 7), dynamic code ($f(), $$x, ${…}, new $c, callbacks handed a variable), include/require with a variable path, decoders, file writers, outbound HTTP, mail, and account hand-over (wp_set_auth_cookie, wp_insert_user, set_role, add_cap, writes to the users tables). What makes a file good is that none of these is reachable from an input, or that the reachable one is proved harmless: a require anchored to __DIR__ or plugin_dir_path(), a callback that is a literal or a closure, a hand-over preceded by wp_signon or a nonce plus capability check, a file write whose path and content are untainted, an outbound request to a literal URL.

The burden of proof is on "good": whatever the analysis cannot decide is tier 4, never tier 2. A malicious corpus (shells, droppers, stealers, auth bypasses, obfuscated variants) that must never come out good and a benign corpus that must never come out anything but good gate every release. Measured on a real 10,688-file site: 0 false rule-bad, 0.76 % of files to the AI.

The AI & Its Limits

Only tier 4 reaches the model, and it reads the whole file (up to 3 MB — larger than any PHP file seen so far; above that a head+tail sample is sent and the verdict stays uncertain, never good). A file the model refuses as too long is judged in overlapping parts and the verdicts combined; every byte is still read. An AI outage is a retriable error, never a verdict.

Both budgets are counted per subscription on the service — every site connected to the same subscription draws from one hourly pool:

LimitDefault
API requests per hour — everything the site asks the service3,000
AI reviews per hour300

A file judged in parts counts once per part. When the AI cap is reached the file is shown as "AI budget for this site is used up for now — re-checked on the next scan" with a Rescan button; nothing is cached for it, so it is judged the next time it comes up. When the request cap is reached the scan stops with that reason and continues from its cache next hour. The scan page shows both counters for the current hour. With the classifier in front of the AI a full first scan of a typical site needs a few dozen reviews, so both caps are safety nets against a runaway, not something a normal site meets.

Findings & Actions

The scanner only flags. It never moves or deletes a file on its own — an AI verdict is a judgement and can be wrong, and a moved file is not always restorable. Each finding shows the path, the reason, the family and who judged it (core, rule, classifier, ai, cache), and offers:

VerdictActions
badView · Ignore (declare a false positive) · Remove (deletes the file / the quarantined copy; needs a fresh factor)
needs review (uncertain, modified core)View · Ignore
scan error / budgetView · Rescan (judge it again now) · Dismiss (drop the entry; the file is not touched)

Ignore records the file's exact content hash: the file is skipped as long as its bytes are unchanged and examined again the moment they change. Ignored files are listed under the findings with an "Examine again" button. View opens the file in a window outside the list, so a live refresh during a scan cannot scroll it away.

Caches

  • Local — a good verdict is remembered for 30 days per file content; a changed file is examined again immediately. Bad, uncertain and error entries are re-examined every run.
  • Shared — the service remembers one verdict per file content for every site. The first site to carry a given plugin version pays the AI once; every other site gets the answer from a hash lookup. A good reached on a partial sample is never shared as good; a genuine uncertain is re-analysed after a week.

What it does not do: it does not judge what JavaScript does in the browser beyond the skimmer shape; it does not verify plugins or themes against their repositories (a repository can ship a vulnerability, and a hash match would say nothing about it); it does not look inside archives.

Logs & Evidence

The File Log

Every write the engine evaluated — blocked, allowed by a permission or whitelist, or (in monitor) recorded as it went through. Each row carries the path, the verdict, the caller, and for a blocked executable write the payload itself. Rows from your own exempt address are marked exempt. This is where you turn a real event into a whitelist rule or an allow-once.

The Firewall Log

What the request firewall and direct-request layer saw: the endpoint, the classifier's label, the country (when known), and whether it was watched or refused. What's seen more than once becomes a suggestion for the direct-request list.

Quarantine

Blocked write payloads are stored under their sha256 in quarantine/ inside the state directory — live exploit code, kept as evidence and as the exact bytes a replay needs. The directory is randomly named and web-denied precisely because of what it holds.

E-mail Summary

An optional daily or weekly summary — what happened and what is waiting for you — quiet when there is nothing to say. Off until you give it an address.

The Loader

Getting the engine to run before WordPress, on a host we don't control, without ever leaving the site unable to boot. Break auto_prepend_file and every URL returns 500, wp-admin included — so the loader is built entirely around not failing that way.

The Three Methods

MethodSAPINotes
.user.iniCGI / FastCGI / PHP-FPM; LiteSpeed with LSPHP_ENABLE_USER_INI=onWritten to the WP root; covers everything below it. Not immediate — cached per worker for user_ini.cache_ttl. lsphp reads it only with that variable in its environment; the plugin checks and says so.
.htaccessmod_php or LiteSpeed EnterpriseAlways inside <IfModule> guards — a bare php_value under PHP-FPM is an immediate 500 for the whole vhost.
mu-pluginanywhereLate boot, after WordPress. The liveness probe reports late so it's never mistaken for a real prepend.

Two-Stage Retirement

Removing a loader is never a delete. Never delete the loader stub — a missing auto_prepend_file is a PHP fatal, and workers cache the directive per directory, so a delete takes every URL to 500 until every worker expires. Instead: stage 1 strips the ini block and replaces the stub with an inert file that exists and does nothing; stage 2 deletes it, but only after user_ini.cache_ttl + 60s, because a worker that re-read the directory just before the strip holds the old entry for one more TTL. Installing a loader cancels any pending retirement.

If You Get Locked Out

The plugin is built so this shouldn't happen — monitor mode never blocks, and if the engine itself ever fatals (a file under its own runtime or the stub — a crash anywhere else in the plugin never counts) it writes an OFF marker, the site keeps serving with the protection off, and every trusted administrator is e-mailed at once; re-arm it from the dashboard. That self-disable is a switch (Rules → If the engine itself crashes, on by default): with it off an engine fatal is a site-wide 500 until repaired over FTP, but nothing can end with the protection quietly off. Turning it on is a factor-gated weakening. If you ever need the manual escape hatch: delete or empty the auto_prepend_file line in .user.ini / .htaccess at the WordPress root over FTP or SSH, then remove the wp-content/mh-security-* folder. The Loader tab documents the exact file and line for your install.

One more thing the loader guards: in an ini file a repeated auto_prepend_file is last-wins, so a line left behind by another plugin below ours would silently replace the engine's prepend. The loader keeps its own directive last, and chains any foreign prepend it finds in the file — while that line and file exist — from its stub.

The Self-Test Lab

Hosting environments differ enough that the only trustworthy answer is a measured one. The lab writes real payloads through real PHP functions into a sandbox directory with enforcement switched on for that directory only, and reports, per vector, whether the operation was stopped — plus the per-operation overhead of the wrapper on this host.

It has already earned its place: ZipArchive::extractTo() was written down as a known bypass, and the lab disproved it — ext/zip extracts through php_stream_open_wrapper(), so it is covered. Vectors that are genuinely gapsexec/shell_exec, move_uploaded_file() outside the media path, link()/symlink(), and any code already executing calling stream_wrapper_restore('file') — are reported honestly per host rather than asserted closed in the docs.

fwrite → .php            stopped     6.2 µs/op
rename → .php            stopped     4.8 µs/op
ZipArchive::extractTo    stopped     7.0 µs/op
shell_exec → file        gap · exec enabled on this host

Settings Reference

Overview

Answers three questions in order: is the engine running, what is each layer doing, and what is waiting for you. The status reads as a sentence — "Running, before WordPress" — with the loader named as the file it actually is, and the maintenance window shown only when one is open.

Protection & Rules

  • Protection — the file-write Mode (monitor / enforce), the maintenance window, and the command-line mode, plus a plain statement of what this layer does not cover.
  • Rules — content detection settings, unattended-work handling, the fatal self-disable switch, and the protected names, extensions and paths the policy enforces.
  • Monitors — the database-write monitor.
  • Firewall — request-firewall mode and rules, crawler rules, and the country database.
  • Direct requests — watch / refuse, and the list of what may be requested directly.
  • Trusted administrators & Write permissions — the registry, factors, and any open permission.
  • Whitelist — your content-fingerprint rules.
  • Loader — the installed method, and the lockout escape hatch.

Command Line (WP-CLI, cron scripts, composer)

The CLI SAPI is configured separately and defaults to log-only, so WP-CLI deployments and composer runs keep working while still being recorded. Tighten it once your deploy pipeline is accounted for.

Scanner

Enable the malware scanner (the consent switch), Automatic scan (the fast pass: off / daily / weekly / monthly), Automatic full scan (both passes: off / weekly / monthly), and Never scan or send these — one path or glob per line, never read or sent; empty means the sensible defaults. Then Scan now, the live progress bar with Stop, the findings with their actions, and the ignored files.

The State Directory

wp-content/mh-security-<128-bit hex>/ — randomised per site because it holds the content of blocked writes, i.e. live exploit payloads. .htaccess, web.config and index.php deny files are written on activation, but the random name is the real protection (nginx ignores .htaccess). It contains the compiled config.php, the generated prepend.php stub, the trusted.php / auth.php / grants.php / whitelist.php control files, the daily event logs, the quarantine, the scanner's working files and its own quarantine, the self-test sandbox, and the kill-switch (OFF) and maintenance (bypass) markers. Its name is anchored by a fixed pointer file, wp-content/mh-security.php, rather than a database option — so an SQL injection cannot point the plugin at an empty directory and silently drop every trusted administrator. Those control files are protected by the policy, not ignored — anything that could write grants.php could issue itself a permission, so only the plugin, with the wrapper suspended, may write them.

Requirements & Gaps

Stated plainly, because pretending otherwise is worse than the gap. This layer stops persistence, not post-exploitation:

GapWhyMitigation
move_uploaded_file()Renames at the C level, never enters a wrapperThe upload gate judges the media temp file first; the direct-request layer refuses anything that got past it from being requested
exec / shell_exec / proc_openA separate process writes the fileDisable them in php.ini; the lab reports whether they're available
stream_wrapper_restore('file')Any executing code can unhook us in one callNone at this layer — it stops persistence, not code already running
Shell / FTP / SSH writesWrite as another processThe direct-request layer: such a file is worth nothing until requested by name, and only entry points and the approved list may be

Changelog

0.5.32 16 September 2026
Public beta release.

← Back to Must-Have Security

This website uses cookies to enhance your browsing experience and ensure the site functions properly. By continuing to use this site, you acknowledge and accept our use of cookies.

Accept All Accept Required Only