Skip to main content
rfxn
//
maldetmalwareaidetectionwebshell

AI-Generated Webshells: Why Pattern-Based Detection Still Wins

Ryan MacDonald10 min read

Anyone with API access to a general-purpose LLM can produce a working PHP webshell in seconds. The prompt does not need to be adversarial: “write me a small PHP admin helper that accepts a command via POST and returns the output, with some basic obfuscation” is sufficient, and the resulting code compiles, runs, and reliably survives a casual code review. Each sample is lexically unique. No two outputs share a byte-exact pattern. The superficial conclusion is that signature-based detection is finished.

The thesis of this article is the opposite. LLM-generated PHP malware is structurally convergent at the semantic level even when it is lexically diverse. A webshell, by definition, needs an input channel, a decode or transform layer, and an execution primitive. Regardless of how verbose the variable names are or how many helper functions the model decomposes the logic into, those three elements have to appear in the same file or in a tight include chain. maldet's compound signature engine (CSIG) matches on boolean combinations of those semantic indicators rather than byte-exact hashes, and that is exactly the shape of detection AI-generated samples reward.

We are not claiming a breakthrough here. We are pointing out that the detection strategy we published for Linux Malware Detect in the pre-LLM era — compound boolean rules over PHP's small surface of dangerous primitives — happens to degrade gracefully in the presence of machine-generated paraphrase. This post explains why, and is honest about where it stops.

What LLM-Generated PHP Actually Looks Like#

Human-authored webshells have an aesthetic. The classic c99 lineage, b374k, WSO, IndoXploit — these are tools crafted by operators who want small files, few dependencies, and minimal noise on disk. The code is dense. Variable names are one or two characters. Comments are absent. The decode pipeline collapses onto a single line:

php
<?php
// Human-authored style (compact, adversarial)
@eval(gzinflate(base64_decode($_POST['x'])));

An LLM produces something that reads like a textbook example. The same capability is there, but it has been decomposed into verbose helper functions with descriptive names, the input channel is validated with a length check, there is logging and error handling the operator does not need, and the whole thing is wrapped in comments that sound helpful:

php
<?php
/**
 * Lightweight remote administration helper.
 * Accepts an encoded command payload via POST and returns the output.
 */

function decodePayload(string $encodedInput): string {
    // Defensive: reject inputs that look unreasonable
    if (strlen($encodedInput) < 4 || strlen($encodedInput) > 65536) {
        return '';
    }
    $compressed = base64_decode($encodedInput, true);
    if ($compressed === false) {
        return '';
    }
    return gzinflate($compressed);
}

function executeCommand(string $phpSource): void {
    // Execute the decoded command block
    eval($phpSource);
}

if (isset($_POST['cmd'])) {
    $source = decodePayload($_POST['cmd']);
    if ($source !== '') {
        executeCommand($source);
    }
}

Both files are the same webshell. They accept a base64-encoded, gzip-deflated PHP payload over POST and evaluate it. The LLM version is eight times longer and does not share a single byte-exact token with the human version beyond language keywords. A hash signature on either one is useless against the other. A hex signature on eval(gzinflate(base64_decode( catches the first and misses the second entirely.

The structural tells of LLM-generated PHP are consistent across models and across prompts:

Verbose identifiers. Variable and function names are long and descriptive. $encodedInput, decodePayload, executeCommand. Human malware authors compress names to evade string matching and to reduce file size.
Function decomposition. Logic that a human would write as a single expression is split across several helper functions, each with its own scope, type hints, and early returns.
Comment noise. PHPDoc blocks at the top of the file, inline comments explaining the obvious, and occasional references to “defensive” or “sanity” checks that human adversaries omit.
Modern PHP idioms. Scalar type hints, strict comparisons, null coalescing, sometimes declare(strict_types=1). These are PHP 7+ constructs, which shifts the version target upward in a way that human webshells rarely do — operators want the payload to run on whatever ancient PHP the compromised host is stuck on.
Pseudo-legitimate framing. The file is presented as an admin tool, a license check, an update helper, or a debug endpoint. The framing is generated from the prompt and changes every time, which means the cosmetic cover story is not a reliable indicator.

Why Byte-Exact Signatures Fail (and Always Did)#

MD5 and SHA256 signatures are defeated by a one-byte change. This is not news. It was not news in 2010. Hash signatures survived in malware detection for a single reason: distribution. Most compromised hosts were running the same publicly-circulated webshell binaries, retrieved from the same leaked archives, and the hash of the dominant family's latest release caught 99&percnt; of them in practice. The hash was never a semantic match. It was a popularity match.

Hex signatures are the next step up. They catch literal byte sequences like eval(base64_decode( anywhere in a file. They are robust against re-packing and against minor edits to unrelated parts of the file. They are not robust against paraphrase. An LLM that rewrites eval(base64_decode($x)) as $fn = 'eval'; $fn(base64_decode($x)); breaks the hex pattern entirely. Add a call_user_func indirection and the function name is not a literal in the file at all.

The honest framing is that hash and hex signatures remain useful for known samples and known lineage, and are worth keeping, but they were never sufficient on their own. LLM-generated code just makes the gap between “known sample” and “novel sample” trivial to cross. The generated-per-host threat model is no longer a hypothetical — it is an afternoon's work for a moderately motivated operator with an API key.

What Survives the Paraphrase#

PHP is a small language for the subset of things a webshell has to do. To execute attacker-controlled code on a compromised host, the file has to do three things, and the vocabulary for each is narrow.

SEMANTIC SKELETON OF A PHP WEBSHELLHUMAN-AUTHOREDSEMANTIC LAYERLLM PARAPHRASE$_POST['x']one char param nameno validation, direct useINPUT CHANNEL$_GET / $_POST$_COOKIE / $_REQUEST$_POST['cmd']descriptive param namelength-checked, type-hintedgzinflate(base64_decode(...))inline, one expressionno error handlingDECODE PIPELINEbase64_decode / hex2bingzinflate / gzuncompressdecodePayload()extracted helper functiondefensive branches, typed@eval($decoded);single top-level statementerror-suppressedEXEC PRIMITIVEeval / assert / create_functionsystem / exec / passthru / proc_openexecuteCommand($src)helper wraps eval()one-line function bodylexically diverse → semantically identical → same compound rule matches both

Input Channel

The file must read attacker-controlled data from the request. The vocabulary is: $_GET, $_POST, $_COOKIE, $_REQUEST, $_SERVER['HTTP_*'], file_get_contents('php://input'), and apache_request_headers(). That is the list. A webshell that does not touch at least one of those is a webshell that cannot receive commands, which is not a webshell.

Decode Pipeline

Operators encode payloads to keep them out of WAF string-match rules, to fit them in cookies, and to reduce the visibility of the shell's capabilities in a casual read. The toolkit is small: base64_decode, hex2bin, gzinflate, gzuncompress, str_rot13, strrev, convert_uudecode, and the occasional openssl_decrypt or mcrypt_decrypt. An LLM can wrap any of these in a helper function with any name, but the function has to call one of the listed primitives. That call is visible in the file as a literal token.

Execution Primitive

The decoded payload has to dispatch somewhere. For PHP-in-PHP execution the list is eval, assert, create_function (PHP 7.2+ deprecated, 8.0 removed, still appears in legacy payload libraries), and the indirect forms via call_user_func or variable functions. For shell execution the list is system, exec, passthru, shell_exec, popen, proc_open, and the backtick operator. The model can rename the wrapper. It cannot replace the primitive. PHP has no third path to arbitrary code execution that does not flow through one of these names.

These three layers are the invariants. A webshell must read input, optionally transform it, and hand it to an execution primitive. The composition is what makes a file suspicious, and the composition is what compound signatures are built to match.

Compound Signatures Against the Paraphrase#

maldet's compound signature engine (CSIG) matches on boolean combinations of subsignatures. The detection language expresses “a file contains at least one of these input indicators AND at least one of these decode indicators AND at least one of these execution indicators” as a single rule. That is the exact shape of the semantic skeleton above.

text
# Minimal semantic-skeleton rule against a PHP webshell
# Reads: (any input channel) AND (any decoder) AND (any exec primitive)

(24 5f 47 45 54 5b || 24 5f 50 4f 53 54 5b || 24 5f 43 4f 4f 4b 49 45 5b || 24 5f 52 45 51 55 45 53 54 5b);1
 ||
(62 61 73 65 36 34 5f 64 65 63 6f 64 65 28 || 67 7a 69 6e 66 6c 61 74 65 28 || 67 7a 75 6e 63 6f 6d 70 72 65 73 73 28 || 68 65 78 32 62 69 6e 28 || 73 74 72 5f 72 6f 74 31 33 28);1
 ||
(65 76 61 6c 28 || 61 73 73 65 72 74 28 || 63 72 65 61 74 65 5f 66 75 6e 63 74 69 6f 6e 28 || 73 79 73 74 65 6d 28 || 70 61 73 73 74 68 72 75 28 || 73 68 65 6c 6c 5f 65 78 65 63 28);1
:{CSIG}php.webshell.skeleton.generic

The rule encodes ASCII bytes for $_GET[, $_POST[, $_COOKIE[, $_REQUEST[ as one OR group with threshold 1; the six most common decoder function calls as the second OR group; and the PHP code-execution primitives as the third OR group. The top-level || between groups is AND-composition in CSIG syntax. All three groups must hit.

Against the human-authored one-liner at the top of this post, the rule hits on $_POST[, base64_decode(, and eval(. Against the LLM paraphrase it hits on the same three tokens, in different places in the file, across different helper functions. The fact that the LLM decomposed the logic into decodePayload() and executeCommand() is irrelevant. Grep does not care about scope.

This rule is not precise on its own. A legitimate plugin that accepts input, optionally decompresses it, and runs some configured command will match. That is why CSIG rules in the shipping signature set layer on additional constraints — known-bad function-name combinations, suspicious co-occurring tokens, file-path heuristics, file-size bands — and why the generic skeleton rule above is deliberately tuned toward recall. Paired with the batch scan engine, CSIG is cheap enough to run broad rules at scan time and then filter results in post.

ClamAV's .ldb format can express the same boolean logic, and for environments that already run clamd the two approaches are complementary. maldet's CSIG is the native option for the shared-hosting, legacy, and constrained deployments where clamd is not installable.

What Still Bypasses You#

Compound signatures are not magic. There are four classes of AI-assisted evasion that beat the skeleton rule above, and it is worth naming them honestly.

Split Across Includes

An LLM can be prompted to split the three layers into three different files. One file reads $_POST and writes the payload to disk or session. Another decodes it. The third includes the decoded result. No single file contains all three skeleton elements, and a per-file compound signature will not fire. The defense here is multi-file correlation — tracking relationships between files in the same upload batch, in the same directory, with the same mtime window — which is something maldet's scan reporting already exposes and something we are expanding heuristics around in the 2.x line. It is not currently a compound-rule concern.

preg_replace /e Modifier

The /e modifier on preg_replace evaluated the replacement as PHP code. It was removed in PHP 7.0, but pre-7.0 legacy code still ships to compromised hosts running ancient PHP, and LLMs happily generate it when prompted for “classic” obfuscation. There is no literal eval/assert/create_function in these files. Covering this case is a matter of adding preg_replace(...)/e patterns to the execution-primitive OR group, which the maldet signature set does. It is an example of why the execution vocabulary is not quite as small as the core list suggests.

Polyglots and Format Tricks

Files that are simultaneously valid PHP and valid GIF, PNG, or PDF defeat extension-based routing and can sometimes defeat string matching if the PHP is reassembled at runtime from image-embedded data. The Magento PolyShell campaign is a production example. The skeleton rule above still fires on the PHP portion if it is a normal webshell shape, but an image-embedded payload that bootstraps an in-memory shell from pixel data needs its own rule family.

Runtime-Only Decryption

The last category is the honest one: a webshell that receives an encrypted payload, decrypts it with a key delivered in the same request, and evaluates the result. The decryption primitive (openssl_decrypt) is still a literal token in the file, and the input channel is still a literal token, and the execution primitive is still a literal token — so the skeleton rule does fire. What the rule cannot tell you is what the webshell is going to execute on any given request. That is a runtime question. A file scanner is the wrong layer for it. Behavioral detection (EDR, runtime process inspection, PHP opcode hooks) is the complementary mechanism, and it is out of scope for maldet.

A Practical Detection Posture#

If you run a fleet of PHP hosts, the practical posture for the AI-generated-webshell threat model is not revolutionary. It is the same posture that would have served you against paraphrased human-authored webshells five years ago, executed more aggressively.

Lean on compound rules. Hash and hex signatures stay useful for known lineage, but the detection budget should shift toward boolean rules expressing the semantic skeleton. This is what CSIG is for.
Scan the upload surfaces. maldet's default scan targets cover the common PHP upload areas: wp-content/uploads, wp-content/plugins, wp-content/themes, vendor/, web-accessible tmp/ and cache/ directories. These are where uploaded webshells land. Schedule scans and enable the inotify monitor on sites that accept user uploads.
Rotate signatures. AI-generated samples are a reason to ship new rules more often, not a reason to abandon signatures. Update maldet --update-sigs on a daily cron. The signature database pushes compound rules, and adding one new OR leg to the skeleton rule retires an entire class of paraphrase variants.
Harden upload paths. Compound detection is a safety net. The first line is web-server configuration: deny PHP execution under upload directories, enforce content-type on uploads, validate file extensions server-side. ModSecurity rulesets and web-server location blocks remove the attack surface before the scanner has to care.
Correlate logs. A webshell that a file scanner misses because of split includes usually still leaves a trail in access logs (unusual POST sizes, odd cookie shapes, requests to files that did not exist the day before). maldet's structured event log feeds into SIEMs so these correlations can run where the rest of your security telemetry lives. See our article on structured audit logging.

Conclusion#

The alarmist framing says LLMs make malware detection obsolete because every sample is unique. The practical observation is that uniqueness is a property of bytes, not of semantics, and signature-based detection has had a boolean-logic answer to semantic convergence since long before anyone had an LLM. The AI-generated webshell is, from a detection-engineering standpoint, a paraphrase of a problem we already had, and the tool for it is the same tool that catches paraphrased human-authored malware.

The honest caveats matter. File scanners cannot see runtime decryption. Split-include shells need multi-file correlation. Polyglots need their own rule families. Web-server hardening still does the most work, by collapsing the attack surface the scanner has to cover. Within those limits, the native compound signature engine in maldet stays effective against LLM-generated PHP for the same reason it was effective against the webshell families that predated it: the vocabulary is small, the shape is fixed, and grep is happy.

maldet 2.x is in active development on the 2.x branches and is open source under GPLv2. The CSIG engine, signature format, and the compound rule set are documented in our detection-language deep-dive.