shelltrap.com
en de

Interactive

Webshell Signal Explorer

Detection is not magic and it is not a single clever rule. It is several weak signals that only mean something together. Here are six cases, including one where the right answer is to do nothing.

Webshell Signal Explorer

Six things a scanner sees on a real host. Pick one and read why it does — or deliberately does not — trigger an action.

The classic first stage. A short file, an encoded string, one call that turns data into code.

Verdict malicious score 88 public_html/wp-content/uploads/2026/03/thumb-cache.php
<?php
/* thumbnail cache — do not edit */
$k = 'ZXhhbXBsZQ==';
$p = base64_decode($k);
eval($p);

Signals

  • heuristics · php.dynamic_eval +45

    Code is built at runtime and executedeval() receives a variable, not a literal. Legitimate application code almost never needs this; loaders always do.

  • heuristics +25

    Decoder immediately before the sinkbase64_decode() feeds the executing call directly. The distance between decoding and execution is the tell — not the function itself.

  • heuristics +20

    PHP inside an upload directorywp-content/uploads/ receives files from the internet. A .php file there has no legitimate reason to exist, whatever it contains.

  • heuristics +8

    Comment mimics platform vocabulary — “do not edit” headers are cheap camouflage against a human reviewer. On its own this proves nothing; it only raises the score once other signals fire.

Three transformations in one expression. The point of the nesting is to defeat a grep for any single function name.

Verdict malicious score 79 public_html/wp-includes/class-wp-image-cache.php
<?php
$s = 'nSjXpH5NmH2Wn2E=';
$x = gzinflate(base64_decode(str_rot13($s)));
$h = "\x73\x79\x73\x74\x65\x6d";
echo strlen($x);

Signals

  • heuristics +35

    Three decoders in one expressionstr_rot13base64_decodegzinflate is a transformation chain, not data handling. Each function is harmless; the composition is the signal.

  • heuristics +24

    Hex-escaped string literal — A long \x.. sequence hides a plain identifier from anything that searches for words. PHP resolves it at parse time; a reader does not.

  • heuristics · php.escape_density +12

    Unusual density of escape sequences — Measured against the rest of the file. On its own this fires on legitimate library code too — which is exactly why it never decides alone.

  • heuristics +15

    File name imitates core, path does not match — The name mimics WordPress core, but no such file exists in the distribution, and its hash is not in the known-good allowlist.

A single strong-looking signal that on its own must not trigger an action. This is what report-only mode is for.

Verdict suspicious score 38 public_html/app/Support/Dispatcher.php
<?php
$a = 'php';
$b = 'info';
$f = $a . $b;
$f();

Signals

  • heuristics · php.variable_call +28

    Call target assembled at runtime — The callee never appears as a literal, so no static rule can name it. Dispatchers, plugin loaders and DI containers do this legitimately every day.

  • heuristics +10

    Identifier split across concatenation — Splitting a name into fragments is a search-evasion pattern. In framework code it is often just readability.

Two signals, no decoder, no upload path, hash unknown but the file sits in an application source tree. The score stays under the threshold: Shelltrap records a finding, notifies, and touches nothing. Quarantining this would break the site.

Magic bytes say GIF, the body says PHP. Upload validators that check the header are satisfied; the web server is not asked.

Verdict malicious score 84 public_html/wp-content/uploads/2026/03/logo.png
GIF89a;
<?php echo 1; ?>

Signals

  • heuristics +40

    PHP open tag with code inside a media file — A <? byte pair alone is not enough — that produced false positives on ordinary JPEGs and was fixed. An explicit tag with executable code after it is.

  • heuristics +26

    Declared type and content disagree — Type detection by magic bytes only steers priority. A file whose extension, magic bytes and content tell three different stories is inspected, never skipped as “just an image”.

  • heuristics +18

    Writable upload path — The file arrived where anonymous visitors can write. Combined with the tag, that answers “how did it get here” before anyone has to ask.

No code at all. Two configuration lines that make the server execute pictures and load an attacker file before every request.

Verdict malicious score 91 public_html/wp-content/uploads/.htaccess + .user.ini
# .htaccess
AddType application/x-httpd-php .png
AddHandler application/x-httpd-php .ico

; .user.ini
auto_prepend_file = /home/example/public_html/wp-content/uploads/.cache.php

Signals

  • heuristics +42

    Extension remapped to the PHP handlerAddType/AddHandler under a document root turns inert uploads into executables. Almost no legitimate site needs this below uploads/.

  • heuristics +38

    `auto_prepend_file` set from inside a site — This is the same mechanism Shelltrap’s own upload gate uses, which is exactly why the product treats a site-level override as a finding rather than trusting it.

  • heuristics +11

    Dot-file in a public directory — The prepended file is hidden from a casual ls. Persistence lives in the files nobody lists.

The real-time watcher sees .htaccess and .user.ini writes as well as content writes, because the event mask includes attribute changes. That is why a configuration-only attack is caught by the same pipeline as a code drop.

Real WordPress core code that trips a heuristic. If a scanner quarantines this, it takes the site down. The honest answer is a known-good allowlist, not a louder alarm.

Verdict clean score 12 public_html/wp-includes/kses.php
<?php
// wp-includes/kses.php — unmodified WordPress core
$string = str_replace( array( '&amp;', '&lt;', '&gt;' ), array( '&', '<', '>' ), $string );
$string = wp_kses_normalize_entities( $string, $context );
$string = preg_replace( '/&([^&\s;]+)(?![0-9a-z;])/', '&amp;$1', $string );

Signals

  • heuristics · php.escape_density +12

    High density of escaped characters — An HTML sanitiser is nothing but escape sequences. PHPMailer, SimplePie and the named-character-reference tables trip the same signal.

  • hash +0

    SHA-256 matches the known-good allowlist — The checksum comes from the published WordPress distribution. The allowlist answers the heuristic — but it never overrides a path-context hit such as PHP inside uploads/.

Verdict clean. One signal, a matching known-good hash, no upload path. Nothing is reported, nothing is moved, and nobody is woken up at 03:00.

Illustration, not malware: every snippet here is inert. Scores and identifiers depend on the active rule generation and are shown to explain the reasoning, not to specify the product.

Why a score and not a rule

A single signal is almost never enough. base64_decode appears in half the plugins on a busy WordPress host. A variable function call is what every dispatcher and dependency-injection container in the PHP world does for a living. High escape density is what an HTML sanitiser is.

Any of those, used alone as a rule, produces a scanner that either misses obvious loaders or quarantines WordPress core. Both failure modes end with the same phone call.

So heuristics in Shelltrap score. Each signal contributes a weight, the weights add up, and the verdict compares the total against a threshold you can move per domain. Every signal that contributed is stored with the finding and shown in the panel, so a verdict is an argument you can read rather than a label you have to trust.

The signals that are not code

Two of the six cases above contain no PHP at all. A rewritten handler in .htaccess and an auto_prepend_file line in .user.ini are configuration, and they are among the most effective persistence mechanisms on shared hosting: they need no shell of their own and survive the cleanup of the file everybody was looking at.

This is exactly why the watcher’s event mask includes attribute changes and why .user.ini and .htaccess always go through the full pipeline, whatever their size. A scanner that only looks at .php files is looking at the wrong half of the problem.

The one that must stay quiet

The last case is the important one. wp-includes/kses.php is WordPress core, unmodified, and it trips a heuristic every time because sanitisers are made of escape sequences. So do PHPMailer, SimplePie and the named-character-reference tables.

The honest answer to that is a known-good allowlist built from published distribution checksums — not a louder alarm and not a quieter heuristic. And the allowlist has a limit of its own: it may not override a path-context hit. A file whose hash is in WordPress core but which now sits in an uploads directory is still a finding, because how it got there is the question.

Try it against your own thinking